From e19948ad6bcfdc3310c2b70430b49ab02eee69f0 Mon Sep 17 00:00:00 2001 From: RogueAutomata <47926856+mepley1@users.noreply.github.com> Date: Sat, 4 Jan 2025 10:24:45 -0600 Subject: [PATCH 1/6] Fix navbar link for androx/variants search --- project/templates/nav-links-stats.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/templates/nav-links-stats.html b/project/templates/nav-links-stats.html index 62aea1e..5980fe6 100644 --- a/project/templates/nav-links-stats.html +++ b/project/templates/nav-links-stats.html @@ -11,4 +11,4 @@ Hostname: *.cn User-Agent: Contains URL Body: Any included -Body: AndroxGh0st/variants{# '0x%255B%255D=%25' #} +Body: AndroxGh0st/variants From e315405ac36c8f3eb21c8c278efd2574b87d82dc Mon Sep 17 00:00:00 2001 From: RogueAutomata <47926856+mepley1@users.noreply.github.com> Date: Sat, 4 Jan 2025 10:30:29 -0600 Subject: [PATCH 2/6] Add body_raw regex search option to search.html 1. Add option to optgroup for body_raw regex search. 2. Label search options more clearly to distinguish regex vs sql like queries --- project/templates/search.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/project/templates/search.html b/project/templates/search.html index 4f04e1e..110339c 100644 --- a/project/templates/search.html +++ b/project/templates/search.html @@ -22,8 +22,9 @@

Search for HTTP requests where:

- - + + +
From e2a71d322b5e60652d9d8bfb787cde65937edf2d Mon Sep 17 00:00:00 2001 From: RogueAutomata <47926856+mepley1@users.noreply.github.com> Date: Sat, 4 Jan 2025 10:31:55 -0600 Subject: [PATCH 3/6] mark password field required in signup form --- project/templates/signup.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/templates/signup.html b/project/templates/signup.html index e8a54d0..e3e85b5 100644 --- a/project/templates/signup.html +++ b/project/templates/signup.html @@ -12,7 +12,7 @@

Create Account

- +

From edf82023ede82510705e650e99481b24c26f5a9e Mon Sep 17 00:00:00 2001 From: RogueAutomata <47926856+mepley1@users.noreply.github.com> Date: Sat, 4 Jan 2025 10:32:30 -0600 Subject: [PATCH 4/6] Log captcha errors more clearly --- project/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/auth.py b/project/auth.py index 5138d66..2b634d6 100644 --- a/project/auth.py +++ b/project/auth.py @@ -115,7 +115,7 @@ def login_post(): answer = r.text result = json.loads(r.text) if 'error-codes' in result: - logging.error(result['error-codes']) + logging.error(f'hCaptcha error: {result["error-codes"]}') if result['success'] != bool(1): logging.error(f'Failed hCaptcha challenge: {get_ip()}') flash('Please complete the Captcha correctly.', 'errorn') From 54f152dbd3547ee9ecb85ded3ae34531027650d1 Mon Sep 17 00:00:00 2001 From: RogueAutomata <47926856+mepley1@users.noreply.github.com> Date: Sat, 4 Jan 2025 10:37:46 -0600 Subject: [PATCH 5/6] test POSTing file data + test detection of hikvision exploit --- test/files.py | 11 +++++++++++ test/hikvision.py | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 test/files.py create mode 100644 test/hikvision.py diff --git a/test/files.py b/test/files.py new file mode 100644 index 0000000..905b61f --- /dev/null +++ b/test/files.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# Send files in request + +import requests + +url = 'http://localhost:5000/test/post-files' + +files = {'file': open('./image.png' ,'rb')} + +x = requests.post(url, files=files) +print(x.status_code) diff --git a/test/hikvision.py b/test/hikvision.py new file mode 100644 index 0000000..e384c3c --- /dev/null +++ b/test/hikvision.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# Test detection of hikvision command injection exploit attempts + +import requests + +url = 'http://localhost:5000/SDK/webLanguage' +data = """ + $(ping -c 1 127.0.0.1)""" +headers = {'content-type': 'application/x-www-form-urlencoded; charset=UTF-8'} +x = requests.put(url, data = data, headers = headers) +print(x.status_code) From 1e1f5d145dc04a37c06c0c7dcf752a6504986597 Mon Sep 17 00:00:00 2001 From: RogueAutomata <47926856+mepley1@users.noreply.github.com> Date: Sat, 4 Jan 2025 12:20:24 -0600 Subject: [PATCH 6/6] Refactor search parser using case matching. 1. Rewrite search parser using case matching instead of a bunch of elifs. + strip whitespace from query text on first read, rather than after matching each case. 2. Don't double-encode body_raw queries (stats.html). 3. Some misc formatting+comments --- project/main.py | 113 +++++++++++++++++++---------------- project/templates/stats.html | 2 +- 2 files changed, 63 insertions(+), 52 deletions(-) diff --git a/project/main.py b/project/main.py index 8d222ae..f0d9699 100644 --- a/project/main.py +++ b/project/main.py @@ -882,8 +882,12 @@ def bodyStats(): @login_required def bodyRawStats(): ''' Get records matching the request body. Regex query. (body_raw column, stored as blob) ''' - body_pattern = unquote(request.args.get('body', '')) + # NOTE: Unquoting it again is what was causing args containing a % to break. + # Flask url-encodes/decodes automatically. + body_pattern = request.args.get('body', '') + #logging.debug(f'body_raw regex pattern: {body_pattern}') + # Validate pattern if not body_pattern or not validate_regex(body_pattern): return create_error_response(400, 'Invalid param', 'Missing or invalid regex pattern.') @@ -1153,7 +1157,7 @@ def hostname_stats(): total_pages = pagination_data['total_pages'], args_for_pagination = pagination_data['args_for_pagination'], totalHits = len(stats), - statName = f'Hostname: {hostname}' + statName = f'Hostname: {hostname_q}' ) @main.route('/stats/headers/single/', methods = ['GET']) @@ -1211,7 +1215,7 @@ def headers_single_json(request_id): @login_required def headers_key_search(): """ Find requests which include a given header. """ - header_name = request.args.get('key', 'no input') + header_name = request.args.get('key', 'no input').strip() if not validate_header_key(header_name): return create_error_response(400, 'Invalid param', 'Invalid header name; value may contain only letters and hyphen.') @@ -1223,7 +1227,7 @@ def headers_key_search(): FROM bots WHERE (JSON_EXTRACT(headers_json, ?)) IS NOT NULL ORDER BY id DESC - LIMIT 100000; + LIMIT 1000000; """ data_tuple = (f'$.{header_name}',) c.execute(sql_query, data_tuple) @@ -1357,14 +1361,14 @@ def full_search_regex(): conn.row_factory = sqlite3.Row conn.create_function("REGEXP", 2, regexp) c = conn.cursor() - + #Get column names sql_query = "PRAGMA table_info(bots)" c.execute(sql_query) columns = [column[1] for column in c.fetchall()] - + # Construct sql query - join " REGEXP OR" for each column sql_query = "SELECT * FROM bots WHERE " - conditions = [f"{column} REGEXP ?" for column in columns] #If using regexp over LIKE + conditions = [f"{column} REGEXP ?" for column in columns] sql_query += ' OR '.join(conditions) sql_query += ' ORDER BY id DESC;' data_list = [q for i in enumerate(columns)] @@ -1452,60 +1456,67 @@ def parse_search_form(): """ Redirect to one of the other views, depending on which search was selected. """ #logging.debug(request.args) #testing chosen_query = request.args.get('chosen_query', '') - query_text = request.args.get('query_text', '') + query_text = request.args.get('query_text', '').strip() - # Flash message if no query input + # Flash message and return search page again if no query input if not query_text or query_text is None: flash('No query input', 'error') return render_template('search.html') # Same, if no field was selected if not chosen_query or chosen_query is None: - flash('Must select a query.', 'error') + flash('Must select a field.', 'error') return render_template('search.html') #Parse and redirect, based on which field was selected - if chosen_query == 'ip_string': - ip_string = query_text - return redirect(url_for('main.ipStats', ipAddr = ip_string)) - elif chosen_query == 'cidr_string': - cidr_string = query_text - return redirect(url_for('main.subnet_stats', net = cidr_string)) - elif chosen_query == 'url': - url = query_text - url = '*' + url + '*' - return redirect(url_for('main.urlStats', url = url)) - elif chosen_query == 'header_string': - header_string = query_text - return redirect(url_for('main.header_string_search', header_string = header_string)) - elif chosen_query == 'header_key': - header_key = query_text.strip().title() - return redirect(url_for('main.headers_key_search', key = header_key)) - elif chosen_query == 'content_type': - ct = query_text.strip() - ct = '%' + ct + '%' - return redirect(url_for('main.content_type_stats', ct = ct)) - elif chosen_query == 'ua_string': - ua_string = query_text - ua_string = '%25' + ua_string + '%25' - return redirect(url_for('main.uaStats', ua = ua_string)) - elif chosen_query == 'body_string': - body_string = query_text - body_string = '%' + body_string + '%' - return redirect(url_for('main.bodyStats', body = body_string)) - elif chosen_query == 'body_raw': - q = query_text - return redirect(url_for('main.bodyRawStats', body = q)) - elif chosen_query == 'hostname_endswith': - hostname_string = query_text.strip() - return redirect(url_for('main.hostname_stats', hostname = hostname_string)) - elif chosen_query == 'hostname_contains': - hostname_string = query_text.strip() - hostname_string = hostname_string + '%' - return redirect(url_for('main.hostname_stats', hostname = hostname_string)) - elif chosen_query == 'any_field': - q = query_text - return redirect(url_for('main.full_search', q = q)) + match chosen_query: + case 'ip_string': + q = query_text + return redirect(url_for('main.ipStats', ipAddr = q)) + case 'cidr_string': + q = query_text + return redirect(url_for('main.subnet_stats', net = q)) + case 'url': + q = query_text + q = '*' + q + '*' + return redirect(url_for('main.urlStats', url = q)) + case 'header_string': + q = query_text + return redirect(url_for('main.header_string_search', header_string = q)) + case 'header_key': + q = query_text.title() + return redirect(url_for('main.headers_key_search', key = q)) + case 'content_type': + q = query_text + q = '%' + q + '%' + return redirect(url_for('main.content_type_stats', ct = q)) + case 'ua_string': + q = query_text + q = '%25' + q + '%25' + return redirect(url_for('main.uaStats', ua = q)) + case 'body_string': + q = query_text + q = '%25' + q + '%25' + return redirect(url_for('main.bodyStats', body = q)) + case 'body_raw': + q = query_text + return redirect(url_for('main.bodyRawStats', body = q)) + case 'hostname_endswith': + q = query_text + return redirect(url_for('main.hostname_stats', hostname = q)) + case 'hostname_contains': + q = query_text + # The view function will prepend another %, so only need to add a trailing one here. + q = q + '%' + return redirect(url_for('main.hostname_stats', hostname = q)) + case 'any_field': + q = query_text + return redirect(url_for('main.full_search', q = q)) + case 'any_field_regex': + q = query_text + return redirect(url_for('main.full_search_regex', q = q)) + case '' | _: + return create_error_response(400, 'Invalid param', 'Invalid value for field name.') # Misc routes diff --git a/project/templates/stats.html b/project/templates/stats.html index ac5b0a1..87e873d 100644 --- a/project/templates/stats.html +++ b/project/templates/stats.html @@ -185,7 +185,7 @@

Most recent requests matching query

{{ row['path'] }} {{row['querystring']|e}} {{row['url']|e}} - {{row['body_raw'].decode(errors='replace')|e}} + {{row['body_raw'].decode(errors='replace')|e}} {{row['body_processed']|e}} {{row['contenttype']|e}} {{row['hostname']}}