Skip to content

Commit c8ac4b4

Browse files
authored
Merge pull request #11 from PaperMtn/feature/username-password-check
Version 3.2.0 Release
2 parents 59af3cb + 226f1e8 commit c8ac4b4

File tree

8 files changed

+183
-53
lines changed

8 files changed

+183
-53
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
## [3.2.0] - 2024-08-14
2+
### Added
3+
- Functionality to search for users who are using their username as the password
4+
- Converts the users username into the following formats:
5+
- All uppercase
6+
- All lowercase
7+
- Remove dot "."
8+
- camelCase (E.g. johnSmith)
9+
- PascalCase (E.g. JohnSmith)
10+
11+
### Fixed
12+
- SUCCESS level logging not properly working for JSON output
13+
114
## [3.1.0] - 2024-08-13
215
### Added
316
- Added new functionality to enhance the custom passwords passed to lil-pwny

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ More information about Lil Pwny can be found [on my blog](https://papermtn.co.uk
1717
- **Custom Password Auditing**: Ability to provide a list of your own custom passwords to check AD users against. This allows you to check user passwords against passwords relevant to your organisation that you suspect people might be using.
1818
- Pass a .txt file with the plaintext passwords you want to search for, these are then NTLM hashed and AD hashes are then compared with this as well as the HIBP hashes.
1919
- **Detect Duplicates**: Return a list of accounts using the same passwords. Useful for finding users using the same password for their administrative and standard accounts.
20+
- **Username as Password**: Detect users that are using their username, or variations of it, as their password.
2021
- **Obfuscated Output**: Obfuscate hashes in output, for if you don't want to handle or store live user NTLM hashes.
2122

2223
### Custom Password List Enhancement
@@ -29,6 +30,20 @@ Lil Pwny provides the functionality to enhance your custom password list by addi
2930
- Passwords with dates appended starting from the year 1950 up to 10 years from today's date (e.g. `password1950`, `password2034`)
3031

3132
A custom password list of 100 plaintext passwords generates 49848660 variations.
33+
34+
### Usernames in Passwords
35+
Lil Pwny looks for users that are using variations of their username as their password.
36+
37+
It converts the users username into the following formats:
38+
39+
- All uppercase
40+
- All lowercase
41+
- Remove dot "."
42+
- camelCase (E.g. johnSmith)
43+
- PascalCase (E.g. JohnSmith)
44+
45+
These are then converted to NTLM hashes, and audited against the AD hashes
46+
3247
## Resources
3348
This application has been developed to make the most of multiprocessing in Python, with the aim of it working as fast as possible on consumer level hardware.
3449

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "lil-pwny"
3-
version = "3.1.0"
3+
version = "3.2.0"
44
description = "Fast offline auditing of Active Directory passwords using Python and multiprocessing"
55
authors = ["PaperMtn <papermtn@protonmail.com>"]
66
license = "GPL-3.0"

src/lil_pwny/__init__.py

Lines changed: 94 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
import traceback
77
from datetime import timedelta
88
from importlib import metadata
9+
from typing import List, Dict
910

1011
from lil_pwny import password_audit, hashing
11-
from lil_pwny.custom_password_enhancer import CustomPasswordEnhancer
12+
from lil_pwny.variant_generators.custom_variant_generator import CustomVariantGenerator
13+
from lil_pwny.variant_generators.username_variant_generator import UsernameVariantGenerator
1214
from lil_pwny.exceptions import FileReadError
1315
from lil_pwny.loggers import JSONLogger, StdoutLogger
1416

@@ -40,18 +42,58 @@ def get_readable_file_size(file_path: str) -> str:
4042
"""
4143

4244
file_size_bytes = os.path.getsize(file_path)
45+
for unit in ['bytes', 'KB', 'MB', 'GB']:
46+
if file_size_bytes < 1024:
47+
return f'{file_size_bytes:.2f} {unit}'
48+
file_size_bytes /= 1024
4349

44-
if file_size_bytes < 1024: # Less than 1 KB
45-
return f"{file_size_bytes} bytes"
46-
elif file_size_bytes < 1024 ** 2: # Less than 1 MB
47-
file_size_kb = file_size_bytes / 1024
48-
return f"{file_size_kb:.2f} KB"
49-
elif file_size_bytes < 1024 ** 3: # Less than 1 GB
50-
file_size_mb = file_size_bytes / (1024 ** 2)
51-
return f"{file_size_mb:.2f} MB"
52-
else: # 1 GB or more
53-
file_size_gb = file_size_bytes / (1024 ** 3)
54-
return f"{file_size_gb:.2f} GB"
50+
51+
def write_hash_temp_file(hash_list: List[str]) -> str:
52+
""" Writes a list of hashes to a temporary file and returns the file path.
53+
54+
Args:
55+
hash_list: A list of hash strings to be written to the temporary file.
56+
Returns:
57+
str: The file path of the temporary file containing the hashes.
58+
"""
59+
60+
with tempfile.NamedTemporaryFile('w', delete=False) as temp_file:
61+
temp_file.write('\n'.join(hash_list))
62+
return temp_file.name
63+
64+
65+
def find_matches(log_handler: JSONLogger or StdoutLogger,
66+
filepath: str,
67+
ad_user_hashes: Dict[str, List[str]],
68+
finding_type: str,
69+
obfuscated: bool,
70+
logging_type: str) -> int:
71+
""" Searches for matches between Active Directory user hashes and a provided hash file, logs the results,
72+
and returns the number of matches found.
73+
74+
Args:
75+
log_handler: The logger instance used to log messages.
76+
filepath: The path to the file containing the hash data to compare against.
77+
ad_user_hashes: A dictionary of NTLM hashes from Active Directory users.
78+
finding_type: The type of match being searched for (e.g., 'hibp', 'custom', 'username').
79+
obfuscated: Whether to obfuscate the matches found by hashing with a random salt.
80+
logging_type: The type of logging output to use ('stdout', 'json', etc.).
81+
Returns:
82+
The number of matches found.
83+
"""
84+
85+
matches = password_audit.search(
86+
log_handler=log_handler,
87+
hibp_hashes_filepath=filepath,
88+
ad_user_hashes=ad_user_hashes,
89+
finding_type=finding_type,
90+
obfuscated=obfuscated)
91+
number_of_matches = len(matches)
92+
if logging_type != 'stdout':
93+
for match in matches:
94+
log_handler.log('NOTIFY', match, notify_type=finding_type)
95+
96+
return number_of_matches
5597

5698

5799
def main():
@@ -143,6 +185,22 @@ def main():
143185
logger.log('CRITICAL', f'Error loading AD user hashes: {str(e)}')
144186
sys.exit(1)
145187

188+
# Check username variations
189+
logger.log('SUCCESS', f'Finding users using passwords that are a variation of their username...')
190+
username_variants = UsernameVariantGenerator().generate_variations(ad_users)
191+
logger.log('DEBUG', f'{len(username_variants)} username variants generated ')
192+
username_hashes = hasher.get_hashes(username_variants)
193+
logger.log('DEBUG', f'Converting username variants to NTLM hashes ')
194+
username_temp_filepath = write_hash_temp_file(username_hashes)
195+
196+
username_count = find_matches(
197+
log_handler=logger,
198+
filepath=username_temp_filepath,
199+
ad_user_hashes=ad_users,
200+
finding_type='username',
201+
obfuscated=obfuscate,
202+
logging_type=logging_type)
203+
146204
# Check HIBP file size
147205
try:
148206
logger.log('SUCCESS', f'Size of HIBP file provided {get_readable_file_size(hibp_file)}')
@@ -153,16 +211,13 @@ def main():
153211
# Compare AD users against HIBP hashes
154212
logger.log('SUCCESS', f'Comparing {ad_lines} AD users against HIBP compromised passwords...')
155213
try:
156-
hibp_results = password_audit.search(
214+
hibp_count = find_matches(
157215
log_handler=logger,
158-
hibp_hashes_filepath=hibp_file,
216+
filepath=hibp_file,
159217
ad_user_hashes=ad_users,
160218
finding_type='hibp',
161-
obfuscated=obfuscate)
162-
hibp_count = len(hibp_results)
163-
if logging_type != 'stdout':
164-
for hibp_match in hibp_results:
165-
logger.log('NOTIFY', hibp_match, notify_type='hibp')
219+
obfuscated=obfuscate,
220+
logging_type=logging_type)
166221
except FileNotFoundError as e:
167222
logger.log('CRITICAL', f'HIBP file not found: {e.filename}')
168223
sys.exit(1)
@@ -183,66 +238,53 @@ def main():
183238
custom_count = 0
184239
variants_count = 0
185240
logger.log('INFO', 'Enhancing custom password list by adding variations...')
186-
custom_client = CustomPasswordEnhancer(min_password_length=int(custom_enhance))
241+
custom_client = CustomVariantGenerator(min_password_length=int(custom_enhance))
187242
for custom_pwd in custom_passwords:
188243
logger.log('DEBUG', f'Generating variants for `{custom_pwd}`...')
189244
temp_custom_passwords = custom_client.enhance_password(custom_pwd)
245+
190246
logger.log('DEBUG', 'Converting custom passwords to NTLM hashes...')
191247
custom_password_hashes = hasher.get_hashes(temp_custom_passwords)
192248
variants_count += len(custom_password_hashes)
193249
logger.log('SUCCESS', f'Generated {len(custom_password_hashes)} variants for `{custom_pwd}`')
194-
with tempfile.NamedTemporaryFile('w', delete=False) as temp_file:
195-
for h in custom_password_hashes:
196-
temp_file.write(f'{h}\n')
197-
temp_file_path = temp_file.name
198-
logger.log('DEBUG', f'Custom hashes written to temp file {temp_file_path}')
199250

251+
custom_temp_file_path = write_hash_temp_file(custom_password_hashes)
252+
logger.log('DEBUG', f'Custom hashes written to temp file {custom_temp_file_path}')
200253
logger.log('INFO', f'Comparing {ad_lines} Active Directory'
201254
f' users against {len(custom_password_hashes)} custom password hashes...')
202-
custom_matches = password_audit.search(
255+
256+
custom_count += find_matches(
203257
log_handler=logger,
204-
hibp_hashes_filepath=temp_file_path,
258+
filepath=custom_temp_file_path,
205259
ad_user_hashes=ad_users,
206260
finding_type='custom',
207-
obfuscated=obfuscate)
208-
os.remove(temp_file_path)
209-
logger.log('DEBUG', f'Temp file {temp_file_path} deleted')
210-
custom_count += len(custom_matches)
211-
if logging_type != 'stdout':
212-
for result in custom_matches:
213-
logger.log('NOTIFY', result, notify_type='custom')
261+
obfuscated=obfuscate,
262+
logging_type=logging_type)
263+
os.remove(custom_temp_file_path)
264+
logger.log('DEBUG', f'Temp file {custom_temp_file_path} deleted')
214265
else:
215266
logger.log('DEBUG', 'Converting custom passwords to NTLM hashes...')
216267
custom_password_hashes = hasher.get_hashes(custom_passwords)
217-
with tempfile.NamedTemporaryFile('w', delete=False) as temp_file:
218-
for h in custom_password_hashes:
219-
temp_file.write(f'{h}\n')
220-
temp_file_path = temp_file.name
221-
logger.log('DEBUG', f'Custom hashes written to temp file {temp_file_path}')
268+
custom_temp_file_path = write_hash_temp_file(custom_password_hashes)
269+
logger.log('DEBUG', f'Custom hashes written to temp file {custom_temp_file_path}')
222270

223271
logger.log('INFO', f'Comparing {ad_lines} Active Directory'
224272
f' users against {len(custom_password_hashes)} custom password hashes...')
225-
custom_matches = password_audit.search(
273+
custom_count += find_matches(
226274
log_handler=logger,
227-
hibp_hashes_filepath=temp_file_path,
275+
filepath=custom_temp_file_path,
228276
ad_user_hashes=ad_users,
229277
finding_type='custom',
230-
obfuscated=obfuscate)
231-
os.remove(temp_file_path)
232-
logger.log('DEBUG', f'Temp file {temp_file_path} deleted')
233-
custom_count = len(custom_matches)
234-
if logging_type != 'stdout':
235-
for result in custom_matches:
236-
logger.log('NOTIFY', result, notify_type='custom')
278+
obfuscated=obfuscate,
279+
logging_type=logging_type)
280+
os.remove(custom_temp_file_path)
281+
logger.log('DEBUG', f'Temp file {custom_temp_file_path} deleted')
237282
except FileNotFoundError as e:
238283
logger.log('CRITICAL', f'Custom password file not found: {e.filename}')
239284
sys.exit(1)
240285
except Exception as e:
241286
logger.log('CRITICAL', f'Error during custom password search: {str(e)}')
242287
sys.exit(1)
243-
finally:
244-
if os.path.exists(temp_file_path):
245-
os.remove(temp_file_path)
246288

247289
# Handle duplicates if requested
248290
duplicate_count = 0
@@ -262,6 +304,7 @@ def main():
262304

263305
logger.log('SUCCESS', 'Audit completed')
264306
logger.log('SUCCESS', f'Total compromised passwords: {total_comp_count}')
307+
logger.log('SUCCESS', f'Passwords matching a variation of the username: {username_count}')
265308
logger.log('SUCCESS', f'Passwords matching HIBP: {hibp_count}')
266309
logger.log('SUCCESS', f'Passwords matching custom password dictionary: {custom_count}')
267310
if custom_enhance:

src/lil_pwny/loggers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def log(self,
4242
message = f'CUSTOM_MATCH: \n' \
4343
f' ACCOUNT: {message.get("username").lower()} HASH: {message.get("hash")} PASSWORD: {message.get("plaintext_password")} OBFUSCATED: {message.get("obfuscated")}'
4444
mes_type = 'CUSTOM'
45+
if notify_type == "username":
46+
message = f'USERNAME_MATCH: \n' \
47+
f' ACCOUNT: {message.get("username").lower()} HASH: {message.get("hash")} PASSWORD: {message.get("plaintext_password")} OBFUSCATED: {message.get("obfuscated")}'
48+
mes_type = 'USERNAME'
4549
if notify_type == "duplicate":
4650
message = 'DUPLICATE: \n' \
4751
f' ACCOUNTS: {message.get("users")} HASH: {message.get("hash")} OBFUSCATED: {message.get("obfuscated")}'
@@ -116,6 +120,12 @@ def log_to_stdout(self,
116120
key_color = Fore.YELLOW
117121
style = Style.NORMAL
118122
mes_type = '!'
123+
elif mes_type == "USERNAME":
124+
base_color = Fore.BLUE
125+
high_color = Fore.BLUE
126+
key_color = Fore.BLUE
127+
style = Style.NORMAL
128+
mes_type = '!'
119129

120130
# Make log level word/symbol coloured
121131
type_colorer = re.compile(r'([A-Z]{3,})', re.VERBOSE)
@@ -195,6 +205,9 @@ def __init__(self, name: str = 'lil pwny', log_queue: Queue = None, **kwargs):
195205
self.notify_format = logging.Formatter(
196206
'{"localtime": "%(asctime)s", "level": "NOTIFY", "source": "%(name)s", "match_type": "%(type)s", '
197207
'"detection_data": %(message)s}')
208+
self.success_format = logging.Formatter(
209+
'{"localtime": "%(asctime)s", "level": "SUCCESS", "source": "%(name)s", '
210+
'"detection_data": %(message)s}')
198211
self.info_format = logging.Formatter(
199212
'{"localtime": "%(asctime)s", "level": "%(levelname)s", "source": "%(name)s", "message":'
200213
' %(message)s}')
@@ -218,6 +231,11 @@ def log(self, level: str, log_data: str or Dict, **kwargs):
218231
self.logger.info(
219232
json.dumps(log_data, cls=EnhancedJSONEncoder),
220233
extra={'type': kwargs.get('notify_type', '')})
234+
elif level.upper() == 'SUCCESS':
235+
self.handler.setFormatter(self.success_format)
236+
self.logger.info(
237+
json.dumps(log_data, cls=EnhancedJSONEncoder),
238+
extra={'type': kwargs.get('notify_type', '')})
221239
elif level.upper() in ['INFO', 'DEBUG']:
222240
self.handler.setFormatter(self.info_format)
223241
self.logger.log(getattr(logging, level.upper()), json.dumps(log_data))

src/lil_pwny/variant_generators/__init__.py

Whitespace-only changes.

src/lil_pwny/custom_password_enhancer.py renamed to src/lil_pwny/variant_generators/custom_variant_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from datetime import datetime
33

44

5-
class CustomPasswordEnhancer:
5+
class CustomVariantGenerator:
66
""" Enhances the custom password with additional variations
77
"""
88

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from typing import Dict, List
2+
3+
4+
class UsernameVariantGenerator:
5+
6+
def generate_variations(self, ad_user_list: Dict[str, List[str]]) -> List[str]:
7+
""" Generates variations of usernames based on specific rules.
8+
- All uppercase
9+
- All lowercase
10+
- Remove dot "."
11+
- camelCase
12+
- PascalCase
13+
14+
Args:
15+
ad_user_list: A dictionary where keys are NTLM hashes and values are lists of usernames.
16+
Returns:
17+
List: A list of generated username variations.
18+
"""
19+
20+
variations = []
21+
22+
for ntlm_hash, usernames in ad_user_list.items():
23+
for uname in usernames:
24+
if '.' in uname:
25+
split_uname = uname.split('.')
26+
27+
if len(split_uname) > 1:
28+
camel_uname = split_uname[0].lower() + ''.join(part.capitalize() for part in split_uname[1:])
29+
variations.append(camel_uname)
30+
31+
pascal_uname = ''.join(part.capitalize() for part in split_uname)
32+
variations.append(pascal_uname)
33+
34+
stripped_uname = uname.replace('.', '')
35+
variations.append(stripped_uname.upper())
36+
variations.append(stripped_uname.lower())
37+
38+
variations.append(uname.upper())
39+
variations.append(uname.lower())
40+
41+
return variations

0 commit comments

Comments
 (0)