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']}} |