diff --git a/Dockerfile b/Dockerfile index 0b9c9fa..ad8a87b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ FROM python:3.11-slim as base +RUN apt-get update && apt-get install -y openjdk-11-jdk +ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 +ENV CLASSPATH=/app/agent/Resources/hsqldb.jar:$CLASSPATH FROM base as builder RUN mkdir /install WORKDIR /install diff --git a/agent/exploits/cve_2024_6633.py b/agent/exploits/cve_2024_6633.py new file mode 100644 index 0000000..216063e --- /dev/null +++ b/agent/exploits/cve_2024_6633.py @@ -0,0 +1,130 @@ +"""Agent Asteroid implementation for CVE-2024-6633""" + +import logging +import socket +import jaydebeapi # type: ignore + +from ostorlab.agent.kb import kb +from ostorlab.agent.mixins import agent_report_vulnerability_mixin + +from agent import definitions +from agent import exploits_registry + +VULNERABILITY_TITLE = "Default Credentials in Fortra FileCatalyst Workflow HSQLDB" +VULNERABILITY_REFERENCE = "CVE-2024-6633" +VULNERABILITY_DESCRIPTION = ( + "Fortra FileCatalyst Workflow versions less than 5.1.7 are vulnerable to a critical issue where " + "default credentials for the internal HSQLDB are exposed. Attackers can exploit this by accessing " + "the database remotely using these credentials via TCP ports 4406 and 9001." +) + +DEFAULT_PORTS = [4406, 9001] +DEFAULT_USER = "sa" +DEFAULT_PASSWORD = "" + +logger = logging.getLogger(__name__) + + +def _create_vulnerability(target: definitions.Target) -> definitions.Vulnerability: + """Creates a vulnerability report based on the detection.""" + entry = kb.Entry( + title=VULNERABILITY_TITLE, + risk_rating="CRITICAL", + short_description=VULNERABILITY_DESCRIPTION, + description=VULNERABILITY_DESCRIPTION, + references={ + "nvd.nist.gov": f"https://nvd.nist.gov/vuln/detail/{VULNERABILITY_REFERENCE}", + }, + recommendation=( + "- Upgrade FileCatalyst Workflow to version 5.1.7 or later.\n" + "- Restrict access to TCP ports 4406 and 9001 from untrusted sources.\n" + "- Monitor database access and consider replacing the default HSQLDB." + ), + security_issue=True, + privacy_issue=False, + has_public_exploit=False, + targeted_by_malware=False, + targeted_by_ransomware=False, + targeted_by_nation_state=False, + ) + technical_detail = ( + f"{target.origin} is vulnerable to {VULNERABILITY_REFERENCE}: " + f"{VULNERABILITY_TITLE}" + ) + return definitions.Vulnerability( + entry=entry, + technical_detail=technical_detail, + risk_rating=agent_report_vulnerability_mixin.RiskRating.CRITICAL, + ) + + +def _is_port_open(host: str, port: int) -> bool: + """Check if the specified port is open on the target host.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + result = s.connect_ex((host, port)) + s.close() + return result == 0 + except socket.error as error: + logger.error("Socket error occurred while checking port %s: %s", port, error) + return False + + +def _attempt_db_connection(db_host: str, port: int) -> bool: + """Attempt to connect to the database using the specified port.""" + try: + conn = jaydebeapi.connect( + "org.hsqldb.jdbcDriver", + f"jdbc:hsqldb:hsql://{db_host}:{port}/hsqldb", + [DEFAULT_USER, DEFAULT_PASSWORD], + "../Resources/hsqldb.jar", + ) + conn.close() + return True + + except jaydebeapi.DatabaseError as e: + logger.error("Database error occurred while connecting: %s", e) + return False + # It was handled like that because the error is coming from a java class java.sql.SQLTransientConnectionException. + except Exception as e: + logger.error("Unexpected error occurred: %s", e) + return False + + +def _detect_vulnerability(host: str) -> bool: + """Detects if the target host is vulnerable to CVE-2024-6633.""" + for port in DEFAULT_PORTS: + if _is_port_open(host, port) is True: + logger.debug("Port %s is open. Attempting to connect...", port) + if _attempt_db_connection(host, port) is True: + logger.debug( + "Vulnerable: Default HSQLDB credentials are active and accessible on port %s.", + port, + ) + return True + else: + logger.debug( + "Port %s is open but could not connect with default credentials.", + port, + ) + else: + logger.debug("Port %s is not open. Host may not be vulnerable.", port) + return False + + +@exploits_registry.register +class CVE20246633Exploit(definitions.Exploit): + """Exploit for CVE-2024-6633: Default Credentials in Fortra FileCatalyst Workflow HSQLDB""" + + def accept(self, target: definitions.Target) -> bool: + """Check if the target is valid for this exploit.""" + return any(_is_port_open(target.host, port) for port in DEFAULT_PORTS) + + def check(self, target: definitions.Target) -> list[definitions.Vulnerability]: + """Checks for vulnerabilities in the target.""" + vulnerabilities = [] + + if _detect_vulnerability(target.host) is True: + vulnerabilities.append(_create_vulnerability(target)) + + return vulnerabilities diff --git a/requirement.txt b/requirement.txt index 4fe8298..b39ef6b 100644 --- a/requirement.txt +++ b/requirement.txt @@ -8,4 +8,5 @@ packaging pygments semantic_version impacket -pysnmp==4.4.6 \ No newline at end of file +pysnmp==4.4.6 +jaydebeapi \ No newline at end of file diff --git a/tests/exploits/cve_2024_6633_test.py b/tests/exploits/cve_2024_6633_test.py new file mode 100644 index 0000000..cd36529 --- /dev/null +++ b/tests/exploits/cve_2024_6633_test.py @@ -0,0 +1,187 @@ +"""Unit tests for Agent Asteroid: CVE-2024-6633""" + +import socket +from unittest import mock +import jaydebeapi # type: ignore + +from agent import definitions +from agent.exploits import cve_2024_6633 + + +def testAccept_whenPortIsOpen_reportTrue() -> None: + """Test the accept method when the target port is open.""" + with mock.patch("agent.exploits.cve_2024_6633._is_port_open", return_value=True): + exploit_instance = cve_2024_6633.CVE20246633Exploit() + target = definitions.Target("tcp", "192.168.1.1", 4406) + assert exploit_instance.accept(target) is True + + +def testAccept_whenPortIsClosed_reportFalse() -> None: + """Test the accept method when the target port is closed.""" + with mock.patch("agent.exploits.cve_2024_6633._is_port_open", return_value=False): + exploit_instance = cve_2024_6633.CVE20246633Exploit() + target = definitions.Target("tcp", "192.168.1.1", 4406) + assert exploit_instance.accept(target) is False + + +def testCheck_whenVulnerable_reportFinding() -> None: + """Test the check method when a vulnerability is found.""" + with mock.patch( + "agent.exploits.cve_2024_6633._detect_vulnerability", return_value=True + ): + exploit_instance = cve_2024_6633.CVE20246633Exploit() + target = definitions.Target("tcp", "192.168.1.1", 4406) + vulnerabilities = exploit_instance.check(target) + assert len(vulnerabilities) == 1 + assert ( + vulnerabilities[0].entry.title + == "Default Credentials in Fortra FileCatalyst Workflow HSQLDB" + ) + + +def testCheck_whenNotVulnerable_reportNothing() -> None: + """Test the check method when no vulnerability is found.""" + with mock.patch( + "agent.exploits.cve_2024_6633._detect_vulnerability", return_value=False + ): + exploit_instance = cve_2024_6633.CVE20246633Exploit() + target = definitions.Target("tcp", "192.168.1.1", 4406) + vulnerabilities = exploit_instance.check(target) + assert len(vulnerabilities) == 0 + + +@mock.patch("agent.exploits.cve_2024_6633.socket.socket") +def testIsPortOpen_whenPortIsOpen_reportTrue(mock_socket: mock.MagicMock) -> None: + """Test _is_port_open when the port is open.""" + mock_socket_instance = mock_socket.return_value + mock_socket_instance.connect_ex.return_value = 0 # Simulate port being open + + result = cve_2024_6633._is_port_open("192.168.1.1", 4406) + + assert ( + result is True + ), f"Expected True, but got {result}. The port should be reported as open." + + +@mock.patch("agent.exploits.cve_2024_6633.socket.socket") +def testIsPortOpen_whenPortIsClosed_reportFalse(mock_socket: mock.MagicMock) -> None: + """Test _is_port_open when the port is closed.""" + mock_socket_instance = mock_socket.return_value + mock_socket_instance.connect_ex.return_value = 1 # Simulate port being closed + + result = cve_2024_6633._is_port_open("192.168.1.1", 4406) + + assert ( + result is False + ), f"Expected False, but got {result}. The port should be reported as closed." + + +@mock.patch("agent.exploits.cve_2024_6633.jaydebeapi.connect") +def testAttemptDbConnection_whenSuccessful_reportTrue( + mock_connect: mock.MagicMock, +) -> None: + """Test _attempt_db_connection when the connection is successful.""" + mock_conn = mock.MagicMock() + mock_connect.return_value = mock_conn + + result = cve_2024_6633._attempt_db_connection("192.168.1.1", 9001) + + assert result is True + mock_conn.close.assert_called_once() + + +@mock.patch("agent.exploits.cve_2024_6633.jaydebeapi.connect") +@mock.patch("agent.exploits.cve_2024_6633.logger") +def testAttemptDbConnection_whenConnectionFails_reportFalse( + mock_logger: mock.MagicMock, + mock_connect: mock.MagicMock, +) -> None: + """Test _attempt_db_connection when the connection fails due to DatabaseError.""" + mock_connect.side_effect = jaydebeapi.DatabaseError("Test DB error") + + result = cve_2024_6633._attempt_db_connection("192.168.1.1", 9001) + + assert result is False, "Expected False when connection fails" + mock_logger.error.assert_called_once_with( + "Database error occurred while connecting: %s", mock.ANY + ) + error_message = mock_logger.error.call_args[0][1] + assert "Test DB error" in str( + error_message + ), "Error message should contain the specific database error" + + +@mock.patch("agent.exploits.cve_2024_6633.jaydebeapi.connect") +@mock.patch("agent.exploits.cve_2024_6633.logger") +def testAttemptDbConnection_whenJavaExceptionOccurs_reportFalse( + mock_logger: mock.MagicMock, + mock_connect: mock.MagicMock, +) -> None: + """Test _attempt_db_connection when a Java exception occurs.""" + mock_connect.side_effect = jaydebeapi.DatabaseError( + "java.sql.SQLTransientConnectionException" + ) + + result = cve_2024_6633._attempt_db_connection("192.168.1.1", 9001) + + assert result is False, "Expected False when a Java exception occurs" + mock_logger.error.assert_called_once_with( + "Database error occurred while connecting: %s", mock.ANY + ) + error_message = mock_logger.error.call_args[0][1] + assert "java.sql.SQLTransientConnectionException" in str( + error_message + ), "Error message should contain the specific Java exception" + + +@mock.patch("agent.exploits.cve_2024_6633.socket.socket") +def testIsPortOpen_whenSocketErrorOccurs_reportFalse( + mock_socket: mock.MagicMock, +) -> None: + """Test _is_port_open when a socket.error occurs.""" + mock_socket_instance = mock_socket.return_value + mock_socket_instance.connect_ex.side_effect = socket.error("Test socket error") + + result = cve_2024_6633._is_port_open("192.168.1.1", 4406) + + assert ( + result is False + ), f"Expected False, but got {result}. The function should return False when a socket.error occurs." + + +def testDetectVulnerability_whenPortOpenAndVulnerable_reportTrue() -> None: + """Test _detect_vulnerability when the port is open and the target is vulnerable.""" + with mock.patch( + "agent.exploits.cve_2024_6633._is_port_open", return_value=True + ), mock.patch( + "agent.exploits.cve_2024_6633._attempt_db_connection", return_value=True + ): + result = cve_2024_6633._detect_vulnerability("192.168.1.1") + + assert ( + result is True + ), f"Expected True, but got {result}. The function should return True when the target is vulnerable." + + +def testDetectVulnerability_whenPortOpenButNotVulnerable_reportFalse() -> None: + """Test _detect_vulnerability when the port is open but the target is not vulnerable.""" + with mock.patch( + "agent.exploits.cve_2024_6633._is_port_open", return_value=True + ), mock.patch( + "agent.exploits.cve_2024_6633._attempt_db_connection", return_value=False + ): + result = cve_2024_6633._detect_vulnerability("192.168.1.1") + + assert ( + result is False + ), f"Expected False, but got {result}. The function should return False when the target is not vulnerable." + + +def testDetectVulnerability_whenPortClosed_reportFalse() -> None: + """Test _detect_vulnerability when the port is closed.""" + with mock.patch("agent.exploits.cve_2024_6633._is_port_open", return_value=False): + result = cve_2024_6633._detect_vulnerability("192.168.1.1") + + assert ( + result is False + ), f"Expected False, but got {result}. The function should return False when the port is closed."