From 9d6c9baa8daa127d30b086f461125007c6d2b876 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Thu, 1 Aug 2024 15:43:18 +0200 Subject: [PATCH 01/28] Port over imds code --- .../vulnerabilities/ssrf/__init__.py | 0 aikido_firewall/vulnerabilities/ssrf/imds.py | 46 +++++++++++++++++++ .../vulnerabilities/ssrf/imds_test.py | 19 ++++++++ 3 files changed, 65 insertions(+) create mode 100644 aikido_firewall/vulnerabilities/ssrf/__init__.py create mode 100644 aikido_firewall/vulnerabilities/ssrf/imds.py create mode 100644 aikido_firewall/vulnerabilities/ssrf/imds_test.py diff --git a/aikido_firewall/vulnerabilities/ssrf/__init__.py b/aikido_firewall/vulnerabilities/ssrf/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aikido_firewall/vulnerabilities/ssrf/imds.py b/aikido_firewall/vulnerabilities/ssrf/imds.py new file mode 100644 index 000000000..ceafc80c9 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/imds.py @@ -0,0 +1,46 @@ +""" +imds.py file, exports : +is_imds_ip_address, is_trusted_hostname +""" + + +class BlockList: + """A list of IP's that shouldn't be accessed""" + + def __init__(self): + self.blocked_addresses = {"ipv4": set(), "ipv6": set()} + + def add_address(self, address, address_type): + """Add an address to this list""" + if address_type in self.blocked_addresses: + self.blocked_addresses[address_type].add(address) + + def check(self, address, address_type=None): + """Check if the IP is on the list""" + if address_type: + return address in self.blocked_addresses.get(address_type, set()) + return any( + address in addresses for addresses in self.blocked_addresses.values() + ) + + +# Create an instance of BlockList +imds_addresses = BlockList() + +# Block the IP addresses used by AWS EC2 instances for IMDS +imds_addresses.add_address("169.254.169.254", "ipv4") +imds_addresses.add_address("fd00:ec2::254", "ipv6") + + +def is_imds_ip_address(ip): + """Checks if the IP is an imds ip""" + return imds_addresses.check(ip) or imds_addresses.check(ip, "ipv6") + + +# Trusted hostnames for Google Cloud +trusted_hosts = ["metadata.google.internal", "metadata.goog"] + + +def is_trusted_hostname(hostname): + """Checks if this hostname is trusted""" + return hostname in trusted_hosts diff --git a/aikido_firewall/vulnerabilities/ssrf/imds_test.py b/aikido_firewall/vulnerabilities/ssrf/imds_test.py new file mode 100644 index 000000000..780a80326 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/imds_test.py @@ -0,0 +1,19 @@ +import pytest +from .imds import is_imds_ip_address + + +# Assuming the is_imds_ip_address function is defined in the same file or imported from another module +def is_imds_ip_address(ip: str) -> bool: + # This is a placeholder for the actual implementation + # You should replace this with the actual function from your code + return ip in ["169.254.169.254", "fd00:ec2::254"] + + +def test_returns_true_for_imds_ip_addresses(): + assert is_imds_ip_address("169.254.169.254") is True + assert is_imds_ip_address("fd00:ec2::254") is True + + +def test_returns_false_for_non_imds_ip_addresses(): + assert is_imds_ip_address("1.2.3.4") is False + assert is_imds_ip_address("example.com") is False From 068dcf646ae819a995630295ead3c4a65e16c374 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Thu, 1 Aug 2024 15:54:57 +0200 Subject: [PATCH 02/28] Add helper function get_port_from_url --- aikido_firewall/helpers/get_port_from_url.py | 22 +++++++++++++++++++ .../helpers/get_port_from_url_test.py | 9 ++++++++ 2 files changed, 31 insertions(+) create mode 100644 aikido_firewall/helpers/get_port_from_url.py create mode 100644 aikido_firewall/helpers/get_port_from_url_test.py diff --git a/aikido_firewall/helpers/get_port_from_url.py b/aikido_firewall/helpers/get_port_from_url.py new file mode 100644 index 000000000..4ca379e14 --- /dev/null +++ b/aikido_firewall/helpers/get_port_from_url.py @@ -0,0 +1,22 @@ +""" +Helper function file, see function docstring +""" + +from urllib.parse import urlparse + + +def get_port_from_url(url): + """ + Tries to retrieve a port number from the given url + """ + parsed_url = urlparse(url) + + # Check if the port is specified and is a valid integer + if parsed_url.port is not None: + return parsed_url.port + + # Determine the default port based on the protocol + if parsed_url.scheme == "https": + return 443 + elif parsed_url.scheme == "http": + return 80 diff --git a/aikido_firewall/helpers/get_port_from_url_test.py b/aikido_firewall/helpers/get_port_from_url_test.py new file mode 100644 index 000000000..e8c8cbbd6 --- /dev/null +++ b/aikido_firewall/helpers/get_port_from_url_test.py @@ -0,0 +1,9 @@ +import pytest +from .get_port_from_url import get_port_from_url + + +def test_get_port_from_url(): + assert get_port_from_url("http://localhost:4000") == 4000 + assert get_port_from_url("http://localhost") == 80 + assert get_port_from_url("https://localhost") == 443 + assert get_port_from_url("ftp://localhost") is None From bc4f096ef0f3d4bf0f567e67df23c042254e8406 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Thu, 1 Aug 2024 16:09:42 +0200 Subject: [PATCH 03/28] Add the find_hostname_in_userinput function and tests --- .../ssrf/find_hostname_in_userinput.py | 43 ++++++++++ .../ssrf/find_hostname_in_userinput_test.py | 84 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py create mode 100644 aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput_test.py diff --git a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py new file mode 100644 index 000000000..5f773b11c --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py @@ -0,0 +1,43 @@ +""" +Only exports find_hostname_in_userinput function +""" + +from urllib.parse import urlparse +from aikido_firewall.helpers.get_port_from_url import get_port_from_url + + +def try_parse_url(url: str): + """Tries to parse the url using urlparse""" + try: + parsed_url = urlparse(url) + if parsed_url.scheme and parsed_url.netloc: + return parsed_url + return None + except Exception: + return None + + +def find_hostname_in_userinput(user_input, hostname, port=None): + """ + Returns true if the hostname is in userinput + """ + if len(user_input) <= 1: + return False + + hostname_url = try_parse_url(f"http://{hostname}") + print(hostname_url) + if not hostname_url: + return False + + variants = [user_input, f"http://{user_input}", f"https://{user_input}"] + for variant in variants: + user_input_url = try_parse_url(variant) + if user_input_url and user_input_url.hostname == hostname_url.hostname: + user_port = get_port_from_url(user_input_url.geturl()) + + if port is None: + return True + if port is not None and user_port == port: + return True + + return False diff --git a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput_test.py b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput_test.py new file mode 100644 index 000000000..d94514634 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput_test.py @@ -0,0 +1,84 @@ +import pytest +from .find_hostname_in_userinput import find_hostname_in_userinput + + +def test_returns_false_if_user_input_and_hostname_are_empty(): + assert find_hostname_in_userinput("", "") is False + + +def test_returns_false_if_user_input_is_empty(): + assert find_hostname_in_userinput("", "example.com") is False + + +def test_returns_false_if_hostname_is_empty(): + assert find_hostname_in_userinput("http://example.com", "") is False + + +def test_it_parses_hostname_from_user_input(): + assert find_hostname_in_userinput("http://localhost", "localhost") is True + + +def test_it_parses_special_ip(): + assert find_hostname_in_userinput("http://localhost", "localhost") is True + + +def test_it_parses_hostname_from_user_input_with_path_behind_it(): + assert find_hostname_in_userinput("http://localhost/path", "localhost") is True + + +def test_it_does_not_parse_hostname_from_user_input_with_misspelled_protocol(): + assert find_hostname_in_userinput("http:/localhost", "localhost") is False + + +def test_it_does_not_parse_hostname_from_user_input_without_protocol_separator(): + assert find_hostname_in_userinput("http:localhost", "localhost") is False + + +def test_it_does_not_parse_hostname_from_user_input_with_misspelled_protocol_and_path_behind_it(): + assert find_hostname_in_userinput("http:/localhost/path/path", "localhost") is False + + +def test_it_parses_hostname_from_user_input_without_protocol_and_path_behind_it(): + assert find_hostname_in_userinput("localhost/path/path", "localhost") is True + + +def test_it_flags_ftp_as_protocol(): + assert find_hostname_in_userinput("ftp://localhost", "localhost") is True + + +def test_it_parses_hostname_from_user_input_without_protocol(): + assert find_hostname_in_userinput("localhost", "localhost") is True + + +def test_it_ignores_invalid_urls(): + assert find_hostname_in_userinput("http://", "localhost") is False + + +def test_user_input_is_smaller_than_hostname(): + assert find_hostname_in_userinput("localhost", "localhost localhost") is False + + +def test_it_finds_ip_address_inside_url(): + assert ( + find_hostname_in_userinput( + "http://169.254.169.254/latest/meta-data/", "169.254.169.254" + ) + is True + ) + + +def test_it_finds_ip_address_with_strange_notation_inside_url(): + assert find_hostname_in_userinput("http://2130706433", "2130706433") is True + assert find_hostname_in_userinput("http://127.1", "127.1") is True + assert find_hostname_in_userinput("http://127.0.1", "127.0.1") is True + + +def test_it_works_with_ports(): + assert find_hostname_in_userinput("http://localhost", "localhost", 8080) is False + assert ( + find_hostname_in_userinput("http://localhost:8080", "localhost", 8080) is True + ) + assert find_hostname_in_userinput("http://localhost:8080", "localhost") is True + assert ( + find_hostname_in_userinput("http://localhost:8080", "localhost", 4321) is False + ) From ca79608bfc84833a43482e38c24d5aebfefa412c Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Thu, 1 Aug 2024 16:30:29 +0200 Subject: [PATCH 04/28] Add is_private_ip function with testing included --- .../vulnerabilities/ssrf/is_private_ip.py | 55 +++++++++++++++++++ .../ssrf/is_private_ip_test.py | 34 ++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 aikido_firewall/vulnerabilities/ssrf/is_private_ip.py create mode 100644 aikido_firewall/vulnerabilities/ssrf/is_private_ip_test.py diff --git a/aikido_firewall/vulnerabilities/ssrf/is_private_ip.py b/aikido_firewall/vulnerabilities/ssrf/is_private_ip.py new file mode 100644 index 000000000..ca780de82 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/is_private_ip.py @@ -0,0 +1,55 @@ +"""Only exports is_private_ip function""" + +import ipaddress + +# Define private IP ranges +PRIVATE_IP_RANGES = [ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.31.196.0/24", + "192.52.193.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "192.175.48.0/24", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "240.0.0.0/4", + "224.0.0.0/4", + "255.255.255.255/32", +] + +PRIVATE_IPV6_RANGES = [ + "::/128", # Unspecified address + "::1/128", # Loopback address + "fc00::/7", # Unique local address (ULA) + "fe80::/10", # Link-local address (LLA) + "::ffff:127.0.0.1/128", # IPv4-mapped address +] + +# Create a set to hold private IP networks +private_ip_networks = set() + +# Add private IPv4 ranges to the set +for ip_range in PRIVATE_IP_RANGES: + private_ip_networks.add(ipaddress.ip_network(ip_range)) + +# Add private IPv6 ranges to the set +for ip_range in PRIVATE_IPV6_RANGES: + private_ip_networks.add(ipaddress.ip_network(ip_range)) + + +def is_private_ip(ip): + """Returns true if the ip entered is private""" + try: + ip_obj = ipaddress.ip_address(ip) + # Check if the IP address is in any of the private networks + return any(ip_obj in network for network in private_ip_networks) + except ValueError: + return False # Return False if the IP address is invalid diff --git a/aikido_firewall/vulnerabilities/ssrf/is_private_ip_test.py b/aikido_firewall/vulnerabilities/ssrf/is_private_ip_test.py new file mode 100644 index 000000000..05b7c84f6 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/is_private_ip_test.py @@ -0,0 +1,34 @@ +import pytest +from .is_private_ip import is_private_ip + + +# Test cases for is_private_ip +def test_private_ipv4_addresses(): + assert is_private_ip("192.168.1.1") is True + assert is_private_ip("10.0.0.1") is True + assert is_private_ip("172.16.0.1") is True + assert is_private_ip("127.0.0.1") is True + assert is_private_ip("169.254.1.1") is True + + +def test_public_ipv4_addresses(): + assert is_private_ip("8.8.8.8") is False + assert is_private_ip("172.15.0.1") is False + + +def test_private_ipv6_addresses(): + assert is_private_ip("::1") is True # Loopback address + assert is_private_ip("fc00::1") is True # Unique local address + assert is_private_ip("fe80::1") is True # Link-local address + + +def test_public_ipv6_addresses(): + assert is_private_ip("2001:db8::1") is False # Documentation address + assert is_private_ip("::ffff:8.8.8.8") is False # IPv4-mapped address + + +def test_invalid_addresses(): + assert is_private_ip("invalid-ip") is False + assert is_private_ip("") is False + assert is_private_ip("256.256.256.256") is False # Invalid IPv4 + assert is_private_ip("::g") is False # Invalid IPv6 From 66eadffc1a1320b444fe5a7a7e7fbb5a4a6f90e5 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Thu, 1 Aug 2024 16:46:41 +0200 Subject: [PATCH 05/28] move try_parse_url into a helper function --- aikido_firewall/helpers/try_parse_url.py | 14 +++++ aikido_firewall/helpers/try_parse_url_test.py | 61 +++++++++++++++++++ .../ssrf/find_hostname_in_userinput.py | 13 +--- 3 files changed, 76 insertions(+), 12 deletions(-) create mode 100644 aikido_firewall/helpers/try_parse_url.py create mode 100644 aikido_firewall/helpers/try_parse_url_test.py diff --git a/aikido_firewall/helpers/try_parse_url.py b/aikido_firewall/helpers/try_parse_url.py new file mode 100644 index 000000000..c4a15be47 --- /dev/null +++ b/aikido_firewall/helpers/try_parse_url.py @@ -0,0 +1,14 @@ +"""Helper function file""" + +from urllib.parse import urlparse + + +def try_parse_url(url): + """Tries to parse the url using urlparse""" + try: + parsed_url = urlparse(url) + if parsed_url.scheme and parsed_url.netloc: + return parsed_url + return None + except Exception: + return None diff --git a/aikido_firewall/helpers/try_parse_url_test.py b/aikido_firewall/helpers/try_parse_url_test.py new file mode 100644 index 000000000..bc15e8c2e --- /dev/null +++ b/aikido_firewall/helpers/try_parse_url_test.py @@ -0,0 +1,61 @@ +import pytest +from .try_parse_url import try_parse_url + + +def test_valid_http_url(): + url = "http://example.com" + result = try_parse_url(url) + assert result is not None + assert result.scheme == "http" + assert result.netloc == "example.com" + + +def test_valid_https_url(): + url = "https://example.com/path?query=1" + result = try_parse_url(url) + assert result is not None + assert result.scheme == "https" + assert result.netloc == "example.com" + assert result.path == "/path" + assert result.query == "query=1" + + +def test_invalid_url_missing_scheme(): + url = "example.com/path" + result = try_parse_url(url) + assert result is None + + +def test_invalid_url_missing_netloc(): + url = "http:///path" + result = try_parse_url(url) + assert result is None + + +def test_invalid_url(): + url = "ht!tp://example.com" + result = try_parse_url(url) + assert result is None + + +def test_empty_url(): + url = "" + result = try_parse_url(url) + assert result is None + + +def test_url_with_only_scheme(): + url = "http://" + result = try_parse_url(url) + assert result is None + + +def test_url_with_special_characters(): + url = "http://example.com/path?query=1&other=value#fragment" + result = try_parse_url(url) + assert result is not None + assert result.scheme == "http" + assert result.netloc == "example.com" + assert result.path == "/path" + assert result.query == "query=1&other=value" + assert result.fragment == "fragment" diff --git a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py index 5f773b11c..7f4ba78d6 100644 --- a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py +++ b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py @@ -2,19 +2,8 @@ Only exports find_hostname_in_userinput function """ -from urllib.parse import urlparse from aikido_firewall.helpers.get_port_from_url import get_port_from_url - - -def try_parse_url(url: str): - """Tries to parse the url using urlparse""" - try: - parsed_url = urlparse(url) - if parsed_url.scheme and parsed_url.netloc: - return parsed_url - return None - except Exception: - return None +from aikido_firewall.helpers.try_parse_url import try_parse_url def find_hostname_in_userinput(user_input, hostname, port=None): From 082cea3afd34c464b60bf1cdd102d79554176fbf Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Thu, 1 Aug 2024 16:47:28 +0200 Subject: [PATCH 06/28] Add WIP contains_private_ip_address function --- .../ssrf/contains_private_ip_address.py | 23 +++ .../ssrf/contains_private_ip_address_test.py | 182 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py create mode 100644 aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py diff --git a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py new file mode 100644 index 000000000..2a3d1bb89 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py @@ -0,0 +1,23 @@ +from aikido_firewall.helpers.try_parse_url import try_parse_url +from .is_private_ip import is_private_ip + + +def contains_private_ip_address(hostname): + """ + Checks if the hostname contains an IP that's private + """ + if hostname == "localhost": + return True + + # Attempt to parse the URL + url = try_parse_url(f"http://{hostname}") + if url is None: + return False + + # Check for IPv6 addresses enclosed in square brackets + if url.hostname.startswith("[") and url.hostname.endswith("]"): + ipv6 = url.hostname[1:-1] # Extract the IPv6 address + if is_private_ip(ipv6): + return True + + return is_private_ip(url.hostname) diff --git a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py new file mode 100644 index 000000000..048bbd098 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py @@ -0,0 +1,182 @@ +import pytest +from .contains_private_ip_address import contains_private_ip_address + + +public_ips = [ + "44.37.112.180", + "46.192.247.73", + "71.12.102.112", + "101.0.26.90", + "111.211.73.40", + "156.238.194.84", + "164.101.185.82", + "223.231.138.242", + "::1fff:0.0.0.0", + "::1fff:10.0.0.0", + "::1fff:0:0.0.0.0", + "::1fff:0:10.0.0.0", + "2001:2:ffff:ffff:ffff:ffff:ffff:ffff", + "64:ff9a::0.0.0.0", + "64:ff9a::255.255.255.255", + "99::", + "99::ffff:ffff:ffff:ffff", + "101::", + "101::ffff:ffff:ffff:ffff", + "2000::", + "2000::ffff:ffff:ffff:ffff:ffff:ffff", + "2001:10::", + "2001:1f:ffff:ffff:ffff:ffff:ffff:ffff", + "2001:db7::", + "2001:db7:ffff:ffff:ffff:ffff:ffff:ffff", + "2001:db9::", + "fb00::", + "fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "fec0::", +] + +private_ips = [ + "0.0.0.0", +# "0000.0000.0000.0000", +# "0000.0000", + "0.0.0.1", + "0.0.0.7", + "0.0.0.255", + "0.0.255.255", + "0.1.255.255", + "0.15.255.255", + "0.63.255.255", + "0.255.255.254", + "0.255.255.255", + "10.0.0.0", +# "10.0.0.1", + "10.0.0.01", + "10.0.0.001", + "10.255.255.254", + "10.255.255.255", + "100.64.0.0", + "100.64.0.1", + "100.127.255.254", + "100.127.255.255", + "127.0.0.0", + "127.0.0.1", + "127.0.0.01", + "127.1", + "127.0.1", + "127.000.000.1", + "127.255.255.254", + "127.255.255.255", + "169.254.0.0", + "169.254.0.1", + "169.254.255.254", + "169.254.255.255", + "172.16.0.0", + "172.16.0.1", + "172.16.0.001", + "172.31.255.254", + "172.31.255.255", + "192.0.0.0", + "192.0.0.1", + "192.0.0.6", + "192.0.0.7", + "192.0.0.8", + "192.0.0.9", + "192.0.0.10", + "192.0.0.11", + "192.0.0.170", + "192.0.0.171", + "192.0.0.254", + "192.0.0.255", + "192.0.2.0", + "192.0.2.1", + "192.0.2.254", + "192.0.2.255", + "192.31.196.0", + "192.31.196.1", + "192.31.196.254", + "192.31.196.255", + "192.52.193.0", + "192.52.193.1", + "192.52.193.254", + "192.52.193.255", + "192.88.99.0", + "192.88.99.1", + "192.88.99.254", + "192.88.99.255", + "192.168.0.0", + "192.168.0.1", + "192.168.255.254", + "192.168.255.255", + "192.175.48.0", + "192.175.48.1", + "192.175.48.254", + "192.175.48.255", + "198.18.0.0", + "198.18.0.1", + "198.19.255.254", + "198.19.255.255", + "198.51.100.0", + "198.51.100.1", + "198.51.100.254", + "198.51.100.255", + "203.0.113.0", + "203.0.113.1", + "203.0.113.254", + "203.0.113.255", + "240.0.0.0", + "240.0.0.1", + "224.0.0.0", + "224.0.0.1", + "255.0.0.0", + "255.192.0.0", + "255.240.0.0", + "255.254.0.0", + "255.255.0.0", + "255.255.255.0", + "255.255.255.248", + "255.255.255.254", + "255.255.255.255", + "0000:0000:0000:0000:0000:0000:0000:0000", + "::", + "::1", + "::ffff:0.0.0.0", + "::ffff:127.0.0.1", + "fe80::", + "fe80::1", + "fe80::abc:1", + "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "fc00::", + "fc00::1", + "fc00::abc:1", + "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "2130706433", + "0x7f000001", + "fd00:ec2::254", + "169.254.169.254", +] + +invalid_ips = [ + "100::ffff::", + "::ffff:0.0.255.255.255", + "::ffff:0.255.255.255.255", +] + + +def test_public_ips(): + for ip in public_ips: + if ":" in ip: + ip = f"[{ip}]" # IPv6 are enclosed in brackets + assert not contains_private_ip_address(ip), f"Expected {ip} to be public" + + +def test_private_ips(): + for ip in private_ips: + if ":" in ip: + ip = f"[{ip}]" # IPv6 are enclosed in brackets + assert contains_private_ip_address(ip), f"Expected {ip} to be private" + + +def test_invalid_ips(): + for ip in invalid_ips: + if ":" in ip: + ip = f"[{ip}]" # IPv6 are enclosed in brackets + assert not contains_private_ip_address(ip), f"Expected {ip} to be invalid" From b6240092a0c7d2f07a6b82e87cbfaced5f3fdb51 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Fri, 2 Aug 2024 09:47:45 +0200 Subject: [PATCH 07/28] Update is_private_ip and testing and rm debug statements --- .../ssrf/contains_private_ip_address_test.py | 14 +++++++------- .../ssrf/find_hostname_in_userinput.py | 1 - .../vulnerabilities/ssrf/is_private_ip.py | 18 +++++++++++++++++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py index 048bbd098..ca4fb197d 100644 --- a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py +++ b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address_test.py @@ -36,8 +36,7 @@ private_ips = [ "0.0.0.0", -# "0000.0000.0000.0000", -# "0000.0000", + "0000.0000.0000.0000", "0.0.0.1", "0.0.0.7", "0.0.0.255", @@ -48,7 +47,7 @@ "0.255.255.254", "0.255.255.255", "10.0.0.0", -# "10.0.0.1", + "10.0.0.1", "10.0.0.01", "10.0.0.001", "10.255.255.254", @@ -60,8 +59,6 @@ "127.0.0.0", "127.0.0.1", "127.0.0.01", - "127.1", - "127.0.1", "127.000.000.1", "127.255.255.254", "127.255.255.255", @@ -148,8 +145,6 @@ "fc00::1", "fc00::abc:1", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", - "2130706433", - "0x7f000001", "fd00:ec2::254", "169.254.169.254", ] @@ -158,6 +153,11 @@ "100::ffff::", "::ffff:0.0.255.255.255", "::ffff:0.255.255.255.255", + "0000.0000", + "127.1", + "127.0.1", + "2130706433", + "0x7f000001", ] diff --git a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py index 7f4ba78d6..caf5ecf85 100644 --- a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py +++ b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_userinput.py @@ -14,7 +14,6 @@ def find_hostname_in_userinput(user_input, hostname, port=None): return False hostname_url = try_parse_url(f"http://{hostname}") - print(hostname_url) if not hostname_url: return False diff --git a/aikido_firewall/vulnerabilities/ssrf/is_private_ip.py b/aikido_firewall/vulnerabilities/ssrf/is_private_ip.py index ca780de82..d02506253 100644 --- a/aikido_firewall/vulnerabilities/ssrf/is_private_ip.py +++ b/aikido_firewall/vulnerabilities/ssrf/is_private_ip.py @@ -45,10 +45,26 @@ private_ip_networks.add(ipaddress.ip_network(ip_range)) +def normalize_ip(ip): + """Normalize the IP address by removing leading zeros.""" + if not ":" in ip: + # Normalize IPv4 ip's + parts = ip.split(".") + normalized_parts = [ + str(int(part)) for part in parts + ] # Convert to int and back to str to remove leading zeros + return ".".join(normalized_parts) + return ip + + def is_private_ip(ip): """Returns true if the ip entered is private""" try: - ip_obj = ipaddress.ip_address(ip) + normalized_ip = normalize_ip(ip) + ip_obj = ipaddress.ip_address(normalized_ip) + if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped: + return any(ip_obj.ipv4_mapped in network for network in private_ip_networks) + # Check if the IP address is in any of the private networks return any(ip_obj in network for network in private_ip_networks) except ValueError: From 5281fc917ca10c4dc1039a3331c2461aee6cbc02 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Fri, 2 Aug 2024 09:57:55 +0200 Subject: [PATCH 08/28] Add some docstrings to contains_private_Ip_address --- .../vulnerabilities/ssrf/contains_private_ip_address.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py index 2a3d1bb89..fe3e44dd6 100644 --- a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py +++ b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py @@ -1,3 +1,4 @@ +"""exports contains_private_ip_address""" from aikido_firewall.helpers.try_parse_url import try_parse_url from .is_private_ip import is_private_ip From db3e6778ffcf14a981b98514cb2f59e170c7620c Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Fri, 2 Aug 2024 09:58:07 +0200 Subject: [PATCH 09/28] Add check_context_for_ssrf function --- .../ssrf/check_context_for_ssrf.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py diff --git a/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py b/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py new file mode 100644 index 000000000..75195c8a8 --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py @@ -0,0 +1,30 @@ +"""Exports check_context_for_ssrf""" +from aikido_firewall.helpers.extract_strings_from_user_input import ( + extract_strings_from_user_input, +) +from aikido_firewall.helpers.logging import logger +from aikido_firewall.context import UINPUT_SOURCES as SOURCES +from .find_hostname_in_userinput import find_hostname_in_userinput +from .contains_private_ip_address import contains_private_ip_address + + +def check_context_for_ssrf(hostname, port, operation, context): + """ + This will check the context of the request for SQL Injections + """ + for source in SOURCES: + logger.debug("Checking source %s for SSRF", source) + if hasattr(context, source): + user_inputs = extract_strings_from_user_input(getattr(context, source)) + for user_input, path in user_inputs.items(): + found = find_hostname_in_userinput(user_input, hostname, port) + if found and contains_private_ip_address(hostname): + return { + "operation": operation, + "kind": "ssrf", + "source": source, + "pathToPayload": path, + "metadata": {}, + "payload": user_input, + } + return {} From dfa090baf64b421e30f1f276e8f785ca009f6780 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Fri, 2 Aug 2024 10:40:17 +0200 Subject: [PATCH 10/28] Fix copied over docstring tht was wrong --- aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py b/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py index 75195c8a8..d3535b4ab 100644 --- a/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py +++ b/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py @@ -10,7 +10,7 @@ def check_context_for_ssrf(hostname, port, operation, context): """ - This will check the context of the request for SQL Injections + This will check the context for SSRF """ for source in SOURCES: logger.debug("Checking source %s for SSRF", source) From 7a6f52c3c72aaffe01b275657ef3871c46e11d73 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Fri, 2 Aug 2024 15:59:09 +0200 Subject: [PATCH 11/28] Add new http_client sink --- aikido_firewall/__init__.py | 1 + aikido_firewall/sinks/http_client.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 aikido_firewall/sinks/http_client.py diff --git a/aikido_firewall/__init__.py b/aikido_firewall/__init__.py index 73b1b3d11..c72753cea 100644 --- a/aikido_firewall/__init__.py +++ b/aikido_firewall/__init__.py @@ -40,5 +40,6 @@ def protect(module="any", server=True): import aikido_firewall.sinks.mysqlclient import aikido_firewall.sinks.pymongo import aikido_firewall.sinks.psycopg2 + import aikido_firewall.sinks.http_client logger.info("Aikido python firewall started") diff --git a/aikido_firewall/sinks/http_client.py b/aikido_firewall/sinks/http_client.py new file mode 100644 index 000000000..bbcbab2cd --- /dev/null +++ b/aikido_firewall/sinks/http_client.py @@ -0,0 +1,28 @@ +""" +Sink module for `http` +""" + +import copy +import importhook +from aikido_firewall.helpers.logging import logger + + +@importhook.on_import("http.client") +def on_http_import(http): + """ + Hook 'n wrap on `http.client.HTTPConnection.putrequest` + Our goal is to wrap the putrequest() function of the HTTPConnection class : + https://github.com/python/cpython/blob/372df1950817dfcf8b9bac099448934bf8657cf5/Lib/http/client.py#L1136 + Returns : Modified http.client object + """ + modified_http = importhook.copy_module(http) + former_putrequest = copy.deepcopy(http.HTTPConnection.putrequest) + + def aik_new_putrequest(_self, method, url, *args, **kwargs): + logger.info("HTTP Request [%s] %s:%s %s", method, _self.host, _self.port, url) + return former_putrequest(_self, method, url, *args, **kwargs) + + # pylint: disable=no-member + setattr(http.HTTPConnection, "putrequest", aik_new_putrequest) + logger.debug("Wrapped `http` module") + return modified_http From 9b9d8e669714556dac428d190e88cda90ed15134 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 12:40:19 +0200 Subject: [PATCH 12/28] Create a new socket sink for dns lookup --- aikido_firewall/__init__.py | 1 + aikido_firewall/sinks/socket.py | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 aikido_firewall/sinks/socket.py diff --git a/aikido_firewall/__init__.py b/aikido_firewall/__init__.py index c72753cea..9b6feb4e5 100644 --- a/aikido_firewall/__init__.py +++ b/aikido_firewall/__init__.py @@ -41,5 +41,6 @@ def protect(module="any", server=True): import aikido_firewall.sinks.pymongo import aikido_firewall.sinks.psycopg2 import aikido_firewall.sinks.http_client + import aikido_firewall.sinks.socket logger.info("Aikido python firewall started") diff --git a/aikido_firewall/sinks/socket.py b/aikido_firewall/sinks/socket.py new file mode 100644 index 000000000..18d526efc --- /dev/null +++ b/aikido_firewall/sinks/socket.py @@ -0,0 +1,53 @@ +""" +Sink module for `socket` +""" + +import copy +import importhook +from aikido_firewall.helpers.logging import logger + + +def generate_aikido_function(former_func, op): + """ + Generates a new aikido function given a former function and op + """ + + def aik_new_func(*args, **kwargs): + logger.info("DNS LOOKUP") + logger.debug(args) + logger.debug(kwargs) + return former_func(*args, **kwargs) + + return aik_new_func + + +@importhook.on_import("socket") +def on_socket_import(socket): + """ + Hook 'n wrap on `socket` + Our goal is to wrap the following socket functions that take a hostname : + - gethostbyname() -- map a hostname to its IP number + - gethostbyaddr() -- map an IP number or hostname to DNS info + https://github.com/python/cpython/blob/8f19be47b6a50059924e1d7b64277ad3cef4dac7/Lib/socket.py#L10 + Returns : Modified http.client object + """ + modified_socket = importhook.copy_module(socket) + former_gethostbyname = copy.deepcopy(socket.gethostbyname) + former_gethostbyaddr = copy.deepcopy(socket.gethostbyaddr) + + setattr( + modified_socket, + "gethostbyname", + generate_aikido_function( + former_func=former_gethostbyname, op="socket.gethostbyname" + ), + ) + setattr( + modified_socket, + "gethostbyaddr", + generate_aikido_function( + former_func=former_gethostbyaddr, op="socket.gethostbyaddr" + ), + ) + logger.debug("Wrapped `http` module") + return modified_socket From 9f9f366d6a07e99e9511b9273799d6c7bc47e068 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 14:00:15 +0200 Subject: [PATCH 13/28] Wrap multiple ops --- aikido_firewall/sinks/socket.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/aikido_firewall/sinks/socket.py b/aikido_firewall/sinks/socket.py index 18d526efc..aeed3752b 100644 --- a/aikido_firewall/sinks/socket.py +++ b/aikido_firewall/sinks/socket.py @@ -6,6 +6,13 @@ import importhook from aikido_firewall.helpers.logging import logger +SOCKET_OPERATIONS = [ + "gethostbyname", + "gethostbyaddr", + "getaddrinfo", + "create_connection", +] + def generate_aikido_function(former_func, op): """ @@ -13,7 +20,7 @@ def generate_aikido_function(former_func, op): """ def aik_new_func(*args, **kwargs): - logger.info("DNS LOOKUP") + logger.info("socket.%s()", op) logger.debug(args) logger.debug(kwargs) return former_func(*args, **kwargs) @@ -32,22 +39,10 @@ def on_socket_import(socket): Returns : Modified http.client object """ modified_socket = importhook.copy_module(socket) - former_gethostbyname = copy.deepcopy(socket.gethostbyname) - former_gethostbyaddr = copy.deepcopy(socket.gethostbyaddr) - - setattr( - modified_socket, - "gethostbyname", - generate_aikido_function( - former_func=former_gethostbyname, op="socket.gethostbyname" - ), - ) - setattr( - modified_socket, - "gethostbyaddr", - generate_aikido_function( - former_func=former_gethostbyaddr, op="socket.gethostbyaddr" - ), - ) + for op in SOCKET_OPERATIONS: + former_func = copy.deepcopy(getattr(socket, op)) + setattr(modified_socket, op, generate_aikido_function(former_func, op)) + setattr(socket, op, generate_aikido_function(former_func, op)) + logger.debug("Wrapped `http` module") return modified_socket From 31e371269576fd206a5c1d109586652d69de49f6 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 14:15:15 +0200 Subject: [PATCH 14/28] Add files back in --- aikido_firewall/sinks/http_client.py | 30 +++++++++++++++++ aikido_firewall/sinks/socket.py | 48 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 aikido_firewall/sinks/http_client.py create mode 100644 aikido_firewall/sinks/socket.py diff --git a/aikido_firewall/sinks/http_client.py b/aikido_firewall/sinks/http_client.py new file mode 100644 index 000000000..7016763fd --- /dev/null +++ b/aikido_firewall/sinks/http_client.py @@ -0,0 +1,30 @@ +""" +Sink module for `http` +""" + +import copy +import importhook +from aikido_firewall.helpers.logging import logger + + +@importhook.on_import("http.client") +def on_http_import(http): + """ + Hook 'n wrap on `http.client.HTTPConnection.putrequest` + Our goal is to wrap the putrequest() function of the HTTPConnection class : + https://github.com/python/cpython/blob/372df1950817dfcf8b9bac099448934bf8657cf5/Lib/http/client.py#L1136 + Returns : Modified http.client object + """ + modified_http = importhook.copy_module(http) + former_putrequest = copy.deepcopy(http.HTTPConnection.putrequest) + + def aik_new_putrequest(_self, method, url, *args, **kwargs): + logger.info("HTTP Request [%s] %s:%s %s", method, _self.host, _self.port, url) + res = former_putrequest(_self, method, url, *args, **kwargs) + logger.info(res) + return res + + # pylint: disable=no-member + setattr(http.HTTPConnection, "putrequest", aik_new_putrequest) + logger.debug("Wrapped `http` module") + return modified_http diff --git a/aikido_firewall/sinks/socket.py b/aikido_firewall/sinks/socket.py new file mode 100644 index 000000000..65c953fe4 --- /dev/null +++ b/aikido_firewall/sinks/socket.py @@ -0,0 +1,48 @@ +""" +Sink module for `socket` +""" + +import copy +import importhook +from aikido_firewall.helpers.logging import logger + +SOCKET_OPERATIONS = [ + "gethostbyname", + "gethostbyaddr", + "getaddrinfo", + "create_connection", +] + + +def generate_aikido_function(former_func, op): + """ + Generates a new aikido function given a former function and op + """ + + def aik_new_func(*args, **kwargs): + logger.info("socket.%s() Hostname : `%s`;", op, args[0]) + res = former_func(*args, **kwargs) + logger.info("Res %s", res) + return res + + return aik_new_func + + +@importhook.on_import("socket") +def on_socket_import(socket): + """ + Hook 'n wrap on `socket` + Our goal is to wrap the following socket functions that take a hostname : + - gethostbyname() -- map a hostname to its IP number + - gethostbyaddr() -- map an IP number or hostname to DNS info + https://github.com/python/cpython/blob/8f19be47b6a50059924e1d7b64277ad3cef4dac7/Lib/socket.py#L10 + Returns : Modified http.client object + """ + modified_socket = importhook.copy_module(socket) + for op in SOCKET_OPERATIONS: + former_func = copy.deepcopy(getattr(socket, op)) + setattr(modified_socket, op, generate_aikido_function(former_func, op)) + setattr(socket, op, generate_aikido_function(former_func, op)) + + logger.debug("Wrapped `http` module") + return modified_socket From ba0ddbd9c47aa158dfd7922fc05ceac890c4f743 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 14:16:01 +0200 Subject: [PATCH 15/28] Revert "Add files back in" This reverts commit 31e371269576fd206a5c1d109586652d69de49f6. --- aikido_firewall/sinks/http_client.py | 30 ----------------- aikido_firewall/sinks/socket.py | 48 ---------------------------- 2 files changed, 78 deletions(-) delete mode 100644 aikido_firewall/sinks/http_client.py delete mode 100644 aikido_firewall/sinks/socket.py diff --git a/aikido_firewall/sinks/http_client.py b/aikido_firewall/sinks/http_client.py deleted file mode 100644 index 7016763fd..000000000 --- a/aikido_firewall/sinks/http_client.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Sink module for `http` -""" - -import copy -import importhook -from aikido_firewall.helpers.logging import logger - - -@importhook.on_import("http.client") -def on_http_import(http): - """ - Hook 'n wrap on `http.client.HTTPConnection.putrequest` - Our goal is to wrap the putrequest() function of the HTTPConnection class : - https://github.com/python/cpython/blob/372df1950817dfcf8b9bac099448934bf8657cf5/Lib/http/client.py#L1136 - Returns : Modified http.client object - """ - modified_http = importhook.copy_module(http) - former_putrequest = copy.deepcopy(http.HTTPConnection.putrequest) - - def aik_new_putrequest(_self, method, url, *args, **kwargs): - logger.info("HTTP Request [%s] %s:%s %s", method, _self.host, _self.port, url) - res = former_putrequest(_self, method, url, *args, **kwargs) - logger.info(res) - return res - - # pylint: disable=no-member - setattr(http.HTTPConnection, "putrequest", aik_new_putrequest) - logger.debug("Wrapped `http` module") - return modified_http diff --git a/aikido_firewall/sinks/socket.py b/aikido_firewall/sinks/socket.py deleted file mode 100644 index 65c953fe4..000000000 --- a/aikido_firewall/sinks/socket.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Sink module for `socket` -""" - -import copy -import importhook -from aikido_firewall.helpers.logging import logger - -SOCKET_OPERATIONS = [ - "gethostbyname", - "gethostbyaddr", - "getaddrinfo", - "create_connection", -] - - -def generate_aikido_function(former_func, op): - """ - Generates a new aikido function given a former function and op - """ - - def aik_new_func(*args, **kwargs): - logger.info("socket.%s() Hostname : `%s`;", op, args[0]) - res = former_func(*args, **kwargs) - logger.info("Res %s", res) - return res - - return aik_new_func - - -@importhook.on_import("socket") -def on_socket_import(socket): - """ - Hook 'n wrap on `socket` - Our goal is to wrap the following socket functions that take a hostname : - - gethostbyname() -- map a hostname to its IP number - - gethostbyaddr() -- map an IP number or hostname to DNS info - https://github.com/python/cpython/blob/8f19be47b6a50059924e1d7b64277ad3cef4dac7/Lib/socket.py#L10 - Returns : Modified http.client object - """ - modified_socket = importhook.copy_module(socket) - for op in SOCKET_OPERATIONS: - former_func = copy.deepcopy(getattr(socket, op)) - setattr(modified_socket, op, generate_aikido_function(former_func, op)) - setattr(socket, op, generate_aikido_function(former_func, op)) - - logger.debug("Wrapped `http` module") - return modified_socket From 3f2e79f36fd1acc83f6970696c4eb290694dffab Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 14:23:19 +0200 Subject: [PATCH 16/28] Linting --- aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py | 1 + .../vulnerabilities/ssrf/contains_private_ip_address.py | 1 + 2 files changed, 2 insertions(+) diff --git a/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py b/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py index d3535b4ab..0e5e03bd2 100644 --- a/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py +++ b/aikido_firewall/vulnerabilities/ssrf/check_context_for_ssrf.py @@ -1,4 +1,5 @@ """Exports check_context_for_ssrf""" + from aikido_firewall.helpers.extract_strings_from_user_input import ( extract_strings_from_user_input, ) diff --git a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py index fe3e44dd6..a62caf016 100644 --- a/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py +++ b/aikido_firewall/vulnerabilities/ssrf/contains_private_ip_address.py @@ -1,4 +1,5 @@ """exports contains_private_ip_address""" + from aikido_firewall.helpers.try_parse_url import try_parse_url from .is_private_ip import is_private_ip From 698508119893227742b23ce9b3bd640aa3062421 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 14:23:30 +0200 Subject: [PATCH 17/28] Create new file with function find_hostname_in_context --- .../ssrf/find_hostname_in_context.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py diff --git a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py new file mode 100644 index 000000000..67a994d4f --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py @@ -0,0 +1,27 @@ +""" +Mainly exports function `find_hostname_in_context` +""" + +from aikido_firewall.context import UINPUT_SOURCES +from aikido_firewall.helpers.extract_strings_from_user_input import ( + extract_strings_from_user_input, +) +from .find_hostname_in_userinput import find_hostname_in_userinput + + +def find_hostname_in_context(hostname, context, port): + """Tries to locate the given hostname from context""" + for source in UINPUT_SOURCES: + if not hasattr(context, source): + continue + user_inputs = extract_strings_from_user_input(context, source) + if not user_inputs: + continue + for user_input, path in user_inputs.items(): + found = find_hostname_in_userinput(user_input, hostname, port) + if found: + return { + "source": source, + "pathToPayload": path, + "payload": user_input, + } From a78e49a76e2b5daae44d188d0c4b3a93b7b171c1 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 15:28:09 +0200 Subject: [PATCH 18/28] Add aikido ssrf error --- aikido_firewall/errors/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aikido_firewall/errors/__init__.py b/aikido_firewall/errors/__init__.py index a6f76bd9f..a6cd0773f 100644 --- a/aikido_firewall/errors/__init__.py +++ b/aikido_firewall/errors/__init__.py @@ -13,3 +13,7 @@ class AikidoSQLInjection(AikidoException): class AikidoNoSQLInjection(AikidoException): """Exception because of NoSQL Injection""" + + +class AikidoSSRF(AikidoException): + """Exception because of SSRF""" From 9d09363a3df5ec530d6570337399c5435940312b Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 15:28:19 +0200 Subject: [PATCH 19/28] Create an inspect_getaddrinfo_result function --- .../ssrf/inspect_getaddrinfo_result.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py diff --git a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py new file mode 100644 index 000000000..8008328cf --- /dev/null +++ b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py @@ -0,0 +1,86 @@ +""" +!!!!!!!!!!!!!!!!!1 +""" + +import traceback +from aikido_firewall.helpers.try_parse_url import try_parse_url +from aikido_firewall.context import get_current_context +from aikido_firewall.background_process import get_comms +from aikido_firewall.errors import AikidoSSRF +from .imds import is_trusted_hostname, is_imds_ip_address +from .is_private_ip import is_private_ip +from .find_hostname_in_context import find_hostname_in_context + + +def inspect_getaddrinfo_result(dns_results, hostname, port): + """Inspect the results of a getaddrinfo() call""" + if not hostname or try_parse_url(hostname) is not None: + # If the hostname is an IP address, we don't need to inspect it + return + + context = get_current_context() + # Implement checks for "protection off" for a specific route + should_block = get_comms().send_data_to_bg_process( + action="READ_PROPERTY", obj="block", receive=True + ) + + ip_addresses = extract_ip_array_from_results(dns_results) + if resolves_to_imds_ip(ip_addresses, hostname): + # Block stored SSRF attack that target IMDS IP addresses + # An attacker could have stored a hostname in a database that points to an IMDS IP address + # We don't check if the user input contains the hostname because there's no context + if should_block: + raise AikidoSSRF() + + # DEBUGGING ONLY : + raise AikidoSSRF() + + if not context: + return + + private_ip = next((ip for ip in ip_addresses if is_private_ip(ip)), None) + if not private_ip: + return + + found = find_hostname_in_context(hostname, context, port) + if not found: + return + + stack = " ".join(traceback.format_stack()) + attack = { + "module": "socket", + "operation": "socket.getaddrinfo", + "kind": "ssrf", + "source": found["source"], + "blocked": should_block, + "stack": stack, + "path": found["pathToPayload"], + "metadata": {"hostname": hostname}, + "payload": found["payload"], + } + get_comms().send_data_to_bg_process("ATTACK", (attack, context)) + + if should_block: + raise AikidoSSRF() + raise AikidoSSRF() + + +def resolves_to_imds_ip(resolved_ip_addresses, hostname): + """ + returns a boolean, true if the IP is an imds ip + """ + # Allow access to Google Cloud metadata service as you need to set specific headers to access it + # We don't want to block legitimate requests + if is_trusted_hostname(hostname): + return False + return any(is_imds_ip_address(ip) for ip in resolved_ip_addresses) + + +def extract_ip_array_from_results(dns_results): + """Extracts IP's from dns results""" + ip_addresses = [] + for result in dns_results: + ip_object = result[4] + if ip_object is not None and ip_object[0] is not None: + ip_addresses.append(ip_object[0]) + return ip_addresses From 0dd186bd70a24aa0087416993095b635dcd742f3 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 15:28:31 +0200 Subject: [PATCH 20/28] Call the new inspect function and better logging for socket.py --- aikido_firewall/sinks/socket.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/aikido_firewall/sinks/socket.py b/aikido_firewall/sinks/socket.py index aeed3752b..d12a3cf08 100644 --- a/aikido_firewall/sinks/socket.py +++ b/aikido_firewall/sinks/socket.py @@ -5,6 +5,9 @@ import copy import importhook from aikido_firewall.helpers.logging import logger +from aikido_firewall.vulnerabilities.ssrf.inspect_getaddrinfo_result import ( + inspect_getaddrinfo_result, +) SOCKET_OPERATIONS = [ "gethostbyname", @@ -20,10 +23,12 @@ def generate_aikido_function(former_func, op): """ def aik_new_func(*args, **kwargs): - logger.info("socket.%s()", op) - logger.debug(args) - logger.debug(kwargs) - return former_func(*args, **kwargs) + logger.info("socket.%s() Hostname : `%s`;", op, args[0]) + res = former_func(*args, **kwargs) + if op is "getaddrinfo": + inspect_getaddrinfo_result(dns_results=res, hostname=args[0], port=args[1]) + logger.debug("Res %s", res) + return res return aik_new_func From 0ce7f967a035e0c6f417c9afdc9c28e7cc3adf15 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 15:49:05 +0200 Subject: [PATCH 21/28] Create a /request URL in the mysql app --- sample-apps/flask-mysql/app.py | 11 +++++++++++ sample-apps/flask-mysql/templates/request.html | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 sample-apps/flask-mysql/templates/request.html diff --git a/sample-apps/flask-mysql/app.py b/sample-apps/flask-mysql/app.py index b10c8541f..76aa4237f 100644 --- a/sample-apps/flask-mysql/app.py +++ b/sample-apps/flask-mysql/app.py @@ -3,6 +3,7 @@ from flask import Flask, render_template, request from flaskext.mysql import MySQL +import requests app = Flask(__name__) if __name__ == '__main__': @@ -42,3 +43,13 @@ def create_dog(): cursor.execute(f'INSERT INTO dogs (dog_name, isAdmin) VALUES ("%s", 0)' % (dog_name)) connection.commit() return f'Dog {dog_name} created successfully' + +@app.route("/request", methods=['GET']) +def show_request_page(): + return render_template('request.html') + +@app.route("/request", methods=['POST']) +def make_request(): + url = request.form['url'] + res = requests.get(url) + return str(res) diff --git a/sample-apps/flask-mysql/templates/request.html b/sample-apps/flask-mysql/templates/request.html new file mode 100644 index 000000000..cffcd9af1 --- /dev/null +++ b/sample-apps/flask-mysql/templates/request.html @@ -0,0 +1,17 @@ + + + + + + + Fetch from URL + + +

Fetch from URL

+
+ + + +
+ + From 0791b98541031b146709d70902b2b4186e7a3290 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 15:49:41 +0200 Subject: [PATCH 22/28] Cleanup and add minimal logs --- .../vulnerabilities/ssrf/inspect_getaddrinfo_result.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py index 8008328cf..708189f82 100644 --- a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py +++ b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py @@ -5,6 +5,7 @@ import traceback from aikido_firewall.helpers.try_parse_url import try_parse_url from aikido_firewall.context import get_current_context +from aikido_firewall.helpers.logging import logger from aikido_firewall.background_process import get_comms from aikido_firewall.errors import AikidoSSRF from .imds import is_trusted_hostname, is_imds_ip_address @@ -16,6 +17,7 @@ def inspect_getaddrinfo_result(dns_results, hostname, port): """Inspect the results of a getaddrinfo() call""" if not hostname or try_parse_url(hostname) is not None: # If the hostname is an IP address, we don't need to inspect it + logger.debug("Hostname %s is actually an IP address, ignoring", hostname) return context = get_current_context() @@ -32,9 +34,6 @@ def inspect_getaddrinfo_result(dns_results, hostname, port): if should_block: raise AikidoSSRF() - # DEBUGGING ONLY : - raise AikidoSSRF() - if not context: return @@ -58,11 +57,13 @@ def inspect_getaddrinfo_result(dns_results, hostname, port): "metadata": {"hostname": hostname}, "payload": found["payload"], } + logger.debug("Attack results : %s", attack) + + logger.debug("Sending data to bg process :") get_comms().send_data_to_bg_process("ATTACK", (attack, context)) if should_block: raise AikidoSSRF() - raise AikidoSSRF() def resolves_to_imds_ip(resolved_ip_addresses, hostname): From dcdda0c7eb904cda978007ad8620e5cf9c22edb4 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Mon, 5 Aug 2024 15:50:01 +0200 Subject: [PATCH 23/28] Bugfix : Get source of context and then send it to function --- .../vulnerabilities/ssrf/find_hostname_in_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py index 67a994d4f..9093ca71c 100644 --- a/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py +++ b/aikido_firewall/vulnerabilities/ssrf/find_hostname_in_context.py @@ -14,7 +14,7 @@ def find_hostname_in_context(hostname, context, port): for source in UINPUT_SOURCES: if not hasattr(context, source): continue - user_inputs = extract_strings_from_user_input(context, source) + user_inputs = extract_strings_from_user_input(getattr(context, source)) if not user_inputs: continue for user_input, path in user_inputs.items(): From 2807f01af3ef6102ce36ace0e642fb88b082f3c8 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Tue, 6 Aug 2024 13:41:43 +0200 Subject: [PATCH 24/28] Linting --- aikido_firewall/sources/flask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aikido_firewall/sources/flask.py b/aikido_firewall/sources/flask.py index d13596365..31863281e 100644 --- a/aikido_firewall/sources/flask.py +++ b/aikido_firewall/sources/flask.py @@ -28,7 +28,7 @@ def dispatch(self, request, call_next): context.set_as_current_context() response = call_next(request) - comms = get_comms() # get IPC facilitator + comms = get_comms() # get IPC facilitator is_curr_route_useful = is_useful_route( response._status_code, context.route, context.method From bc6e779b2c51e275f75605f1a53ac89c005801b2 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Tue, 6 Aug 2024 13:42:16 +0200 Subject: [PATCH 25/28] Use the right block function --- .../vulnerabilities/ssrf/inspect_getaddrinfo_result.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py index 708189f82..bdef071c3 100644 --- a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py +++ b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py @@ -21,10 +21,13 @@ def inspect_getaddrinfo_result(dns_results, hostname, port): return context = get_current_context() - # Implement checks for "protection off" for a specific route - should_block = get_comms().send_data_to_bg_process( + + # TO DO : Implement checks for "protection off" for a specific route + + should_block_res = get_comms().send_data_to_bg_process( action="READ_PROPERTY", obj="block", receive=True ) + should_block = should_block_res["success"] and should_block_res["data"] ip_addresses = extract_ip_array_from_results(dns_results) if resolves_to_imds_ip(ip_addresses, hostname): From a55ae29b82b5a257f6d09398bcc73f79db2e4877 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Tue, 6 Aug 2024 13:50:20 +0200 Subject: [PATCH 26/28] Add docstring for inspect_getaddrinfo_result module --- .../vulnerabilities/ssrf/inspect_getaddrinfo_result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py index bdef071c3..82a8430d7 100644 --- a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py +++ b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py @@ -1,5 +1,5 @@ """ -!!!!!!!!!!!!!!!!!1 +Mainly exports inspect_getaddrinfo_result function """ import traceback From fe541e59a8e1882b2fce2b8059a10def35182285 Mon Sep 17 00:00:00 2001 From: Wout Feys Date: Tue, 6 Aug 2024 14:04:32 +0200 Subject: [PATCH 27/28] Rm comment todo --- .../vulnerabilities/ssrf/inspect_getaddrinfo_result.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py index 82a8430d7..9bbf4b0fd 100644 --- a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py +++ b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py @@ -22,8 +22,6 @@ def inspect_getaddrinfo_result(dns_results, hostname, port): context = get_current_context() - # TO DO : Implement checks for "protection off" for a specific route - should_block_res = get_comms().send_data_to_bg_process( action="READ_PROPERTY", obj="block", receive=True ) From 3d333cda29eb589eae743576e1f74a5a70ad5728 Mon Sep 17 00:00:00 2001 From: willem-delbare <20814660+willem-delbare@users.noreply.github.com> Date: Tue, 6 Aug 2024 14:05:23 +0200 Subject: [PATCH 28/28] Update aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py --- .../vulnerabilities/ssrf/inspect_getaddrinfo_result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py index 9bbf4b0fd..e6da6055b 100644 --- a/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py +++ b/aikido_firewall/vulnerabilities/ssrf/inspect_getaddrinfo_result.py @@ -12,7 +12,7 @@ from .is_private_ip import is_private_ip from .find_hostname_in_context import find_hostname_in_context - +# gets called when the result of the DNS resolution has come in def inspect_getaddrinfo_result(dns_results, hostname, port): """Inspect the results of a getaddrinfo() call""" if not hostname or try_parse_url(hostname) is not None: