diff --git a/BloodBash.py b/BloodBash.py index 9fa3431..504ef61 100644 --- a/BloodBash.py +++ b/BloodBash.py @@ -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 @@ -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")) @@ -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")) @@ -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 @@ -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 @@ -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')}]") diff --git a/test_bloodbash.py b/test_bloodbash.py index 81dd6d0..fc21bc1 100755 --- a/test_bloodbash.py +++ b/test_bloodbash.py @@ -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() \ No newline at end of file