Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions BloodBash.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,41 @@ def print_verbose_summary(G, domain_filter=None):
# ────────────────────────────────────────────────
# Helpers (Extended for Azure)
# ────────────────────────────────────────────────
UAC_FLAGS = {
0x00000001: "SCRIPT",
0x00000002: "ACCOUNTDISABLE",
0x00000008: "HOMEDIR_REQUIRED",
0x00000010: "LOCKOUT",
0x00000020: "PASSWD_NOTREQD",
0x00000040: "PASSWD_CANT_CHANGE",
0x00000080: "ENCRYPTED_TEXT_PWD_ALLOWED",
0x00000100: "TEMP_DUPLICATE_ACCOUNT",
0x00000200: "NORMAL_ACCOUNT",
0x00000800: "INTERDOMAIN_TRUST_ACCOUNT",
0x00001000: "WORKSTATION_TRUST_ACCOUNT",
0x00002000: "SERVER_TRUST_ACCOUNT",
0x00010000: "DONT_EXPIRE_PASSWORD",
0x00020000: "MNS_LOGON_ACCOUNT",
0x00040000: "SMARTCARD_REQUIRED",
0x00080000: "TRUSTED_FOR_DELEGATION",
0x00100000: "NOT_DELEGATED",
0x00200000: "USE_DES_KEY_ONLY",
0x00400000: "DONT_REQ_PREAUTH",
0x00800000: "PASSWORD_EXPIRED",
0x01000000: "TRUSTED_TO_AUTH_FOR_DELEGATION",
0x04000000: "PARTIAL_SECRETS_ACCOUNT",
}

def decode_uac(value):
try:
value = int(value)
except (TypeError, ValueError):
return str(value)
flags = [name for bit, name in UAC_FLAGS.items() if value & bit]
if flags:
return f"{value} ({', '.join(flags)})"
return str(value)

def get_bool_prop_ci(props, keys, default=False):
if not isinstance(props, dict):
return default
Expand Down Expand Up @@ -549,7 +584,9 @@ def print_password_never_expires(G, domain_filter=None):
password_never_expires = get_bool_prop_ci(props, ['passwordneverexpires', 'PasswordNeverExpires'])
if password_never_expires:
found = True
console.print(f"[yellow]Password Never Expires enabled[/yellow]: [green]{d['name']}[/green]")
uac_raw = props.get('useraccountcontrol') or props.get('UserAccountControl')
uac_str = f" | UAC: {decode_uac(uac_raw)}" if uac_raw is not None else ""
console.print(f"[yellow]Password Never Expires enabled[/yellow]: [green]{d['name']}[/green]{uac_str}")
add_finding("Password Never Expires", f"User {d['name']} has 'Password Never Expires' set")
if found:
console.print(Panel("[bold yellow]Impact:[/bold yellow] Passwords may never expire, leading to old/weak passwords persisting indefinitely.\n[bold]Mitigation:[/bold] Review and enforce password policies; consider resetting passwords for affected accounts.\n[bold]Tools:[/bold] Use PowerShell (Get-ADUser) or AD tools to audit.", title="Abuse Suggestions: Password Never Expires", border_style="yellow"))
Expand All @@ -569,7 +606,9 @@ def print_password_not_required(G, domain_filter=None):
password_not_required = get_bool_prop_ci(props, ['passwordnotrequired', 'PasswordNotRequired'])
if password_not_required:
found = True
console.print(f"[red]Password Not Required enabled[/red]: [green]{d['name']}[/green]")
uac_raw = props.get('useraccountcontrol') or props.get('UserAccountControl')
uac_str = f" | UAC: {decode_uac(uac_raw)}" if uac_raw is not None else ""
console.print(f"[red]Password Not Required enabled[/red]: [green]{d['name']}[/green]{uac_str}")
add_finding("Password Not Required", f"User {d['name']} has 'Password Not Required' set")
if found:
console.print(Panel("[bold red]Impact:[/bold red] No password required for login, enabling easy account takeover or unauthorized access.\n[bold]Abuse:[/bold] Log in without a password; escalate privileges if account has rights.\n[bold]Mitigation:[/bold] Enforce passwords; disable or monitor such accounts.\n[bold]Tools:[/bold] ADUC, PowerShell, or BloodHound for auditing.", title="Abuse Suggestions: Password Not Required", border_style="red"))
Expand Down Expand Up @@ -1055,7 +1094,9 @@ def print_kerberoastable(G, domain_filter=None):
enabled = props.get('enabled', props.get('Enabled', True))
if hasspn and not sensitive and enabled:
found = True
console.print(f" • [cyan]{d['name']}[/cyan]")
uac_raw = props.get('useraccountcontrol') or props.get('UserAccountControl')
uac_str = f" | UAC: {decode_uac(uac_raw)}" if uac_raw is not None else ""
console.print(f" • [cyan]{d['name']}[/cyan]{uac_str}")
count += 1
if count >= max_display:
remaining = sum(1 for n_inner, d_inner in G.nodes(data=True) if d_inner.get('type', '').lower() == 'user' and get_bool_prop_ci(d_inner.get('props', {}), ['hasspn', 'hasSPN', 'has_spn']) and not d_inner.get('props', {}).get('sensitive', d_inner.get('props', {}).get('Sensitive', False)) and d_inner.get('props', {}).get('enabled', d_inner.get('props', {}).get('Enabled', True))) - max_display
Expand Down Expand Up @@ -1086,7 +1127,9 @@ def print_as_rep_roastable(G, domain_filter=None):
enabled = props.get('enabled', props.get('Enabled', True))
if dontreqpreauth and not sensitive and enabled:
found = True
console.print(f" • [cyan]{d['name']}[/cyan]")
uac_raw = props.get('useraccountcontrol') or props.get('UserAccountControl')
uac_str = f" | UAC: {decode_uac(uac_raw)}" if uac_raw is not None else ""
console.print(f" • [cyan]{d['name']}[/cyan]{uac_str}")
count += 1
if count >= max_display:
remaining = sum(1 for n_inner, d_inner in G.nodes(data=True) if d_inner.get('type', '').lower() == 'user' and get_bool_prop_ci(d_inner.get('props', {}), ['dontreqpreauth', 'dontReqPreauth', 'dont_req_preauth']) and not d_inner.get('props', {}).get('sensitive', d_inner.get('props', {}).get('Sensitive', False)) and d_inner.get('props', {}).get('enabled', d_inner.get('props', {}).get('Enabled', True))) - max_display
Expand Down Expand Up @@ -1224,7 +1267,10 @@ def inspect_node(G, identifier, domain_filter=None):
console.print(f"[cyan]Is Azure:[/cyan] {d.get('is_azure', False)}")
console.print("[dim]Properties:[/dim]")
for k, v in sorted(d.get('props', {}).items()):
console.print(f" {k}: {v}")
if k.lower() == 'useraccountcontrol':
console.print(f" {k}: {decode_uac(v)}")
else:
console.print(f" {k}: {v}")
console.print("[dim]Outgoing edges:[/dim]")
for _, tgt, edata in G.out_edges(oid, data=True):
console.print(f" → [green]{G.nodes[tgt]['name']}[/green] [{edata.get('label')}]")
Expand Down
71 changes: 71 additions & 0 deletions test_bloodbash.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,5 +711,76 @@ def test_adcs_vulnerabilities_fixed(self):
output = self._capture_output(bloodbash_globals['print_adcs_vulnerabilities'], G)
self.assertIn("ESC1/ESC2", output)
self.assertGreater(len(bloodbash_globals['global_findings']), 0) # Ensure findings were added
# ────────────────────────────────────────────────
# Tests for decode_uac() (UAC attribute translation)
# ────────────────────────────────────────────────
def test_decode_uac_single_flag(self):
# 0x2 = ACCOUNTDISABLE
result = bloodbash_globals['decode_uac'](2)
self.assertIn("2", result)
self.assertIn("ACCOUNTDISABLE", result)

def test_decode_uac_multiple_flags(self):
# 514 = 0x202 = ACCOUNTDISABLE (0x2) + NORMAL_ACCOUNT (0x200)
result = bloodbash_globals['decode_uac'](514)
self.assertIn("514", result)
self.assertIn("ACCOUNTDISABLE", result)
self.assertIn("NORMAL_ACCOUNT", result)

def test_decode_uac_dont_expire_password(self):
# 66048 = 0x10200 = NORMAL_ACCOUNT (0x200) + DONT_EXPIRE_PASSWORD (0x10000)
result = bloodbash_globals['decode_uac'](66048)
self.assertIn("DONT_EXPIRE_PASSWORD", result)
self.assertIn("NORMAL_ACCOUNT", result)

def test_decode_uac_dont_req_preauth(self):
# 0x400000 = DONT_REQ_PREAUTH (AS-REP roastable flag)
result = bloodbash_globals['decode_uac'](0x400000)
self.assertIn("DONT_REQ_PREAUTH", result)

def test_decode_uac_string_integer_input(self):
# decode_uac should accept a numeric string and parse it correctly
result = bloodbash_globals['decode_uac']("512")
self.assertIn("512", result)
self.assertIn("NORMAL_ACCOUNT", result)

def test_decode_uac_zero_no_flags(self):
# 0 matches no bitmask, should return "0" without any flag name
result = bloodbash_globals['decode_uac'](0)
self.assertEqual(result, "0")

def test_decode_uac_invalid_input(self):
# Non-numeric string should be returned as-is
result = bloodbash_globals['decode_uac']("not_a_number")
self.assertEqual(result, "not_a_number")

def test_decode_uac_in_kerberoastable_output(self):
# Verify that UAC is displayed alongside kerberoastable users
G = nx.MultiDiGraph()
# 512 = NORMAL_ACCOUNT, a common UAC value for enabled accounts
G.add_node("K", name="KerbUACUser@LAB.LOCAL", type="User", props={
"hasspn": True,
"sensitive": False,
"enabled": True,
"useraccountcontrol": 512,
})
output = self._capture_output(bloodbash_globals['print_kerberoastable'], G)
self.assertIn("KerbUACUser@LAB.LOCAL", output)
self.assertIn("NORMAL_ACCOUNT", output)

def test_decode_uac_in_asrep_output(self):
# Verify that UAC is displayed alongside AS-REP roastable users
G = nx.MultiDiGraph()
# 4194816 = NORMAL_ACCOUNT (0x200) + DONT_REQ_PREAUTH (0x400000)
G.add_node("A", name="AsRepUACUser@LAB.LOCAL", type="User", props={
"dontreqpreauth": True,
"sensitive": False,
"enabled": True,
"useraccountcontrol": 0x400200,
})
output = self._capture_output(bloodbash_globals['print_as_rep_roastable'], G)
self.assertIn("AsRepUACUser@LAB.LOCAL", output)
self.assertIn("DONT_REQ_PREAUTH", output)

if __name__ == '__main__':
unittest.main()
Loading