Compare commits

...
66 Commits
Author SHA1 Message Date
Miroslav Štampar 6faa9bb929 Release 1.10.11 (#6165) 2026-10-30 17:30:50 +01:00
Miroslav Štampar 2ff5db17ba Fixes #6165 2026-10-30 17:26:13 +01:00
Miroslav Štampar c42ebd7acf Fixes #6164 2026-10-29 14:24:18 +01:00
Miroslav Štampar 2d046c1a75 Fixes #6163 2026-10-29 14:15:37 +01:00
Miroslav Štampar f8a42cf016 Fixes CI/CD errors 2026-10-27 13:47:04 +01:00
Miroslav Štampar 81d17b1ad2 Fixes #6162 2026-10-27 13:29:36 +01:00
Miroslav Štampar 083d9d0e6f Fix for Ctrl-C hang on Python 3.14 (leaked cache locks) 2026-10-27 13:11:04 +01:00
Miroslav Štampar a01582b83f Minor update 2026-10-27 11:10:16 +01:00
Miroslav Štampar 94f8cf8136 Minor update 2026-10-27 09:34:04 +01:00
Miroslav Štampar 6a94f23251 Update related to the #6161 2026-10-27 09:23:47 +01:00
Miroslav Štampar 6df778743f Update related to the #6161 2026-10-27 09:17:37 +01:00
Miroslav Štampar 6e06114947 Fixes #6157 2026-10-25 16:29:02 +01:00
Miroslav Štampar c9745f883d Minor update 2026-10-23 00:33:58 +02:00
Miroslav Štampar 11afdcb026 Fixes #6152 2026-10-21 13:51:35 +02:00
Miroslav Štampar 5b8a2faab1 Doing some cleaning for web search requests 2026-10-21 12:10:18 +02:00
Miroslav Štampar da21091459 Update regarding the search engines naggings 2026-10-21 11:08:07 +02:00
Raju Ranjan 5e2a6aeec8 fix(search): parse current Bing result links (#6150)
While looking into #6009 I noticed the Bing fallback in -g never
returns anything, even when Bing itself serves a normal results page.

Two things changed on Bing's side. The result anchor now carries
target="_blank" before href, so '<h2><a href="..." h=' no longer
matches at all. And the href is no longer the target URL but a
tracking redirect (https://www.bing.com/ck/a?...&u=a1<base64url>),
where the real link sits base64url-encoded after the "a1" prefix
in the u parameter.

The regex now allows extra attributes on both tags and captures
either the u payload of a ck/a redirect or a plain href. The payload
is decoded with decodeBase64(), which already deals with the
URL-safe alphabet and the missing padding. Plain hrefs still work
the same as before (with &amp; unescaped), in case Bing serves the
old markup to some clients.

Checked against a saved live results page (0 -> 10 links) and the
old markup, and --smoke-test passes. Google still can't be scraped
since it only serves a JS page now, so this doesn't fully close
#6009, but Bing is usable again as a fallback when DuckDuckGo blocks
the IP.
2026-10-21 10:41:33 +02:00
Miroslav Štampar 8461fd402b Fixes #6151 2026-10-21 10:37:13 +02:00
Miroslav Štampar 9b0e262560 Fix related to the #6149 2026-10-20 10:07:33 +02:00
Miroslav Štampar d57415479a Minor update 2026-10-19 18:37:58 +02:00
Miroslav Štampar d0ad9b2e34 Adding cross-DBMS security configuration audit (--audit) 2026-10-19 18:34:39 +02:00
Miroslav Štampar 6530184e2c Implements #4271 2026-10-19 11:46:27 +02:00
Miroslav Štampar e90078184d Implements --links (#21) 2026-10-19 10:19:34 +02:00
Miroslav Štampar 559d6f4543 Increasing the GraphQL timeout in tests 2026-10-18 22:37:59 +02:00
Miroslav Štampar 71af9b3d8a Fixes CI/CD issues 2026-10-18 22:21:02 +02:00
Miroslav Štampar 516f55935c Implements #4973 2026-10-18 22:14:12 +02:00
Miroslav Štampar b208262c37 Fixes #4708 2026-10-18 21:54:21 +02:00
Miroslav Štampar 9a1660aa12 Minor cleaning after #6146 2026-10-17 13:06:38 +02:00
Bharath Vemula 0cf2f6db07 Fix REST API multi-target scans returning only the last target's results (#2503) (#6146)
A scan covering multiple targets inside a single API task (crawl,
'-m' bulk file, forms) ran every target through one StdDbOut keyed by
the single taskid, so _storeData()'s COMPLETE branch deleted each
earlier target's TARGET/TECHNIQUES rows - /scan/<id>/data returned
only the last target's results.

Mirror the --report-json fix for #6123: controller.start() now sets
kb.reportTaskId per target for API scans too, StdDbOut.write() keys
data rows '<taskid>-<n>' per target, and scan_data() folds the
per-target rows back into the task's flat result in target order.
Single-target scans keep the exact same response shape.

Regression tests in tests/test_api.py (TestStdDbOutMultiTarget,
TestScanDataMultiTarget).
2026-10-17 13:02:34 +02:00
Miroslav Štampar c94f6d0041 Update regarding #6145 2026-10-16 12:15:59 +02:00
Miroslav Štampar 5b180533c1 Fixes #6143 2026-10-11 21:20:29 +02:00
Miroslav Štampar 2c6e4d85b5 Fixes #6142 2026-10-10 12:00:33 +02:00
Mr.X 1b1f93ae5c Make test_ssti and test_xpath runnable standalone (#6136)
Every tests/test_*.py file provides an

    if __name__ == "__main__":
        unittest.main(...)

entrypoint so it can be run directly, except test_ssti.py and
test_xpath.py. Running either of those directly produced no output and
executed none of its tests; they only ran under `unittest discover`.

Add the missing entrypoint to both so they behave like the rest of the
suite. No test logic changed (62 and 48 tests respectively still pass).
2026-10-05 23:54:39 +02:00
Miroslav Štampar 65b6e7b4ac Fixes #6137 2026-10-05 23:46:59 +02:00
Miroslav Štampar 1d9965d003 Fixes #6133 2026-09-28 22:20:27 +02:00
Miroslav Štampar 4d518ff813 Fixes #6132 2026-09-28 09:01:30 +02:00
Miroslav Štampar a6e832bb8e Fixes #6131 2026-09-28 08:54:38 +02:00
Miroslav Štampar 3cfba468d7 Dealing with ResourceWarning and DeprecationWarning 2026-09-28 01:03:43 +02:00
Miroslav Štampar 8473e2edc6 Fixes CI/CD errors 2026-09-28 00:49:25 +02:00
Miroslav Štampar 0974a0d859 Fixes CI/CD errors 2026-09-28 00:43:00 +02:00
Miroslav Štampar 2e3e4385ca Added shared Interactsh poller with DNS/HTTP OOB support 2026-09-28 00:38:03 +02:00
Miroslav Štampar c36d056481 Minor patch for shellExec (adding timeout) 2026-09-25 17:01:07 +02:00
Miroslav Štampar a3d29174fb Fixes intermittent pypy-2.7 CI failure (kb.reportTaskId test leak) 2026-09-25 11:32:30 +02:00
Miroslav Štampar 92b652177b Fixes #6129 2026-09-25 11:00:58 +02:00
Miroslav Štampar 8ac458c6bc Fixes #6128 2026-09-23 09:25:22 +02:00
Miroslav Štampar e1537fe809 Fixes #6127 2026-09-23 09:12:03 +02:00
Miroslav Štampar 2171eb123c Fixes CI/CD issues 2026-09-20 12:05:29 +02:00
Miroslav Štampar 31005fe4df Fixing an TypeError issue with a crypto.crypt() 2026-09-20 11:59:21 +02:00
Miroslav Štampar 0c491b7551 Minor regex fix 2026-09-19 15:57:54 +02:00
Miroslav Štampar 5365c7e53d Fixing broken CI/CD 2026-09-18 14:48:31 +02:00
Miroslav Štampar ce01e65255 Patch related to #6124 2026-09-18 14:13:31 +02:00
Miroslav Štampar 7eb06ce635 Fixes #6122 2026-09-18 00:53:16 +02:00
Miroslav Štampar bda62dcf28 Fixes #6123 2026-09-18 00:41:57 +02:00
Miroslav Štampar fa96906f76 Fixing CI/CD errors 2026-09-15 11:36:10 +02:00
Miroslav Štampar 6224344224 Minor update regarding the #6120 2026-09-15 11:26:54 +02:00
Miroslav Štampar 7cbbbaf2a1 Patch related to the #6120 2026-09-15 09:50:39 +02:00
Miroslav Štampar 6c75ad64c4 Update related to #6117 2026-09-14 13:02:11 +02:00
Miroslav Štampar 5f6c303f8a Fixes #6108 2026-09-09 00:59:57 +02:00
Miroslav Štampar 604c5bc111 Fixes #6109 2026-09-08 22:10:57 +02:00
Miroslav Štampar d486742eec Fixes #6106 2026-09-07 12:06:22 +02:00
Miroslav Štampar a56a6b1f25 Fixes #6107 2026-09-07 12:03:02 +02:00
Miroslav Štampar 1ee368f78a Fixes #6105 2026-09-04 09:47:33 +02:00
Miroslav Štampar e10092efcd Fixes #6104 2026-09-04 09:03:25 +02:00
Miroslav Štampar 691d2fa7ca Update of docus 2026-09-02 15:51:19 +02:00
Miroslav Štampar 3fc41ca370 Salvaging (verified) what's useful from #6103 2026-09-02 07:53:32 +02:00
Ramachandran Thamarai Semmenianandchan770 3b8a8d3d09 Do not crash on a JWT with a non-base64url signature (fixes #6101) (#6102)
JWT_REGEX accepts a signature segment of any length and parseJWT only
validates the header/payload, so a value that matches the JWT pattern but
carries a malformed/truncated signature (e.g. 41 base64url characters,
which is length % 4 == 1 and thus impossible base64) reached crackHMAC.
There decodeBase64(signature) let binascii.Error propagate, aborting the
whole run with an unhandled exception during checkJWT().

Guard the signature decode: a signature that is not valid base64url cannot
be an HMAC we could verify, so crackHMAC now treats it as not crackable
(returns None) instead of raising. The other auditJWT findings are
unaffected. Added a regression doctest.

Co-authored-by: chan770 <[email protected]>
2026-09-01 10:24:58 +02:00
98 changed files with 4419 additions and 216 deletions

No files matched your search

@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT name AS audit_key,CASE WHEN value_in_use IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),value_in_use) END AS audit_value
FROM master.sys.configurations
WHERE name IN ('xp_cmdshell','clr enabled','Ole Automation Procedures','Ad Hoc Distributed Queries')
) audit_rows WHERE 1=1 ORDER BY audit_key
+11
View File
@@ -0,0 +1,11 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective database user' AS audit_key,CASE WHEN CURRENT_USER IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),CURRENT_USER) END AS audit_value
UNION ALL
SELECT 'effective login' AS audit_key,CASE WHEN SUSER_SNAME() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),SUSER_SNAME()) END AS audit_value
UNION ALL
SELECT 'original login' AS audit_key,CASE WHEN ORIGINAL_LOGIN() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),ORIGINAL_LOGIN()) END AS audit_value
UNION ALL
SELECT 'database' AS audit_key,CASE WHEN DB_NAME() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),DB_NAME()) END AS audit_value
UNION ALL
SELECT 'sysadmin' AS audit_key,CASE WHEN IS_SRVROLEMEMBER('sysadmin') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),IS_SRVROLEMEMBER('sysadmin')) END AS audit_value
) audit_rows WHERE 1=1 ORDER BY audit_key
+6
View File
@@ -0,0 +1,6 @@
SELECT audit_key,audit_value FROM (
SELECT N'visible linked servers' AS audit_key,N'V'+CONVERT(NVARCHAR(MAX),COUNT(*)) AS audit_value FROM master.sys.servers WHERE is_linked=1
UNION ALL
SELECT N'link '+CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT AS audit_key,N'V'+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'<unknown>')+N' (data access: '+RTRIM(CASE is_data_access_enabled WHEN 1 THEN N'enabled' WHEN 0 THEN N'disabled' ELSE N'unavailable' END)+N'; RPC out: '+RTRIM(CASE is_rpc_out_enabled WHEN 1 THEN N'enabled' WHEN 0 THEN N'disabled' ELSE N'unavailable' END)+N')' AS audit_value
FROM master.sys.servers v WHERE is_linked=1 AND (SELECT COUNT(*) FROM master.sys.servers s WHERE s.is_linked=1 AND s.server_id<=v.server_id)<=%LIMIT%
) audit_rows WHERE 1=1 ORDER BY audit_key
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'execute xp_cmdshell' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.dbo.xp_cmdshell','OBJECT','EXECUTE') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.dbo.xp_cmdshell','OBJECT','EXECUTE')) END AS audit_value
UNION ALL
SELECT 'read configuration metadata' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.sys.configurations','OBJECT','SELECT') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.sys.configurations','OBJECT','SELECT')) END AS audit_value
UNION ALL
SELECT 'read linked-server metadata' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.sys.servers','OBJECT','SELECT') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.sys.servers','OBJECT','SELECT')) END AS audit_value
) audit_rows WHERE 1=1 ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT LOWER(VARIABLE_NAME) AS audit_key,CASE WHEN VARIABLE_VALUE IS NULL THEN 'N' ELSE CONCAT('V',VARIABLE_VALUE) END AS audit_value
FROM performance_schema.global_variables
WHERE LOWER(VARIABLE_NAME) IN ('local_infile','require_secure_transport','read_only','super_read_only','general_log','log_bin','skip_networking','skip_grant_tables')
) audit_rows ORDER BY audit_key
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT LOWER(VARIABLE_NAME) AS audit_key,CASE WHEN VARIABLE_VALUE IS NULL THEN 'N' ELSE CONCAT('V',VARIABLE_VALUE) END AS audit_value
FROM information_schema.GLOBAL_VARIABLES
WHERE LOWER(VARIABLE_NAME) IN ('local_infile','require_secure_transport','read_only','super_read_only','general_log','log_bin','skip_networking','skip_grant_tables')
) audit_rows ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT 'secure_file_priv' AS audit_key,CASE WHEN @@GLOBAL.secure_file_priv IS NULL THEN 'N' ELSE CONCAT('V',CAST(@@GLOBAL.secure_file_priv AS CHAR)) END AS audit_value
UNION ALL
SELECT 'max_allowed_packet' AS audit_key,CASE WHEN @@GLOBAL.max_allowed_packet IS NULL THEN 'N' ELSE CONCAT('V',CAST(@@GLOBAL.max_allowed_packet AS CHAR)) END AS audit_value
) audit_rows ORDER BY audit_key
+7
View File
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective account' AS audit_key,CASE WHEN CURRENT_USER() IS NULL THEN 'N' ELSE CONCAT('V',CAST(CURRENT_USER() AS CHAR)) END AS audit_value
UNION ALL
SELECT 'login identity' AS audit_key,CASE WHEN USER() IS NULL THEN 'N' ELSE CONCAT('V',CAST(USER() AS CHAR)) END AS audit_value
UNION ALL
SELECT 'server version' AS audit_key,CASE WHEN VERSION() IS NULL THEN 'N' ELSE CONCAT('V',CAST(VERSION() AS CHAR)) END AS audit_value
) audit_rows ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT 'direct FILE grant' AS audit_key,CONCAT('V',CAST(COUNT(*) AS CHAR)) AS audit_value
FROM information_schema.USER_PRIVILEGES
WHERE PRIVILEGE_TYPE='FILE' AND GRANTEE=CONCAT(CHAR(39),LEFT(CURRENT_USER(),LENGTH(CURRENT_USER())-LENGTH(SUBSTRING_INDEX(CURRENT_USER(),'@',-1))-1),CHAR(39),'@',CHAR(39),SUBSTRING_INDEX(CURRENT_USER(),'@',-1),CHAR(39))
) audit_rows ORDER BY audit_key
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT name AS audit_key,CASE WHEN value IS NULL THEN 'N' ELSE 'V'||value END AS audit_value
FROM v$parameter
WHERE name IN ('remote_os_authent','remote_login_passwordfile','sql92_security','audit_trail','os_authent_prefix')
) audit_rows ORDER BY audit_key
+12
View File
@@ -0,0 +1,12 @@
SELECT audit_key,audit_value FROM (
SELECT 'visible directory grants' AS audit_key,'V'||TO_CHAR(COUNT(*)) AS audit_value
FROM all_directories d JOIN all_tab_privs p ON p.table_schema=d.owner AND p.table_name=d.directory_name
WHERE p.type='DIRECTORY' AND p.privilege IN ('READ','WRITE') AND (p.grantee=SYS_CONTEXT('USERENV','CURRENT_USER') OR p.grantee='PUBLIC' OR p.grantee IN (SELECT role FROM session_roles))
UNION ALL
SELECT audit_key,audit_value FROM (
SELECT d.directory_name||' ['||p.privilege||'] (via '||p.grantee||')' AS audit_key,'V'||d.directory_path AS audit_value
FROM all_directories d JOIN all_tab_privs p ON p.table_schema=d.owner AND p.table_name=d.directory_name
WHERE p.type='DIRECTORY' AND p.privilege IN ('READ','WRITE') AND (p.grantee=SYS_CONTEXT('USERENV','CURRENT_USER') OR p.grantee='PUBLIC' OR p.grantee IN (SELECT role FROM session_roles))
ORDER BY d.directory_name,p.privilege,p.grantee
) audit_details WHERE ROWNUM<=%LIMIT%
) audit_rows ORDER BY audit_key
+9
View File
@@ -0,0 +1,9 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective account' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','CURRENT_USER') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','CURRENT_USER')) END AS audit_value FROM DUAL
UNION ALL
SELECT 'login identity' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','SESSION_USER') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','SESSION_USER')) END AS audit_value FROM DUAL
UNION ALL
SELECT 'database' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','DB_NAME') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','DB_NAME')) END AS audit_value FROM DUAL
UNION ALL
SELECT 'SYSDBA session' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','ISDBA') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','ISDBA')) END AS audit_value FROM DUAL
) audit_rows ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT 'DBA role active' AS audit_key,'V'||CASE WHEN COUNT(*)>0 THEN 'yes' ELSE 'no' END AS audit_value FROM session_roles WHERE role='DBA'
UNION ALL
SELECT privilege,'Vgranted' FROM session_privs WHERE privilege IN ('CREATE ANY DIRECTORY','CREATE EXTERNAL JOB','EXECUTE ANY PROCEDURE','ALTER SYSTEM','SELECT ANY DICTIONARY')
) audit_rows ORDER BY audit_key
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT name AS audit_key,CASE WHEN setting IS NULL THEN 'N' ELSE 'V'||setting END AS audit_value
FROM pg_catalog.pg_settings
WHERE name IN ('ssl','password_encryption','row_security','logging_collector','log_statement','log_connections','log_disconnections')
) audit_rows ORDER BY audit_key
+7
View File
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective account' AS audit_key,CASE WHEN CURRENT_USER IS NULL THEN 'N' ELSE 'V'||CAST(CURRENT_USER AS TEXT) END AS audit_value
UNION ALL
SELECT 'login identity' AS audit_key,CASE WHEN SESSION_USER IS NULL THEN 'N' ELSE 'V'||CAST(SESSION_USER AS TEXT) END AS audit_value
UNION ALL
SELECT 'database' AS audit_key,CASE WHEN CURRENT_DATABASE() IS NULL THEN 'N' ELSE 'V'||CAST(CURRENT_DATABASE() AS TEXT) END AS audit_value
) audit_rows ORDER BY audit_key
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'superuser' AS audit_key,'V'||CASE WHEN rolsuper THEN '1' ELSE '0' END AS audit_value FROM pg_catalog.pg_roles WHERE rolname=CURRENT_USER
UNION ALL
SELECT 'bypass RLS','V'||CASE WHEN rolbypassrls THEN '1' ELSE '0' END FROM pg_catalog.pg_roles WHERE rolname=CURRENT_USER
UNION ALL
SELECT rolname,'V'||CASE WHEN pg_has_role(oid,'USAGE') THEN '1' ELSE '0' END FROM pg_catalog.pg_roles WHERE rolname IN ('pg_read_server_files','pg_write_server_files','pg_execute_server_program')
) audit_rows ORDER BY audit_key
+14 -1
View File
@@ -2,6 +2,9 @@
<root>
<dbms value="MySQL">
<error regexp="org\.apache\.doris\." fork="Doris"/>
<error regexp="com\.starrocks\." fork="StarRocks"/>
<error regexp="Getting syntax error (at|from) line \d+, column \d+" fork="StarRocks"/>
<error regexp="SQL syntax.*?MySQL"/>
<error regexp="Warning.*?\Wmysqli?_"/>
<error regexp="MySQLSyntaxErrorException"/>
@@ -24,6 +27,15 @@
</dbms>
<dbms value="PostgreSQL">
<error regexp="crdb_internal\." fork="CockroachDB"/>
<error regexp="github\.com/cockroachdb/cockroach/pkg/sql" fork="CockroachDB"/>
<error regexp="com\.yugabyte\.(util\.PSQLException|core\.v3)" fork="YugabyteDB"/>
<error regexp="\(yb/\w+/\w+\.cc:\d+\)" fork="YugabyteDB"/>
<error regexp="org\.opengauss\.(util\.PSQLException|core\.v3)" fork="OpenGauss"/>
<error regexp="(duckdb\.duckdb|_duckdb)\.(Parser|Binder|Catalog|Conversion)Exception" fork="DuckDB"/>
<error regexp="org\.duckdb\.DuckDB" fork="DuckDB"/>
<error regexp="Parser Error: [^\n]{0,40}at or near" fork="DuckDB"/>
<error regexp="(Catalog Error: Table with name|Binder Error: Referenced column)" fork="DuckDB"/>
<error regexp="PostgreSQL.*?ERROR"/>
<error regexp="Warning.*?\Wpg_"/>
<error regexp="valid PostgreSQL result"/>
@@ -133,7 +145,7 @@
</dbms>
<dbms value="SAP MaxDB">
<error regexp="SQL error.*?POS([0-9]+)"/>
<error regexp="SQL[- ]error.*?POS\(\d+\)"/>
<error regexp="Warning.*?\Wmaxdb_"/>
<error regexp="DriverSapDB"/>
<error regexp="-3014.*?Invalid end of SQL statement"/>
@@ -203,6 +215,7 @@
</dbms>
<dbms value="Presto">
<error regexp="io\.trino\.(jdbc|spi)\." fork="Trino"/>
<error regexp="com\.facebook\.presto\.jdbc"/>
<error regexp="io\.prestosql\.jdbc"/>
<error regexp="com\.simba\.presto\.jdbc"/>
+21
View File
@@ -1365,6 +1365,27 @@ Tag: <test>
</details>
</test>
<!-- No parentheses or functions (e.g. value-wide character whitelists, #6145): TRUE keeps the original
sort column, FALSE raises a runtime regular expression error ('(' pattern) -->
<test>
<title>MySQL boolean-based blind - ORDER BY clause (RLIKE)</title>
<stype>1</stype>
<level>3</level>
<risk>1</risk>
<clause>3</clause>
<where>3</where>
<vector>CASE WHEN [INFERENCE] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</vector>
<request>
<payload>CASE WHEN [RANDNUM]=[RANDNUM] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</payload>
</request>
<response>
<comparison>CASE WHEN [RANDNUM]=[RANDNUM1] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</comparison>
</response>
<details>
<dbms>MySQL</dbms>
</details>
</test>
<!-- Works in MySQL, Oracle, etc. -->
<test>
<title>HAVING boolean-based blind - WHERE, GROUP BY clause</title>
+9 -1
View File
@@ -173,6 +173,10 @@
</dbms>
<dbms value="Microsoft SQL Server">
<links>
<inband query="SELECT CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT+N' -> '+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'&lt;unknown&gt;') FROM master.sys.servers WHERE is_linked=1 ORDER BY server_id"/>
<blind query="SELECT link_name FROM (SELECT CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT+N' -> '+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'&lt;unknown&gt;') AS link_name,ROW_NUMBER() OVER (ORDER BY server_id) AS rn FROM master.sys.servers WHERE is_linked=1) AS links WHERE rn=%d+1" count="SELECT COUNT(*) FROM master.sys.servers WHERE is_linked=1"/>
</links>
<cast query="CAST(%s AS NVARCHAR(MAX))"/>
<length query="LTRIM(STR(LEN(%s+'.')-1))"/>
<isnull query="ISNULL(%s,' ')"/>
@@ -253,6 +257,10 @@
</dbms>
<dbms value="Oracle">
<links>
<inband query="SELECT OWNER||'.'||DB_LINK||' -> '||NVL(HOST,'&lt;unknown&gt;') FROM ALL_DB_LINKS ORDER BY OWNER,DB_LINK"/>
<blind query="SELECT link_name FROM (SELECT OWNER||'.'||DB_LINK||' -> '||NVL(HOST,'&lt;unknown&gt;') AS link_name,ROW_NUMBER() OVER (ORDER BY OWNER,DB_LINK) AS rn FROM ALL_DB_LINKS) WHERE rn=%d+1" count="SELECT COUNT(*) FROM ALL_DB_LINKS"/>
</links>
<cast query="CAST(%s AS VARCHAR(4000))"/>
<length query="LENGTH(%s)"/>
<isnull query="NVL(%s,' ')"/>
@@ -1931,7 +1939,7 @@
</dbs>
<tables>
<inband query="SELECT CASE table_schema WHEN '' THEN 'default' ELSE table_schema END,table_name FROM INFORMATION_SCHEMA.TABLES" condition="table_schema"/>
<blind query="SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END ORDER BY table_name" count="SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END"/>
<blind query="SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END ORDER BY table_name LIMIT 1 OFFSET %d" count="SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END"/>
</tables>
<columns>
<inband query="SELECT column_name,spanner_type FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name='%s' AND table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END" condition="column_name"/>
+22 -4
View File
@@ -19,6 +19,7 @@ non-ASCII byte is unavoidable. Compatibility shims live in `lib/core/compat.py`
|-------|------|---------|
| CLI | `sqlmap.py` -> `main()` | the scanner. Applies runtime patches, parses options, runs a scan. |
| REST API | `sqlmapapi.py` | `-s` server / `-c` client wrappers around `lib/utils/api.py`. |
| Library | `import sqlmap` -> `lib/utils/library.py` | `scan()` / `scanFromRequest()` for programmatic callers; like the API, it drives the engine as a subprocess. |
`main()` (sqlmap.py) does, in order: `dirtyPatches()` (monkey-patches stdlib for
quirks/security - see below), `setPaths()`, `init()` (option parsing + environment
@@ -58,7 +59,7 @@ Identifiers in the codebase are camelCase.
| `lib/core/` | conf/kb model, common helpers, settings, enums, dump, session, agent, option parsing |
| `lib/controller/` | the scan orchestrator (`controller.py`), detection checks (`checks.py`), enumeration dispatch (`action.py`), DBMS handler selection (`handler.py`) |
| `lib/request/` | HTTP layer: `connect.py` (sending), `comparison.py` (the true/false oracle), `inject.py` (value extraction), protocol handlers, response processing |
| `lib/techniques/` | the exploitation engines: `blind/inference.py`, `error/use.py`, `union/{test,use}.py`, `dns/` |
| `lib/techniques/` | the exploitation engines: `blind/{inference,multibit}.py`, `error/use.py`, `union/{test,use}.py`, `dns/`, plus one directory per non-SQL family (`ssti/`, `nosql/`, `xpath/`, ...) |
| `lib/parse/` | parsing of inputs: CLI, config, HTTP request/log files, HTML, sitemap, and the XML payload/boundary loader (`payloads.py`) |
| `lib/utils/` | feature modules: `api.py` (REST), `hashdb.py` (session), `crawler.py`, `hash.py` (cracking), `har.py`, `brute.py`, `search.py`, ... |
| `lib/takeover/` | OS-level takeover: shells, file access, UDF, registry, Metasploit, `xp_cmdshell` |
@@ -130,6 +131,7 @@ Once a parameter is injectable, value extraction is dispatched by
| Technique | Engine | Mechanism |
|-----------|--------|-----------|
| boolean-based blind | `blind/inference.py: bisection()` | binary-search each character via true/false oracle |
| multi-bit blind (`--multi-bit`) | `blind/multibit.py` | opt-in; reads several bits per request out of the rows a listing page renders |
| time-based blind / stacked | `blind/inference.py` (time compare) | same bisection, oracle is a measured delay |
| error-based | `error/use.py: errorUse()` | parse the value straight out of a provoked DB error |
| UNION query | `union/{test,use}.py` | column-count detection then `UNION SELECT` extraction |
@@ -141,6 +143,14 @@ Once a parameter is injectable, value extraction is dispatched by
(intentional). Multi-threaded extraction is coordinated via `kb.locks` and
`getCurrentThreadData()` (`lib/core/threads.py`).
**Non-SQL engines.** `--nosql`, `--xpath`, `--ldap`, `--ssti`, `--graphql`, `--hql`, `--sparql`,
`--odata`, `--xslt`, `--xxe` and `--jwt` do not go through the pipeline above at all. Each one is a
self-contained scanner in `lib/techniques/<family>/inject.py`, dispatched from `controller.py` and
registered once in `NONSQL_TECHNIQUES` (`lib/core/settings.py`) - that tuple is what the rest of the
code tests against, so a new family is declared there and nowhere else. The response comparison,
reflection stripping and blind-bit classification they all need live in `lib/utils/nonsql.py`
instead of being copied per family.
---
## 7. DBMS abstraction
@@ -155,7 +165,10 @@ Enumeration is DBMS-agnostic at the top and specialized underneath:
pieces and supplying dialect specifics.
- **`data/xml/queries.xml`** - per-DBMS SQL query templates (banner, current user, table
enumeration, casting, etc.) keyed by DBMS. The generic code asks for a query by name;
the dialect comes from XML.
the dialect comes from XML. It also carries the per-DBMS `<gadgets>` - side-effecting scalar
expressions (e.g. PostgreSQL `dblink_exec`) that `getGadget()` (`lib/request/inject.py`) probes
once and caches in `kb.gadget`, so `--sql-query` / `--file-write` / `--os-cmd` still work from an
injection point that has no stacked queries.
`conf.dbmsHandler` (set in `handler.py`) is the live object that `action()` calls into.
@@ -180,12 +193,16 @@ Enumeration is DBMS-agnostic at the top and specialized underneath:
`lib/request/connect.py` (`Connect.getPage`) is the single HTTP chokepoint. Around it:
protocol handlers (`httpshandler`, `redirecthandler`, `chunkedhandler`, `rangehandler`,
persistent connections via `lib/request/keepalive.py`), response processing (`basic.py`), and the
comparison oracle (`comparison.py`).
comparison oracle (`comparison.py`). Alternative transports hang off the same chokepoint: the
standard-library HTTP/2 client (`http2.py`, `--http2`, which also carries the `--timeless` oracle in
`timeless.py`) and WebSocket targets (`websocket.py`).
**Tamper scripts** (`tamper/`) mutate the payload just before sending to evade WAF/IPS.
Each file exposes a `tamper(payload, **kwargs)` and a `__priority__`; `--tamper=a,b,c`
chains them in priority order. They are payload-string transforms only (no engine
coupling), which is why they compose freely.
coupling), which is why they compose freely. When a WAF/IPS is identified, `lib/utils/wafbypass.py`
ranks the plausible candidates and `_autoWafBypass()` (`controller.py`) trials them one at a time -
a candidate is adopted only if re-running the detection through it brings the injection back.
---
@@ -227,6 +244,7 @@ Two complementary layers:
| a constant/threshold | `lib/core/settings.py` |
| how injection is *detected* | `data/xml/boundaries.xml` + `data/xml/payloads/*.xml`, then `lib/controller/checks.py` |
| how a value is *extracted* | `lib/request/inject.py` + the relevant `lib/techniques/` engine |
| a non-SQL technique (SSTI, NoSQL, XPath, ...) | `lib/techniques/<family>/inject.py` (+ `NONSQL_TECHNIQUES` in `settings.py`) |
| the true/false decision | `lib/request/comparison.py` |
| a per-DBMS query/dialect | `data/xml/queries.xml` + `plugins/dbms/<dbms>/` |
| enumeration behavior | `plugins/generic/*.py` |
+12 -2
View File
@@ -15,10 +15,12 @@
* Added the switch `--xslt`. It tests for XSLT injection. The engine names itself in the response. sqlmap then dumps the XML document that the stylesheet transforms. It also reads the files that the engine can reach. When the engine exposes an extension bridge (PHP `php:function` or the Xalan `java:` namespace), sqlmap reads any file through it, and with `--os-cmd` or `--os-shell` it runs operating system commands.
* Added the switch `--xxe`. It tests for XML External Entity injection. It uses in-band, error-based, and out-of-band channels.
* Added the switch `--jwt`. It examines JSON Web Tokens for weak keys and for injection in the claims.
* sqlmap now offers to get around a WAF/IPS on its own. Once it identifies one and the ordinary payloads come back blocked, it drops the scanner fingerprint and tries the tamper scripts that suit that WAF, and it keeps the first one that brings the injection back.
## Speed
* Added the switch `--timeless`. It reads each blind bit from the HTTP/2 response order. It does not use a delay. sqlmap calibrates the target first, and it uses the usual time-based technique if the target is not applicable.
* Added the switch `--multi-bit`. On a page that renders rows, one request reads a bit from each row that comes back instead of a single bit altogether.
* Added set-membership (Huffman) retrieval for blind dumps. It needs fewer requests for each character. Use `--no-huffman` to stop it.
* Added keyset (seek) pagination for blind table dumps. Use `--no-keyset` to stop it.
* Added parallel retrieval of values in blind mode. Each thread retrieves a different value.
@@ -53,10 +55,13 @@
* Added time-based payloads for CUBRID.
* Added out-of-band DNS channels for H2 and ClickHouse.
* Added PostgreSQL command execution through a PL extension.
* Added the running of non-query statements without stacked queries through a gadget. On PostgreSQL, when the `dblink` extension is present, `--sql-query`, `--file-write`, `--os-cmd`, and `--os-shell` now work from a plain (e.g. boolean-based) injection point.
* Added the running of non-query statements without stacked queries through a gadget. On PostgreSQL, when the `dblink` extension is there and the current user can run it, `--sql-query`, `--file-write`, `--os-cmd`, and `--os-shell` now work from a plain (e.g. boolean-based) injection point.
* Added file read and file write support for SQLite through the `fileio` extension functions `readfile` and `writefile`.
* Added the data access and the security type of a routine to the output of `--procs` on MySQL and PostgreSQL, so a routine that runs as its definer or that modifies data stands out.
* Added the tamper scripts `blindbinary`, `dollarquote`, `infoschema2innodb`, `oraclequote`, and `sign`.
* Added stacked query payloads for Microsoft SQL Server and Sybase that carry no semicolon, so a filtered semicolon alone no longer hides the technique.
* Added the error signatures that tell the MySQL and the PostgreSQL forks apart: Doris, StarRocks, CockroachDB, YugabyteDB, OpenGauss, DuckDB, and Trino. These forks keep the wording of the engine that they come from, so only a leaked driver package or an engine-internal source reference gives them away.
* Made the fingerprinting payloads friendlier to a WAF, so the version detection survives where it used to get blocked.
* Added the tamper scripts `blindbinary`, `castprefix`, `dollarquote`, `infoschema2innodb`, `mid2leftright`, `mssqlnosemicolon`, `odbcbrace`, `oraclequote`, `quote2ltat`, `sign`, `sleep2hex`, and `uniontable`.
## Fewer dependencies
@@ -93,6 +98,11 @@
* Made the heuristic hints of the non-SQL switches exclusive. A signature no longer matches the errors of a different engine, an ordinary SQL error, or a page that only contains the name of a template engine.
* Corrected the GraphQL validation signatures. They now match the quotes in the way that the JSON body escapes them.
* Added the error signatures of Mako and of DynamoDB. sqlmap did not recognise the errors of these two back-ends.
* sqlmap no longer stops with an exception on a JWT whose signature is not valid base64url. Such a signature can not be an HMAC that sqlmap could verify, so it is reported as not crackable instead.
* Corrected the uppercasing of the keywords in the shown queries. A word that merely contains a keyword (e.g. a table named `selected`) and a keyword inside a quoted string are now left alone.
* Corrected the cracking of the old Oracle password hashes when the user name or the password does not come in as text.
* sqlmap now skips a UNION test whose request holds no character instead of stopping with an exception. Only a hand-edited `payloads/union_query.xml` can hold such a test.
* The gadget check now runs the gadget itself instead of only looking for the extension. A user that can not use it no longer gets a false success.
## Quality
+10
View File
@@ -31,6 +31,7 @@ def buildHandler():
from lib.core.enums import CHARSET_TYPE
from lib.core.enums import EXPECTED
from lib.core.exception import SqlmapDataException
from lib.core.exception import SqlmapUnsupportedFeatureException
from lib.request.inject import checkBooleanExpression
from lib.request.inject import getValue
from plugins.generic.enumeration import Enumeration
@@ -53,6 +54,15 @@ def buildHandler():
# reuses what --columns already enumerated
self._scopeCache = {} # table -> resolved schema (see _scopeFor)
def __getattr__(self, name):
# this handler only mixes in Enumeration/Miscellaneous (read-only, boolean-oracle
# extraction); any DBMS-specific capability (--os-*/--file-*/--sql-shell/--udf-*/--reg-*/
# --cleanup) has no generic equivalent - fail cleanly instead of an AttributeError deep
# in action(). Leading underscore excluded so internal/dunder lookups behave normally.
if name.startswith("_"):
raise AttributeError(name)
raise SqlmapUnsupportedFeatureException("Esperanto does not support '%s' (DBMS-agnostic engine covers enumeration/dump only)" % name)
def _engine(self):
if self._esp is None:
# esperanto is a PURE boolean-oracle engine: every probe is one true/false
+3 -2
View File
@@ -18,13 +18,14 @@ declare -x SCRIPTPATH="${0}"
PROJECT_FULLPATH=${SCRIPTPATH%/*}/$PROJECT
SETTINGS_FULLPATH=${SCRIPTPATH%/*}/$SETTINGS
git diff $SETTINGS_FULLPATH | grep "VERSION =" > /dev/null && exit 0
git diff HEAD -- $SETTINGS_FULLPATH | grep "^+VERSION =" > /dev/null && exit 0 # Note: manually set VERSION (staged or not, e.g. "git commit -a") is kept as is
if [ -f $SETTINGS_FULLPATH ]
then
LINE=$(grep -o ${SETTINGS_FULLPATH} -e '^VERSION = "[0-9.]*"')
# Note: a new month restarts the counter (i.e. '.0' gets tagged by post-commit hook), while a version already a month ahead (early release) just keeps counting
declare -a LINE
INCREMENTED=$(python -c "import re, sys, time; version = re.search('\"([0-9.]*)\"', sys.argv[1]).group(1); _ = version.split('.'); _.extend([0] * (4 - len(_))); _[-1] = str(int(_[-1]) + 1); month = str(time.gmtime().tm_mon); _[-1] = '0' if _[-2] != month else _[-1]; _[-2] = month; print sys.argv[1].replace(version, '.'.join(_))" "$LINE")
INCREMENTED=$(python -c "import re, sys, time; version = re.search('\"([0-9.]*)\"', sys.argv[1]).group(1); _ = version.split('.'); _.extend([0] * (4 - len(_))); _[-1] = str(int(_[-1]) + 1); month = time.gmtime().tm_mon; _[-2:] = _[-2:] if (int(_[-2]) - month) % 12 in (0, 1) else [str(month), '0']; print sys.argv[1].replace(version, '.'.join(_))" "$LINE")
if [ -n "$INCREMENTED" ]
then
sed -i "s/${LINE}/${INCREMENTED}/" $SETTINGS_FULLPATH
+12
View File
@@ -134,6 +134,18 @@ def action():
if conf.getProcs:
conf.dumper.procedures(conf.dbmsHandler.getProcedures())
if conf.getLinks:
try:
conf.dumper.links(conf.dbmsHandler.getLinks())
except SqlmapNoneDataException as ex:
logger.critical(ex)
if conf.getAudit:
try:
conf.dumper.audit(conf.dbmsHandler.getAudit())
except SqlmapNoneDataException as ex:
logger.critical(ex)
if conf.getPasswordHashes:
try:
conf.dumper.userSettings("database management system users password hashes", conf.dbmsHandler.getPasswordHashes(), "password hash", CONTENT_TYPE.PASSWORDS)
+33 -18
View File
@@ -15,6 +15,7 @@ import time
from extra.beep.beep import beep
from lib.core.agent import agent
from lib.core.common import Backend
from lib.core.common import arrayizeValue
from lib.core.common import extractRegexResult
from lib.core.common import extractStructuralTokens
from lib.core.common import extractTextTagContent
@@ -183,7 +184,11 @@ def checkSqlInjection(place, parameter, value):
if kb.reduceTests is None and not conf.testFilter and (intersect(Backend.getErrorParsedDBMSes(), SUPPORTED_DBMS, True) or kb.heuristicDbms or injection.dbms):
msg = "it looks like the back-end DBMS is '%s'. " % (Format.getErrorParsedDBMSes() or kb.heuristicDbms or joinValue(injection.dbms, '/'))
msg += "Do you want to skip test payloads specific for other DBMSes? [Y/n]"
kb.reduceTests = (Backend.getErrorParsedDBMSes() or [kb.heuristicDbms]) if readInput(msg, default='Y', boolean=True) else []
# mirror msg's fallback chain (error-parsed -> heuristic -> injection.dbms) - falling
# back only through the first two (as before) left kb.reduceTests as [None] whenever
# injection.dbms alone satisfied the 'if' above, silently skipping every DBMS-specific
# test payload for the rest of the scan
kb.reduceTests = (Backend.getErrorParsedDBMSes() or ([kb.heuristicDbms] if kb.heuristicDbms else arrayizeValue(injection.dbms))) if readInput(msg, default='Y', boolean=True) else []
# If the DBMS has been fingerprinted (via DBMS-specific error
# message, via simple heuristic check or via DBMS-specific
@@ -398,7 +403,7 @@ def checkSqlInjection(place, parameter, value):
# Parse test's <request>
comment = agent.getComment(test.request) if len(conf.boundaries) > 1 else None
fstPayload = agent.cleanupPayload(test.request.payload, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None)
fstPayload = agent.cleanupPayload(test.request.payload, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause)
for boundary in boundaries:
injectable = False
@@ -512,13 +517,13 @@ def checkSqlInjection(place, parameter, value):
# payload was successful
# Parse test's <response>
for method, check in test.response.items():
check = agent.cleanupPayload(check, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None)
check = agent.cleanupPayload(check, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause)
# In case of boolean-based blind SQL injection
if method == PAYLOAD.METHOD.COMPARISON:
# Generate payload used for comparison
def genCmpPayload():
sndPayload = agent.cleanupPayload(test.response.comparison, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None)
sndPayload = agent.cleanupPayload(test.response.comparison, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause)
# Forge response payload by prepending with
# boundary's prefix and appending the boundary's
@@ -622,7 +627,7 @@ def checkSqlInjection(place, parameter, value):
# A real boolean injection's TRUE condition reproduces the normal page, so this
# status-code asymmetry is the classic false positive - refuse it here.
if not kb.negativeLogic and trueCode in WAF_BLOCK_HTTP_CODES and (falseCode or 0) < 400 and (kb.heuristicCode or 200) < 400:
warnMsg = "%sparameter '%s' TRUE/FALSE responses differ only by a blocked HTTP %d vs %d status, " % ("%s " % paramType if paramType != parameter else "", parameter, trueCode, falseCode)
warnMsg = "%sparameter '%s' TRUE/FALSE responses differ only by a blocked HTTP %d vs %s status, " % ("%s " % paramType if paramType != parameter else "", parameter, trueCode, falseCode)
warnMsg += "which is characteristic of a WAF/IPS block rather than a SQL injection; skipping as a likely false positive"
logger.warning(warnMsg)
injectable = False
@@ -925,7 +930,9 @@ def heuristicCheckDbms(injection):
may be
"""
retVal = False
# None (not False) on failure - every caller gates re-running this on 'kb.heuristicDbms is
# None', and a stale False from an earlier parameter's failed check would wrongly block that
retVal = None
if conf.skipHeuristics:
return retVal
@@ -1223,6 +1230,14 @@ def heuristicCheckSqlInjection(place, parameter):
if conf.beep:
beep()
def _search(regex):
# Note: on a rare (e.g. huge) response the regex engine itself can fail, and losing one
# advisory heuristic beats losing the whole run (e.g. #5994 and #6105)
try:
return re.search(regex, page or "")
except (SystemError, RuntimeError) as ex:
logger.debug("skipping heuristic check because of a regex engine failure ('%s')" % getSafeExString(ex))
try:
for match in re.finditer(FI_ERROR_REGEX, page or ""):
if randStr1.lower() in match.group(0).lower():
@@ -1234,71 +1249,71 @@ def heuristicCheckSqlInjection(place, parameter):
break
except (SystemError, RuntimeError) as ex:
logger.debug("Skipping FI heuristic due to regex failure: %s", getSafeExString(ex))
logger.debug("skipping heuristic check because of a regex engine failure ('%s')" % getSafeExString(ex))
if not conf.nosql and re.search(NOSQL_ERROR_REGEX, page or ""):
if not conf.nosql and _search(NOSQL_ERROR_REGEX):
infoMsg = "heuristic (NoSQL) test shows that %sparameter '%s' might be vulnerable to NoSQL injection attacks (rerun with switch '--nosql')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.graphql and re.search(GRAPHQL_ERROR_REGEX, page or ""):
if not conf.graphql and _search(GRAPHQL_ERROR_REGEX):
infoMsg = "heuristic (GraphQL) test shows that %sparameter '%s' appears to be a GraphQL endpoint (rerun with switch '--graphql')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.ldap and re.search(LDAP_ERROR_REGEX, page or ""):
if not conf.ldap and _search(LDAP_ERROR_REGEX):
infoMsg = "heuristic (LDAP) test shows that %sparameter '%s' might be vulnerable to LDAP injection (rerun with switch '--ldap')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.xpath and re.search(XPATH_ERROR_REGEX, page or ""):
if not conf.xpath and _search(XPATH_ERROR_REGEX):
infoMsg = "heuristic (XPath) test shows that %sparameter '%s' might be vulnerable to XPath injection (rerun with switch '--xpath')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.ssti and re.search(SSTI_ERROR_REGEX, page or ""):
if not conf.ssti and _search(SSTI_ERROR_REGEX):
infoMsg = "heuristic (SSTI) test shows that %sparameter '%s' might be vulnerable to server-side template injection (rerun with switch '--ssti')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.hql and re.search(HQL_ERROR_REGEX, page or ""):
if not conf.hql and _search(HQL_ERROR_REGEX):
infoMsg = "heuristic (HQL) test shows that %sparameter '%s' might be vulnerable to HQL/JPQL (Hibernate ORM) injection (rerun with switch '--hql')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.xslt and re.search(XSLT_ERROR_REGEX, page or ""):
if not conf.xslt and _search(XSLT_ERROR_REGEX):
infoMsg = "heuristic (XSLT) test shows that %sparameter '%s' might be vulnerable to XSLT injection (rerun with switch '--xslt')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.sparql and re.search(SPARQL_ERROR_REGEX, page or ""):
if not conf.sparql and _search(SPARQL_ERROR_REGEX):
infoMsg = "heuristic (SPARQL) test shows that %sparameter '%s' might be vulnerable to SPARQL injection (rerun with switch '--sparql')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.odata and re.search(ODATA_ERROR_REGEX, page or ""):
if not conf.odata and _search(ODATA_ERROR_REGEX):
infoMsg = "heuristic (OData) test shows that %sparameter '%s' might be vulnerable to OData $filter injection (rerun with switch '--odata')" % ("%s " % paramType if paramType != parameter else "", parameter)
logger.info(infoMsg)
if conf.beep:
beep()
if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and re.search(XXE_ERROR_REGEX, page or ""):
if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and _search(XXE_ERROR_REGEX):
infoMsg = "heuristic (XXE) test shows that the XML request body might be vulnerable to XML External Entity injection (rerun with switch '--xxe')"
logger.info(infoMsg)
@@ -1317,7 +1332,7 @@ def checkJWT():
headers, missing expiry), hinting at '--jwt' for active confirmation and injection.
"""
if kb.jwtChecked or conf.jwt:
if kb.get("jwtChecked") or conf.jwt:
return
kb.jwtChecked = True
+16 -4
View File
@@ -223,6 +223,9 @@ def _autoWafBypass(place, parameter, value):
return retVal
def _targetInfo():
return {"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}
def _showInjections():
if conf.wizard and kb.wizardMode:
kb.wizardMode = False
@@ -234,13 +237,12 @@ def _showInjections():
header = "sqlmap resumed the following injection point(s) from stored session"
if conf.api:
conf.dumper.string("", {"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, content_type=CONTENT_TYPE.TARGET)
conf.dumper.string("", _targetInfo(), content_type=CONTENT_TYPE.TARGET)
conf.dumper.string("", kb.injections, content_type=CONTENT_TYPE.TECHNIQUES)
else:
# --report-json: capture the same TARGET/TECHNIQUES structures the API emits, without
# printing them (the human-readable injection points are rendered just below)
# --report-json: capture the same TECHNIQUES structure the API emits, without printing it (the
# human-readable injection points are rendered just below); TARGET is reported per target in start()
if conf.reportJson:
conf.dumper._reportData({"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, CONTENT_TYPE.TARGET)
conf.dumper._reportData(kb.injections, CONTENT_TYPE.TECHNIQUES)
data = "".join(set(_formatInjection(_) for _ in kb.injections)).rstrip("\n")
@@ -376,6 +378,11 @@ def start():
for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets:
targetCount += 1
# --report-json / REST API: give each target its own taskid in the shared collector, so a multi-target
# run (e.g. '-m' bulk file) doesn't have later targets overwrite earlier ones (see _reportData/StdDbOut.write)
if conf.reportJson or conf.api:
kb.reportTaskId = targetCount
try:
if conf.checkInternet:
infoMsg = "checking for Internet connection"
@@ -517,6 +524,11 @@ def start():
setupTargetEnv()
# --report-json: every tested target gets its own group with its identity, also when nothing is found
# (otherwise its errors, e.g. connection failures, would have no group to land in)
if conf.reportJson:
conf.dumper._reportData(_targetInfo(), CONTENT_TYPE.TARGET)
if not any((conf.graphql,)) and not checkConnection(suppressOutput=conf.forms):
continue
+42 -11
View File
@@ -61,6 +61,31 @@ from lib.core.settings import UNICODE_ENCODING
from lib.core.unescaper import unescaper
from thirdparty import six
def _isGroupOrderByClause(clause):
"""
Returns True if (test's/injection's) clause is exclusively GROUP BY and/or ORDER BY
>>> _isGroupOrderByClause([PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY])
True
>>> _isGroupOrderByClause([PAYLOAD.CLAUSE.WHERE, PAYLOAD.CLAUSE.ORDER_BY])
False
"""
return bool(clause) and set(clause) <= set((PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY))
def _markerSafeRandom(generator):
"""
Returns a random value (e.g. for [RANDNUM]) not containing the --string/--not-string page marker,
as a reflected one (e.g. echoed in a DBMS error message) would otherwise fake the comparison
"""
markers = filterNone((conf.get("string"), conf.get("notString")))
while True:
retVal = getUnicode(generator())
if not any(_ in retVal for _ in markers):
return retVal
class Agent(object):
"""
This class defines the SQL agent methods.
@@ -113,7 +138,11 @@ class Agent(object):
paramDict = conf.paramDict[place]
origValue = getUnicode(paramDict[parameter])
newValue = getUnicode(newValue) if newValue else newValue
base64Encoding = re.sub(r" \(.+", "", parameter) in conf.base64Parameter
try:
# Dirty patch for Python 3.14.7 re.sub() regression (e.g. https://github.com/sqlmapproject/sqlmap/issues/6128)
base64Encoding = re.sub(r" \(.+", "", parameter) in conf.base64Parameter
except TypeError:
base64Encoding = False
if place == PLACE.URI or BOUNDED_INJECTION_MARKER in origValue:
paramString = origValue
@@ -180,7 +209,7 @@ class Agent(object):
newValue = "%s%s" % (value, newValue)
newValue = self.cleanupPayload(newValue, origValue) or ""
newValue = self.cleanupPayload(newValue, origValue, clause=kb.injection.clause if kb.get("injection") else None) or ""
if base64Encoding:
_newValue = newValue
@@ -292,9 +321,9 @@ class Agent(object):
# after the prefix or it is in GROUP BY / ORDER BY (<clause>)
elif getTechnique() == PAYLOAD.TECHNIQUE.STACKED:
query = kb.injection.prefix
elif kb.injection.clause == [2, 3] or kb.injection.clause == [2] or kb.injection.clause == [3]:
elif _isGroupOrderByClause(kb.injection.clause):
query = kb.injection.prefix
elif clause == [2, 3] or clause == [2] or clause == [3]:
elif _isGroupOrderByClause(clause):
query = prefix
# In any other case prepend with the full prefix
@@ -352,7 +381,7 @@ class Agent(object):
return re.sub(r";\W*;", ";", expression) if trimEmpty else expression
def cleanupPayload(self, payload, origValue=None):
def cleanupPayload(self, payload, origValue=None, clause=None):
if not isinstance(payload, six.string_types):
return
@@ -371,16 +400,18 @@ class Agent(object):
payload = payload.replace(value, replacements[value])
for _ in set(re.findall(r"(?i)\[RANDNUM(?:\d+)?\]", payload)):
payload = payload.replace(_, str(randomInt()))
payload = payload.replace(_, _markerSafeRandom(randomInt))
for _ in set(re.findall(r"(?i)\[RANDSTR(?:\d+)?\]", payload)):
payload = payload.replace(_, randomStr())
payload = payload.replace(_, _markerSafeRandom(randomStr))
if origValue is not None:
origValue = getUnicode(origValue)
if "[ORIGVALUE]" in payload:
payload = getUnicode(payload).replace("[ORIGVALUE]", origValue if origValue.isdigit() else unescaper.escape("'%s'" % origValue))
# Note: in a GROUP BY/ORDER BY payload a (plain identifier) original value is the column itself - quoting it would turn it into a constant
raw = origValue.isdigit() or (_isGroupOrderByClause(clause) and re.search(r"\A[\w.]+\Z", origValue) is not None)
payload = getUnicode(payload).replace("[ORIGVALUE]", origValue if raw else unescaper.escape("'%s'" % origValue))
if "[ORIGINAL]" in payload:
payload = getUnicode(payload).replace("[ORIGINAL]", origValue)
@@ -422,10 +453,10 @@ class Agent(object):
payload = payload.replace(SINGLE_QUOTE_MARKER, "'")
for _ in set(re.findall(r"\[RANDNUM(?:\d+)?\]", payload, re.I)):
payload = payload.replace(_, str(randomInt()))
payload = payload.replace(_, _markerSafeRandom(randomInt))
for _ in set(re.findall(r"\[RANDSTR(?:\d+)?\]", payload, re.I)):
payload = payload.replace(_, randomStr())
payload = payload.replace(_, _markerSafeRandom(randomStr))
if hashDBRetrieve(HASHDB_KEYS.DBMS_FORK) in (FORK.MEMSQL, FORK.TIDB, FORK.DRIZZLE):
payload = re.sub(r"(?i)\bORD\(", "ASCII(", payload)
@@ -627,7 +658,7 @@ class Agent(object):
fieldsSelectCase = re.search(r"\ASELECT%s\s+(\(CASE WHEN\s+.+\s+END\))" % prefixRegex, query, re.I)
fieldsSelectFrom = re.search(r"\ASELECT%s\s+(.+?)\s+FROM " % prefixRegex, query, re.I)
fieldsExists = re.search(r"EXISTS\(([^)]*)\)\Z", query, re.I)
fieldsSelect = re.search(r"\ASELECT%s\s+(.*)" % prefixRegex, query, re.I)
fieldsSelect = re.search(r"\ASELECT%s\s+(.*)" % prefixRegex, query, re.I | re.S)
fieldsSubstr = re.search(r"\A(SUBSTR|MID\()", query, re.I)
fieldsMinMaxstr = re.search(r"(?:MIN|MAX)\(([^\(\)]+)\)", query, re.I)
fieldsNoSelect = query
+85 -10
View File
@@ -27,6 +27,7 @@ import platform
import posixpath
import random
import re
import signal
import socket
import string
import subprocess
@@ -997,7 +998,7 @@ def setColor(message, color=None, bold=False, level=None, istty=None):
if bold or color:
retVal = colored(message, color=color, on_color=None, attrs=("bold",) if bold else None)
elif level:
elif level and hasattr(LOGGER_HANDLER, "colorize"):
try:
level = getattr(logging, level, None)
except:
@@ -2476,22 +2477,58 @@ def getConsoleWidth(default=80):
return width or default
def shellExec(cmd):
def shellExec(cmd, timeout=None):
"""
Executes arbitrary shell command
Executes arbitrary shell command, optionally bounded by 'timeout' seconds - killing (and
flagging) a hung child instead of blocking forever, as callers otherwise have no other
watchdog around this call (e.g. --vuln-test runs one such call per entry, unattended)
>>> shellExec('echo 1').strip() == '1'
True
"""
retVal = ""
timedOut = []
try:
retVal = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()[0] or ""
popenKwargs = {"shell": True, "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT}
if timeout:
# shell=True's Popen.pid is the shell, not the (possibly grandchild) command it runs -
# killing just that pid leaves the real child holding the stdout pipe open, so
# communicate() keeps blocking past the deadline; run it in its own group/session instead
# so the whole tree can be killed at once
if IS_WIN:
popenKwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popenKwargs["preexec_fn"] = os.setsid
process = subprocess.Popen(cmd, **popenKwargs)
def _kill():
timedOut.append(True)
try:
if IS_WIN:
subprocess.call(["taskkill", "/F", "/T", "/PID", str(process.pid)])
else:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except Exception:
pass
timer = threading.Timer(timeout, _kill) if timeout else None
if timer:
timer.daemon = True
timer.start()
retVal = process.communicate()[0] or ""
if timer:
timer.cancel()
except Exception as ex:
retVal = getSafeExString(ex)
finally:
retVal = getText(retVal)
if timedOut:
retVal += "\n[shellExec] child process tree killed after exceeding %d-second timeout" % timeout
return retVal
@@ -4071,6 +4108,35 @@ def getDaysFromLastUpdate():
return int(time.time() - os.path.getmtime(paths.SQLMAP_SETTINGS_PATH)) // (3600 * 24)
def pythonBuildInfo():
"""
Returns markers of non-default interpreter builds/modes (e.g. free-threading, JIT)
>>> isinstance(pythonBuildInfo(), str)
True
"""
import sysconfig
retVal = []
# Note: free-threading builds compile in their own GC and deferred reference counting (e.g. https://github.com/python/cpython/issues/160682, where a still
# running function gets collected), both of which stay in play even when the GIL itself got re-enabled in runtime, hence checking the build and not only the state
try:
if sysconfig.get_config_var("Py_GIL_DISABLED"):
retVal.append("free-threading" if not sys._is_gil_enabled() else "free-threading with GIL")
except:
pass
try:
if sys._jit.is_enabled():
retVal.append("JIT")
except:
if os.getenv("PYTHON_JIT") == '1':
retVal.append("JIT")
return ", ".join(retVal)
def unhandledExceptionMessage():
"""
Returns detailed message about occurred unhandled exception
@@ -4079,6 +4145,11 @@ def unhandledExceptionMessage():
True
"""
from lib.core.revision import getRevisionNumber # Note: not in the header because of the circular import
revision = getRevisionNumber()
buildInfo = pythonBuildInfo()
errMsg = "unhandled exception occurred in %s. It is recommended to retry your " % VERSION_STRING
errMsg += "run with the latest development version from official GitHub "
errMsg += "repository at '%s'. If the exception persists, please open a new issue " % GIT_PAGE
@@ -4086,8 +4157,8 @@ def unhandledExceptionMessage():
errMsg += "with the following text and any other information required to "
errMsg += "reproduce the bug. Developers will try to reproduce the bug, fix it accordingly "
errMsg += "and get back to you\n"
errMsg += "Running version: %s\n" % VERSION_STRING[VERSION_STRING.find('/') + 1:]
errMsg += "Python version: %s\n" % PYVERSION
errMsg += "Running version: %s%s\n" % (VERSION_STRING[VERSION_STRING.find('/') + 1:], (" (%s)" % revision) if revision else "")
errMsg += "Python version: %s%s\n" % (PYVERSION, (" (%s)" % buildInfo) if buildInfo else "")
errMsg += "Operating system: %s\n" % platform.platform()
errMsg += "Command line: %s\n" % re.sub(r".+?\bsqlmap\.py\b", "sqlmap.py", getUnicode(" ".join(sys.argv), encoding=getattr(sys.stdin, "encoding", None)))
errMsg += "Technique: %s\n" % (enumValueToNameLookup(PAYLOAD.TECHNIQUE, getTechnique()) if getTechnique() is not None else ("DIRECT" if conf.get("direct") else None))
@@ -5622,16 +5693,20 @@ def parseRequestFile(reqFile, checkParams=True):
if re.search(BURP_XML_HISTORY_REGEX, content, re.I | re.S):
reqResList = []
for match in re.finditer(BURP_XML_HISTORY_REGEX, content, re.I | re.S):
port, request = match.groups()
port, protocol, request = match.groups()
protocol = (protocol or "").lower()
try:
request = decodeBase64(request, binary=False)
except (binascii.Error, TypeError):
continue
_ = re.search(r"%s:.+" % re.escape(HTTP_HEADER.HOST), request)
if _:
host = _.group(0).strip()
if not re.search(r":\d+\Z", host) and int(port) != 80:
request = request.replace(host, "%s:%d" % (host, int(port)))
host = value = _.group(0).strip()
if not re.search(r":\d+\Z", host) and int(port) != (443 if protocol == "https" else 80):
value = "%s:%d" % (value, int(port))
if protocol and "://" not in value: # Note: item's <protocol> (as the port alone can't tell e.g. HTTPS on 8443)
value = re.sub(r"\A([^:]+:\s*)", r"\g<1>%s://" % protocol, value)
request = request.replace(host, value)
reqResList.append(request)
else:
reqResList = [content]
+37 -2
View File
@@ -9,8 +9,40 @@ import copy
import threading
from collections import OrderedDict
from thirdparty import six
from thirdparty.six.moves import collections_abc as _collections
class NoLock(object):
"""
No-op stand-in for a lock, used where the guarded operations are already atomic on their own
>>> with NoLock():
... True
True
"""
def __enter__(self):
return self
def __exit__(self, *args):
return False
def cacheLock(reentrant=False):
"""
Returns a lock for guarding the (OrderedDict based) caches. Only Python 2 needs a real one, as its
OrderedDict is implemented in Python (#5619), while on Python 3 each operation is atomic. Moreover,
since Python 3.14 a Ctrl-C landing right after a lock got acquired by the 'with' statement leaves it
acquired forever (i.e. '__exit__' never gets called), hanging every thread that touches the cache
>>> isinstance(cacheLock(), NoLock) == six.PY3
True
"""
if six.PY2:
return threading.RLock() if reentrant else threading.Lock()
else:
return NoLock()
class AttribDict(dict):
"""
This class defines the dictionary with added capability to access members as attributes
@@ -133,7 +165,7 @@ class LRUDict(object):
def __init__(self, capacity):
self.capacity = capacity
self.cache = OrderedDict()
self.__lock = threading.Lock()
self.__lock = cacheLock()
def __len__(self):
return len(self.cache)
@@ -159,7 +191,10 @@ class LRUDict(object):
self.cache.pop(key)
except KeyError:
if len(self.cache) >= self.capacity:
self.cache.popitem(last=False)
try:
self.cache.popitem(last=False)
except KeyError: # Note: concurrently emptied by another thread (unlocked on Python 3)
pass
self.cache[key] = value
def set(self, key, value):
+10 -3
View File
@@ -9,6 +9,7 @@ import functools
import threading
from lib.core.datatype import LRUDict
from lib.core.datatype import cacheLock
from lib.core.settings import MAX_CACHE_ITEMS
from lib.core.threads import getCurrentThreadData
@@ -40,7 +41,7 @@ def cachedmethod(f):
"""
_cache[f] = LRUDict(capacity=MAX_CACHE_ITEMS)
_method_locks[f] = threading.RLock()
_method_locks[f] = cacheLock(reentrant=True)
def _freeze(val):
if isinstance(val, (list, set, tuple)):
@@ -61,7 +62,10 @@ def cachedmethod(f):
with lock:
if key in cache:
return cache[key]
try:
return cache[key]
except KeyError: # Note: evicted in between (unlocked on Python 3)
pass
except TypeError:
# Note: fallback (slow-path) for unhashable arguments
@@ -73,7 +77,10 @@ def cachedmethod(f):
with lock:
if key in cache:
return cache[key]
try:
return cache[key]
except KeyError:
pass
except TypeError:
# Note: genuinely uncacheable arguments; skip caching altogether
return f(*args, **kwargs)
+11 -1
View File
@@ -108,11 +108,15 @@ class Dump(object):
collector is active - which is only ever the case for a CLI --report-json run, never under
--api - so this never double-captures alongside StdDbOut. A None content_type is resolved
via the kb.partRun fallback (e.g. the fingerprint line), mirroring the API exactly.
Keyed by kb.reportTaskId rather than the fixed REPORT_TASKID so that a multi-target run
(e.g. '-m' bulk file) keeps each target's results separate instead of later targets
overwriting earlier ones under the same content_type.
"""
if conf.get("reportCollector") is not None:
from lib.utils.api import _storeData, REPORT_TASKID
_storeData(conf.reportCollector, REPORT_TASKID, stdoutEncode(clearColors(data)), CONTENT_STATUS.COMPLETE, content_type)
_storeData(conf.reportCollector, kb.get("reportTaskId", REPORT_TASKID), stdoutEncode(clearColors(data)), CONTENT_STATUS.COMPLETE, content_type)
def flush(self):
if self._outputFP:
@@ -220,6 +224,12 @@ class Dump(object):
def procedures(self, procedures):
self.lister("stored procedures", procedures, content_type=CONTENT_TYPE.PROCEDURES)
def links(self, links):
self.lister("database links", links, content_type=CONTENT_TYPE.LINKS)
def audit(self, audit):
self.lister("database security audit", audit, content_type=CONTENT_TYPE.AUDIT)
def userSettings(self, header, userSettings, subHeader, content_type=None):
self._areAdmins = set()
+14 -12
View File
@@ -352,18 +352,17 @@ class PAYLOAD(object):
3: "High risk",
}
CLAUSE = {
0: "Always",
1: "WHERE",
2: "GROUP BY",
3: "ORDER BY",
4: "LIMIT",
5: "OFFSET",
6: "TOP",
7: "Table name",
8: "Column name",
9: "Pre-WHERE (non-query)",
}
class CLAUSE(object):
ALWAYS = 0
WHERE = 1
GROUP_BY = 2
ORDER_BY = 3
LIMIT = 4
OFFSET = 5
TOP = 6
TABLE_NAME = 7
COLUMN_NAME = 8
PRE_WHERE = 9
class METHOD(object):
COMPARISON = "comparison"
@@ -430,6 +429,8 @@ class CONTENT_TYPE(object):
REG_READ = 25
STATEMENTS = 26
PROCEDURES = 27
LINKS = 28
AUDIT = 29
class CONTENT_STATUS(object):
IN_PROGRESS = 0
@@ -463,6 +464,7 @@ class MKSTEMP_PREFIX(object):
BIG_ARRAY = "sqlmapbigarray-"
SPECIFIC_RESPONSE = "sqlmapresponse-"
PREPROCESS = "sqlmappreprocess-"
AUTH_REFRESH = "sqlmapauthrefresh-"
class TIMEOUT_STATE(object):
NORMAL = 0
+76 -5
View File
@@ -142,6 +142,7 @@ from lib.parse.configfile import configFileParser
from lib.parse.payloads import loadBoundaries
from lib.parse.payloads import loadPayloads
from lib.request.basic import checkCharEncoding
from lib.request import authrefresh
from lib.request.basicauthhandler import SmartHTTPBasicAuthHandler
from lib.request.chunkedhandler import ChunkedHandler
from lib.request.connect import Connect as Request
@@ -847,6 +848,24 @@ def _listTamperingFunctions():
comment = match.group(1).strip()
dataToStdout("* %s - %s\n" % (setColor(os.path.basename(script), "yellow"), re.sub(r" *\n *", " ", comment.split("\n\n")[0].strip())))
def _argSpec(function):
"""
Cross Py2/Py3 argument-name introspection - inspect.getargspec() is deprecated since
Python 3.0 and removed entirely since 3.11, while inspect.getfullargspec() (used here
whenever available) covers the same 'args'/'keywords' need without the warning/removal
"""
return inspect.getfullargspec(function) if hasattr(inspect, "getfullargspec") else inspect.getargspec(function)
def _kwargsName(function):
"""
Name of 'function's **kwargs-style catch-all parameter, or None - the field holding it is
called 'varkw' on a getfullargspec() result and 'keywords' on a (Python 2) getargspec() one
"""
spec = _argSpec(function)
return getattr(spec, "varkw", None) or getattr(spec, "keywords", None)
def _setTamperingFunctions():
"""
Loads tampering functions from given script(s)
@@ -905,8 +924,15 @@ def _setTamperingFunctions():
priority = PRIORITY.NORMAL if not hasattr(module, "__priority__") else module.__priority__
priority = priority if priority is not None else PRIORITY.LOWEST
if not isinstance(priority, int):
warnMsg = "tamper module '%s' has an invalid value for '__priority__' " % filename[:-3]
warnMsg += "(assuming '%d')" % PRIORITY.NORMAL
logger.warning(warnMsg)
priority = PRIORITY.NORMAL
for name, function in inspect.getmembers(module, inspect.isfunction):
if name == "tamper" and (hasattr(inspect, "signature") and all(_ in inspect.signature(function).parameters for _ in ("payload", "kwargs")) or inspect.getargspec(function).args and inspect.getargspec(function).keywords == "kwargs"):
if name == "tamper" and (hasattr(inspect, "signature") and all(_ in inspect.signature(function).parameters for _ in ("payload", "kwargs")) or _argSpec(function).args and _kwargsName(function) == "kwargs"):
found = True
kb.tamperFunctions.append(function)
function.__name__ = module.__name__
@@ -1016,7 +1042,7 @@ def _setPreprocessFunctions():
for name, function in inspect.getmembers(module, inspect.isfunction):
try:
if name == "preprocess" and inspect.getargspec(function).args and all(_ in inspect.getargspec(function).args for _ in ("req",)):
if name == "preprocess" and _argSpec(function).args and all(_ in _argSpec(function).args for _ in ("req",)):
found = True
kb.preprocessFunctions.append(function)
@@ -1099,7 +1125,7 @@ def _setPostprocessFunctions():
raise SqlmapSyntaxException("cannot import postprocess module '%s' (%s)" % (getUnicode(filename[:-3]), getSafeExString(ex)))
for name, function in inspect.getmembers(module, inspect.isfunction):
if name == "postprocess" and inspect.getargspec(function).args and all(_ in inspect.getargspec(function).args for _ in ("page", "headers", "code")):
if name == "postprocess" and _argSpec(function).args and all(_ in _argSpec(function).args for _ in ("page", "headers", "code")):
found = True
kb.postprocessFunctions.append(function)
@@ -1127,6 +1153,42 @@ def _setPostprocessFunctions():
errMsg += "(Note: find template script at '%s')" % filename
raise SqlmapGenericException(errMsg)
def _setAuthRefreshFunction():
"""
Load the authentication refresh hook without performing a login during initialization.
"""
if conf.authRefresh:
try:
script = safeFilepathEncode(conf.authRefresh.strip())
if not os.path.isfile(script):
raise SqlmapFilePathException("auth refresh script '%s' does not exist" % script)
if not script.endswith(".py"):
raise SqlmapSyntaxException("auth refresh script should have an extension '.py'")
dirname, filename = os.path.split(os.path.abspath(script))
if dirname not in sys.path:
sys.path.insert(0, dirname)
logger.info("loading auth refresh module '%s'" % filename[:-3])
try:
getattr(importlib, "invalidate_caches", lambda: None)()
module = __import__(safeFilepathEncode(filename[:-3]))
function = getattr(module, "refresh", None)
if not inspect.isfunction(function):
raise ValueError("missing function 'refresh()'")
spec = _argSpec(function)
if len(spec.args) > len(spec.defaults or ()) or any(_ not in (getattr(spec, "kwonlydefaults", None) or {}) for _ in getattr(spec, "kwonlyargs", ())):
raise ValueError("function 'refresh()' must be callable without arguments")
except Exception:
raise SqlmapSyntaxException("unable to load 'refresh()' from auth refresh script '%s'" % script)
except (SqlmapFilePathException, SqlmapSyntaxException) as ex:
errMsg = "%s. You can find a template script at '%s'" % (getSafeExString(ex), authrefresh.createTemplate())
raise type(ex)(errMsg)
kb.authRefreshFunction = function
def _setThreads():
if not isinstance(conf.threads, int) or conf.threads <= 0:
conf.threads = 1
@@ -1268,7 +1330,7 @@ def _setHTTPHandlers():
match = re.search(r"\A([^:]*):([^:]*)@([^@]+)\Z", _.netloc)
if match:
username, password = match.group(1), match.group(2)
username, password = _urllib.parse.unquote(match.group(1)), _urllib.parse.unquote(match.group(2))
else:
username, password = None, None
@@ -1313,6 +1375,9 @@ def _setHTTPHandlers():
if conf.proxyCred:
# Reference: http://stackoverflow.com/questions/34079/how-to-specify-an-authenticated-proxy-for-a-python-http-connection
proxyString = "%s@" % conf.proxyCred
elif match:
# Note: credentials from the proxy URL itself (kept URL-encoded, as urllib decodes them on its own)
proxyString = "%s:%s@" % (match.group(1), match.group(2))
else:
proxyString = ""
@@ -2292,9 +2357,13 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.lastParserStatus = None
kb.locks = AttribDict()
for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"):
for _ in ("authRefresh", "cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"):
kb.locks[_] = threading.Lock()
kb.authRefreshHeaders = {}
kb.authRefreshVersion = 0
kb.authRefreshFailed = False
kb.matchRatio = None
kb.maxConnectionsFlag = False
kb.trueLength = None
@@ -2402,6 +2471,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.passwordMgr = None
kb.postprocessFunctions = []
kb.preprocessFunctions = []
kb.authRefreshFunction = None
kb.skipVulnHost = None
kb.storeCrawlingChoice = None
kb.tamperFunctions = []
@@ -3146,6 +3216,7 @@ def init():
_setTamperingFunctions()
_setPreprocessFunctions()
_setPostprocessFunctions()
_setAuthRefreshFunction()
_setTrafficOutputFP()
_setupHTTPCollector()
_setHttpOptions()
+4 -1
View File
@@ -163,6 +163,8 @@ optDict = {
"getPasswordHashes": ("boolean", "Passwords"),
"getPrivileges": ("boolean", "Privileges"),
"getRoles": ("boolean", "Roles"),
"getLinks": "boolean",
"getAudit": "boolean",
"getDbs": ("boolean", "Databases"),
"getTables": ("boolean", "Tables"),
"getColumns": ("boolean", "Columns"),
@@ -256,8 +258,9 @@ optDict = {
"hexConvert": "boolean",
"outputDir": "string",
"parseErrors": "boolean",
"postprocess": "string",
"authRefresh": "string",
"preprocess": "string",
"postprocess": "string",
"repair": "boolean",
"reportJson": "string",
"saveConfig": "string",
+17 -15
View File
@@ -12,7 +12,6 @@ import random
import re
import string
import sys
import time
from lib.core.enums import DBMS
from lib.core.enums import DBMS_DIRECTORY_NAME
@@ -20,7 +19,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.8.57"
VERSION = "1.10.11.0"
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)
@@ -204,17 +203,14 @@ JITTER_JUNK_RESPONSES = (
("", 200),
)
# Regular expression used for extracting results from Google search
GOOGLE_REGEX = r"webcache\.googleusercontent\.com/search\?q=cache:[^:]+:([^+]+)\+&amp;cd=|url\?\w+=((?![^>]+webcache\.googleusercontent\.com)http[^>]+)&(sa=U|rct=j)"
# Google Search consent cookie
GOOGLE_CONSENT_COOKIE = "CONSENT=YES+shp.gws-%s-0-RC1.%s+FX+740" % (time.strftime("%Y%m%d"), "".join(random.sample(string.ascii_lowercase, 2)))
# Regular expression used for extracting results from DuckDuckGo search
DUCKDUCKGO_REGEX = r'<a class="result__url" href="(htt[^"]+)'
# Regular expression used for extracting results from Bing search
BING_REGEX = r'<h2><a href="([^"]+)" h='
BING_REGEX = r'<h2[^>]*><a [^>]*href="(?:https?://www\.bing\.com/ck/a\?[^"]*?[?;]u=a1([^&"]+)[^"]*|([^"]+))"'
# Markers of search engine abuse/captcha pages (i.e. used IP address got blocked)
SEARCH_ABUSE_MARKERS = ("detected unusual traffic", "issue with the Tor Exit Node you are currently using", "anomaly.js", "Verifying your browser", "automated queries")
# Dummy user agent for search (if default one returns different results)
DUMMY_SEARCH_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"
@@ -586,7 +582,7 @@ WEBSCARAB_SPLITTER = "### Conversation"
BURP_REQUEST_REGEX = r"={10,}\s+([A-Z]{3,} .+?)\s+(={10,}|\Z)"
# Regex used for parsing XML Burp saved history items
BURP_XML_HISTORY_REGEX = r'<port>(\d+)</port>.*?<request base64="true"><!\[CDATA\[([^]]+)'
BURP_XML_HISTORY_REGEX = r'<port>(\d+)</port>\s*(?:<protocol>\s*(https?)\s*</protocol>)?.*?<request base64="true"><!\[CDATA\[([^]]+)'
# Encoding used for Unicode data
UNICODE_ENCODING = "utf8"
@@ -612,6 +608,9 @@ MYSQL_UNION_VALUE_CAST = "CONVERT(%s USING utf8mb4) COLLATE utf8mb4_bin"
# Row count at/above which keyset (seek) pagination is used automatically for table dumps when a usable integer-key cursor exists (smaller tables keep the plain LIMIT/OFFSET path; '--keyset' forces it regardless of size)
KEYSET_MIN_ROWS = 1000
# Maximum detailed metadata rows per variable-size security audit listing
AUDIT_MAX_ROWS = 20
# Number of Huffman (set-membership) character attempts made before their escape ratio is judged; at/above it, escapes reaching half of all attempts latch the technique off (safety against trimmed/blocked long IN() payloads)
HUFFMAN_PROBE_LIMIT = 8
@@ -1193,7 +1192,10 @@ XPATH_ERROR_SIGNATURES = (
("Python ElementTree", r"xml\.etree\.ElementTree\.(?:ParseError|Element)"),
# NOT XSLT: a dedicated '--xslt' engine owns those errors now, and claiming them here made every
# XSLT parser error suggest '--xpath' as well
("Generic XPath", r"XPath.*?(?:error|exception|syntax)"),
# NOTE: the gap has to stay bounded (like in 'Handlebars' below). An unbounded '.*?' turns this
# into a quadratic scan of every long line that merely carries the word 'xpath' (e.g. minified
# JS/JSON), which took ~25s on a 400KB response - and blew up the regex engine itself (#6105)
("Generic XPath", r"XPath[^\n]{0,100}?(?:error|exception|syntax)"),
("Generic XPath", r"Invalid XPath|XPath evaluation failed"),
)
@@ -1337,7 +1339,7 @@ SSTI_ERROR_SIGNATURES = (
("Velocity", r"org\.apache\.velocity\.(?:runtime|exception)\.\w+|ParseErrorException|MethodInvocationException|ResourceNotFoundException"),
("Spring EL / Thymeleaf", r"org\.springframework\.expression\.\w+|org\.thymeleaf\.\w+|SpelEvaluationException|TemplateProcessingException|ExpressionParsingException"),
("Struts2 (OGNL)", r"ognl\.(?:OgnlException|NoSuchPropertyException|MethodFailedException|InappropriateExpressionException|ExpressionSyntaxException)|com\.opensymphony\.xwork2|org\.apache\.struts2|There is no Action mapped for|Struts (?:Problem Report|has detected an unhandled exception)"),
("ERB", r"\(erb\):\d+|NameError.*undefined local variable"),
("ERB", r"\(erb\):\d+|NameError[^\n]{0,100}?undefined local variable"),
# NOTE: these must stay anchored to a diagnostic. The bare product names matched any page that
# carries the word 'pug'/'jade'/'handlebars' (a surname, a colour, a <script src=> of the runtime),
# and the bare 'ParseError' matched lxml.etree.XSLTParseError and ElementTree.ParseError
@@ -1361,7 +1363,7 @@ XXE_ERROR_SIGNATURES = (
("Java (Xerces/JAXP)", r"(?:org\.xml\.sax\.SAXParseException|com\.sun\.org\.apache\.xerces|javax\.xml\.stream\.XMLStreamException|The (?:entity|element type) \"[^\"]*\" was referenced|DOCTYPE is disallowed when the feature|External (?:DTD|parsed entities|Entity): failed|\"[^\"]*\" must be declared|had to be read but the maximum)"),
(".NET System.Xml", r"(?:System\.Xml\.XmlException|For security reasons DTD is prohibited|Reference to undeclared entity|An error occurred while parsing EntityName|XmlTextReaderImpl)"),
("Python expat", r"(?:xml\.parsers\.expat\.ExpatError|undefined entity|not well-formed \(invalid token\)|ExpatError)"),
("Ruby Nokogiri/REXML", r"(?:Nokogiri::XML::SyntaxError|REXML::ParseException|Entity .* not defined)"),
("Ruby Nokogiri/REXML", r"(?:Nokogiri::XML::SyntaxError|REXML::ParseException|Entity [^\n]{0,100}? not defined)"),
("Go encoding/xml", r"XML syntax error on line \d+"),
# NOTE: 'unexpected end of ...' is what every parser says, not what an XML parser says. It matched
# the "Unexpected end of query" of BaseX, the "Unexpected <EOF>" of GraphQL and the "Unexpected end
@@ -1417,7 +1419,7 @@ HQL_ERROR_SIGNATURES = (
("Hibernate", r"(?:unexpected (?:token:|end of subtree|AST node)|Could not (?:resolve|interpret) (?:attribute|root entity|path|property))"),
("EclipseLink / JPQL", r"(?:org\.eclipse\.persistence\.exceptions\.JPQLException|Exception \[EclipseLink|Problem compiling \[|An exception occurred while creating a query)"),
("JPA / JPQL", r"(?:javax|jakarta)\.persistence\.(?:PersistenceException|Query(?:Syntax|Timeout)?Exception)"),
("Generic HQL/JPQL", r"(?:HQL|JPQL|EJBQL)\b.*?(?:error|exception|syntax|not (?:mapped|resolve))"),
("Generic HQL/JPQL", r"(?:HQL|JPQL|EJBQL)\b[^\n]{0,100}?(?:error|exception|syntax|not (?:mapped|resolve))"),
)
HQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in HQL_ERROR_SIGNATURES)
@@ -1484,7 +1486,7 @@ SPARQL_ERROR_SIGNATURES = (
# too - matching on it alone mislabelled them as RDF4J, so only the package name is kept
("RDF4J / GraphDB", r"org\.eclipse\.rdf4j|org\.openrdf\.query"),
("Blazegraph", r"com\.bigdata\.rdf|\bBlazegraph\b"),
("rdflib", r"rdflib\.plugins\.sparql|\bParseException\b.*?(?:SPARQL|sparql)"),
("rdflib", r"rdflib\.plugins\.sparql|\bParseException\b[^\n]{0,100}?(?:SPARQL|sparql)"),
("Stardog", r"com\.(?:complexible\.)?stardog"),
)
+11 -3
View File
@@ -561,7 +561,11 @@ def _resumeDBMS():
dbms = value.lower()
dbmsVersion = [UNKNOWN_DBMS_VERSION]
_ = "(%s)" % ('|'.join(SUPPORTED_DBMS))
_ = re.search(r"\A%s (.*)" % _, dbms, re.I)
try:
# Dirty patch for a Python 3.13/3.14 re module regression (e.g. https://github.com/sqlmapproject/sqlmap/issues/6128, https://github.com/sqlmapproject/sqlmap/issues/6137)
_ = re.search(r"\A%s (.*)" % _, dbms, re.I)
except TypeError:
_ = None
if _:
dbms = _.group(1).lower()
@@ -666,8 +670,12 @@ def _createFilesDir():
if not any((conf.fileRead, conf.commonFiles, conf.xxe)):
return
# Note: normalize the hostname consistently with conf.outputPath / conf.dumpPath (see _createDumpDir)
conf.filePath = paths.SQLMAP_FILES_PATH % normalizeUnicode(getUnicode(conf.hostname))
# Note: normalize the hostname consistently with conf.outputPath / conf.dumpPath (see _createDumpDir).
# safeStringFormat() (not raw '%') because SQLMAP_FILES_PATH embeds SQLMAP_OUTPUT_PATH, which comes
# straight from --output-dir - a literal '%' anywhere in that user-supplied path (e.g. an unexpanded
# '$(date +%Y%m%d)' on a shell that doesn't support command substitution) would otherwise make the
# '%' operator try to parse it as a format directive and crash
conf.filePath = safeStringFormat(paths.SQLMAP_FILES_PATH, normalizeUnicode(getUnicode(conf.hostname)))
if not os.path.isdir(conf.filePath):
try:
+6 -1
View File
@@ -113,6 +113,8 @@ def vulnTest(tests=None, label="vuln"):
("-u \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
("-d \"<direct>\" --flush-session --dump -T creds --dump-format=SQLITE --binary-fields=password_hash --where \"user_id=5\"", ("3137396164343563366365326362393763663130323965323132303436653831", "dumped to SQLITE database")),
("-d \"<direct>\" --flush-session --banner --schema --sql-query=\"UPDATE users SET name='foobar' WHERE id=4; SELECT * FROM users; SELECT 987654321\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "4,foobar,nameisnull", "'987654321'",)),
("-d \"<direct>\" --links", ("back-end DBMS: SQLite", "enumeration of database links is not supported on SQLite", "~unhandled exception")),
("-d \"<direct>\" --audit", ("back-end DBMS: SQLite", "security configuration audit is not supported on SQLite", "~unhandled exception")),
("-u <base>csrf --data=\"id=1&csrf_token=1\" --banner --answers=\"update=y\" --flush-session --technique=B", ("back-end DBMS: SQLite", "banner: '3.")),
("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")),
)
@@ -268,7 +270,10 @@ def vulnTest(tests=None, label="vuln"):
os.environ["SQLMAP_UNSAFE_EVAL"] = '1'
output = shellExec(cmd)
# GraphQL blind-dumps every table, including the wide LDAP directory fixture. It takes
# ~124s locally on PyPy and can exceed 180s on CI; give that entry more room without
# weakening its checks or changing the watchdog for the remaining entries (see #6129).
output = shellExec(cmd, timeout=300 if "--graphql" in options else 180)
if not all((check in output if not check.startswith('~') else check[1:] not in output) for check in checks) or "unhandled exception" in output:
dataToStdout("---\n\n$ %s\n" % cmd)
+1
View File
@@ -60,6 +60,7 @@ class _ThreadData(threading.local):
self.lastHTTPError = None
self.lastRedirectMsg = None
self.lastQueryDuration = 0
self.authRefreshTime = 0
self.lastPage = None
self.lastRequestMsg = None
self.lastRequestUID = 0
+13 -4
View File
@@ -138,8 +138,8 @@ def cmdLineParser(argv=None):
target.add_argument("-r", dest="requestFile",
help="Load HTTP request from a file")
target.add_argument("-g", dest="googleDork",
help="Process Google dork results as target URLs")
target.add_argument("-g", "--dork", dest="googleDork", metavar="DORK",
help="Process search engine dork results as target URLs")
target.add_argument("-c", dest="configFile",
help="Load options from a configuration INI file")
@@ -484,6 +484,9 @@ def cmdLineParser(argv=None):
enumeration.add_argument("--hostname", dest="getHostname", action="store_true",
help="Retrieve DBMS server hostname")
enumeration.add_argument("--audit", dest="getAudit", action="store_true",
help="Audit DBMS security configuration and capabilities")
enumeration.add_argument("--is-dba", dest="isDba", action="store_true",
help="Detect if the DBMS current user is DBA")
@@ -499,6 +502,9 @@ def cmdLineParser(argv=None):
enumeration.add_argument("--roles", dest="getRoles", action="store_true",
help="Enumerate DBMS users roles")
enumeration.add_argument("--links", dest="getLinks", action="store_true",
help="Enumerate DBMS links")
enumeration.add_argument("--dbs", dest="getDbs", action="store_true",
help="Enumerate DBMS databases")
@@ -730,8 +736,8 @@ def cmdLineParser(argv=None):
general.add_argument("--fresh-queries", dest="freshQueries", action="store_true",
help="Ignore query results stored in session file")
general.add_argument("--gpage", dest="googlePage", type=int,
help="Use Google dork results from specified page number")
general.add_argument("--gpage", dest="googlePage", type=int, metavar="PAGE",
help="Use dork results from specified page number")
general.add_argument("--har", dest="harFile",
help="Log all HTTP traffic into a HAR file")
@@ -745,6 +751,9 @@ def cmdLineParser(argv=None):
general.add_argument("--parse-errors", dest="parseErrors", action="store_true",
help="Parse and display DBMS error messages from responses")
general.add_argument("--auth-refresh", dest="authRefresh",
help="Run script to refresh auth on HTTP 401")
general.add_argument("--preprocess", dest="preprocess",
help="Use given script(s) for preprocessing (request)")
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
import os
import re
import tempfile
from lib.core.common import openFile
from lib.core.data import conf
from lib.core.data import kb
from lib.core.data import logger
from lib.core.enums import MKSTEMP_PREFIX
from lib.core.exception import SqlmapConnectionException
from lib.core.exception import SqlmapGenericException
from thirdparty import six
from thirdparty.six.moves import urllib as _urllib
def createTemplate():
handle, filename = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.AUTH_REFRESH, suffix=".py")
os.close(handle)
with openFile(filename, "w+") as f:
f.write("#!/usr/bin/env\n\ndef refresh():\n # Obtain a fresh token here (use a timeout for login requests).\n # Custom headers also work, e.g. {'X-Auth-Token': token}.\n return {'Authorization': 'Bearer <fresh token>'}\n")
with openFile(os.path.join(os.path.dirname(filename), "__init__.py"), "w+") as f:
f.write("pass")
return filename
def sameOrigin(first, second):
# checkSameHost deliberately ignores schemes and ports, which is unsuitable for credentials.
def origin(url):
parts = _urllib.parse.urlsplit(url)
return parts.scheme.lower(), parts.hostname, parts.port or (443 if parts.scheme.lower() == "https" else 80)
return origin(first) == origin(second)
def apply(req):
"""Overlay refreshed headers after preprocess hooks; return the credential generation sent."""
if not conf.authRefresh or not sameOrigin(req.get_full_url(), conf.url):
return None
with kb.locks.authRefresh:
if kb.authRefreshFailed:
raise SqlmapGenericException("authentication refresh failed; cannot continue with expired credentials")
for name, value in kb.authRefreshHeaders.items():
# Hooks/raw request files can leave differently-cased copies of the same header.
for headers in (req.headers, req.unredirected_hdrs):
for key in list(headers):
if key.lower() == name.lower():
del headers[key]
req.add_header(name, value)
return kb.authRefreshVersion
def refresh(version):
"""Refresh once for all workers that sent the same expired credentials."""
with kb.locks.authRefresh:
if kb.authRefreshFailed:
raise SqlmapConnectionException("authentication refresh failed; cannot continue with expired credentials")
if version != kb.authRefreshVersion:
return
logger.info("refreshing authentication after HTTP 401")
try:
headers = kb.authRefreshFunction()
if not isinstance(headers, dict) or not headers:
raise ValueError()
for name, value in headers.items():
if not isinstance(name, six.string_types) or not re.match(r"\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z", name):
raise ValueError()
if not isinstance(value, six.string_types) or not value or re.search(r"[\x00-\x1f\x7f]", value):
raise ValueError()
value.encode("latin-1")
if name.lower() in ("host", "content-length", "transfer-encoding", "connection", "proxy-authorization"):
raise ValueError()
if len(set(_.lower() for _ in headers)) != len(headers):
raise ValueError()
except Exception:
kb.authRefreshFailed = True
# Hook exceptions can contain tokens/passwords; do not include their text.
raise SqlmapConnectionException("auth refresh hook failed or returned invalid headers. Expected a non-empty dictionary of authentication headers. You can find a template script at '%s'" % createTemplate())
kb.authRefreshHeaders = dict(headers)
kb.authRefreshVersion += 1
def stripHeaders(headers):
"""Remove refreshed credentials before following a redirect to another origin."""
names = set(_.lower() for _ in kb.authRefreshHeaders)
return dict((name, value) for name, value in headers.items() if name.lower() not in names)
+1 -1
View File
@@ -442,8 +442,8 @@ def processResponse(page, responseHeaders, code=None, status=None):
rawResponse = "%s %s %s\n%s\n%s" % (_http_client.HTTPConnection._http_vsn_str, code or "", status or "", "".join(getUnicode(responseHeaders.headers if responseHeaders else [])), page[:IDENTYWAF_PARSE_PAGE_LIMIT] if not kb.checkWafMode else page[:HEURISTIC_PAGE_SIZE_THRESHOLD])
with kb.locks.identYwaf:
identYwaf.non_blind.clear()
try:
identYwaf.non_blind.clear()
if identYwaf.non_blind_check(rawResponse, silent=True):
for waf in set(identYwaf.non_blind):
if waf not in kb.identifiedWafs:
+34 -7
View File
@@ -22,6 +22,7 @@ import time
import traceback
from lib.request import websocket
from lib.request import authrefresh
from lib.request.websocket import WebSocketException
from lib.core.agent import agent
@@ -469,6 +470,8 @@ class Connect(object):
responseHeaders = None
logHeaders = u""
skipLogTraffic = False
authRetry = False
authVersion = None
raise404 = raise404 and not kb.ignoreNotFound
@@ -683,6 +686,9 @@ class Connect(object):
else:
post, headers = req.data, req.headers
authVersion = authrefresh.apply(req)
headers = req.headers
requestHeaders += "\r\n".join(["%s: %s" % (u"-".join(_.capitalize() for _ in getUnicode(key).split(u'-')) if hasattr(key, "capitalize") else getUnicode(key), getUnicode(value)) for (key, value) in req.header_items()])
if not getRequestHeader(req, HTTP_HEADER.COOKIE) and conf.cj:
@@ -730,12 +736,12 @@ class Connect(object):
raise SqlmapMissingDependence("native HTTP/2 client does not support SOCKS proxies (omit '--http2' or use an HTTP proxy)")
try:
conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy)
conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy, sensitive_headers=tuple(kb.authRefreshHeaders) if conf.authRefresh else ())
except IOError as ex:
raise _http_client.HTTPException(getSafeExString(ex))
else:
if conn.code >= 400:
raise _urllib.error.HTTPError(url, conn.code, conn.msg, conn.info(), io.BytesIO(conn.read()))
raise _urllib.error.HTTPError(conn.geturl(), conn.code, conn.msg, conn.info(), io.BytesIO(conn.read()))
requestMsg = re.sub(r" HTTP/[0-9.]+\r\n", " %s\r\n" % conn.http_version, requestMsg, count=1)
@@ -863,7 +869,7 @@ class Connect(object):
page = None
responseHeaders = None
if checking:
if checking and not (ex.code == _http_client.UNAUTHORIZED and authVersion is not None):
return None, None, None
try:
@@ -887,7 +893,8 @@ class Connect(object):
code = ex.code
status = getUnicode(getattr(ex, "reason", None) or getSafeExString(ex).split(": ", 1)[-1])
kb.originalCode = kb.originalCode or code
if not (code == _http_client.UNAUTHORIZED and authVersion is not None):
kb.originalCode = kb.originalCode or code
threadData.lastHTTPError = (threadData.lastRequestUID, code, status)
kb.httpErrorCodes[code] = kb.httpErrorCodes.get(code, 0) + 1
@@ -913,7 +920,15 @@ class Connect(object):
singleTimeLogMessage(errMsg, logging.CRITICAL)
raise SystemExit
if ex.code not in (conf.ignoreCode or []):
if ex.code == _http_client.UNAUTHORIZED and authVersion is not None and authrefresh.sameOrigin(ex.geturl(), url):
if kwargs.get("authRetried"):
with kb.locks.authRefresh:
if authVersion == kb.authRefreshVersion:
kb.authRefreshFailed = True
raise SqlmapConnectionException("authentication refresh did not authorize the retried request (401)")
authrefresh.refresh(authVersion)
authRetry = True
elif ex.code not in (conf.ignoreCode or []):
if ex.code == TOO_MANY_REQUESTS_HTTP_CODE or (ex.code == _http_client.SERVICE_UNAVAILABLE and Connect._parseRetryAfter(responseHeaders) is not None):
retried = Connect._rateLimitRetry(responseHeaders, ex.code, **kwargs)
if retried is not None:
@@ -1072,6 +1087,10 @@ class Connect(object):
errMsg += "function '%s' ('%s')" % (function.__name__, getSafeExString(ex))
raise SqlmapGenericException(errMsg)
# a postprocess function may replace responseHeaders with a plain dict lacking
# the internal '.headers' attribute expected further down (e.g. in processResponse())
responseHeaders = patchHeaders(responseHeaders)
if isinstance(page, six.binary_type):
if HTTP_HEADER.CONTENT_TYPE in (responseHeaders or {}) and not re.search(TEXT_CONTENT_TYPE_REGEX, responseHeaders[HTTP_HEADER.CONTENT_TYPE]):
page = six.text_type(page, errors="ignore")
@@ -1095,6 +1114,12 @@ class Connect(object):
socket.setdefaulttimeout(conf.timeout)
if authRetry:
# Authentication latency is not evidence of a time-based SQL injection.
threadData.authRefreshTime += calculateDeltaSeconds(start)
kwargs["authRetried"] = True
return Connect.getPage(**kwargs)
# Dirty patch for Python3.11.0a7 (e.g. https://github.com/sqlmapproject/sqlmap/issues/5091)
if not sys.version.startswith("3.11."):
if conf.retryOn and re.search(conf.retryOn, page or "", re.I):
@@ -1634,7 +1659,7 @@ class Connect(object):
match = re.search(r"['\"]", post)
if match:
quote = match.group(0)
post = re.sub(r"\}\Z", "%s%s}" % (',' if re.search(r"\w", post) else "", "%s%s%s:%s" % (quote, name, quote, entry if entry.isdigit() else "%s%s%s" % (quote, entry, quote))), post)
post = re.sub(r"\}\Z", "%s%s}" % (',' if re.search(r"\w", post) else "", "%s%s%s:%s" % (quote, name, quote, entry if entry.isdigit() else "%s%s%s" % (quote, entry.replace('\\', r'\\'), quote))), post)
else:
post += "%s%s=%s" % (delimiter, name, entry)
elif get is not None:
@@ -1747,6 +1772,8 @@ class Connect(object):
start = time.time()
authRefreshTime = threadData.authRefreshTime
if kb.nullConnection and not content and not response and not timeBasedCompare:
noteResponseTime = False
@@ -1802,7 +1829,7 @@ class Connect(object):
return value
page, headers, code = Connect.getPage(url=_(kb.secondReq[0]), post=_(kb.secondReq[2]), method=kb.secondReq[1], cookie=kb.secondReq[3], silent=silent, auxHeaders=dict(auxHeaders, **dict(kb.secondReq[4])), response=response, raise404=False, ignoreTimeout=timeBasedCompare, refreshing=True)
threadData.lastQueryDuration = calculateDeltaSeconds(start)
threadData.lastQueryDuration = max(0, calculateDeltaSeconds(start) - (threadData.authRefreshTime - authRefreshTime))
kb.originalCode = code if kb.originalCode is None else kb.originalCode
kb.originalPage = page if kb.originalPage is None else kb.originalPage
+55 -22
View File
@@ -235,8 +235,9 @@ class InteractshDNSServer(object):
is a drop-in for conf.dnsServer.
"""
_POLL_TRIES = 6 # a triggered lookup surfaces at interactsh within a couple of seconds;
_POLL_DELAY = 1.0 # poll up to ~6s per retrieval before treating the channel as silent
# Default time budget (seconds) pop() spends waiting on the poller queue for a
# not-yet-arriving captured lookup. Overridable per instance for fast tests.
_POP_DEADLINE = 6
def __init__(self, server=None):
from lib.request.interactsh import Interactsh, hasCrypto
@@ -249,42 +250,74 @@ class InteractshDNSServer(object):
if not self._client.registered:
raise socket.error("could not register with an interactsh interaction server")
# One background poller drains the remote session; pop() consumes from its in-memory
# queue with zero network calls.
self._client._ensurePoller()
self.domain = self._client.dnsDomain()
self._seen = set()
self._running = True
self._initialized = True
def stop(self):
"""Best-effort shutdown: stop the single background poller (join it) then deregister.
stop() also deregisters, and the poller is the only code that drains the remote
session, so it must be stopped before deregistering."""
self._running = False
client = getattr(self, "_client", None)
if client is not None:
client.stop()
def run(self):
"""No background listener is needed - interactsh does the receiving."""
pass
def pop(self, prefix=None, suffix=None):
def _matchesDns(self, record, prefix, suffix):
# Protocol gate: the shared poller queues dns/http/https together, so a record can land in
# the queue for another consumer. Never hand an HTTP record to the DNS channel - doing so
# would make pop() return a request line as though it were a DNS lookup, and the HTTP
# consumer would then lose that interaction (the exact cross-consumer loss the shared queue
# was introduced to prevent).
if record.get("protocol") != "dns":
return False
name = record.get("full-id")
if not name:
return False
if name in self._seen: # already handed out once, never return it again
return False
if prefix is None and suffix is None:
return True
if prefix and suffix and re.search(
r"%s\..+\.%s" % (re.escape(prefix), re.escape(suffix)), name, re.I):
return True
return False
def pop(self, prefix=None, suffix=None, deadline=None, sync=False):
"""
Returns a captured DNS lookup name matching the given prefix/suffix
(prefix.<query result>.suffix.<correlation domain>), mirroring DNSServer.pop().
Unlike the synchronous local DNSServer (which reads a query captured during the
very request), interactsh is remote and eventually-consistent: a just-triggered
lookup takes a moment to reach the collector and surface via its poll API. So we
poll a few times before giving up, instead of reading once.
lookup takes a moment to reach the collector and surface via its poll API. The
background poller stages matching names in a queue, so pop() waits on that queue
(no network call) until the ~6s deadline, then returns None.
``deadline`` is the number of seconds to wait for a not-yet-arriving lookup
(``deadline=0`` is a snapshot of the current queue with no wait); ``None`` selects the
class default (``_POP_DEADLINE``). This None semantics differs from ``_consume``, where
``None`` means "don't wait" - here it means "wait the default". It is resolved here to
an absolute timestamp for the poller's Condition. ``sync`` forces the direct-poll
fallback path.
"""
for attempt in range(self._POLL_TRIES):
for name in self._client.dnsNames():
if name in self._seen:
continue
if prefix is None and suffix is None:
self._seen.add(name)
return name
if prefix and suffix and re.search(r"%s\..+\.%s" % (re.escape(prefix), re.escape(suffix)), name, re.I):
self._seen.add(name)
return name
if attempt < self._POLL_TRIES - 1:
time.sleep(self._POLL_DELAY)
selector = lambda record: self._matchesDns(record, prefix, suffix)
# deadline=None -> use the class default budget; a number -> that many seconds.
budget = self._POP_DEADLINE if deadline is None else deadline
matched = self._client._consume(selector, deadline=time.time() + budget, sync=sync)
if matched:
matched_id = matched[0].get("full-id")
self._seen.add(matched_id)
return matched_id
return None
if __name__ == "__main__":
+2 -2
View File
@@ -1576,7 +1576,7 @@ def proxy_tuple(proxy, cred=None):
def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redirects=True,
max_redirects=10, proxy=None, verify=False, ssl_context=None):
max_redirects=10, proxy=None, verify=False, ssl_context=None, sensitive_headers=()):
req_headers = headers or {}
current_method = method
current_body = body
@@ -1614,7 +1614,7 @@ def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redi
old_parts = parts
new_parts = urlsplit(new_url)
if _origin_tuple(old_parts) != _origin_tuple(new_parts):
req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS)
req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS.union(_as_bytes(_).lower() for _ in sensitive_headers))
method_bytes = _as_bytes(current_method)
change_to_get = status == 303 and method_bytes != b"HEAD"
+5 -2
View File
@@ -28,6 +28,8 @@ except ImportError:
pass
_protocols = filterNone(getattr(ssl, _, None) for _ in ("PROTOCOL_TLS_CLIENT", "PROTOCOL_TLSv1_2", "PROTOCOL_TLSv1_1", "PROTOCOL_TLSv1", "PROTOCOL_SSLv3", "PROTOCOL_SSLv23", "PROTOCOL_SSLv2"))
# Note: SNI (i.e. server_hostname) is a TLS extension, hence only those are used in here (Note: PROTOCOL_TLSv1 is not compiled in on OpenSSL builds without TLS 1.0 support)
_tlsProtocols = filterNone(getattr(ssl, _, None) for _ in ("PROTOCOL_TLS_CLIENT", "PROTOCOL_TLSv1_2", "PROTOCOL_TLSv1_1", "PROTOCOL_TLSv1"))
_lut = dict((getattr(ssl, _), _) for _ in dir(ssl) if _.startswith("PROTOCOL_"))
_contexts = {}
@@ -64,7 +66,7 @@ class HTTPSConnection(_http_client.HTTPSConnection):
# Reference(s): https://docs.python.org/2/library/ssl.html#ssl.SSLContext
# https://www.mnot.net/blog/2014/12/27/python_2_and_tls_sni
if hasattr(ssl, "SSLContext"):
for protocol in (_ for _ in _protocols if _ >= ssl.PROTOCOL_TLSv1):
for protocol in (_ for _ in _protocols if _ in _tlsProtocols):
sock = None
try:
sock = create_sock()
@@ -91,7 +93,8 @@ class HTTPSConnection(_http_client.HTTPSConnection):
else:
for header, value in conf.httpHeaders:
if header.lower() == "host":
hostname = value
match = re.search(r"\A\[(?P<hostname>.+)\](:\d+)?\Z", value.strip())
hostname = match.group("hostname") if match else value.rsplit(":", 1)[0] if ":" in value else value
break
hostname = hostname if re.search(r"\A[\d.]+\Z", hostname or "") is None else None
result = _contexts[protocol].wrap_socket(sock, do_handshake_on_connect=True, server_hostname=hostname)
+376 -4
View File
@@ -6,8 +6,11 @@ See the file 'LICENSE' for copying permission
"""
import base64
import collections
import threading
import json
import time
import hashlib
from lib.core.common import randomStr
from lib.core.convert import getBytes
@@ -51,6 +54,13 @@ class Interactsh(object):
self.registered = False
self._key = None
self._dnsNonce = None
self._poller = None
# Guards the poller create/start decision so two callers racing on an unstarted
# session cannot each build their own poller and destructively poll one session.
self._pollerLock = threading.Lock()
# Set under _pollerLock while stop() tears down: blocks _ensurePoller from restarting the
# poller in the gap between stop() and close()/deregister().
self._stopping = False
if not _HAS_CRYPTO:
return
@@ -102,12 +112,20 @@ class Interactsh(object):
handlers.append(ProxyHandler({"http": conf.proxy, "https": conf.proxy}))
request = _Request(url, data=getBytes(post) if post is not None else None, headers=headers)
response = build_opener(*handlers).open(request, timeout=conf.get("timeout") or 30)
# request_timeout() is the single source of truth for every interactsh network op so
# InteractshPoller.stop() can join for exactly one /poll network call.
response = build_opener(*handlers).open(request, timeout=self.request_timeout())
return getText(response.read())
except Exception as ex:
logger.debug("OOB request to '%s' failed: %s" % (url, getText(ex)))
return None
def request_timeout(self):
"""Network timeout (seconds) applied to every /poll and /deregister request. Interactsh
derives its /poll network timeout identically, so InteractshPoller.stop() can join for
exactly one of these before the thread is guaranteed to have exited."""
return conf.get("timeout") or _DEFAULT_NETWORK_TIMEOUT
def url(self):
"""Return a fresh unique callback URL (host = correlationId + nonce)."""
nonce = randomStr(OOB_NONCE_LENGTH, lowercase=True)
@@ -122,9 +140,120 @@ class Interactsh(object):
return "%s%s.%s" % (self.correlationId, self._dnsNonce, self.server)
def dnsNames(self):
"""Poll and return the fully-qualified names (minus the server suffix) of the
DNS lookups captured so far, e.g. 'prefix.<hex>.suffix.<correlationId><nonce>'."""
return [_.get("full-id") for _ in self.poll() if _.get("protocol") == "dns" and _.get("full-id")]
"""Return the fully-qualified names (minus the server suffix) of the DNS lookups
captured so far, e.g. 'prefix.<hex>.suffix.<correlationId><nonce>'.
This is a *snapshot* over the single background poller's in-memory queue: it ensures the
poller is running and drains whatever it has already staged, so it does not itself issue
a fresh /poll. On the very first call after registration the poller may not have finished
its first round-trip yet and can therefore return [] even though interactions exist -
retry the call rather than treating that first call as authoritative."""
return [_.get("full-id")
for _ in self._consume(
lambda _: _.get("protocol") == "dns" and _.get("full-id"),
None)]
def httpRequests(self):
"""Return the captured HTTP interactions (collector half of an HTTP-based out-of-band
data channel). Unlike DNS, where the value rides the queried hostname, an HTTP callback
carries its payload in the request line/body - so the interaction itself is the evidence.
interactsh stores each record with a ``raw-request`` (the full dumped request), a
``protocol`` (``http`` or ``https``) and a ``remote-address`` - it does not pre-parse the
path/query. A value with no safe-in-URL character set is therefore embedded in a
query-string *parameter name* (the path stays a constant request line and only the
attacker-controlled name varies) and the decoded names are returned here.
"""
retVal = []
for _ in self._consume(lambda _: _.get("protocol") in ("http", "https"), None):
retVal.append(self._renderHttpRequest(_))
return retVal
def _renderHttpRequest(self, record):
interaction = {
"full-id": record.get("full-id") or "",
"client": record.get("remote-address") or record.get("client") or "",
"request": record.get("raw-request") or "",
"query-param-names": [],
}
raw = interaction["request"]
target = _parseHttpRequestTarget(raw) if raw else (record.get("full-id") or "")
if target:
interaction["query-param-names"] = _extractQueryStringNames(target)
return interaction
def _ensurePoller(self):
"""Start the single background /poll consumer (idempotent). The poller is the only code
that calls the remote endpoint; DNS/HTTP consumers then read the same records from
memory, so they never drain each other. No-op without crypto, since /poll cannot then be
decrypted."""
if not _HAS_CRYPTO:
return
# The whole create-or-reuse-and-start sequence is under one lock, so exactly one
# background poller is ever created and started for this session. A poller must only run
# for a live, registered session: skip if the session was never registered, and skip if
# stop() is in progress (so no poller can be restarted between stop() and deregister()).
with self._pollerLock:
if not self.registered or self._stopping:
return
poller = getattr(self, "_poller", None)
if poller is None:
self._poller = poller = InteractshPoller(self)
if not poller.isRunning():
poller.start()
def _consume(self, selector, deadline=None, sync=False):
"""Return records matching selector.
For normal registered operation the single background poller drains the remote session
(zero extra network calls from the consumer, and exactly one caller ever drains it), so
we ensure it is running and read its in-memory queue. ``sync=True`` forces the direct
``poll()`` path for the deprecated fallback / explicit testing.
``deadline`` is an absolute timestamp: ``None`` drains the current queue only (no wait),
used by snapshot-style callers such as ``httpRequests``/``dnsNames``.
Outside ``sync=True`` only the background poller ever calls the remote ``poll()`` - the
invariant this method enforces. A stale poller (never started, or started then stopped by
``stop()``) yields ``[]`` rather than a second, unsynchronised ``poll()`` that could
destructively drain the shared session from the wrong caller.
``sync=True`` forces the direct-poll path, but only for a live, registered session that
has no background poller already draining it - the poller is the only code that is allowed
to call the remote endpoint, so a spurious ``sync=True`` during normal operation drains the
running poller's queue instead of adding a second destructive ``poll()`` caller. Once
``stop()`` has begun, no caller initiates another remote ``poll()`` - not this
compatibility path either."""
if sync:
with self._pollerLock:
if self._stopping or not self.registered:
return []
poller = getattr(self, "_poller", None)
if poller is None or not poller.isRunning():
# Keep the lock across this compatibility poll so _ensurePoller() cannot start
# a background poller concurrently in the gap between the check and poll()
# (which would then become a second /poll caller).
return [record for record in self.poll() if self._safeSelector(selector, record)]
# Releasing the lock before draining the poller's queue keeps _ensurePoller() free too.
return poller.drain_matching(selector, deadline)
self._ensurePoller()
poller = getattr(self, "_poller", None)
if poller is not None and poller.isRunning():
return poller.drain_matching(selector, deadline)
return []
def _safeSelector(self, selector, record):
try:
return bool(selector(record))
except Exception as ex:
logger.debug("interactsh selector error: %s" % getText(ex))
return False
def poll(self):
"""Return the list of decrypted interaction records captured so far."""
@@ -168,8 +297,251 @@ class Interactsh(object):
return interactions
return []
def stop(self):
"""Stop the background poller (join it) then deregister the interaction.
The poller is the only code that drains the remote /poll session, so it must be
stopped before deregistering; stopping it first also prevents it from re-staging
interactions after close() is called (best-effort on a dead/broken poller).
Invariant: after stop()/close() returns, this object owns no active polling thread. If the
poller cannot be stopped within the budget it is deliberately left running (and this
interaction left registered) so a deregister can never race a still-active /poll drain -
a later stop()/close() re-runs it and deregisters once the thread has exited."""
# Set _stopping and capture the poller under _pollerLock (the start/stop race lives under
# that lock); release the lock before joining so a concurrent _ensurePoller() caller is
# not blocked for a whole join. The lock prevents a restart of this same poller between here
# and deregister(); only the (now-dead) poller is joined.
poller = None
with self._pollerLock:
self._stopping = True
poller = getattr(self, "_poller", None)
stopped = True
if poller is not None:
try:
stopped = poller.stop()
except Exception:
logger.debug("error while stopping interactsh poller")
stopped = False
if not stopped:
# A /poll network call is still draining the session; skip deregister this time and let
# the next stop()/close() deregister once the thread has actually exited.
return
self._deregister()
def close(self):
"""Canonical complete teardown: stop the poller AND deregister.
close() predates the background-poller addition, so every existing/future caller can
keep using it without learning about stop(); making it do the full shutdown means the
invariant holds regardless of which entry point is taken: after close() returns this
object owns no active polling thread."""
self.stop()
def _deregister(self):
if self.registered:
body = json.dumps({"correlation-id": self.correlationId, "secret-key": self.secret})
self._request("https://%s/deregister" % self.server, post=body)
self.registered = False
# Delay between two remote /poll round-trips in the background poller.
_POP_POLL_DELAY = 1.0
# Network timeout (seconds) applied to each interactsh /poll and /deregister request (see
# Interactsh._request). InteractshPoller.stop() joins for one of these (with a little slack) so a
# /poll wedged at the remote endpoint cannot keep a dead poller's thread alive past stop().
_DEFAULT_NETWORK_TIMEOUT = 30
def _parseHttpRequestTarget(raw_request):
"""Return the request target (undecoded) from the first line of a raw HTTP request.
It is deliberately left raw: decoding before splitting on '&'/'=' would corrupt encoded
delimiters (a value with an unsafe-in-URL character set may hit the same host as its own
name, e.g. ``?aa%26bb=1`` must parse to one name ``aa&bb``, not two). Downstream code decodes
each name individually.
"""
if not raw_request:
return ""
first_line = raw_request.splitlines()[0]
parts = first_line.split(None, 2)
if len(parts) >= 2:
target = parts[1]
else:
target = first_line
return target
def _unquote(value):
try:
from urllib import unquote
except ImportError:
from urllib.parse import unquote
return unquote(value)
def _extractQueryStringNames(raw_target):
"""Return the ordered, de-duplicated query-string parameter names from a raw (undecoded) target.
Split the raw target on literal '&' and the first raw '=', then percent-decode only each
parameter name individually so encoded delimiters (``%26`` '&', ``%3D`` '=', ``%25`` '%') are
preserved in the decoded name rather than misinterpreted as structure.
"""
try:
query = raw_target.split("?", 1)[1]
except IndexError:
return []
names = []
for pair in query.split("&"):
if not pair:
continue
encoded_name = pair.split("=", 1)[0]
name = _unquote(encoded_name)
if name and name not in names:
names.append(name)
return names
class InteractshPoller(object):
"""A single background consumer of one interactsh session: one thread polls /poll and queues
complete interaction records under a Condition, so the DNS and HTTP consumers read the same
records from memory without ever calling the remote endpoint twice or draining each other."""
def __init__(self, client):
self._client = client
self._queue = collections.deque()
self._lock = threading.RLock()
self._cond = threading.Condition(self._lock)
self._seen = set()
self._running = False
self._thread = None
def start(self):
"""Start the poller (idempotent)."""
with self._lock:
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._loop)
self._thread.daemon = True
self._thread.start()
def stop(self):
"""Stop the poller and join the thread until it actually exits.
Returns True if the poll thread has exited, False if it could not be stopped within the
budget (its in-flight /poll request is still running). stop() flips _running and wakes the
loop, so the thread exits after its current /poll returns - join therefore only has to
outlast one /poll network operation, whose timeout is derived from the client's request
timeout. Joining is retried on every call, so a second stop() after a failed first still
joins the (now-sleeping-loop) thread instead of bailing out before _running was read.
Only a genuinely hung /poll network call exceeds the budget; the common case exits the
instant the loop wakes. This is why the stated invariant holds strictly - it is retried,
not capped at a guessed timeout."""
with self._lock:
self._running = False
self._cond.notify_all()
thread = self._thread
if not thread or not thread.is_alive():
return True
# stop() has already woken the loop, so the thread exits after its current /poll returns.
# One join that outlasts one /poll network call is enough; the slack covers the loop's own
# work before it re-checks _running. A hung request is guarded by retrying stop().
fn = getattr(self._client, "request_timeout", None)
network_timeout = fn() if callable(fn) else _DEFAULT_NETWORK_TIMEOUT
thread.join(network_timeout + _POP_POLL_DELAY)
return not thread.is_alive()
def isRunning(self):
return self._running and self._thread is not None and self._thread.is_alive()
def _loop(self):
while self._running:
try:
records = self._client.poll()
with self._cond:
# Recheck _running after poll() returns: stop() may have set it False while the
# network call was in flight. Drop the just-received records without staging
# them, so a stopped poller can never repopulate its queue after shutdown.
if not self._running:
break
for record in records:
# Only queue interaction protocols sqlmap consumes (dns/http/https). Other
# interaction types (smtp/ldap/...) would never be drained by sqlmap's
# consumers and would otherwise accumulate in _queue for the whole session;
# bounding the queue to what we actually use keeps it from growing.
if record.get("protocol") not in ("dns", "http", "https"):
continue
# Fingerprint the *interaction*, not the payload host ("full-id").
# protocol + full-id + unique-id only identify the receiving subdomain
# (a single host drives many interactions); timestamp is a separate, per-
# interaction field that keeps two identical requests to that host distinct.
# Per-record try: a malformed interaction (a non-string field making the
# join raise, or a bytes value getBytes() cannot decode) throws here. Keeping
# it inside the per-record handler means one odd interaction skips itself
# without aborting every later interaction in the already-drained batch, which
# the outer batch-level except would otherwise do.
try:
rid_str = "|".join((record.get("protocol") or "",
record.get("full-id") or "",
record.get("unique-id") or "",
record.get("timestamp") or "",
record.get("raw-request") or "",
record.get("remote-address") or ""))
# Hash before storing so _seen never retains a full raw-request (bounded
# to a fixed-length hexdigest); the review noted raw-request retention
# grew _seen for the whole session. Hashlib is only consulted when we
# have a record to fingerprint.
rid = hashlib.sha256(getBytes(rid_str)).hexdigest()
except Exception as ex:
logger.debug("interactsh fingerprint error: %s" % getText(ex))
continue
if rid in self._seen:
continue
self._seen.add(rid)
self._queue.append(record)
self._cond.notify_all()
except Exception as ex:
logger.debug("interactsh poller poll error: %s" % getText(ex))
if self._running:
time.sleep(_POP_POLL_DELAY)
def _safeSelector(self, selector, record):
try:
return bool(selector(record))
except Exception as ex:
logger.debug("interactsh selector error: %s" % getText(ex))
return False
def drain_matching(self, selector, deadline=None):
"""Return all currently-queued records matching selector (removing them).
``deadline=None`` drains the current queue with no wait (snapshot-style callers such as
``httpRequests``/``dnsNames``); only ``InteractshDNSServer.pop`` passes an absolute
deadline to wait for new arrivals. The wait and the scan share one condition critical
section so a producer cannot enqueue+notify between releasing and reacquiring the lock,
which would otherwise stall another 0.5 s even when a match already exists."""
while True:
with self._cond:
matched = [record for record in self._queue if self._safeSelector(selector, record)]
if matched:
for record in matched:
self._queue.remove(record)
return matched
# Check _running before the first wait as well: stop() may have fired between the
# scan and a wait() call, so the matching notification has already happened - a bare
# wait() would otherwise sleep the whole budget for nothing.
if not self._running:
return []
if deadline is None:
return []
remaining = deadline - time.time()
if remaining <= 0:
return []
self._cond.wait(timeout=remaining)
# Wake-up was not for a match; if stop() fired in the meantime no producer can
# ever enqueue one, so exit promptly instead of looping for the full deadline.
if not self._running:
return []
+4
View File
@@ -32,6 +32,7 @@ from lib.core.settings import MAX_TOTAL_REDIRECTIONS
from lib.core.threads import getCurrentThreadData
from lib.request.basic import decodePage
from lib.request.basic import parseResponse
from lib.request import authrefresh
from thirdparty import six
from thirdparty.six.moves import http_client as _http_client
from thirdparty.six.moves import urllib as _urllib
@@ -176,6 +177,9 @@ class SmartRedirectHandler(_urllib.request.HTTPRedirectHandler):
req.headers[HTTP_HEADER.COOKIE] = delimiter.join("%s=%s" % (key, cookies[key]) for key in cookies)
try:
if conf.authRefresh and not authrefresh.sameOrigin(req.get_full_url(), redurl):
req.headers = authrefresh.stripHeaders(req.headers)
req.unredirected_hdrs = authrefresh.stripHeaders(req.unredirected_hdrs)
result = _urllib.request.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers)
except _urllib.error.HTTPError as ex:
result = error = ex
+25 -9
View File
@@ -67,6 +67,7 @@ from lib.core.settings import INFERENCE_MARKER
from lib.core.settings import INFERENCE_NOT_EQUALS_CHAR
from lib.core.settings import INFERENCE_UNKNOWN_CHAR
from lib.core.settings import MAX_BISECTION_LENGTH
from lib.core.settings import MAX_INT
from lib.core.settings import MAX_REVALIDATION_STEPS
from lib.core.settings import NULL
from lib.core.settings import PARTIAL_HEX_VALUE_MARKER
@@ -108,6 +109,19 @@ def normalizedExpression(expression):
return retVal
def replaceInferenceOperator(payload, replacement):
"""
Replaces only the inference comparison operator (i.e. the one right in front of the compared
value placeholder), leaving any other occurrence (e.g. inside the original parameter value) intact
>>> replaceInferenceOperator("1FF>6182 AND ASCII(SUBSTRING((%s),%d,1))>%d", "!=")
'1FF>6182 AND ASCII(SUBSTRING((%s),%d,1))!=%d'
>>> replaceInferenceOperator("a>b AND SUBSTR((%s),%d,1)>'%c'", "=")
"a>b AND SUBSTR((%s),%d,1)='%c'"
"""
return re.sub(r"%s(?=%%d|'%%c')" % re.escape(INFERENCE_GREATER_CHAR), lambda match: replacement, payload)
def getHuffmanPrior(order, scale, dbms=None):
"""
Character-level order-N Markov model {context: {ordinal: count}} used to warm the Huffman
@@ -349,7 +363,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
elif Backend.isDbms(DBMS.FRONTBASE):
match = re.search(r"\ASELECT\b(\s+TOP\s*\([^)]+\)\s+)?(.+)\bFROM\b(.+)\Z", expression, re.I)
if match:
payload = payload.replace(INFERENCE_GREATER_CHAR, " FROM %s)%s" % (match.group(3).strip(), INFERENCE_GREATER_CHAR))
payload = replaceInferenceOperator(payload, " FROM %s)%s" % (match.group(3).strip(), INFERENCE_GREATER_CHAR))
payload = payload.replace("SUBSTRING", "(SELECT%sSUBSTRING" % (match.group(1) if match.group(1) else " "), 1)
expression = match.group(2).strip()
@@ -508,9 +522,9 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
markingValue = "'%s'" % CHAR_INFERENCE_MARK
unescapedCharValue = unescaper.escape("'%s'" % decodeIntToUnicode(posValue))
forgedPayload = agent.extractPayload(payload) or ""
forgedPayload = replaceInferenceOperator(agent.extractPayload(payload) or "", INFERENCE_EQUALS_CHAR)
forgedPayload = forgedPayload.replace(markingValue, unescapedCharValue)
forgedPayload = safeStringFormat(forgedPayload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, posValue))
forgedPayload = safeStringFormat(forgedPayload, (expressionUnescaped, idx, posValue))
result = Request.queryPage(agent.replacePayload(payload, forgedPayload), timeBasedCompare=timeBasedCompare, raise404=False)
incrementCounter(getTechnique())
@@ -529,7 +543,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
threadData = getCurrentThreadData()
validationPayload = re.sub(r"(%s.*?)%s(.*?%s)" % (PAYLOAD_DELIMITER, INFERENCE_GREATER_CHAR, PAYLOAD_DELIMITER), r"\g<1>%s\g<2>" % INFERENCE_NOT_EQUALS_CHAR, payload)
validationPayload = replaceInferenceOperator(payload, INFERENCE_NOT_EQUALS_CHAR) if PAYLOAD_DELIMITER in payload else payload
if "'%s'" % CHAR_INFERENCE_MARK not in payload:
forgedPayload = safeStringFormat(validationPayload, (expressionUnescaped, idx, value))
@@ -667,7 +681,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
# If it disagrees, the IN() channel is unreliable here: latch the technique off so the
# classic '>' bisection takes over for the rest of the run (graceful fallback).
if not kb.huffmanValidated:
verifyPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, value))
verifyPayload = safeStringFormat(replaceInferenceOperator(payload, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, value))
verified = Request.queryPage(verifyPayload, timeBasedCompare=timeBasedCompare, raise404=False)
incrementCounter(getTechnique())
if verified:
@@ -738,7 +752,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
return None
elif len(charTbl) == 1:
forgedPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, charTbl[0]))
forgedPayload = safeStringFormat(replaceInferenceOperator(payload, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, charTbl[0]))
result = Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
incrementCounter(getTechnique())
@@ -864,7 +878,9 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
# elements we use a xrange, which is a virtual
# list
if expand and shiftTable:
charTbl = xrange(maxChar + 1, (maxChar + 1) << shiftTable.pop())
# Cap the expansion so its length never exceeds what len() can report
# (a run of all-true probes would otherwise overflow on 32-bit builds)
charTbl = xrange(maxChar + 1, min((maxChar + 1) << shiftTable.pop(), maxChar + 1 + MAX_INT))
originalTbl = xrange(charTbl[0], charTbl[-1] + 1)
maxChar = maxValue = charTbl[-1]
minValue = charTbl[0]
@@ -960,7 +976,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
choice = sorted(bits.items(), key=lambda _: abs(_[1]))[0][0]
mask = 1 << choice
forgedPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, "&%d%s" % (mask, INFERENCE_GREATER_CHAR)), (expressionUnescaped, idx, 0))
forgedPayload = safeStringFormat(replaceInferenceOperator(payload, "&%d%s" % (mask, INFERENCE_GREATER_CHAR)), (expressionUnescaped, idx, 0))
result = Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
incrementCounter(getTechnique())
@@ -972,7 +988,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
bit += 1
if candidates:
forgedPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, candidates[0]))
forgedPayload = safeStringFormat(replaceInferenceOperator(payload, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, candidates[0]))
result = Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
incrementCounter(getTechnique())
+4 -1
View File
@@ -74,7 +74,10 @@ def dnsUse(payload, expression):
if Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.PGSQL):
query = agent.prefixQuery("; %s" % expressionUnescaped)
query = "%s%s" % (query, queries[Backend.getIdentifiedDbms()].comment.query)
if conf.suffix is not None:
query = agent.suffixQuery(query, comment="", suffix=conf.suffix)
else:
query = "%s%s" % (query, queries[Backend.getIdentifiedDbms()].comment.query)
forgedPayload = agent.payload(newValue=query)
else:
forgedPayload = safeStringFormat(payload, (expressionUnescaped, randomInt(1), randomInt(3)))
+5 -2
View File
@@ -37,6 +37,7 @@ from lib.core.common import singleTimeDebugMessage
from lib.core.common import singleTimeWarnMessage
from lib.core.common import unArrayizeValue
from lib.core.common import wasLastResponseDBMSError
from lib.core.common import zeroDepthSearch
from lib.core.compat import xrange
from lib.core.convert import decodeBase64
from lib.core.convert import getUnicode
@@ -344,8 +345,10 @@ def unionUse(expression, unpack=True, dump=False):
kb.partRun = getPartRun(alias=False) if (conf.api or conf.reportJson) else None
if expressionFieldsList and len(expressionFieldsList) > 1 and "ORDER BY" in expression.upper():
# Removed ORDER BY clause because UNION does not play well with it
expression = re.sub(r"(?i)\s*ORDER BY\s+[\w,]+", "", expression)
# Only remove outer ordering. Nested ORDER BY clauses can select which
# rows survive a ROWNUM/TOP limit and must remain intact.
positions = set(index for keyword in set(re.findall(r"(?i)ORDER BY", expression)) for index in zeroDepthSearch(expression, keyword))
expression = re.sub(r"(?i)ORDER BY\s+[\w,]+", lambda match: "" if match.start() in positions else match.group(0), expression)
debugMsg = "stripping ORDER BY clause from statement because "
debugMsg += "it does not play well with UNION query SQL injection"
singleTimeDebugMessage(debugMsg)
+22 -3
View File
@@ -265,9 +265,19 @@ def writeReportJson(collector, filepath):
"""
Writes the collected results to filepath as JSON, in the same shape as the REST API's
/scan/<id>/data response, wrapped with a small 'meta' block for standalone consumers.
A multi-target run (e.g. '-m' bulk file) stores each target under its own taskid (see
kb.reportTaskId), so every taskid present in the collector is assembled here; a single-target
run still yields exactly one taskid and keeps the flat {success, data, error} shape.
"""
result = _assembleData(collector, REPORT_TASKID)
taskids = sorted(row[0] for row in collector.execute("SELECT DISTINCT taskid FROM data")) or [REPORT_TASKID]
if len(taskids) > 1:
result = {"success": True, "targets": [_assembleData(collector, taskid) for taskid in taskids]}
else:
result = _assembleData(collector, taskids[0])
result["meta"] = {
"api_version": int(RESTAPI_VERSION.split(".")[0]), # MAJOR only - the part that matters for client compatibility
"sqlmap_version": VERSION_STRING,
@@ -434,7 +444,9 @@ class StdDbOut(object):
def write(self, value, status=CONTENT_STATUS.IN_PROGRESS, content_type=None):
if self.messagetype == "stdout":
_storeData(conf.databaseCursor, self.taskid, value, status, content_type)
# Note: per-target key in multi-target scans, so a later target's COMPLETE row doesn't delete an earlier one's (#2503)
taskid = "%s-%s" % (self.taskid, kb.reportTaskId) if kb.get("reportTaskId") else self.taskid
_storeData(conf.databaseCursor, taskid, value, status, content_type)
else:
conf.databaseCursor.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (self.taskid, str(value) if value else ""))
@@ -467,7 +479,8 @@ class ReportErrorRecorder(logging.Handler):
def emit(self, record):
try:
self.collector.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (REPORT_TASKID, str(record.msg % record.args if record.args else record.msg)))
taskid = kb.get("reportTaskId", REPORT_TASKID)
self.collector.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (taskid, str(record.msg % record.args if record.args else record.msg)))
except Exception:
pass
@@ -807,6 +820,12 @@ def scan_data(taskid):
# Read all data and error messages from the IPC database (shared assembler - same output as --report-json)
result = _assembleData(DataStore.current_db, taskid)
# Note: fold in per-target rows ('<taskid>-<n>', see StdDbOut.write) of multi-target scans, in target order
prefix = "%s-" % taskid
subtaskids = [_[0] for _ in DataStore.current_db.execute("SELECT DISTINCT taskid FROM data WHERE taskid LIKE ?", (prefix + '%',)) if _[0][len(prefix):].isdigit()]
for subtaskid in sorted(subtaskids, key=lambda _: int(_[len(prefix):])):
result["data"].extend(_assembleData(DataStore.current_db, subtaskid)["data"])
logger.debug("(%s) Retrieved scan data and error messages" % taskid)
return jsonize(result)
+13
View File
@@ -79,6 +79,7 @@ from lib.core.enums import HASH
from lib.core.enums import MKSTEMP_PREFIX
from lib.core.exception import SqlmapDataException
from lib.core.exception import SqlmapUserQuitException
from lib.core.patch import dirtyPatches
from lib.core.settings import COMMON_PASSWORD_SUFFIXES
from lib.core.settings import COMMON_PASSWORDS
from lib.core.settings import COMMON_USER_COLUMNS
@@ -101,6 +102,16 @@ from thirdparty import six
from thirdparty.colorama.initialise import init as coloramainit
from thirdparty.six.moves import queue as _queue
# On Windows, multiprocessing's 'spawn' start method boots a fresh interpreter per cracking
# worker (_bruteProcessVariantA/B/Salted below) to unpickle its target function, which re-imports
# this module from scratch without ever running the main process's startup dirtyPatches() - so
# process-wide monkeypatches it installs (e.g. the 'reversible' codec error handler used by
# getUnicode()/openFile()) are missing in the worker, raising LookupError on every hashed entry
# (#6143). Placing the (idempotent) call here means that very re-import applies it. POSIX fork()
# workers inherit the parent's patches already, so this only needs to run on Windows.
if IS_WIN:
dirtyPatches()
def mysql_passwd(password, uppercase=True):
"""
Reference(s):
@@ -406,6 +417,8 @@ def crypt_generic_passwd(password, salt, **kwargs):
'rl.3StKT.4T8M'
"""
password = getText(password) # crypt.crypt() requires native str (unicode on Py3, bytes on Py2/PyPy), unlike the hashlib-based functions here
return getText(crypt(password, salt))
def unix_md5_passwd(password, salt, magic="$1$", **kwargs):
+9 -1
View File
@@ -89,6 +89,8 @@ def crackHMAC(token, secrets, limit=None):
's3cr3t'
>>> crackHMAC(token, ["admin", "letmein"]) is None
True
>>> crackHMAC("eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.x", ["secret"]) is None
True
"""
data = parseJWT(token)
@@ -97,7 +99,13 @@ def crackHMAC(token, secrets, limit=None):
fn = HMAC_ALGORITHMS[data["header"]["alg"].upper()]
signingInput = getBytes(data["signingInput"])
target = decodeBase64(data["signature"], binary=True)
try:
target = decodeBase64(data["signature"], binary=True)
except Exception:
# a signature segment that is not valid base64url (e.g. a truncated/malformed token that still
# matches the JWT pattern) cannot be an HMAC we could verify, so it is simply not crackable
return None
for index, secret in enumerate(secrets):
if limit is not None and index >= limit:
+9 -8
View File
@@ -12,6 +12,7 @@ from lib.core.common import getSafeExString
from lib.core.common import popValue
from lib.core.common import pushValue
from lib.core.common import urlencode
from lib.core.convert import decodeBase64
from lib.core.convert import getBytes
from lib.core.convert import getUnicode
from lib.core.data import conf
@@ -25,9 +26,8 @@ from lib.core.exception import SqlmapBaseException
from lib.core.settings import BING_REGEX
from lib.core.settings import DUCKDUCKGO_REGEX
from lib.core.settings import DUMMY_SEARCH_USER_AGENT
from lib.core.settings import GOOGLE_CONSENT_COOKIE
from lib.core.settings import GOOGLE_REGEX
from lib.core.settings import HTTP_ACCEPT_ENCODING_HEADER_VALUE
from lib.core.settings import SEARCH_ABUSE_MARKERS
from lib.core.settings import UNICODE_ENCODING
from lib.request.basic import decodePage
from thirdparty.six.moves import http_client as _http_client
@@ -89,7 +89,6 @@ def _search(dork):
requestHeaders = {
HTTP_HEADER.USER_AGENT: dict(conf.httpHeaders).get(HTTP_HEADER.USER_AGENT, DUMMY_SEARCH_USER_AGENT),
HTTP_HEADER.ACCEPT_ENCODING: HTTP_ACCEPT_ENCODING_HEADER_VALUE,
HTTP_HEADER.COOKIE: GOOGLE_CONSENT_COOKIE,
}
gpage = conf.googlePage if conf.googlePage > 1 else 1
@@ -97,11 +96,13 @@ def _search(dork):
encoded = urlencode(dork, convall=True)
# Note: (name, url, POST data, regex, regex flags, match->link). Ordered by current scraping reliability; tried in turn until one yields results (DuckDuckGo currently being the only consistently scrapeable one)
# Note: DuckDuckGo's first result page holds 10 entries, while each subsequent one holds 15 (hence the non-linear offset)
ddgOffset = 0 if gpage == 1 else 10 + (gpage - 2) * 15
# Note: (name, url, POST data, regex, regex flags, match->link). Ordered by current scraping reliability; tried in turn until one yields results (Google is not in here as it serves results only to JavaScript capable clients)
engines = (
("DuckDuckGo", "https://html.duckduckgo.com/html/", "q=%s&s=%d" % (encoded, (gpage - 1) * 30), DUCKDUCKGO_REGEX, re.I | re.S, lambda match: match.group(1).replace("&amp;", "&")),
("Bing", "https://www.bing.com/search?q=%s&first=%d" % (encoded, (gpage - 1) * 10 + 1), None, BING_REGEX, re.I | re.S, lambda match: match.group(1)),
("Google", "https://www.google.com/search?q=%s&num=100&hl=en&complete=0&safe=off&filter=0&btnG=Search&start=%d" % (encoded, (gpage - 1) * 100), None, GOOGLE_REGEX, re.I, lambda match: match.group(1) or match.group(2)),
("DuckDuckGo", "https://html.duckduckgo.com/html/", "q=%s&s=%d" % (encoded, ddgOffset), DUCKDUCKGO_REGEX, re.I | re.S, lambda match: match.group(1).replace("&amp;", "&")),
("Bing", "https://www.bing.com/search?q=%s&first=%d" % (encoded, (gpage - 1) * 10 + 1), None, BING_REGEX, re.I | re.S, lambda match: decodeBase64(match.group(1), binary=False) if match.group(1) else match.group(2).replace("&amp;", "&")),
)
for name, url, data, regex, flags, extract in engines:
@@ -123,7 +124,7 @@ def _search(dork):
break # Note: stop at the first engine that actually returns results (others are only fallbacks)
# Note: switch proxy (if available) when an abuse/captcha page was served (instead of pointlessly falling through to the next engine from the same blocked IP)
if conf.proxyList and (("detected unusual traffic" in page) or ("issue with the Tor Exit Node you are currently using" in page)):
if conf.proxyList and any(marker in page for marker in SEARCH_ABUSE_MARKERS):
warnMsg = "%s has detected 'unusual' traffic from the used IP address" % name
raise SqlmapBaseException(warnMsg)
+53
View File
@@ -0,0 +1,53 @@
#!/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 AUDIT_MAX_ROWS
from plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.MSSQL
# Snippets keep an outer WHERE for the existing blind TOP/NOT IN limiter.
queries = (("identity", ("audit_identity",)), ("permissions", ("audit_permissions",)),
("configuration", ("audit_configuration",)), ("linked servers", ("audit_links",)))
flags = dict((key, ("enabled", "disabled")) for key in ("xp_cmdshell", "clr enabled", "Ole Automation Procedures", "Ad Hoc Distributed Queries"))
flags.update(dict((key, ("yes", "no")) for key in ("sysadmin", "execute xp_cmdshell", "read configuration metadata", "read linked-server metadata")))
def failure(self, scope, groups):
permissions = groups.get("permissions") or {}
for key, name, target in (("read configuration metadata", "configuration", "master.sys.configurations"),
("read linked-server metadata", "linked servers", "master.sys.servers")):
if scope == name and permissions.get(key) == "0":
return "%s: SELECT permission missing on %s" % (scope, target)
return GenericAudit.failure(self, scope, groups)
def explain(self, groups):
configuration = groups.get("configuration") or {}
permissions = groups.get("permissions") or {}
identity = groups.get("identity") or {}
result = []
if identity.get("sysadmin") == "1" and configuration.get("xp_cmdshell") == "0":
if self._auditCanExecute():
result.append("Command execution: sysadmin can enable xp_cmdshell; use --os-cmd or --os-shell")
else:
result.append("Command execution: sysadmin can enable xp_cmdshell; no statement execution route identified")
elif configuration.get("xp_cmdshell") == "1" and permissions.get("execute xp_cmdshell") == "1":
if self._auditCanExecute():
result.append("Command execution: xp_cmdshell enabled and EXECUTE granted; use --os-cmd or --os-shell")
else:
result.append("Command execution: xp_cmdshell enabled and EXECUTE granted; no statement execution route identified")
elif permissions.get("execute xp_cmdshell") == "0":
result.append("Command execution: EXECUTE permission missing on master.dbo.xp_cmdshell")
elif configuration.get("xp_cmdshell") == "0":
result.append("Command execution: xp_cmdshell is disabled")
links = groups.get("linked servers") or {}
count = links.get("visible linked servers")
if count and count.isdigit() and int(count) > AUDIT_MAX_ROWS:
shown = sum(key.startswith("link ") for key in links)
result.append("Linked servers: showing %d of %s; use --links for the full list" % (shown, count))
return result
+44
View File
@@ -0,0 +1,44 @@
#!/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.enums import DBMS, FORK
from plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.MYSQL
flags = dict((key, ("enabled", "disabled")) for key in ("local_infile", "require_secure_transport", "read_only", "super_read_only", "general_log", "log_bin", "skip_networking", "skip_grant_tables"))
flags["direct FILE grant"] = ("present", "absent")
def __init__(self):
configuration = ("audit_configuration_mariadb", "audit_configuration") if Backend.isFork(FORK.MARIADB) else ("audit_configuration", "audit_configuration_mariadb")
self.queries = (("identity", ("audit_identity",)), ("configuration", configuration),
("file access", ("audit_file_access",)), ("privileges", ("audit_privileges",)))
def explain(self, groups):
facts = groups.get("file access") or {}
privileges = groups.get("privileges") or {}
result = []
disabled = False
if "secure_file_priv" in facts:
value = facts["secure_file_priv"]
# MariaDB can expose an unrestricted value as SQL NULL; MySQL can expose
# its disabled value as the literal string "NULL". Keep the raw fact.
disabled = (value is None or value.upper() == "NULL") and not Backend.isFork(FORK.MARIADB)
if disabled:
result.append("File operations: secure_file_priv=NULL blocks --file-read and --file-write")
elif not value:
result.append("File operations: no directory restriction from secure_file_priv")
else:
result.append("File operations: --file-read and --file-dest paths must be inside '%s'" % value)
if privileges.get("direct FILE grant") == "1" and not disabled:
message = "File read: FILE granted; use --file-read with a server-side path"
if facts.get("max_allowed_packet"):
message += " (file size < %s bytes)" % facts["max_allowed_packet"]
result.append(message)
return result
+1 -1
View File
@@ -34,7 +34,7 @@ class Connector(GenericConnector):
try:
self.connector = pymysql.connect(host=self.hostname, user=self.user, passwd=self.password, db=self.db, port=self.port, connect_timeout=conf.timeout, use_unicode=True)
except (pymysql.OperationalError, pymysql.InternalError, pymysql.ProgrammingError, struct.error) as ex:
except (pymysql.OperationalError, pymysql.InternalError, pymysql.ProgrammingError, struct.error, UnicodeEncodeError) as ex:
raise SqlmapConnectionException(getSafeExString(ex))
self.initCursor()
+23
View File
@@ -0,0 +1,23 @@
#!/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 AUDIT_MAX_ROWS
from plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.ORACLE
queries = (("identity", ("audit_identity",)), ("configuration", ("audit_configuration",)),
("privileges", ("audit_privileges",)), ("directory grants", ("audit_directories",)))
def explain(self, groups):
grants = groups.get("directory grants") or {}
count = grants.get("visible directory grants")
if count and count.isdigit() and int(count) > AUDIT_MAX_ROWS:
return ["Directory grants: showing %d of %s" % (len(grants) - 1, count)]
return []
+33
View File
@@ -0,0 +1,33 @@
#!/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 plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.PGSQL
queries = (("identity", ("audit_identity",)), ("configuration", ("audit_configuration",)),
("privileges", ("audit_privileges",)))
flags = dict((key, ("yes", "no")) for key in ("superuser", "bypass RLS", "pg_read_server_files", "pg_write_server_files", "pg_execute_server_program"))
def explain(self, groups):
privileges = groups.get("privileges") or {}
superuser = privileges.get("superuser") == "1"
result = []
if superuser or privileges.get("pg_read_server_files") == "1":
result.append("File read: server-file privilege granted; use --file-read with a server-side path")
if superuser and self._auditCanExecute():
result.append("File write: superuser and statement execution available; use --file-write and --file-dest")
if superuser or privileges.get("pg_execute_server_program") == "1":
if self._auditCanExecute():
result.append("Command execution: COPY FROM PROGRAM privilege granted; use --os-cmd or --os-shell")
else:
result.append("Command execution: COPY FROM PROGRAM privilege granted; no statement execution route identified")
if superuser or privileges.get("bypass RLS") == "1":
result.append("Row security: current role bypasses row-level security")
return result
+113
View File
@@ -0,0 +1,113 @@
#!/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 arrayizeValue
from lib.core.common import getSQLSnippet
from lib.core.common import isListLike
from lib.core.common import isNoneValue
from lib.core.common import isStackingAvailable
from lib.core.common import unArrayizeValue
from lib.core.convert import getUnicode
from lib.core.data import conf
from lib.core.data import kb
from lib.core.data import logger
from lib.core.enums import CHARSET_TYPE
from lib.core.enums import EXPECTED
from lib.core.exception import SqlmapNoneDataException
from lib.core.settings import AUDIT_MAX_ROWS
from lib.utils.safe2bin import safecharencode
from lib.request import inject
from thirdparty import six
class Audit(object):
# Each snippet returns (audit_key, audit_value). Values are tagged N (SQL NULL) or V<text>
# so extraction's normal NULL/empty-string handling cannot silently change their meaning.
dbms = None
queries = ()
flags = {}
def _auditFetch(self, snippet):
# Extraction locates fields and FROM clauses on one line.
query = getSQLSnippet(self.dbms, snippet, LIMIT=str(AUDIT_MAX_ROWS)).replace("\r", " ").replace("\n", " ")
try:
values = inject.getValue(query, fromUser=True, batch=True, safeCharEncode=False)
except SqlmapNoneDataException:
values = None
if isNoneValue(values):
# Extraction can return None for both zero rows and a failed query.
# Confirm emptiness rather than inferring a database error from it.
countQuery = "SELECT COUNT(*) FROM (%s) audit_count" % query.rsplit(" ORDER BY ", 1)[0]
try:
count = inject.getValue(countQuery, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
except SqlmapNoneDataException:
return None
return {} if getUnicode(unArrayizeValue(count)) == "0" else None
if isListLike(values) and len(values) == 2 and not any(isListLike(_) for _ in values):
values = [values]
facts = {}
for row in arrayizeValue(values):
if not isListLike(row) or len(row) != 2:
return None
key, value = row
if isNoneValue(key) or not isinstance(value, six.string_types):
return None
if value == "N":
value = None
elif value.startswith("V"):
value = value[1:]
else:
return None
# DBMS CHAR expressions can pad the keys; preserve value whitespace.
facts[getUnicode(key).rstrip(" ")] = value
return facts
def failure(self, scope, groups):
return "%s: retrieval failed" % scope
def _auditCanExecute(self):
# Use known execution routes; audit must not probe takeover gadgets.
return bool(conf.direct or isStackingAvailable() or kb.get("gadget"))
def explain(self, groups):
return []
def getAudit(self):
if kb.data.cachedAudit:
return kb.data.cachedAudit
logger.info("fetching security configuration and session capabilities")
groups = {}
result = []
for scope, snippets in self.queries:
facts = None
for snippet in snippets:
facts = self._auditFetch(snippet)
if facts is not None:
break
groups[scope] = facts
if facts is None:
result.append(self.failure(scope, groups))
continue
if not facts:
result.append("%s: no entries" % scope)
for key in sorted(facts):
if facts[key] is None:
value = "<NULL>"
elif facts[key] == "":
value = "<empty>"
else:
value = facts[key]
if key in self.flags:
if value.lower() in ("1", "on", "true", "yes"):
value = self.flags[key][0]
elif value.lower() in ("0", "off", "false", "no"):
value = self.flags[key][1]
result.append("%s / %s: %s" % (scope, key, value))
result.extend(self.explain(groups))
result = [safecharencode(_) for _ in result]
kb.data.cachedAudit = result
return result
+10 -8
View File
@@ -12,10 +12,12 @@ import sys
from lib.core.common import Backend
from lib.core.common import dataToStdout
from lib.core.common import getSQLSnippet
from lib.core.common import isListLike
from lib.core.common import isStackingAvailable
from lib.core.common import joinValue
from lib.core.common import readCachedFileContent
from lib.core.common import splitFields
from lib.core.common import zeroDepthSearch
from lib.core.compat import xrange
from lib.core.convert import getUnicode
from lib.core.data import conf
@@ -148,12 +150,12 @@ class Custom(object):
if not filename:
continue
snippet = getSQLSnippet(Backend.getDbms(), filename)
# User SQL is not a bundled procedure template: preserve literals and percent tokens.
snippet = readCachedFileContent(filename)
queries = [_.strip() for _ in splitFields(snippet, delimiter=';' if zeroDepthSearch(snippet, ';') else '\n') if _.strip()]
if snippet and all(query.strip().upper().startswith("SELECT") for query in (_ for _ in snippet.split(';' if ';' in snippet else '\n') if _)):
for query in (_ for _ in snippet.split(';' if ';' in snippet else '\n') if _):
query = query.strip()
if query:
conf.dumper.sqlQuery(query, self.sqlQuery(query))
else:
if queries and all(query.upper().startswith("SELECT") for query in queries):
for query in queries:
conf.dumper.sqlQuery(query, self.sqlQuery(query))
elif queries:
conf.dumper.sqlQuery(snippet, self.sqlQuery(snippet))
+49
View File
@@ -72,6 +72,8 @@ class Databases(object):
kb.data.dumpedTable = {}
kb.data.cachedStatements = []
kb.data.cachedProcedures = []
kb.data.cachedLinks = []
kb.data.cachedAudit = []
def getCurrentDb(self):
infoMsg = "fetching current database"
@@ -1181,6 +1183,53 @@ class Databases(object):
return kb.data.cachedStatements
def getLinks(self):
dbms = Backend.getIdentifiedDbms()
if dbms not in queries or "links" not in queries[dbms]:
logger.warning("enumeration of database links is not supported on %s" % dbms)
return []
if kb.data.cachedLinks:
return kb.data.cachedLinks
logger.info("fetching database links visible to the current user")
rootQuery = queries[dbms].links
if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct:
values = inject.getValue(rootQuery.inband.query, blind=False, time=False)
if not isNoneValue(values):
for value in arrayizeValue(values):
value = unArrayizeValue(value)
if not isNoneValue(value):
kb.data.cachedLinks.append(value.strip())
if not kb.data.cachedLinks and isInferenceAvailable() and not conf.direct:
count = inject.getValue(rootQuery.blind.count, union=False, error=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
if count in (0, "0"):
return []
elif not isNumPosStrValue(count):
logger.warning("unable to retrieve the number of database links")
return []
for index in getLimitRange(count):
value = unArrayizeValue(inject.getValue(rootQuery.blind.query % index, union=False, error=False))
if not isNoneValue(value):
kb.data.cachedLinks.append(value.strip())
return kb.data.cachedLinks
def getAudit(self):
from importlib import import_module
from lib.core.settings import DBMS_DIRECTORY_DICT
dbms = Backend.getIdentifiedDbms()
if dbms not in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE) or Backend.isFork(FORK.DUCKDB):
logger.warning("security configuration audit is not supported on %s" % dbms)
return []
module = import_module("plugins.dbms.%s.audit" % DBMS_DIRECTORY_DICT[dbms])
return module.Audit().getAudit()
def getProcedures(self):
infoMsg = "fetching stored procedures"
logger.info(infoMsg)
+14 -3
View File
@@ -25,8 +25,8 @@ bulkFile =
# Example (file content): POST /login.jsp HTTP/1.1\nHost: example.com\nUser-Agent: Mozilla/4.0\n\nuserid=joe&password=guessme
requestFile =
# Rather than providing a target URL, let Google return target
# hosts as result of your Google dork expression. For a list of Google
# Rather than providing a target URL, let the search engine return target
# hosts as result of your dork expression. For a list of
# dorks see Google Hacking Database at
# https://www.exploit-db.com/google-hacking-database
# Example: +ext:php +inurl:"&id=" +intext:"powered by "
@@ -544,6 +544,14 @@ getComments = False
# Valid: True or False
getStatements = False
# Enumerate database management system links and their destinations.
# Valid: True or False
getLinks = False
# Audit database security configuration and current-session capabilities.
# Valid: True or False
getAudit = False
# Back-end database management system database to enumerate.
db =
@@ -789,7 +797,7 @@ forms = False
# Valid: True or False
freshQueries = False
# Use Google dork results from specified page number.
# Use dork results from specified page number.
# Valid: integer
# Default: 1
googlePage = 1
@@ -805,6 +813,9 @@ outputDir =
# Valid: True or False
parseErrors = False
# Run a Python refresh() script returning replacement headers on HTTP 401.
authRefresh =
# Use given script(s) for preprocessing of request.
preprocess =
+25
View File
@@ -8,10 +8,24 @@ See the file 'LICENSE' for copying permission
from __future__ import print_function
try:
import os
import sys
sys.dont_write_bytecode = True
# Reference: https://github.com/python/cpython/issues/156319 - CPython's tier-2 optimizer can
# corrupt frame locals (raising exceptions the executed code cannot produce), but only when the
# experimental JIT is on AND a sys.monitoring tool (debugger/profiler/coverage run) is active at
# the same time - neither alone triggers it. So only silence monitoring when the JIT is also on;
# otherwise leave it alone (e.g. a legitimate `coverage run sqlmap.py ...` must keep working).
if hasattr(sys, "monitoring") and (sys._jit.is_enabled() if hasattr(sys, "_jit") else (sys.version_info >= (3, 13) and os.environ.get("PYTHON_JIT") == '1')):
for _ in range(6): # valid tool id range (Reference: https://docs.python.org/3/library/sys.monitoring.html)
try:
if sys.monitoring.get_tool(_) is not None:
sys.monitoring.set_events(_, sys.monitoring.events.NO_EVENTS)
except Exception:
pass
try:
__import__("lib.utils.versioncheck") # this has to be the first non-standard import
except ImportError:
@@ -558,6 +572,17 @@ def main():
logger.critical(errMsg)
raise SystemExit
elif (sys._jit.is_enabled() if hasattr(sys, "_jit") else (sys.version_info >= (3, 13) and os.environ.get("PYTHON_JIT") == '1')):
errMsg = "the experimental Python JIT compiler is turned on. CPython has an open defect where "
errMsg += "the tier-2 optimizer runs the wrong instruction stream against a correct frame, raising "
errMsg += "exceptions that the executed code cannot produce "
errMsg += "(Reference: 'https://github.com/python/cpython/issues/156319'). Please rerun with it "
errMsg += "turned off (e.g. 'PYTHON_JIT=0') and report the problem only if it still occurs"
logger.critical(errMsg)
print()
dataToStdout(excMsg)
raise SystemExit
for match in re.finditer(r'File "(.+?)", line', excMsg):
file_ = match.group(1)
try:
+5
View File
@@ -38,6 +38,8 @@ def tamper(payload, **kwargs):
'1 AND A BETWEEN B AND B--'
>>> tamper('1 AND LAST_INSERT_ROWID()=LAST_INSERT_ROWID()')
'1 AND LAST_INSERT_ROWID() BETWEEN LAST_INSERT_ROWID() AND LAST_INSERT_ROWID()'
>>> tamper('CASE WHEN 1=2 THEN id ELSE 0 END')
'CASE WHEN 1 BETWEEN 2 AND 2 THEN id ELSE 0 END'
"""
retVal = payload
@@ -58,4 +60,7 @@ def tamper(payload, **kwargs):
_ = "%s %s BETWEEN %s AND %s" % (match.group(2), match.group(4), match.group(5), match.group(5))
retVal = retVal.replace(match.group(0), _)
# e.g. CASE WHEN 1=1 THEN ... (expression-position payloads with no AND/OR to anchor on)
retVal = re.sub(r"(?i)(\bWHEN\s+)([\w.]+)\s*(?<![<>!])=(?!=)\s*([\w.]+)(?=\s)", r"\g<1>\g<2> BETWEEN \g<3> AND \g<3>", retVal)
return retVal
+5
View File
@@ -0,0 +1,5 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir flask==3.1.3 pymysql==1.2.3 'psycopg[binary]==3.3.6' python-tds==1.17.1 oracledb==26.0.1
WORKDIR /lab
COPY server.py db.py seed.py probe.py /lab/
CMD ["python", "/lab/server.py"]
+42
View File
@@ -0,0 +1,42 @@
# Disposable security audit matrix
Run from the repository root on a local Docker host:
```sh
python3 tests/integration/audit/run.py
```
Requires Python 3, Docker Compose, Internet access for images and roughly 12 GB
available memory. The SQL Server image requires an x86-64 host. Oracle image
support also depends on host architecture and page size. Application drivers are
installed only inside the fixture image; sqlmap gains no dependencies.
The runner creates an isolated `sqlmap-audit-<pid>` Compose project. Database ports
are internal; deliberately injectable HTTP endpoints bind to random localhost
ports. Credentials are synthetic and fixed for these disposable databases. The
runner does not access the remote sqlmap testbed or existing database services.
It removes its own containers, networks and volumes on completion, including a
failed assertion. `--keep` retains them for investigation; remove the retained
project with `docker compose -p <project> -f tests/integration/audit/compose.yml down --volumes`.
The matrix covers MySQL 8, MariaDB 10.11, PostgreSQL 16, SQL Server 2022 and
Oracle Free 23, with administrator, application and restricted accounts. Two
additional MySQL instances exercise unrestricted and disabled file operations.
It compares native metadata results with real UNION SQLi for every account and
boolean blind SQLi for each engine's application account, using five threads.
Variable listings retain total counts and show at most 20 details. Additional
scenarios cover oversized listings, escaped control characters, confirmed empty
queries, RLS enforcement and command advice with and without stacked execution.
It also checks that audits leave configuration unchanged, that revoking grants
and changing settings changes the result, and that database root with FILE
privilege still cannot read a root-only fixture file. Filesystem probes and
configuration changes belong to the fixture provisioner; `--audit` performs
metadata reads only.
Use `--native-only` for database queries and permission/configuration/filesystem
checks without the HTTP scans. Logs and native reference results go to a printed
temporary directory. For a previously seeded retained project:
```sh
python3 tests/integration/audit/run.py --project sqlmap-audit-<id> --reuse --keep
```
+82
View File
@@ -0,0 +1,82 @@
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: AuditOnly!2026
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: auditlab
command: ['--secure-file-priv=/var/lib/mysql-files', '--local-infile=OFF']
mem_limit: 1g
mariadb:
image: mariadb:10.11
environment:
MARIADB_ROOT_PASSWORD: AuditOnly!2026
MARIADB_ROOT_HOST: '%'
MARIADB_DATABASE: auditlab
command: ['--secure-file-priv=', '--local-infile=ON']
mem_limit: 1g
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: AuditOnly!2026
POSTGRES_DB: auditlab
mem_limit: 1g
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: Y
MSSQL_PID: Developer
MSSQL_SA_PASSWORD: AuditOnly!2026
mem_limit: 2g
oracle:
image: gvenzl/oracle-free:23.26.0-slim-faststart
environment:
ORACLE_PASSWORD: AuditOnly!2026
APP_USER: audit_owner
APP_USER_PASSWORD: AuditOnly!2026
mem_limit: 4g
web-mysql:
build: .
image: sqlmap-audit-fixture:local
environment: {ENGINE: mysql}
ports: ['127.0.0.1::8080']
web-mariadb:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mariadb}
ports: ['127.0.0.1::8080']
web-postgres:
image: sqlmap-audit-fixture:local
environment: {ENGINE: postgres}
ports: ['127.0.0.1::8080']
web-mssql:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mssql}
ports: ['127.0.0.1::8080']
web-oracle:
image: sqlmap-audit-fixture:local
environment: {ENGINE: oracle}
ports: ['127.0.0.1::8080']
mysql-open:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: AuditOnly!2026
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: auditlab
command: ['--secure-file-priv=', '--local-infile=ON']
mem_limit: 1g
mysql-disabled:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: AuditOnly!2026
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: auditlab
command: ['--secure-file-priv=NULL', '--local-infile=OFF']
mem_limit: 1g
web-mysql-open:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mysql, DB_HOST: mysql-open}
ports: ['127.0.0.1::8080']
web-mysql-disabled:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mysql, DB_HOST: mysql-disabled}
ports: ['127.0.0.1::8080']
+27
View File
@@ -0,0 +1,27 @@
import os
import threading
engine = os.environ['ENGINE']
password = 'AuditOnly!2026'
host = os.environ.get('DB_HOST', engine)
pools = {}
poolLock = threading.Lock()
def connect(account, pooled=False):
if engine in ('mysql', 'mariadb'):
import pymysql
return pymysql.connect(host=host, user=account, password=password, database='auditlab', autocommit=True)
if engine == 'postgres':
import psycopg
return psycopg.connect(host=host, user=account, password=password, dbname='auditlab', autocommit=True)
if engine == 'mssql':
import pytds
return pytds.connect(host, user=account, password=password, database='master', autocommit=True)
import oracledb
if pooled:
with poolLock:
if account not in pools:
pools[account] = oracledb.create_pool(user=account, password=password, dsn=host+'/FREEPDB1', min=1, max=10, increment=1)
return pools[account].acquire()
return oracledb.connect(user=account, password=password, dsn=host+'/FREEPDB1', mode=oracledb.AUTH_MODE_SYSDBA if account == 'sys' else oracledb.AUTH_MODE_DEFAULT)
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env python
"""Execute fixture SQL over a real DB-API connection for ground-truth checks."""
import json
import sys
from db import connect
conn = connect(sys.argv[1])
try:
cursor = conn.cursor()
cursor.execute(sys.stdin.read())
print(json.dumps(cursor.fetchall() if cursor.description else [], default=str))
finally:
conn.close()
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Opt-in Docker audit matrix. Runtime sqlmap itself acquires metadata only.
This fixture provisioner deliberately changes settings, grants and synthetic files inside
its own disposable containers, then verifies the audit over native SQL and real SQLi.
"""
from __future__ import print_function
import argparse
import concurrent.futures
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[3]
LAB = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / 'tests'))
from _testutils import bootstrap, set_dbms
bootstrap()
from lib.core.enums import DBMS, FORK, EXPECTED
from lib.core.common import Backend, getSQLSnippet, unArrayizeValue
from lib.core.data import conf
from lib.core.settings import AUDIT_MAX_ROWS
from lib.request import inject
from plugins.generic.databases import Databases
ENGINES = {'mysql': (DBMS.MYSQL, 'root'), 'mariadb': (DBMS.MYSQL, 'root'),
'postgres': (DBMS.PGSQL, 'postgres'), 'mssql': (DBMS.MSSQL, 'sa'),
'oracle': (DBMS.ORACLE, 'audit_owner'),
'mysql-open': (DBMS.MYSQL, 'root'), 'mysql-disabled': (DBMS.MYSQL, 'root')}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--project', default='sqlmap-audit-%d' % os.getpid())
parser.add_argument('--reuse', action='store_true', help='Use an already seeded audit project')
parser.add_argument('--keep', action='store_true', help='Keep this disposable project for debugging')
parser.add_argument('--native-only', action='store_true', help='Skip the HTTP SQLi matrix')
args = parser.parse_args()
if not re.match(r'^sqlmap-audit-[a-z0-9-]+$', args.project):
parser.error('project must have the sqlmap-audit- prefix')
compose = ['docker', 'compose', '-p', args.project, '-f', str(LAB / 'compose.yml')]
logs = Path(tempfile.mkdtemp(prefix='sqlmap-audit-matrix-'))
print('Logs: %s' % logs, flush=True)
def command(argv, data=None, check=True, timeout=240):
result = subprocess.run(argv, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout)
if check and result.returncode:
raise AssertionError('%s\n%s\n%s' % (' '.join(argv), result.stdout, result.stderr))
return result
def sql(engine, account, query, check=True):
result = command(compose + ['exec', '-T', 'web-' + engine, 'python', '/lab/probe.py', account], query, check)
return json.loads(result.stdout) if result.returncode == 0 else None
def native(engine, account, statement=False):
dbms = ENGINES[engine][0]
set_dbms(dbms)
# MariaDB is a MySQL plugin fork; native probes don't run the normal fingerprint.
original_fork = Backend.isFork
Backend.isFork = staticmethod(lambda fork: engine == 'mariadb' and fork == FORK.MARIADB)
original_direct = conf.direct
conf.direct = statement
original = inject.getValue
calls = []
def getValue(query, **kwargs):
assert query.startswith('SELECT ')
calls.append(query)
rows = sql(engine, account, query, check=False)
return unArrayizeValue(rows) if kwargs.get("expected") == EXPECTED.INT else rows
inject.getValue = getValue
try:
result = Databases().getAudit()
assert calls
assert not any(line.startswith('OS boundary:') for line in result)
return sorted(set(result))
finally:
conf.direct = original_direct
inject.getValue = original
Backend.isFork = staticmethod(original_fork)
def scan(engine, account, technique, expected):
address = command(compose + ['port', 'web-' + engine, '8080']).stdout.strip()
url = 'http://%s/%s/get_int?id=1' % (address, account)
label = '%s-%s-%s' % (engine, account, technique)
cmd = [sys.executable, str(ROOT / 'sqlmap.py'), '-u', url,
'--dbms=' + ENGINES[engine][0], '--batch', '--technique=' + technique,
'--threads=5', '--audit', '--skip-waf', '--disable-precon', '--flush-session',
'--output-dir=' + str(logs / label)]
result = command(cmd, check=False, timeout=900)
(logs / (label + '.log')).write_text(result.stdout + result.stderr)
assert result.returncode == 0, label
audit_output = re.search(r'^database security audit[^\n]*\n((?:\[\*\] [^\n]*\n)+)', result.stdout, re.M)
assert audit_output, label
actual = sorted(set(re.findall(r'^\[\*\] (.*)$', audit_output.group(1), re.M)))
assert actual == expected, '%s: missing=%r extra=%r' % (label, sorted(set(expected)-set(actual)), sorted(set(actual)-set(expected)))
print('SQLi %s PASS' % label, flush=True)
try:
if not args.reuse:
command(compose + ['build', 'web-mysql'])
command(compose + ['up', '-d'])
with concurrent.futures.ThreadPoolExecutor(max_workers=7) as pool:
futures = [pool.submit(command, compose + ['exec', '-T', 'web-' + engine, 'python', '/lab/seed.py']) for engine in ENGINES]
for future in futures:
future.result()
expected = {}
for engine, (dbms, admin) in ENGINES.items():
for alias, account in (('admin', admin), ('app', 'audit_app'), ('limited', 'audit_limited')):
snapshot_query = getSQLSnippet(dbms, 'audit_configuration_mariadb' if engine == 'mariadb' else 'audit_configuration', LIMIT=str(AUDIT_MAX_ROWS))
before = sql(engine, admin, snapshot_query) if alias == 'admin' else None
result = native(engine, account)
if alias == 'admin':
assert sql(engine, admin, snapshot_query) == before, 'audit changed configuration'
expected[engine, alias] = result
(logs / ('%s-%s-native.json' % (engine, alias))).write_text(json.dumps(result, indent=2))
assert any('/ effective account:' in line or '/ effective login:' in line for line in result)
if alias == 'admin':
assert not any(re.match(r'^(identity|configuration|privileges|file access): retrieval failed', line) for line in result), (engine,result)
if engine == 'postgres' and alias == 'app':
assert 'privileges / pg_read_server_files: yes' in result
assert 'privileges / pg_execute_server_program: no' in result
if engine == 'mssql' and alias == 'limited':
assert 'configuration: SELECT permission missing on master.sys.configurations' in result
assert 'linked servers: SELECT permission missing on master.sys.servers' in result
if engine == 'oracle' and alias == 'app':
assert any('AUDIT_FILES [READ]' in line for line in result)
if engine == 'oracle' and alias == 'limited':
assert 'directory grants / visible directory grants: 0' in result
assert any(line.startswith('configuration: retrieval failed') for line in result)
print('Native %s/%s PASS' % (engine,alias), flush=True)
# Database root with FILE still cannot read files denied to the service OS user.
for engine in ('mysql', 'mariadb', 'mysql-open', 'mysql-disabled'):
container = command(compose + ['ps', '-q', engine]).stdout.strip()
command(['docker','exec','-u','0',container,'sh','-c',
'mkdir -p /var/lib/mysql-files; printf AUDIT_READABLE > /var/lib/mysql-files/readable.txt; '
'printf AUDIT_PRIVATE > /var/lib/mysql-files/root-only.txt; '
'printf AUDIT_OUTSIDE > /tmp/audit-outside.txt; '
'chmod 644 /var/lib/mysql-files/readable.txt /tmp/audit-outside.txt; '
'chmod 600 /var/lib/mysql-files/root-only.txt'])
readable = sql(engine, 'root', "SELECT LOAD_FILE('/var/lib/mysql-files/readable.txt')")[0][0]
private = sql(engine, 'root', "SELECT LOAD_FILE('/var/lib/mysql-files/root-only.txt')")[0][0]
outside = sql(engine, 'root', "SELECT LOAD_FILE('/tmp/audit-outside.txt')")[0][0]
assert private is None
assert command(['docker','exec','-u','mysql',container,'test','-r','/var/lib/mysql-files/root-only.txt'],check=False).returncode != 0
if engine == 'mysql-disabled':
assert readable is None and outside is None
assert any(line in expected[engine,'admin'] for line in ('file access / secure_file_priv: <NULL>', 'file access / secure_file_priv: NULL'))
assert any('secure_file_priv=NULL blocks' in line for line in expected[engine,'admin'])
elif engine == 'mysql':
assert readable is not None and outside is None
else:
assert readable is not None and outside is not None
assert ('file access / secure_file_priv: <NULL>' if engine == 'mariadb' else 'file access / secure_file_priv: <empty>') in expected[engine,'admin']
assert any('no directory restriction' in line for line in expected[engine,'admin'])
assert not any('OS boundary:' in line for line in expected[engine,'admin'])
print('Filesystem boundary %s PASS' % engine, flush=True)
# Changes are made by this fixture provisioner, never by --audit.
sql('postgres','postgres','REVOKE pg_read_server_files FROM audit_app')
changed = native('postgres','audit_app')
assert 'privileges / pg_read_server_files: no' in changed
sql('postgres','postgres','GRANT pg_read_server_files TO audit_app')
sql('mssql','sa',"EXEC sp_configure 'clr enabled',0; RECONFIGURE")
assert 'configuration / clr enabled: disabled' in native('mssql','audit_app')
sql('mssql','sa',"EXEC sp_configure 'clr enabled',1; RECONFIGURE")
sql('oracle','sys','REVOKE SELECT ON sys.v_$parameter FROM audit_app')
assert any(line.startswith('configuration: retrieval failed') for line in native('oracle','audit_app'))
sql('oracle','sys','GRANT SELECT ON sys.v_$parameter TO audit_app')
print('Grant/configuration changes reflected by audit PASS', flush=True)
# A successful zero-row SELECT must not be reported as a failed query.
from plugins.generic import audit as auditModule
original_snippet = auditModule.getSQLSnippet
auditModule.getSQLSnippet = lambda *args, **kwargs: "SELECT audit_key,audit_value FROM (SELECT 'empty' AS audit_key,'Vempty' AS audit_value) audit_rows WHERE 1=0"
try:
assert native('mysql','root') == ['configuration: no entries', 'file access: no entries', 'identity: no entries', 'privileges: no entries']
finally:
auditModule.getSQLSnippet = original_snippet
print('Confirmed empty metadata query PASS', flush=True)
# Independent semantics: row_security=off must fail, not bypass a policy.
sql('postgres','postgres', 'CREATE TABLE audit_rls (id INT)')
sql('postgres','postgres', 'INSERT INTO audit_rls VALUES (1),(2)')
sql('postgres','postgres', 'GRANT SELECT ON audit_rls TO audit_app')
sql('postgres','postgres', 'ALTER TABLE audit_rls ENABLE ROW LEVEL SECURITY')
sql('postgres','postgres', 'CREATE POLICY audit_policy ON audit_rls USING (id=1)')
assert sql('postgres','audit_app','SELECT * FROM audit_rls',check=False) is None
assert not any('bypasses row-level security' in line for line in native('postgres','audit_app'))
sql('postgres','postgres','ALTER ROLE audit_app BYPASSRLS')
assert sql('postgres','audit_app','SELECT COUNT(*) FROM audit_rls')[0][0] == 2
assert any('bypasses row-level security' in line for line in native('postgres','audit_app'))
sql('postgres','postgres','ALTER ROLE audit_app NOBYPASSRLS')
sql('postgres','postgres','DROP TABLE audit_rls')
sql('postgres','postgres','GRANT pg_execute_server_program TO audit_app')
no_route = native('postgres','audit_app')
assert any('COPY FROM PROGRAM privilege granted; no statement execution route identified' in line for line in no_route)
assert not any('--os-cmd' in line for line in no_route)
with_route = native('postgres','audit_app', statement=True)
assert any('COPY FROM PROGRAM privilege granted; use --os-cmd' in line for line in with_route)
if not args.native_only:
scan('postgres','app','U',no_route)
scan('postgres','app','BS',with_route)
sql('postgres','postgres','REVOKE pg_execute_server_program FROM audit_app')
print('Independent RLS and execution-route scenarios PASS', flush=True)
# The fixture creates real metadata beyond the limit and verifies exact totals.
for index in range(AUDIT_MAX_ROWS + 5):
sql('mssql','sa', "EXEC sp_addlinkedserver @server=N'audit_bound_%02d',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'bound.invalid'" % index)
hostile = "audit_control\n[*] forged\r\x1b[2J"
sql('mssql','sa', "EXEC sp_addlinkedserver @server=N'%s',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'control.invalid'" % hostile)
bounded = native('mssql','sa')
assert 'linked servers / visible linked servers: 27' in bounded
assert sum(line.startswith('linked servers / link ') for line in bounded) == AUDIT_MAX_ROWS
assert 'Linked servers: showing 20 of 27; use --links for the full list' in bounded
# The control-character name sorts outside the first details by server_id; test it separately below.
if not args.native_only:
scan('mssql','admin','U',bounded)
for index in range(AUDIT_MAX_ROWS + 5):
sql('mssql','sa', "EXEC sp_dropserver @server=N'audit_bound_%02d',@droplogins='droplogins'" % index)
hostile_result = native('mssql','sa')
assert any('audit_control\\n[*] forged\\r\\x1b[2J' in line for line in hostile_result)
assert not any('\n' in line or '\r' in line or '\x1b' in line for line in hostile_result)
if not args.native_only:
scan('mssql','admin','U',hostile_result)
sql('mssql','sa', "EXEC sp_dropserver @server=N'%s',@droplogins='droplogins'" % hostile)
for index in range(AUDIT_MAX_ROWS + 5):
path = "/tmp/audit-bound-%02d" % index if index else "/tmp/audit\n[*] forged\x1b[2J"
sql('oracle','sys', "CREATE DIRECTORY AUDIT_BOUND_%02d AS '%s'" % (index,path))
sql('oracle','sys', 'GRANT READ ON DIRECTORY AUDIT_BOUND_%02d TO audit_owner' % index)
bounded = native('oracle','audit_owner')
assert sum(line.startswith('directory grants / ') and '/ visible directory grants:' not in line for line in bounded) == AUDIT_MAX_ROWS
assert any(line.startswith('Directory grants: showing 20 of ') for line in bounded)
assert any('audit\\n[*] forged\\x1b[2J' in line for line in bounded)
if not args.native_only:
scan('oracle','admin','U',bounded)
for index in range(AUDIT_MAX_ROWS + 5):
sql('oracle','sys','DROP DIRECTORY AUDIT_BOUND_%02d' % index)
print('Bounded listings and control-character scenarios PASS', flush=True)
if not args.native_only:
jobs = []
for engine in ENGINES:
for alias in ('admin','app','limited'):
jobs.append((engine,alias,'U'))
if engine not in ('mysql-open','mysql-disabled'):
jobs.append((engine,'app','B'))
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
futures = [pool.submit(scan,engine,alias,technique,expected[engine,alias]) for engine,alias,technique in jobs]
for future in futures:
future.result()
print('Audit matrix PASS; logs: %s' % logs, flush=True)
finally:
if not args.keep:
command(compose + ['down','--volumes','--remove-orphans'])
if __name__ == '__main__':
main()
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python
"""Create test accounts and known grants only in the disposable audit databases."""
import time
from db import connect, engine
admin = {'mysql': 'root', 'mariadb': 'root', 'postgres': 'postgres', 'mssql': 'sa', 'oracle': 'sys'}[engine]
for attempt in range(120):
try:
conn = connect(admin)
break
except Exception:
if attempt == 119:
raise
time.sleep(1)
cursor = conn.cursor()
def execute(sql):
cursor.execute(sql)
if engine in ('mysql', 'mariadb'):
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
for user in ('audit_app', 'audit_limited'):
execute("CREATE USER '%s'@'%%' IDENTIFIED BY 'AuditOnly!2026'" % user)
execute("GRANT SELECT ON auditlab.* TO '%s'@'%%'" % user)
execute("GRANT SELECT ON performance_schema.* TO 'audit_app'@'%'")
execute("GRANT FILE ON *.* TO 'audit_app'@'%'")
elif engine == 'postgres':
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
for user in ('audit_app', 'audit_limited'):
execute("CREATE USER %s WITH PASSWORD 'AuditOnly!2026'" % user)
execute('GRANT SELECT ON users TO %s' % user)
execute('GRANT pg_read_server_files TO audit_app')
execute('GRANT pg_write_server_files TO audit_app')
execute("ALTER ROLE audit_app SET row_security=off")
elif engine == 'mssql':
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
for user in ('audit_app', 'audit_limited'):
execute("CREATE LOGIN %s WITH PASSWORD='AuditOnly!2026',CHECK_POLICY=OFF" % user)
execute('CREATE USER %s FOR LOGIN %s' % (user,user))
execute('GRANT SELECT ON users TO %s' % user)
execute('DENY SELECT ON OBJECT::sys.configurations TO audit_limited')
execute('DENY SELECT ON OBJECT::sys.servers TO audit_limited')
execute("EXEC sp_configure 'show advanced options',1")
execute('RECONFIGURE')
execute("EXEC sp_configure 'clr enabled',1")
execute('RECONFIGURE')
execute("EXEC sp_addlinkedserver @server=N'audit_reporting',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'reporting.invalid'")
execute("EXEC sp_serveroption @server=N'audit_reporting',@optname=N'rpc out',@optvalue=N'true'")
else:
execute('GRANT DBA TO audit_owner')
for user in ('audit_app', 'audit_limited'):
execute('CREATE USER %s IDENTIFIED BY "AuditOnly!2026"' % user)
execute('GRANT CREATE SESSION TO %s' % user)
execute('CREATE TABLE audit_owner.users (id NUMBER PRIMARY KEY,name VARCHAR2(100),surname VARCHAR2(100))')
for user in ('audit_app', 'audit_limited'):
execute('GRANT SELECT ON audit_owner.users TO %s' % user)
execute('CREATE SYNONYM %s.users FOR audit_owner.users' % user)
execute('CREATE SYNONYM system.users FOR audit_owner.users')
execute('GRANT SELECT ON sys.v_$parameter TO audit_app')
execute("CREATE DIRECTORY AUDIT_FILES AS '/tmp/audit-files'")
execute('GRANT READ ON DIRECTORY AUDIT_FILES TO audit_app')
execute('CREATE SYNONYM audit_owner.audit_fixture_users FOR audit_owner.users')
# Insert into the application schema; the SYS/SYSTEM account is only the fixture provisioner.
execute("INSERT INTO audit_owner.users VALUES (1,'luther','blisset')")
execute("INSERT INTO audit_owner.users VALUES (2,'fluffy','bunny')")
conn.commit()
if engine != 'oracle':
execute("INSERT INTO users VALUES (1,'luther','blisset')")
execute("INSERT INTO users VALUES (2,'fluffy','bunny')")
conn.close()
print('%s fixture seeded' % engine)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python
"""Deliberately injectable HTTP fixture. Run only on the isolated audit lab network."""
import html
from flask import Flask, request
from db import connect, engine
app = Flask(__name__)
@app.get('/<account>/get_int')
def query(account):
accounts = {'admin': {'mysql': 'root', 'mariadb': 'root', 'postgres': 'postgres', 'mssql': 'sa', 'oracle': 'audit_owner'},
'app': dict.fromkeys(('mysql', 'mariadb', 'postgres', 'mssql', 'oracle'), 'audit_app'),
'limited': dict.fromkeys(('mysql', 'mariadb', 'postgres', 'mssql', 'oracle'), 'audit_limited')}
if account not in accounts:
return 'unknown account', 404
conn = None
try:
conn = connect(accounts[account][engine], pooled=True)
cursor = conn.cursor()
cursor.execute('SELECT id,name,surname FROM users WHERE id=%s' % request.args.get('id', '1'))
rows = cursor.fetchall()
body = '<html><body><table>'
for row in rows:
body += '<tr>' + ''.join('<td>%s</td>' % html.escape('' if value is None else str(value)) for value in row) + '</tr>'
return body + '</table></body></html>'
except Exception as ex:
# Render the driver's message, not Python's repr of its argument tuple.
message = next((value for value in ex.args if isinstance(value, str)), str(ex))
return '<html><body>%s</body></html>' % html.escape(message)
finally:
if conn is not None:
conn.close()
app.run(host='0.0.0.0', port=8080, threaded=True)
+21
View File
@@ -186,6 +186,12 @@ class TestPrefixSuffix(unittest.TestCase):
class TestGetFields(unittest.TestCase):
def test_multiline_scalar_literal(self):
field = "HEX('a;\n b')"
result = agent.getFields("SELECT %s" % field)
self.assertEqual(result[5], [field])
self.assertEqual(result[6], field)
def test_extracts_select_list(self):
# getFields(query) returns an 8-tuple; the fields-bearing slots are:
# [0],[1] = regex match objects for the SELECT/expression (must be found, not None)
@@ -556,6 +562,21 @@ class TestCleanupPayloadOrigValue(DbmsStateMixin, unittest.TestCase):
out = agent.cleanupPayload("p=[ORIGINAL]", origValue="raw")
self.assertEqual(out, "p=raw")
def test_origvalue_identifier_raw_for_group_order_by(self):
self.assertEqual(agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[2, 3]), "p.name")
self.assertIn("'p.name'", agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[1, 2, 3]))
def test_random_values_avoid_string_marker(self):
# a reflected [RANDNUM] (e.g. quoted back in a DBMS error message) containing the --string
# marker would fake a True response (e.g. --string=98 vs. 8988)
saved = conf.string
conf.string = "9"
try:
for _ in range(200):
self.assertNotIn("9", agent.cleanupPayload("[RANDNUM] [RANDNUM1]"))
finally:
conf.string = saved
def test_space_replace_marker(self):
out = agent.cleanupPayload("a[SPACE_REPLACE]b")
self.assertEqual(out, "a%sb" % kb.chars.space)
+78
View File
@@ -30,6 +30,7 @@ bootstrap()
import lib.utils.api as api
from lib.core.data import conf
from lib.core.data import kb
from lib.core.convert import encodeBase64
from lib.core.enums import CONTENT_STATUS, CONTENT_TYPE
from thirdparty.bottle.bottle import default_app
@@ -308,6 +309,63 @@ class TestStdDbOutAndLogRecorder(unittest.TestCase):
self.assertEqual(rows[0][2], "hello world")
class TestStdDbOutMultiTarget(unittest.TestCase):
"""
Regression coverage for issue #2503: a multi-target scan (crawl or '-m' bulk
file) inside a single REST API task runs every target through one StdDbOut
instance. StdDbOut.write() keys each target's data rows by kb.reportTaskId
('<taskid>-<n>'), so a later target's COMPLETE row cannot delete an earlier
target's rows of the same content_type in _storeData() - mirroring the
--report-json fix for #6123 (see test_report.py's TestMultiTargetTaskId).
"""
def setUp(self):
self.db = api.Database(":memory:")
self.db.connect("client")
self.db.init()
self._saved_stdout = sys.stdout
self._saved_cursor = conf.get("databaseCursor")
self._saved_partRun = kb.get("partRun")
self._had_reportTaskId = "reportTaskId" in kb
self._saved_reportTaskId = kb.get("reportTaskId")
conf.databaseCursor = self.db
kb.partRun = None
def tearDown(self):
sys.stdout = self._saved_stdout
conf.databaseCursor = self._saved_cursor
kb.partRun = self._saved_partRun
if self._had_reportTaskId:
kb.reportTaskId = self._saved_reportTaskId
elif "reportTaskId" in kb:
del kb.reportTaskId
self.db.disconnect()
def test_second_target_does_not_overwrite_first(self):
std = api.StdDbOut("t1", messagetype="stdout")
sys.stdout = self._saved_stdout # StdDbOut hijacked it; call write() directly
kb.reportTaskId = 1
std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
kb.reportTaskId = 2
std.write({"url": "http://host2/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
rows = self.db.execute("SELECT taskid FROM data ORDER BY id")
self.assertEqual([row[0] for row in rows], ["t1-1", "t1-2"]) # neither target's row was deleted
def test_without_report_task_id_keeps_plain_taskid(self):
# e.g. output written before controller.start() reaches its target loop
if "reportTaskId" in kb:
del kb.reportTaskId
std = api.StdDbOut("t1", messagetype="stdout")
sys.stdout = self._saved_stdout
std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
rows = self.db.execute("SELECT taskid FROM data")
self.assertEqual([row[0] for row in rows], ["t1"])
# ---------------------------------------------------------------------------
# HTTP routes (WSGI test client)
# ---------------------------------------------------------------------------
@@ -485,6 +543,26 @@ class TestScanQueryRoutes(_ApiServerCase):
self.assertFalse(parsed["success"])
class TestScanDataMultiTarget(_ApiServerCase):
"""scan_data() folds the per-target '<taskid>-<n>' rows (see StdDbOut.write) back into the task's flat result, in target order."""
def test_data_merges_per_target_rows(self):
taskid = self._new_task()
api._storeData(self.db, "%s-2" % taskid, {"url": "http://host2/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
api._storeData(self.db, "%s-1" % taskid, {"url": "http://host1/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid)
self.assertEqual(code, 200)
self.assertTrue(parsed["success"])
self.assertEqual([d["value"]["url"] for d in parsed["data"]], ["http://host1/?id=1", "http://host2/?id=1"])
def test_data_ignores_other_tasks_rows(self):
taskid = self._new_task()
api._storeData(self.db, "othertask-1", {"url": "http://other/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid)
self.assertTrue(parsed["success"])
self.assertEqual(parsed["data"], [])
class TestScanStart(_ApiServerCase):
"""scan_start, with the subprocess-spawning seam (engine_start) monkeypatched."""
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Audit acquisition preserves NULL/empty/unknown states and interpretation boundaries.
Real DBMS/SQLi coverage is opt-in under tests/integration/audit/.
"""
import importlib
import os
import subprocess
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap, set_dbms, reset_dbms
bootstrap()
from lib.core.common import getSQLSnippet
from lib.core.data import conf, kb, paths, queries
from lib.core.enums import DBMS, FORK, EXPECTED, PAYLOAD
from lib.core.settings import DBMS_DIRECTORY_DICT
from lib.request import inject
from plugins.generic.audit import Audit
from plugins.generic.databases import Databases
tearDownModule = reset_dbms
class TestAudit(unittest.TestCase):
def setUp(self):
self._saved = dict(conf)
self._cache = kb.data.get('cachedAudit')
self._getValue = inject.getValue
self._forced = kb.get('forcedDbms')
self._injection = kb.injection.data
self._gadget = kb.get("gadget")
kb.injection.data = {}
kb.gadget = None
conf.direct = False
kb.data.cachedAudit = []
set_dbms(DBMS.MYSQL)
def tearDown(self):
kb.injection.data = self._injection
kb.gadget = self._gadget
inject.getValue = self._getValue
kb.data.cachedAudit = self._cache
kb.forcedDbms = self._forced
conf.clear()
conf.update(self._saved)
def test_preserves_null_empty_and_marker_like_values(self):
inject.getValue = lambda *a, **k: [['null', 'N'], ['empty', 'V'], ['text', 'VNULL'], ['prefix', 'VVvalue'], ['padded ', 'Vkeep '], ['trailing\n', 'Vvalue']]
audit = Audit()
audit.dbms = DBMS.MYSQL
self.assertEqual(audit._auditFetch('audit_identity'), {'null': None, 'empty': '', 'text': 'NULL', 'prefix': 'Vvalue', 'padded': 'keep ', 'trailing\n': 'value'})
def test_single_row_and_failed_results(self):
audit = Audit()
audit.dbms = DBMS.MYSQL
for value, expected in ((['name', 'Vvalue'], {'name': 'value'}), (None, None), ([], None), (['truncated'], None), ([['key', 'invalid']], None)):
inject.getValue = lambda *a, **k: value
self.assertEqual(audit._auditFetch('audit_identity'), expected)
def test_major_dbmses_dispatch_without_xml_audit_blocks(self):
inject.getValue = lambda *a, **k: [['effective account', 'Vtest']]
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
d = Databases()
self.assertNotIn('audit', queries[dbms])
result = d.getAudit()
self.assertTrue(any('test' in item for item in result))
self.assertFalse(any('OS boundary:' in item for item in result))
def test_unsupported_dbmses_do_not_query(self):
inject.getValue = lambda *a, **k: self.fail('unsupported DBMS must not query')
for dbms in queries:
if dbms not in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
self.assertEqual(Databases().getAudit(), [])
def test_failures_are_not_reported_as_disabled(self):
inject.getValue = lambda *a, **k: None
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
result = Databases().getAudit()
self.assertTrue(any('retrieval failed' in item for item in result))
self.assertFalse(any(item.endswith(': disabled') for item in result))
def test_cache_and_bulk_queries_use_existing_extraction(self):
calls = []
def getValue(query, **kwargs):
self.assertTrue(kwargs.get('fromUser'))
self.assertTrue(kwargs.get('batch'))
self.assertNotIn('\n', query)
self.assertNotIn('\r', query)
calls.append(query)
return [['effective account', 'Vtest']]
inject.getValue = getValue
d = Databases()
result = d.getAudit()
count = len(calls)
self.assertEqual(count, 4)
self.assertEqual(d.getAudit(), result)
self.assertEqual(len(calls), count)
def test_mysql_directory_null_and_empty_are_different(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
audit = MySQLAudit()
cases = ((None, 'blocks'), ('NULL', 'blocks'), ('', 'no directory restriction'), ('/var/lib/mysql-files/', 'must be inside'))
for value, expected in cases:
result = audit.explain({'file access': {'secure_file_priv': value}})
self.assertTrue(any(expected in item for item in result))
self.assertFalse(any('must be inside' in item for item in audit.explain({})))
def test_mariadb_selects_fork_specific_configuration(self):
from lib.core.common import Backend
from plugins.dbms.mysql.audit import Audit as MySQLAudit
original = Backend.isFork
Backend.isFork = staticmethod(lambda fork: fork == FORK.MARIADB)
try:
self.assertEqual(MySQLAudit().queries[1][1][0], 'audit_configuration_mariadb')
self.assertFalse(any('blocks' in item for item in MySQLAudit().explain({'file access': {'secure_file_priv': None}})))
finally:
Backend.isFork = staticmethod(original)
def test_mssql_explicit_permission_denial(self):
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
audit = MSSQLAudit()
groups = {'permissions': {'read configuration metadata': '0'}}
self.assertEqual(audit.failure('configuration', groups), 'configuration: SELECT permission missing on master.sys.configurations')
self.assertEqual(audit.failure('linked servers', groups), 'linked servers: retrieval failed')
def test_guidance_requires_retrieved_capabilities(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
from plugins.dbms.oracle.audit import Audit as OracleAudit
for audit in (MySQLAudit(), PostgreSQLAudit(), MSSQLAudit(), OracleAudit()):
self.assertEqual(audit.explain({}), [])
self.assertEqual(MySQLAudit().explain({'privileges': {'direct FILE grant': '0'}}), [])
result = MySQLAudit().explain({'file access': {'secure_file_priv': '/allowed/', 'max_allowed_packet': '1024'}, 'privileges': {'direct FILE grant': '1'}})
self.assertTrue(any('must be inside' in item for item in result))
self.assertTrue(any('file size < 1024 bytes' in item for item in result))
result = MySQLAudit().explain({'file access': {'secure_file_priv': None}, 'privileges': {'direct FILE grant': '1'}})
self.assertFalse(any('FILE granted; use' in item for item in result))
result = PostgreSQLAudit().explain({'privileges': {'superuser': '0', 'pg_read_server_files': '1', 'pg_execute_server_program': '0'}})
self.assertTrue(any('--file-read' in item for item in result))
self.assertFalse(any('--os-cmd' in item for item in result))
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '0'}})
self.assertTrue(any('EXECUTE permission missing' in item for item in result))
self.assertFalse(any('--os-cmd' in item for item in result))
conf.direct = True
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '0'}, 'identity': {'sysadmin': '1'}})
self.assertTrue(any('can enable xp_cmdshell' in item for item in result))
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '1'}})
self.assertTrue(any('--os-cmd' in item for item in result))
result = PostgreSQLAudit().explain({'privileges': {'pg_execute_server_program': '1'}})
self.assertTrue(any('--os-cmd' in item for item in result))
def test_execution_advice_uses_known_routes_without_probing(self):
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
inject.getValue = lambda *a, **k: self.fail('guidance must not send probes')
pg = {'privileges': {'pg_execute_server_program': '1'}}
ms = {'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '1'}}
for audit, groups in ((PostgreSQLAudit(), pg), (MSSQLAudit(), ms)):
for route in ('none', 'stacked', 'gadget', 'direct'):
kb.injection.data = {PAYLOAD.TECHNIQUE.STACKED: {'title': 'stacked queries'}} if route == 'stacked' else {}
kb.gadget = {'command': 'known gadget'} if route == 'gadget' else None
conf.direct = route == 'direct'
result = audit.explain(groups)
self.assertEqual(any('--os-cmd' in item for item in result), route != 'none')
self.assertEqual(any('no statement execution route identified' in item for item in result), route == 'none')
def test_rls_setting_does_not_grant_bypass(self):
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
result = PostgreSQLAudit().explain({'configuration': {'row_security': 'off'}, 'privileges': {'superuser': '0', 'bypass RLS': '0'}})
self.assertFalse(any('bypasses' in item for item in result))
result = PostgreSQLAudit().explain({'privileges': {'bypass RLS': '1'}})
self.assertTrue(any('bypasses' in item for item in result))
def test_empty_result_requires_confirmed_zero_count(self):
audit = Audit()
audit.dbms = DBMS.MYSQL
for count, expected in ((0, {}), ('0', {}), ([[0]], {}), (None, None), ('1', None)):
calls = []
def getValue(query, **kwargs):
calls.append(query)
return count if kwargs.get('expected') == EXPECTED.INT else None
inject.getValue = getValue
self.assertEqual(audit._auditFetch('audit_identity'), expected)
self.assertEqual(len(calls), 2)
audit.queries = (('empty scope', ('audit_identity',)),)
inject.getValue = lambda query, **kwargs: 0 if kwargs.get('expected') == EXPECTED.INT else None
self.assertEqual(audit.getAudit(), ['empty scope: no entries'])
def test_control_characters_are_escaped_in_facts_and_guidance(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
value = "/allowed/\n[*] forged\r\x1b[2J"
inject.getValue = lambda query, **kwargs: [['secure_file_priv', 'V' + value]] if '@@GLOBAL.secure_file_priv' in query else [['name\nforged', 'Vsafe']]
result = MySQLAudit().getAudit()
self.assertTrue(any('\\n[*] forged' in item for item in result))
self.assertFalse(any(any(char in item for char in ('\n', '\r', '\x1b')) for item in result))
def test_listing_summary_reports_displayed_and_total(self):
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
from plugins.dbms.oracle.audit import Audit as OracleAudit
links = {'visible linked servers': '200', 'link one': 'destination'}
self.assertIn('Linked servers: showing 1 of 200; use --links for the full list', MSSQLAudit().explain({'linked servers': links}))
grants = {'visible directory grants': '200', 'ONE [READ]': '/one'}
self.assertEqual(OracleAudit().explain({'directory grants': grants}), ['Directory grants: showing 1 of 200'])
def test_only_observed_settings_are_listed_once(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
def getValue(query, **kwargs):
if '@@GLOBAL.secure_file_priv' in query:
return [['secure_file_priv', 'V/allowed/']]
return [['local_infile', 'VOFF']]
inject.getValue = getValue
result = MySQLAudit().getAudit()
self.assertFalse(any('super_read_only' in item for item in result))
self.assertEqual(sum('/ secure_file_priv:' in item for item in result), 1)
self.assertFalse(any('not established' in item or 'does not establish' in item for item in result))
def test_snippets_are_single_read_only_statements(self):
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
audit = importlib.import_module('plugins.dbms.%s.audit' % DBMS_DIRECTORY_DICT[dbms]).Audit()
for scope, snippets in audit.queries:
for snippet in snippets:
sql = getSQLSnippet(dbms, snippet, LIMIT="20")
self.assertTrue(sql.startswith('SELECT '))
self.assertNotIn(';', sql.replace('; RPC out: ', ''))
self.assertIn('audit_value', sql)
def test_help_is_one_line(self):
output = subprocess.check_output([sys.executable, os.path.join(paths.SQLMAP_ROOT_PATH, 'sqlmap.py'), '-hh'])
lines = [line for line in output.decode('utf-8').splitlines() if '--audit' in line]
self.assertEqual(len(lines), 1)
self.assertLessEqual(len(lines[0]), 78)
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Authentication refresh hook loading and replay against a local HTTP server.
"""
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.core.data import conf, kb, paths, cmdLineOptions
from lib.core.enums import MKSTEMP_PREFIX, REDIRECTION
from lib.core.exception import SqlmapConnectionException, SqlmapFilePathException, SqlmapSyntaxException
from lib.core.option import _setAuthRefreshFunction, _setKnowledgeBaseAttributes
from lib.core.threads import getCurrentThreadData
from lib.request import authrefresh
from lib.request.connect import Connect
from lib.request.redirecthandler import SmartRedirectHandler
from thirdparty.six.moves import BaseHTTPServer, socketserver
from thirdparty.six.moves import urllib as _urllib
_GET_PAGE = Connect.__dict__['getPage']
_QUERY_PAGE = Connect.__dict__['queryPage']
def _assertTemplate(testCase, exception):
match = re.search(r"template script at '([^']+)'", str(exception))
testCase.assertIsNotNone(match)
path = match.group(1)
try:
testCase.assertTrue(os.path.basename(path).startswith(MKSTEMP_PREFIX.AUTH_REFRESH))
testCase.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), '__init__.py')))
with open(path) as stream:
source = stream.read()
namespace = {}
eval(compile(source, path, 'exec'), namespace)
testCase.assertEqual(namespace['refresh'](), {'Authorization': 'Bearer <fresh token>'})
testCase.assertNotIn(paths.SQLMAP_EXTRAS_PATH, path)
finally:
os.unlink(path)
class _Server(socketserver.ThreadingMixIn, BaseHTTPServer.HTTPServer):
daemon_threads = True
class _Handler(BaseHTTPServer.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
self.do_POST()
def do_POST(self):
body = self.rfile.read(int(self.headers.get('Content-Length', '0')))
token = self.headers.get('X-Auth-Token')
if self.path == '/refresh':
self.server.refreshes += 1
code, body = 200, b'{"token":"fresh"}'
else:
with self.server.lock:
self.server.requests.append((self.path, body, token))
if token != self.server.token:
self.server.expired += 1
if self.server.expired >= 2:
self.server.bothExpired.set()
if token != self.server.token and self.server.syncExpired:
self.server.bothExpired.wait(3)
code = 200 if token == self.server.token else 401
body = b'ok' if code == 200 else b'expired'
self.send_response(302 if self.path == '/redirect' and code == 200 else code)
if self.path == '/redirect' and code == 200:
self.send_header('Location', self.server.redirect)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
class TestAuthRefresh(unittest.TestCase):
def setUp(self):
self.conf = dict(conf)
self.cmdLineOptions = dict(cmdLineOptions)
cmdLineOptions.method = None
self.kb = dict(kb)
self.threadData = dict(getCurrentThreadData().__dict__)
getCurrentThreadData().__dict__.clear()
getCurrentThreadData().reset()
self.timeout = socket.getdefaulttimeout()
self.urlopen = _urllib.request.urlopen
# Other engine tests replace these descriptors; use the real methods even when an
# earlier Python 2 fixture restored a bare function instead of a staticmethod.
self.methods = Connect.__dict__['getPage'], Connect.__dict__['queryPage']
Connect.getPage, Connect.queryPage = _GET_PAGE, _QUERY_PAGE
_setKnowledgeBaseAttributes(False)
kb.wizardMode = True
self.server = _Server(('127.0.0.1', 0), _Handler)
self.server.requests = []
self.server.refreshes = 0
self.server.token = 'fresh'
self.server.expired = 0
self.server.syncExpired = False
self.server.bothExpired = threading.Event()
self.server.lock = threading.Lock()
self.thread = threading.Thread(target=self.server.serve_forever)
self.thread.daemon = True
self.thread.start()
conf.url = 'http://127.0.0.1:%d/test' % self.server.server_port
conf.hostname, conf.scheme, conf.port = '127.0.0.1', 'http', self.server.server_port
conf.path = '/test'
conf.httpHeaders = [('X-Auth-Token', 'expired')]
conf.parameters = {}
conf.data = None
conf.abortCode = []
conf.ignoreCode = []
conf.authRefresh = 'test_refresh.py'
conf.batch = True
conf.keepAlive = False
conf.retryOn = None
conf.timeout = 5
conf.ignoreRedirects = False
conf.scope = None
kb.choices.redirect = REDIRECTION.YES
_urllib.request.urlopen = _urllib.request.build_opener(_urllib.request.ProxyHandler({}), SmartRedirectHandler()).open
def refresh():
# A real, application-specific token exchange outside sqlmap's request engine.
response = _urllib.request.urlopen(conf.url.replace('/test', '/refresh'), timeout=3)
try:
return {'X-Auth-Token': json.loads(response.read().decode('ascii'))['token']}
finally:
response.close()
kb.authRefreshFunction = refresh
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(5)
_urllib.request.urlopen = self.urlopen
Connect.getPage, Connect.queryPage = self.methods
socket.setdefaulttimeout(self.timeout)
conf.clear()
conf.update(self.conf)
cmdLineOptions.clear()
cmdLineOptions.update(self.cmdLineOptions)
kb.clear()
kb.update(self.kb)
getCurrentThreadData().__dict__.clear()
getCurrentThreadData().__dict__.update(self.threadData)
def test_expiry_mid_scan_preserves_payload_and_reuses_headers(self):
self.server.token = 'expired'
self.assertEqual(Connect.getPage()[2], 200)
self.assertEqual(self.server.refreshes, 0)
self.server.token = 'fresh'
payload = b'id=1%20AND%201%3D1&other=keep'
self.assertEqual(Connect.getPage(get='marker=123', post=payload)[2], 200)
self.assertEqual(self.server.requests[-2:], [('/test?marker=123', payload, 'expired'), ('/test?marker=123', payload, 'fresh')])
self.assertEqual(Connect.getPage()[2], 200)
self.assertEqual(self.server.refreshes, 1)
self.assertEqual(getCurrentThreadData().lastCode, 200)
self.assertEqual(getCurrentThreadData().lastPage, 'ok')
self.assertNotEqual(kb.originalCode, 401)
def test_second_401_stops_after_one_replay(self):
kb.authRefreshFunction = lambda: {'X-Auth-Token': 'still-expired'}
with self.assertRaises(SqlmapConnectionException):
Connect.getPage()
self.assertEqual(len(self.server.requests), 2)
self.assertTrue(kb.authRefreshFailed)
def test_abort_code_takes_precedence(self):
conf.abortCode = [401]
with self.assertRaises(SystemExit):
Connect.getPage()
self.assertEqual(self.server.refreshes, 0)
self.assertEqual(len(self.server.requests), 1)
def test_without_option_preserves_401_behavior(self):
conf.authRefresh = None
with self.assertRaises(SqlmapConnectionException):
Connect.getPage()
self.assertEqual(self.server.refreshes, 0)
def test_concurrent_401s_share_one_refresh(self):
self.server.syncExpired = True
results, errors = [], []
def worker():
try:
results.append(Connect.getPage()[2])
except Exception as ex:
errors.append(ex)
workers = [threading.Thread(target=worker) for _ in range(2)]
for workerThread in workers:
workerThread.start()
for workerThread in workers:
workerThread.join(10)
self.assertFalse(workerThread.is_alive())
self.assertEqual(errors, [])
self.assertEqual(results, [200, 200])
self.assertEqual(self.server.refreshes, 1)
self.assertEqual(self.server.expired, 2)
def test_invalid_hook_results_are_rejected_without_leaking_secrets(self):
for result in (None, {}, 'secret-token', {'X-Token': 'secret\r\nInjected: value'}, {'Host': 'elsewhere'}, {'X-Token': 42}, {'X-Token': 'secret', 'x-token': 'other'}):
kb.authRefreshFailed = False
kb.authRefreshFunction = lambda: result
with self.assertRaises(SqlmapConnectionException) as caught:
authrefresh.refresh(0)
self.assertNotIn('secret', str(caught.exception))
_assertTemplate(self, caught.exception)
self.assertEqual(kb.authRefreshVersion, 0)
def test_hook_exception_is_sanitized(self):
def refresh():
raise ValueError('password=secret')
kb.authRefreshFunction = refresh
with self.assertRaises(SqlmapConnectionException) as caught:
Connect.getPage()
self.assertNotIn('secret', str(caught.exception))
_assertTemplate(self, caught.exception)
self.assertEqual(len(self.server.requests), 1)
def test_credentials_are_scoped_to_scheme_host_and_port(self):
authrefresh.refresh(0)
for url in ('http://elsewhere/test', 'https://127.0.0.1/test', 'http://127.0.0.1:1/test'):
req = _urllib.request.Request(url)
self.assertIsNone(authrefresh.apply(req))
self.assertEqual(req.headers, {})
self.assertTrue(authrefresh.sameOrigin('https://example.com', 'https://example.com:443/path'))
def test_refresh_overrides_stale_preprocess_headers(self):
kb.preprocessFunctions = [lambda req: req.add_header('X-Auth-Token', 'expired')]
self.assertEqual(Connect.getPage()[2], 200)
self.assertEqual(self.server.refreshes, 1)
def test_redirect_to_another_port_does_not_receive_refreshed_token(self):
authrefresh.refresh(0)
other = _Server(('127.0.0.1', 0), _Handler)
other.requests, other.token, other.expired = [], None, 0
other.lock = threading.Lock()
other.syncExpired = False
thread = threading.Thread(target=other.serve_forever)
thread.daemon = True
thread.start()
try:
self.server.redirect = 'http://127.0.0.1:%d/landing' % other.server_port
Connect.getPage(url=conf.url.replace('/test', '/redirect'))
self.assertEqual(other.requests, [('/landing', b'', None)])
self.assertEqual(self.server.refreshes, 1)
finally:
other.shutdown()
other.server_close()
thread.join(5)
def test_auth_refresh_latency_is_excluded_from_query_timing(self):
import lib.request.connect as connect
originalPage, originalDelta = Connect.getPage, connect.calculateDeltaSeconds
def getPage(**kwargs):
getCurrentThreadData().authRefreshTime += 12
return 'ok', None, 200
Connect.getPage = staticmethod(getPage)
connect.calculateDeltaSeconds = lambda start: 15
try:
Connect.queryPage(content=True)
self.assertEqual(getCurrentThreadData().lastQueryDuration, 3)
finally:
Connect.getPage = staticmethod(originalPage)
connect.calculateDeltaSeconds = originalDelta
class TestAuthRefreshLoader(unittest.TestCase):
def setUp(self):
self.directory = tempfile.mkdtemp(prefix='sqlmap-authrefresh-')
self.oldOption = conf.authRefresh
self.oldFunction = kb.authRefreshFunction
self.oldPath = list(sys.path)
def tearDown(self):
conf.authRefresh = self.oldOption
kb.authRefreshFunction = self.oldFunction
sys.path[:] = self.oldPath
for name in list(sys.modules):
if name.startswith('authrefresh_test_'):
del sys.modules[name]
shutil.rmtree(self.directory)
def script(self, name, content):
path = os.path.join(self.directory, 'authrefresh_test_%s.py' % name)
with open(path, 'w') as stream:
stream.write(content)
conf.authRefresh = path
def test_load_does_not_call_refresh(self):
self.script('valid', "def refresh():\n raise AssertionError('must only run on 401')\n")
_setAuthRefreshFunction()
self.assertTrue(callable(kb.authRefreshFunction))
def test_missing_invalid_and_empty_scripts_point_to_template(self):
for name, source in (('empty', ''), ('wrong', 'def other():\n pass\n'), ('args', 'def refresh(req):\n pass\n'), ('syntax', 'def refresh(:')):
self.script(name, source)
with self.assertRaises(SqlmapSyntaxException) as caught:
_setAuthRefreshFunction()
_assertTemplate(self, caught.exception)
conf.authRefresh = os.path.join(self.directory, 'missing.py')
with self.assertRaises(SqlmapFilePathException) as caught:
_setAuthRefreshFunction()
_assertTemplate(self, caught.exception)
def test_help_is_one_line(self):
output = subprocess.check_output([sys.executable, os.path.join(paths.SQLMAP_ROOT_PATH, 'sqlmap.py'), '-hh'])
lines = output.decode('utf-8').splitlines()
matches = [i for i, line in enumerate(lines) if '--auth-refresh' in line]
self.assertEqual(len(matches), 1)
self.assertIn('HTTP 401', lines[matches[0]])
self.assertLessEqual(len(lines[matches[0]]), 78)
for option in ('--preprocess', '--postprocess'):
self.assertLess(matches[0], next(i for i, line in enumerate(lines) if option in line))
if __name__ == '__main__':
unittest.main()
+57
View File
@@ -113,6 +113,7 @@ from lib.core.common import (
safeSQLIdentificatorNaming,
saveConfig,
serializeObject,
setColor,
setTechnique,
splitFields,
trimAlphaNum,
@@ -523,6 +524,19 @@ class TestParseRequestFileBurp(unittest.TestCase):
self.assertEqual(method, HTTPMETHOD.GET)
self.assertEqual(cookie, "SID=xyz")
def test_burp_xml_history_protocol(self):
# Note: item's <protocol> decides the scheme (the port alone can't tell e.g. HTTPS on 8443 or HTTP on 443)
req = base64.b64encode(b"GET /vuln.php?id=1 HTTP/1.1\r\nHost: www.target.com\r\n\r\n").decode()
items = "".join('<item><port>%s</port>\n<protocol>%s</protocol><request base64="true"><![CDATA[%s]]></request></item>' % (port, protocol, req) for port, protocol in ((8443, "https"), (443, "http"), (443, "https"), (8080, "")))
path = _write_temp("<items>%s</items>" % items, ".xml")
try:
targets = list(parseRequestFile(path))
finally:
os.unlink(path)
self.assertEqual([_[0] for _ in targets], ["https://www.target.com:8443/vuln.php?id=1", "http://www.target.com:443/vuln.php?id=1", "https://www.target.com:443/vuln.php?id=1", "http://www.target.com:8080/vuln.php?id=1"])
self.assertTrue(all("://" not in value for _ in targets for key, value in _[4] if key == "Host"))
def test_post_body_captured(self):
content = (
"======================================================\n"
@@ -1784,6 +1798,49 @@ class TestCheckOldOptions(unittest.TestCase):
self.assertIsNone(checkOldOptions(["-u", "http://test.invalid/?id=1", "--banner"]))
class TestSetColorHandlerMismatch(unittest.TestCase):
"""
Regression test for issue #6122: setColor() decided whether to colorize purely from
conf.disableColoring/IS_TTY, independent of whether the installed LOGGER_HANDLER actually
supports colorize() (a plain logging.StreamHandler - installed for --disable-coloring, or as
the ansistrm-unavailable fallback - never defines it). A multiprocessing hash-cracking worker
hit exactly this mismatch (conf.disableColoring not carried over into the worker) and crashed
with an AttributeError that got misreported as "there was a problem while hashing entry".
"""
def setUp(self):
import lib.core.common as common_mod
self._common_mod = common_mod
self._saved_handler = common_mod.LOGGER_HANDLER
self._saved_disableColoring = conf.get("disableColoring")
def tearDown(self):
self._common_mod.LOGGER_HANDLER = self._saved_handler
conf.disableColoring = self._saved_disableColoring
def test_plain_handler_without_colorize_does_not_raise(self):
import logging
self._common_mod.LOGGER_HANDLER = logging.StreamHandler() # no .colorize(), like the --disable-coloring handler
conf.disableColoring = False # the desync: coloring "should" apply, but handler can't
result = setColor("[INFO] current status: abcde", istty=True) # must not raise
self.assertIsInstance(result, str)
def test_colorizing_handler_still_used(self):
# sanity check: a handler that DOES define colorize() is unaffected by the guard
calls = []
class _FakeColorizingHandler(object):
def colorize(self, message, levelno, force=False):
calls.append((message, levelno, force))
return "COLORIZED"
self._common_mod.LOGGER_HANDLER = _FakeColorizingHandler()
conf.disableColoring = False
result = setColor("[INFO] current status: abcde", istty=True)
self.assertEqual(result, "COLORIZED")
self.assertEqual(len(calls), 1)
if __name__ == "__main__":
unittest.main(verbosity=2)
+9
View File
@@ -113,14 +113,23 @@ class TestConfigFileRoundTrip(unittest.TestCase):
try:
conf.url = "http://127.0.0.1:1/?id=1"
conf.commonTables = True
conf.getLinks = True
conf.getAudit = True
conf.authRefresh = "refresh_auth.py"
conf.tablePrefix = "zzz"
saveConfig(conf, path)
conf.url, conf.commonTables, conf.tablePrefix = None, False, "overwritten"
conf.getLinks = False
conf.getAudit = False
conf.authRefresh = None
configFileParser(path)
self.assertEqual(conf.url, "http://127.0.0.1:1/?id=1")
self.assertTrue(conf.commonTables)
self.assertTrue(conf.getLinks)
self.assertTrue(conf.getAudit)
self.assertEqual(conf.authRefresh, "refresh_auth.py")
self.assertEqual(conf.tablePrefix, "zzz")
finally:
conf.clear()
+129 -1
View File
@@ -17,6 +17,8 @@ the suite.
"""
import os
import sqlite3
import subprocess
import sys
import unittest
@@ -25,7 +27,7 @@ from _testutils import bootstrap, set_dbms, reset_dbms
bootstrap()
from lib.core.data import conf, kb
from lib.core.data import conf, kb, queries
from lib.core.enums import EXPECTED, PAYLOAD
import plugins.generic.databases as dbmod
from plugins.generic.databases import Databases
@@ -284,6 +286,28 @@ class TestGetTables(_BaseEnumTest):
self.assertIn("testdb", result)
self.assertEqual(sorted(result["testdb"]), sorted(tables))
def test_get_tables_inference_spanner(self):
# Spanner's blind template must page per row (it used to crash with "not all arguments converted", #6151)
set_dbms("Spanner")
self._enable_inference()
d = self._fresh()
conf.db = "testdb"
conf.tbl = None
tables = ["t_a", "t_b"]
queries_ = []
def gv(query, *a, **k):
if k.get("expected") == EXPECTED.INT:
return str(len(tables))
queries_.append(query)
return [tables[len(queries_) - 1]]
dbmod.inject.getValue = gv
result = d.getTables()
self.assertEqual(sorted(result["testdb"]), sorted(tables))
self.assertEqual([_.endswith("OFFSET %d" % i) for i, _ in enumerate(queries_)], [True, True])
class TestGetColumns(_BaseEnumTest):
def _run_direct(self, dbms, db, tbl, rows):
@@ -500,6 +524,110 @@ class TestGetSchema(_BaseEnumTest):
self.assertEqual(result["testdb"]["users"].get("id"), "int")
class TestGetLinks(_BaseEnumTest):
def setUp(self):
_BaseEnumTest.setUp(self)
self._saved_links = kb.data.get("cachedLinks")
def tearDown(self):
kb.data.cachedLinks = self._saved_links
_BaseEnumTest.tearDown(self)
def test_unsupported_dbms_does_not_query(self):
d = self._fresh()
dbmod.inject.getValue = lambda *a, **k: self.fail("unsupported DBMS must not query links")
saved = dbmod.logger.warning
warnings = []
try:
dbmod.logger.warning = warnings.append
for dbms in queries:
if "links" not in queries[dbms]:
set_dbms(dbms)
self.assertEqual(d.getLinks(), [])
self.assertIn(dbms, warnings[-1])
finally:
dbmod.logger.warning = saved
def test_direct_links_and_cache(self):
conf.direct = True
for dbms in ("Microsoft SQL Server", "Oracle"):
set_dbms(dbms)
d = self._fresh()
calls = []
def gv(query, **kwargs):
calls.append(query)
return [["remote -> db.example"], ["other -> <unknown>"]]
dbmod.inject.getValue = gv
expected = ["remote -> db.example", "other -> <unknown>"]
self.assertEqual(d.getLinks(), expected)
self.assertEqual(d.getLinks(), expected)
self.assertEqual(calls, [queries[dbms].links.inband.query])
def test_blind_links_use_zero_based_paging(self):
self._enable_inference()
for dbms in ("Microsoft SQL Server", "Oracle"):
set_dbms(dbms)
d = self._fresh()
calls = []
values = iter(["2", ["first -> host1"], ["second -> host2"]])
def gv(query, **kwargs):
calls.append(query)
return next(values)
dbmod.inject.getValue = gv
self.assertEqual(d.getLinks(), ["first -> host1", "second -> host2"])
root = queries[dbms].links
self.assertEqual(calls, [root.blind.count, root.blind.query % 0, root.blind.query % 1])
def test_empty_direct_results(self):
set_dbms("Microsoft SQL Server")
conf.direct = True
d = self._fresh()
dbmod.inject.getValue = lambda *a, **k: None
self.assertEqual(d.getLinks(), [])
def test_oracle_query_paging_and_null_destination(self):
# Evaluate the catalog/paging SQL with compatible SQLite expressions; live Oracle is
# not required for this check of owner-qualified names, NULL handling and row offsets.
conn = sqlite3.connect(":memory:")
try:
conn.create_function("NVL", 2, lambda value, default: default if value is None else value)
conn.execute("CREATE TABLE ALL_DB_LINKS (OWNER TEXT, DB_LINK TEXT, HOST TEXT)")
conn.executemany("INSERT INTO ALL_DB_LINKS VALUES (?,?,?)", [("PUBLIC", "REMOTE", "db.example"), ("APP", "REMOTE", None)])
root = queries["Oracle"].links
expected = ["APP.REMOTE -> <unknown>", "PUBLIC.REMOTE -> db.example"]
self.assertEqual([row[0] for row in conn.execute(root.inband.query)], expected)
self.assertEqual(conn.execute(root.blind.count).fetchone()[0], 2)
self.assertEqual([conn.execute(root.blind.query % i).fetchone()[0] for i in range(2)], expected)
self.assertIsNone(conn.execute(root.blind.query % 2).fetchone())
finally:
conn.close()
def test_empty_or_unavailable_blind_count_does_not_fetch_rows(self):
set_dbms("Oracle")
self._enable_inference()
for count in (0, "0", None, "unavailable"):
d = self._fresh()
def gv(query, **kwargs):
self.assertEqual(kwargs.get("expected"), EXPECTED.INT)
return count
dbmod.inject.getValue = gv
self.assertEqual(d.getLinks(), [])
def test_help_is_one_line(self):
output = subprocess.check_output([sys.executable, os.path.join(os.path.dirname(__file__), "..", "sqlmap.py"), "-hh"])
lines = output.decode("utf-8").splitlines()
helpLines = [line for line in lines if "--links" in line]
self.assertEqual(len(helpLines), 1)
self.assertIn("Enumerate DBMS links", helpLines[0])
self.assertLessEqual(len(helpLines[0]), 78)
class TestGetProcedures(_BaseEnumTest):
def test_procedures_direct_pgsql(self):
set_dbms("PostgreSQL")
+74 -2
View File
@@ -43,9 +43,13 @@ bootstrap()
from lib.core.agent import agent
from lib.core.common import Backend
from lib.core.data import conf, kb
from lib.core.data import conf, kb, queries
from lib.core.datatype import AttribDict
from lib.core.datatype import InjectionDict
from lib.core.threads import getCurrentThreadData
from lib.core.enums import DBMS
from lib.core.enums import PAYLOAD
from lib.core.enums import PLACE
from lib.core.exception import SqlmapNotVulnerableException
from lib.core.settings import DNS_BOUNDARIES_ALPHABET
from lib.core.settings import MAX_DNS_LABEL
@@ -80,7 +84,8 @@ class _HighPortDNSServer(DNSServer):
except socket.error:
pass
_CONF = {"dnsDomain": "exfil.test", "hexConvert": False, "api": False, "verbose": 0, "forceDns": False}
_CONF = {"dnsDomain": "exfil.test", "hexConvert": False, "api": False, "verbose": 0, "forceDns": False,
"suffix": None, "direct": False}
_KB = {"dnsTest": True, "dnsMode": False, "bruteMode": False, "safeCharEncode": False}
@@ -252,6 +257,73 @@ class TestDnsExfilEngineMssql(TestDnsExfilEngine):
DBMS_NAME = "Microsoft SQL Server"
class TestDnsStackedSuffix(_DnsCase):
def setUp(self):
_DnsCase.setUp(self)
restore_attrs(self._s_agent)
self._saved_target = {k: conf.get(k) for k in ("parameters", "paramDict", "prefix")}
self._saved_injection = {k: kb.get(k) for k in ("injection", "technique", "forceWhere")}
conf.parameters = {PLACE.GET: "id=1"}
conf.paramDict = {PLACE.GET: {"id": "1"}}
conf.prefix = " /* custom prefix */"
kb.technique = PAYLOAD.TECHNIQUE.BOOLEAN
kb.forceWhere = None
kb.injection = InjectionDict()
kb.injection.place = PLACE.GET
kb.injection.parameter = "id"
kb.injection.prefix = conf.prefix
kb.injection.suffix = "; SELECT 999-- inferred suffix"
kb.injection.clause = [1]
kb.injection.data[PAYLOAD.TECHNIQUE.BOOLEAN] = AttribDict({"where": PAYLOAD.WHERE.ORIGINAL, "comment": "-- inferred comment"})
def tearDown(self):
for k, v in self._saved_target.items():
conf[k] = v
for k, v in self._saved_injection.items():
kb[k] = v
_DnsCase.tearDown(self)
def _extract_with_suffix(self, suffix):
secret = "The quick brown fox jumps over the lazy dog 0123456789"
conf.suffix = suffix
for dbms in (DBMS.MSSQL, DBMS.PGSQL):
set_dbms(dbms)
self._install_oracle(secret)
oracle = Connect.queryPage
payloads = []
def require_suffix(payload, **kwargs):
sql = agent.removePayloadDelimiters(payload)
payloads.append(sql)
# Model an endpoint that accepts the DNS payload only with its required ending.
if suffix and not sql.endswith(suffix):
return None
return oracle(payload, **kwargs)
Connect.queryPage = staticmethod(require_suffix)
self.assertEqual(dnsmod.dnsUse("unused", "SELECT secret"), secret, dbms)
self.assertGreater(len(payloads), 1, dbms)
for payload in payloads:
self.assertTrue(payload.startswith("id=1%s; " % conf.prefix), dbms)
self.assertNotIn("-- inferred", payload)
if suffix is None:
self.assertTrue(payload.endswith(queries[dbms].comment.query), dbms)
elif suffix:
self.assertTrue(payload.endswith(suffix), dbms)
else:
self.assertFalse(payload.endswith(queries[dbms].comment.query), dbms)
def test_explicit_suffix_survives_every_chunk(self):
self._extract_with_suffix("; SELECT 4973-- required suffix")
def test_default_comment_without_explicit_suffix(self):
self._extract_with_suffix(None)
def test_explicit_empty_suffix_does_not_add_comment(self):
self._extract_with_suffix("")
class TestDnsLabelInvariant(_DnsCase):
"""The exfil chunk is hex-encoded into ONE DNS label, so the label dnsUse emits must never
exceed the 63-octet DNS label limit - otherwise the query carries an invalid (over-long) label
+489 -14
View File
@@ -17,6 +17,7 @@ import struct
import sys
import threading
import time
import types
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -100,6 +101,33 @@ def _wait_popped(srv, prefix, suffix, timeout=WAIT_TIMEOUT):
return None
def _stop_client_poller(client):
"""Best-effort shutdown of a client's single background poller, used by stubbed close()
so test teardown never joins a live thread or reaches the network."""
poller = getattr(client, "_poller", None)
if poller is not None:
try:
poller.stop()
except Exception:
pass
def _start_test_poller(client):
"""Start a real InteractshPoller around a fake poll() without requiring pycryptodome.
Interactsh._ensurePoller() intentionally no-ops when crypto is unavailable because a real
interactsh /poll response cannot then be decrypted. These tests replace poll() with an
already-decoded in-memory fake, so their queue/poller behavior should not depend on whether
the test interpreter happens to have Crypto installed (notably PyPy 2.7 in CI).
"""
from lib.request.interactsh import InteractshPoller
poller = getattr(client, "_poller", None)
if poller is None:
client._poller = poller = InteractshPoller(client)
poller.start()
return poller
class _SendFailOnceSocket(object):
"""Wraps a real UDP socket; first sendto() raises (simulated transient failure)"""
def __init__(self, real):
@@ -322,27 +350,62 @@ class TestDNSServerConcurrency(unittest.TestCase):
self.assertEqual(self.srv.pop("gg" + chr(ord("g")), "mm" + chr(ord("g"))), None)
if __name__ == "__main__":
unittest.main(verbosity=2)
class TestInteractshDNSServer(unittest.TestCase):
"""The interactsh-backed DNS collector must present the same pop(prefix, suffix)
accounting as DNSServer, matching only prefix.<result>.suffix names and never
returning the same captured lookup twice."""
def _collector(self, names):
class _FakeClient(object):
registered = True
def dnsDomain(self): return "corr0000000000000nnc.oast.fun"
def dnsNames(self): return list(names)
def _collector(self, names, deadline=None, start_poller=True):
"""Build an InteractshDNSServer (bypassing __init__) over a fake client.
The fake drives the real poller-aware consume path (production code, not a stub), returns
its records ONCE destructively - exactly as a real interactsh endpoint drains storage -
and, because close()/_request() are stubbed, can never reach the network at teardown.
``deadline`` sets this instance's pop() search budget (seconds); ``start_poller`` runs the
single background consumer, as normal registered operation would.
"""
from lib.request.interactsh import Interactsh
client = Interactsh.__new__(Interactsh) # bypass network __init__
client.registered = True
client.correlationId = "corr0000000000000nnc"
client.server = "oast.fun"
client.secret = "test-secret"
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
client._stopping = False # __init__ sets it; set it here too
client._dnsNonce = None # lets the real dnsDomain() run (lazy-initialises it)
names_batch = list(names)
def poll():
# return the whole batch once, then nothing - mirrors an interactsh endpoint
# draining its pending storage in a single /poll response
if names_batch:
batch = [{"protocol": "dns", "full-id": n, "unique-id": n} for n in names_batch]
del names_batch[:]
return batch
return []
client.poll = poll
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
# teardown (srv.stop() -> client.stop() -> close) must never touch the network
client.close = lambda: _stop_client_poller(client)
client._request = lambda *a, **k: None
srv = InteractshDNSServer.__new__(InteractshDNSServer)
srv._client = _FakeClient()
srv._client = client
srv.domain = srv._client.dnsDomain()
srv._seen = set()
srv._running = True
srv._initialized = True
srv._POLL_TRIES = 1 # no real sleeps in unit tests
srv._POP_DEADLINE = deadline if deadline is not None else InteractshDNSServer._POP_DEADLINE
self.addCleanup(srv.stop) # stops the poller then deregisters (close() is network-stubbed)
if start_poller:
poller = _start_test_poller(srv._client)
t = time.time() + 2.0
while not poller._queue and time.time() < t: # wait for the destructive batch to stage
time.sleep(0.01)
return srv
def test_pop_matches_prefix_suffix_and_dedups(self):
@@ -350,16 +413,428 @@ class TestInteractshDNSServer(unittest.TestCase):
srv = self._collector(names)
got = srv.pop("aaa", "zzz")
self.assertEqual(got, "aaa.5345435245540a.zzz.corr0000000000000nnc")
self.assertIsNone(srv.pop("aaa", "zzz")) # already consumed
self.assertIsNone(srv.pop("aaa", "zzz", deadline=0)) # consumed only once (snapshot)
srv.stop()
def test_pop_does_not_consume_a_sharing_http_record(self):
"""Regression for the cross-consumer loss the shared poller was introduced to prevent.
pop() and httpRequests() draw from the same background poller queue. If pop() does not
verify the record's protocol, a queued HTTP interaction whose full-id matches the DNS
window would be returned as though it were a DNS lookup, and the HTTP consumer would
then lose it. Both records here share the SAME full-id; pop() must return the DNS name
only and leave the HTTP record for httpRequests()."""
from lib.request.interactsh import Interactsh
dns_full = "aaa.deadbeef.bbb.corr0000000000000nnc"
http_full = "aaa.deadbeef.bbb.corr0000000000000nnc" # shared full-id with the DNS record
recs = [
{"protocol": "dns", "full-id": dns_full, "unique-id": dns_full,
"remote-address": "1.1.1.1", "raw-request": "QUERY"},
{"protocol": "http", "full-id": http_full,
"raw-request": "GET /?cafebabe=1 HTTP/1.1", "remote-address": "2.2.2.2"},
]
client = Interactsh.__new__(Interactsh) # bypass network __init__
client.registered = True
client.correlationId = "corr0000000000000nnc"
client.server = "oast.fun"
client.secret = "test-secret"
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
client._stopping = False # __init__ sets it; set it here too
client._dnsNonce = None
rec_batch = list(recs)
def poll():
# return the whole batch once, then nothing - mirrors an interactsh endpoint
# draining its pending storage in a single /poll response
if rec_batch:
batch = rec_batch[:]
del rec_batch[:]
return batch
return []
client.poll = poll
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
client.close = lambda: _stop_client_poller(client)
client._request = lambda *a, **k: None
srv = InteractshDNSServer.__new__(InteractshDNSServer)
srv._client = client
srv.domain = srv._client.dnsDomain()
srv._seen = set()
srv._running = True
srv._initialized = True
srv._POP_DEADLINE = 1.0
self.addCleanup(srv.stop)
poller = _start_test_poller(srv._client) # real queue/poller, fake already-decoded poll()
t = time.time() + 2.0
while not poller._queue and time.time() < t: # wait for the destructive batch to stage
time.sleep(0.01)
self.assertEqual(srv.pop("aaa", "bbb"), dns_full) # DNS record claimed
self.assertFalse(srv.pop("aaa", "bbb", deadline=0)) # snapshot after the DNS record is gone
# the HTTP record with the same full-id is still there for the HTTP consumer
out = client.httpRequests()
self.assertEqual(len(out), 1)
self.assertEqual(out[0]["full-id"], http_full)
self.assertEqual(out[0]["query-param-names"], ["cafebabe"])
def test_pop_no_match(self):
srv = self._collector(["aaa.deadbeef.qqq.corr0000000000000nnc"])
srv = self._collector(["aaa.deadbeef.qqq.corr0000000000000nnc"], deadline=1.0, start_poller=False)
self.assertIsNone(srv.pop("aaa", "zzz"))
def test_pop_any(self):
srv = self._collector(["whatever.corr0000000000000nnc"])
self.assertEqual(srv.pop(), "whatever.corr0000000000000nnc")
srv.stop()
def test_run_is_noop(self):
self._collector([]).run() # must not raise
srv = self._collector([])
srv.run() # must not raise
srv.stop()
def test_pop_with_no_poller_installed(self):
"""Backwards-compatible: a server without a running poller resolves pop() via the direct
(synchronous) poll path."""
names = ["mmm.cafebabefff.ccc.corr0000000000000nnc"]
srv = self._collector(names, start_poller=False)
self.assertEqual(srv.pop("mmm", "ccc", sync=True), names[0])
srv.stop()
def test_background_poller_stages_names_instantly(self):
"""With the single background poller running, pop() reads a staged name from its in-memory
queue with no extra /poll round-trip."""
names = ["aaa.deadbeef.bbb.corr0000000000000nnc"]
srv = self._collector(names) # poller started and its destructive batch staged
try:
self.assertTrue(srv._client._poller.isRunning())
self.assertEqual(_wait_popped(srv, "aaa", "bbb"), names[0])
self.assertIsNone(srv.pop("aaa", "bbb", deadline=0)) # only one lookup exists (snapshot)
finally:
srv.stop()
def test_background_poller_preserves_foreign_window_names(self):
"""A name for a *different* window survives its own pop() scan - pop(zzz,yyy) must not drain
the aaa/bbb record, which pop(aaa,bbb) then returns."""
names = ["aaa.deadbeef.bbb.corr0000000000000nnc"]
srv = self._collector(names, deadline=1.0)
try:
self.assertFalse(srv.pop("zzz", "yyy")) # wrong window yields nothing (~1s)
self.assertEqual(srv.pop("aaa", "bbb"), names[0])
self.assertIsNone(srv.pop("aaa", "bbb", deadline=0)) # only one lookup (snapshot)
finally:
srv.stop()
def test_ensure_poller_starts_only_one_background_thread(self):
"""Concurrent _ensurePoller() callers must converge on a single background poller.
The create-and-start decision runs under _pollerLock, so two threads racing on an
unstarted session cannot each build their own poller and destructively poll the same
interaction session concurrently - precisely what this redesign exists to prevent."""
from lib.request.interactsh import Interactsh, InteractshPoller, hasCrypto
if not hasCrypto():
self.skipTest("requires pycryptodome")
client = Interactsh.__new__(Interactsh)
client.registered = True
client.correlationId = "corr0000000000000nnc"
client.server = "oast.fun"
client.secret = "test-secret"
client._dnsNonce = None
client._poller = None
client._pollerLock = threading.Lock()
client._stopping = False # set by __init__ in the real client; set it here too
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
# stub the remote endpoint and deregister path so the poll loop and client.stop() stay
# offline while we probe the create/start synchronisation
client.poll = lambda: []
client._request = lambda *a, **k: "[]"
started = []
orig_start = InteractshPoller.start
def counting_start(self):
started.append(self) # record every poller whose start() is invoked
orig_start(self)
client._poller = None
InteractshPoller.start = counting_start
try:
threads = [threading.Thread(target=client._ensurePoller) for _ in range(16)]
for t in threads:
t.start()
for t in threads:
t.join()
finally:
InteractshPoller.start = orig_start
# the single poller was created exactly once and is still running (only one thread
# ever polled the session)
self.assertEqual(len(started), 1) # start() invoked exactly once
self.assertEqual(len(set(id(p) for p in started)), 1) # exactly one InteractshPoller
self.assertIn(client._poller, started)
self.assertTrue(client._poller._running)
# stop the daemon poller before this test returns so its /poll loop (stubbed to [])
# does not keep looping once per second for the rest of the test process
client._poller.stop()
def test_close_stops_poller_and_deregisters(self):
"""close() must remain the canonical complete teardown: after it returns this object owns
no active polling thread.
Interactsh.close() predates the background poller and only deregistered, so a running
poller could keep polling (and race the remote session during deregistration). close()
now routes through stop()."""
from lib.request.interactsh import Interactsh, hasCrypto
if not hasCrypto():
self.skipTest("requires pycryptodome")
client = Interactsh.__new__(Interactsh)
client.registered = True
client.correlationId = "corr0000000000000nnc"
client.server = "oast.fun"
client.secret = "test-secret"
client._dnsNonce = None
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
client._stopping = False # __init__ sets it; set it here too
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
# stub remote endpoint and deregister path so the poll loop and client.stop() stay offline
client.poll = lambda: []
client._request = lambda *a, **k: "[]"
client._ensurePoller() # start the single background poller
self.assertTrue(client._poller.isRunning())
client.close() # close() must stop the poller and deregister
# isRunning() would be False even while the thread still runs when poll() wedged past the
# join budget; assert the underlying thread is actually gone so the invariant is real.
self.assertFalse(client._poller._thread.is_alive()) # no active polling thread remains
self.assertFalse(client._poller.isRunning())
self.assertFalse(client.registered)
# cleanup: idempotent - already stopped/deregistered
self.addCleanup(client.stop)
def test_consume_does_not_poll_while_stopping(self):
"""_consume() must not do its own destructive /poll during shutdown.
_consume() used to fall back to self.poll() whenever the poller was not running. Even
though _ensurePoller() refuses to start a new poller once _stopping is set, pop() can still
reach that fallback: _stopping is set, the poller is stopped, but _consume() could then
directly call self.poll() (a destructive /poll) just before stop()'s close(). A direct
poll during shutdown is exactly the unsynchronised drain that breaks the shared-session
invariant, so a stopped poller must yield [] - never a direct poll()."""
from lib.request.interactsh import Interactsh, hasCrypto
if not hasCrypto():
self.skipTest("requires pycryptodome")
client = Interactsh.__new__(Interactsh) # bypass network __init__
client.registered = True
client.correlationId = "corr0000000000000nnc"
client.server = "oast.fun"
client.secret = "test-secret"
client._poller = None
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
client._stopping = True # simulate an active stop()
client._dnsNonce = None
calls = {"poll": 0}
def spy_poll():
calls["poll"] += 1
return []
client.poll = spy_poll
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
# even with no poller at all, a stopped session must not call poll() from _consume
# (drain_matching on a non-running poller yields [])
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns"), [])
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns"), [])
self.assertEqual(calls["poll"], 0) # sync=False: ensurePoller refused while stopping
# sync=True must NOT initiate a remote poll once shutdown has begun: the direct-poll path
# is gated behind _stopping==False, so a stopped session drains the (empty) queue instead
# - no caller initiates another /poll after stop(), not even this compatibility path.
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
self.assertEqual(calls["poll"], 0)
# sanity: sync=True still drives poll() on a live (not-stopping) session - this guards
# against over-closing the safety check to sync itself. _stopping stays False, poller
# never starts (sync drains directly), so nothing needs cleaning up.
client._stopping = False
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
self.assertEqual(calls["poll"], 1)
def test_consume_does_not_second_poll_while_poller_running(self):
"""sync=True must not add a second /poll caller while the background poller is already running.
The shared-poller invariant is that only ONE code path ever calls the remote /poll endpoint.
A spurious sync=True during normal operation used to be the footgun: it called self.poll()
even when a live poller was already draining the session, yielding a second destructive
/poll. With a running poller sync=True must drain the poller's in-memory queue instead -
no additional poll() call - and the decision must be serialized under _pollerLock so a
concurrent _ensurePoller() cannot start a new poller in the gap between the check and the
(absent) poll()."""
from lib.request.interactsh import Interactsh, hasCrypto
if not hasCrypto():
self.skipTest("requires pycryptodome")
client = Interactsh.__new__(Interactsh) # bypass network __init__
client.registered = True
client.correlationId = "corr0000000000000nnc"
client.server = "oast.fun"
client.secret = "test-secret"
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
client._stopping = False
client._dnsNonce = None
client._poller = None
# stub remote + deregister paths so any direct poll() stays offline
client.poll = lambda: []
client._request = lambda *a, **k: "[]"
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
# start the single background poller, then replace poll with a counting spy AFTER staging.
# Count only calls made from THIS (foreground) thread: the legitimate background poller
# thread is permitted to poll(), so thread identity - not a bare call count - isolates the
# footgun (sync=True issuing a second /poll) from routine background polling.
main_thread = threading.current_thread()
foreground_calls = []
client._ensurePoller()
self.assertTrue(client._poller.isRunning())
def spy_poll():
if threading.current_thread() is main_thread:
foreground_calls.append(1)
return []
client.poll = spy_poll
# sync=True on a live, poller-running session drains the queue; the running poller is the
# only code that issues /poll, so the foreground must never call poll() directly.
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
self.assertEqual(foreground_calls, []) # sync=True issued no direct /poll
client.stop() # join the single (still-running) poll thread
class TestInteractshHTTPRequests(unittest.TestCase):
"""HTTP-based out-of-band retrieval is the collector half of an HTTP-OOB SQLi exfil channel.
interactsh returns full 'http' protocol records with a verbatim 'raw-request', a
'protocol' ("http" or "https") and a 'remote-address' - the request line, not a parsed
path/query. Because an HTTP callback can be blocked (e.g. firewalled query string) and a
value may contain characters unsafe in a query-string name, the payload is embedded in a
query-string *parameter name* and httpRequests() decodes and returns the distinct names.
The request target is split on the *raw* '?'/'&'/first '=' and only each name is percent-
decoded individually, so encoded delimiter bytes survive (``%26``->'&', ``%3D``->'=',
``%25``->'%'); decoding the whole target first would corrupt them."""
def _client(self, records):
"""Fake client carrying the records in poll(), wired to the real poller-aware consume path.
Records are returned once (destructive, like a draining interactsh endpoint) and the
poller is started so httpRequests() exercises the normal registered-poller path; teardown
cannot reach the network."""
from lib.request.interactsh import Interactsh
client = Interactsh.__new__(Interactsh) # bypass network __init__
client.registered = True
client.correlationId = "corr0000000000000nnc"
client.server = "oast.fun"
client.secret = "test-secret"
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
client._stopping = False # __init__ sets it; set it here too
rec_batch = list(records)
def poll():
# return the whole batch once, then nothing - mirrors an interactsh endpoint
# draining its pending storage in a single /poll response
if rec_batch:
batch = rec_batch[:]
del rec_batch[:]
return batch
return []
client.poll = poll
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
client.close = lambda: _stop_client_poller(client)
client._request = lambda *a, **k: None
poller = _start_test_poller(client)
t = time.time() + 2.0
while not poller._queue and time.time() < t:
time.sleep(0.01)
# client.stop() (registered as cleanup) stops the single background poller then
# deregisters, so the poll thread never outlives this test and never reaches the network
self.addCleanup(client.stop)
return client
def test_extracts_query_param_names_from_raw_request(self):
records = [{"protocol": "http", "full-id": "a.b.c", "raw-request": "GET /?deadbeef=cafebabe HTTP/1.1", "remote-address": "1.2.3.4"}]
client = self._client(records)
out = client.httpRequests()
self.assertEqual(len(out), 1)
# only the query-string *name* is the attacker-controlled exfil slot; the value rides on it
self.assertEqual(out[0]["query-param-names"], ["deadbeef"])
self.assertEqual(out[0]["client"], "1.2.3.4")
self.assertEqual(out[0]["request"], "GET /?deadbeef=cafebabe HTTP/1.1")
def test_decodes_each_name_individually(self):
"""Encoded delimiter bytes must survive split: each name is decoded on its own."""
records = [
{"protocol": "https", "full-id": "a", "raw-request": "GET /?aa%26bb=1 HTTP/1.1"}, # %26 -> '&'
{"protocol": "https", "full-id": "b", "raw-request": "GET /?cc%3Ddd=1 HTTP/1.1"}, # %3D -> '='
{"protocol": "https", "full-id": "c", "raw-request": "GET /?ee%25ff=1 HTTP/1.1"}, # %25 -> '%'
]
client = self._client(records)
out = client.httpRequests()
self.assertEqual([r["query-param-names"] for r in out], [["aa&bb"], ["cc=dd"], ["ee%ff"]])
def test_skips_non_http_records(self):
records = [{"protocol": "dns", "full-id": "x.y"}]
client = self._client(records)
self.assertEqual(client.httpRequests(), [])
def test_distinct_names_are_not_duplicated(self):
records = [{"protocol": "http", "full-id": "a", "raw-request": "GET /?deadbeef=1 HTTP/1.1"},
{"protocol": "http", "full-id": "b", "raw-request": "GET /?cafebabe=2 HTTP/1.1"}]
client = self._client(records)
out = client.httpRequests()
names = [name for r in out for name in r["query-param-names"]]
self.assertEqual(set(names), {"deadbeef", "cafebabe"})
def test_empty_poll_returns_empty(self):
client = self._client([])
self.assertEqual(client.httpRequests(), [])
def test_selector_exception_is_absorbed(self):
"""A selector that raises must be caught (not propagated out of httpRequests/_consume)"""
records = [{"protocol": "http", "full-id": "a", "raw-request": "GET /?x=1 HTTP/1.1"}]
client = self._client(records)
def boom(record):
raise ValueError("boom")
self.assertEqual(client._consume(boom, deadline=None), [])
def test_multiple_http_same_host_are_not_dropped(self):
"""The reviewer's core case: several HTTP requests hitting the same interactsh host - i.e.
sharing 'full-id' - must all survive the poller's dedup. Dedup is by the interaction
itself (protocol+full-id+id/timestamp+raw-request), never by the shared host."""
records = [
{"protocol": "dns", "full-id": "abc..."},
{"protocol": "http", "full-id": "abc...", "raw-request": "GET /?deadbeef=1 HTTP/1.1"},
{"protocol": "http", "full-id": "abc...", "raw-request": "GET /?cafebabe=1 HTTP/1.1"},
]
client = self._client(records)
out = client.httpRequests()
names = [name for r in out for name in r["query-param-names"]]
self.assertEqual(set(names), {"deadbeef", "cafebabe"})
if __name__ == "__main__":
unittest.main(verbosity=2)
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Unit tests for lib/utils/search.py (dork search), exercising the engine loop by
MOCKING the network layer (_fetch).
No network is involved: _fetch is patched to return canned search engine result
pages, so we can assert both on the links extracted per engine and on the
abuse/captcha detection that triggers proxy rotation (SEARCH_ABUSE_MARKERS).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.core.data import conf
from lib.core.exception import SqlmapBaseException
from lib.core.settings import SEARCH_ABUSE_MARKERS
import lib.utils.search as smod
BING_PAGE = '<h2><a target="_blank" href="https://www.bing.com/ck/a?!&amp;&amp;p=1&amp;u=a1aHR0cHM6Ly93d3cuZXhhbXBsZS5jb20vYS5waHA_aWQ9MQ&amp;ntb=1">a</a></h2>'
DUCKDUCKGO_PAGE = '<a class="result__url" href="https://www.example.com/b.php?id=2">b</a>'
class TestDorkSearch(unittest.TestCase):
def setUp(self):
conf.googlePage = 1
conf.proxyList = None
self._fetch = smod._fetch
def tearDown(self):
smod._fetch = self._fetch
def _pages(self, *pages):
"""Patches _fetch to serve `pages` in engine order (None meaning no reply)"""
served = list(pages)
self.requested = []
def fetch(url, headers, data=None):
self.requested.append((url, data))
return served.pop(0) if served else None
smod._fetch = fetch
def test_duckduckgo_results(self):
self._pages(DUCKDUCKGO_PAGE)
self.assertEqual(smod._search("dork"), ["https://www.example.com/b.php?id=2"])
def test_bing_fallback_decodes_redirect(self):
self._pages(None, BING_PAGE)
self.assertEqual(smod._search("dork"), ["https://www.example.com/a.php?id=1"])
def test_duckduckgo_paging_offset(self):
# Note: first result page holds 10 entries, each subsequent one 15 (and not the uniform 30 as previously used)
for gpage, offset in ((1, 0), (2, 10), (3, 25), (4, 40)):
conf.googlePage = gpage
self._pages(DUCKDUCKGO_PAGE)
smod._search("dork")
self.assertEqual(self.requested[0][1], "q=dork&s=%d" % offset)
def test_abuse_page_switches_proxy(self):
# Note: without '--proxy-list' there is nothing to switch to, hence no exception
for marker in SEARCH_ABUSE_MARKERS:
self._pages("<html>%s</html>" % marker)
conf.proxyList = None
self.assertEqual(smod._search("dork"), [])
self._pages("<html>%s</html>" % marker)
conf.proxyList = ["http://127.0.0.1:8080"]
self.assertRaises(SqlmapBaseException, smod._search, "dork")
def test_results_page_is_not_mistaken_for_abuse(self):
self._pages(DUCKDUCKGO_PAGE)
conf.proxyList = ["http://127.0.0.1:8080"]
self.assertEqual(smod._search("dork"), ["https://www.example.com/b.php?id=2"])
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -76,6 +76,17 @@ class _CaptureCase(unittest.TestCase):
class TestStringAndLister(_CaptureCase):
def test_audit_output(self):
self.d.audit(["xp_cmdshell: disabled", "CLR integration: unavailable"])
self.assertIn("database security audit [2]", self.text())
self.assertIn("xp_cmdshell: disabled", self.text())
self.assertIn("CLR integration: unavailable", self.text())
def test_links_output(self):
self.d.links(["remote -> db.example"])
self.assertIn("database links [1]", self.text())
self.assertIn("remote -> db.example", self.text())
def test_string_scalar_quoted(self):
# a plain string fact is rendered as "header: 'value'"
self.d.string("current user", "root@localhost")
+43
View File
@@ -49,9 +49,15 @@ CORPUS = [
u"\U00010348", u"Aé€\U00010348Z",
]
# every connection _oracle()/_disguisedOracle() open outlives the helper call (the returned
# closure keeps using it), so callers can't close it themselves - tracked here and closed once
# in tearDownModule() instead of leaking to GC (ResourceWarning: unclosed database)
_OPEN_CONNECTIONS = []
def _oracle(value=None, is_null=False):
con = sqlite3.connect(":memory:")
_OPEN_CONNECTIONS.append(con)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES (?)", (None if is_null else value,))
con.commit()
@@ -84,6 +90,7 @@ def _disguisedOracle(blocked=None, rewrites=(), funcs=()):
`funcs` registers extra SQL functions (e.g. a variadic CONCAT SQLite lacks). The
engine never sees SQLite - it must adapt to whatever back-end the scenario emulates."""
con = sqlite3.connect(":memory:")
_OPEN_CONNECTIONS.append(con)
for name, narg, fn in funcs:
con.create_function(name, narg, fn)
for stmt in _SEED:
@@ -221,6 +228,7 @@ class TestEsperanto(unittest.TestCase):
# hostExtract must mirror the native EXACT/AMBIGUOUS verdict: in a mode with no byte-exact
# witness (ordinal), both must be WHOLE_BUT_AMBIGUOUS, not native-ambiguous/host-exact.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('Admin-42')")
con.commit()
@@ -249,6 +257,7 @@ class TestEsperanto(unittest.TestCase):
# must reject the shortened token -> fall back to cell-by-cell (which reads the true
# length via substring), so a 400-char value is recovered whole, not silently as 300.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
big = "A" * 400
con.execute("INSERT INTO t VALUES (1, ?)", (big,))
@@ -273,6 +282,7 @@ class TestEsperanto(unittest.TestCase):
# operands -> bisection converges off-by-one. The final `expr = recovered` check must
# reject it (fail closed) rather than return a silently-wrong integer.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
def ask(cond):
c = cond
@@ -302,6 +312,7 @@ class TestEsperanto(unittest.TestCase):
# (LENGTH(..)>n, UNICODE(..)>n) converges off-by-one; the '=' confirmation must fail closed
# rather than hand back silently-wrong code-point text as exact.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('Admin-42')")
con.commit()
@@ -329,6 +340,7 @@ class TestEsperanto(unittest.TestCase):
# (e.g. utf-8 bytes with an embedded NUL heuristically read as UTF-16). Bytes are faithful;
# only a PROVEN codec makes the decoded text exact.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES (?)", (u"é",)) # utf-8 C3 A9 -> non-ASCII bytes
con.execute("CREATE TABLE a (v TEXT)")
@@ -356,6 +368,7 @@ class TestEsperanto(unittest.TestCase):
# Round 8 #5: once a byte-length witness is selected, an UNDECIDED reading must reject the
# bytes (fail closed), never treat "couldn't verify" as "matched".
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('abc')")
con.commit()
@@ -389,6 +402,7 @@ class TestEsperanto(unittest.TestCase):
# never ran on the very failure it defends against. The canary must run whenever a textcast
# was applied, and an unproven cast must not certify source identity.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
con.execute(u"INSERT INTO t VALUES (1, 'café')")
con.commit()
@@ -415,6 +429,7 @@ class TestEsperanto(unittest.TestCase):
# the engine must escalate to the cast+hex path (which the framed dump proves works) and
# recover the value exactly. Without hex, it stays honestly incomplete (no false-complete).
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute(u"INSERT INTO t VALUES ('café-€')")
con.commit()
@@ -466,6 +481,7 @@ class TestEsperanto(unittest.TestCase):
# False). the full truth table must reject 'gt' (and fall to BETWEEN) so counts/lengths
# aren't read off-by-one. reproduced: extractInteger('5',max=10) used to return 6.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
def ask(cond):
try:
@@ -481,6 +497,7 @@ class TestEsperanto(unittest.TestCase):
# without a proven hex/binary witness (or code-codepoint), a value is WHOLE_BUT_AMBIGUOUS,
# never EXACT - plain '=' is collation-dependent and can't certify byte-exactness.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('Zz')")
con.commit()
@@ -510,6 +527,7 @@ class TestEsperanto(unittest.TestCase):
def test_host_maxlen_zero(self):
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('abcdef')")
con.commit()
@@ -541,6 +559,7 @@ class TestEsperanto(unittest.TestCase):
# `complete` (walk finished) must be distinct from `exact` (bytes proven identical).
# a case-insensitive collation recovers a WHOLE value that is NOT exact.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('A')")
con.commit()
@@ -568,6 +587,7 @@ class TestEsperanto(unittest.TestCase):
# hostExtract returns an ExtractResult: a bounded read is TRUNCATED (not a bare string),
# and an undecided (None) host observation degrades to a FAILED result, never a fake bit.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute("INSERT INTO t VALUES ('abcdef')")
con.commit()
@@ -602,6 +622,7 @@ class TestEsperanto(unittest.TestCase):
# key's first column repeats -> `> prev` paging silently drops rows sharing it, so it
# must be REJECTED (COUNT(*) != COUNT(DISTINCT col)); only a unique column is accepted.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE k (a INT, b INT, u INT, nul INT)")
con.executemany("INSERT INTO k VALUES (?,?,?,?)", [(1, 1, 10, 1), (1, 2, 20, None), (2, 1, 30, 3)])
con.commit()
@@ -651,6 +672,7 @@ class TestEsperanto(unittest.TestCase):
def test_enumerate(self):
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE alpha (x)")
con.execute("CREATE TABLE beta (y)")
con.commit()
@@ -668,6 +690,7 @@ class TestEsperanto(unittest.TestCase):
def test_dump(self):
# row DATA byte-exact, including commas / quotes / unicode (hex framing keeps intact)
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE users (id, uname, note)")
truth = [(1, u"admin", u"all,good"), (2, u"o'brien", u"café,€"), (3, u"x", u"")]
for row in truth:
@@ -692,6 +715,7 @@ class TestEsperanto(unittest.TestCase):
# extraction. Quotes/commas/NULLs in the data must survive (a single value has
# no framing ambiguity). This is the scavenger's whole reason to exist.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE users (id INTEGER, name TEXT, email TEXT)")
for row in ((1, "luther", "[email protected]"), (2, "o'brien", "x,y@z"), (3, "wu", None)):
con.execute("INSERT INTO users VALUES (?,?,?)", row)
@@ -723,6 +747,7 @@ class TestEsperanto(unittest.TestCase):
# catalog detection, brute-force existence probing, and identifier quoting are
# all COUNT-free (scalar-subquery existence), so tables/columns/dump still work.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE users (id INTEGER, name TEXT)")
con.execute("INSERT INTO users VALUES (1, 'admin'), (2, 'root')")
con.commit()
@@ -747,6 +772,7 @@ class TestEsperanto(unittest.TestCase):
def test_quoting(self):
# reserved-word / spaced column names must be quoted, not interpolated raw
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute('CREATE TABLE q (id INTEGER, "order" TEXT, "group by" TEXT)')
con.execute('INSERT INTO q VALUES (1, ?, ?)', ("a'b", "x,y"))
con.commit()
@@ -767,6 +793,7 @@ class TestEsperanto(unittest.TestCase):
def test_strategy_handoff(self):
# the frozen InferenceStrategy is a *sufficient* host interface, and immutable
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE k (v TEXT)")
con.execute("INSERT INTO k VALUES ('Str4t3gy!')")
con.commit()
@@ -788,6 +815,7 @@ class TestEsperanto(unittest.TestCase):
def test_pattern_match_fallback(self):
# SUBSTR + LENGTH + hex + code fns ALL blacklisted -> pure GLOB/LIKE floor
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE flag (id INTEGER, v TEXT)")
con.execute("INSERT INTO flag VALUES (1, 'FLAG{no_SUBSTR_%_needed}')")
con.commit()
@@ -812,6 +840,7 @@ class TestEsperanto(unittest.TestCase):
# like 'all_products' must escape them (\_ ESCAPE '\'), not treat them as
# match-anything. GLOB is blocked here to force LIKE (where '_'/'%' are magic).
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE v (val TEXT)")
con.execute("INSERT INTO v VALUES ('all_products')")
for t in ("all_products", "wp_users", "sales%2024"): # '_'/'%' in identifiers
@@ -838,6 +867,7 @@ class TestEsperanto(unittest.TestCase):
def test_length_from_substring(self):
# every length fn blacklisted but SUBSTR present -> length derived from the end
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t6 (v TEXT)")
con.execute(u"INSERT INTO t6 VALUES ('Admin-42€')")
con.commit()
@@ -859,6 +889,7 @@ class TestEsperanto(unittest.TestCase):
# a permission/charset wall mid-walk (oracle can't decide the keyset bound)
# must STOP with partial results, never crash with OracleUndecided
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
for t in ("alpha", "beta", "gamma"):
con.execute("CREATE TABLE %s (x)" % t)
con.commit()
@@ -884,6 +915,7 @@ class TestEsperanto(unittest.TestCase):
# RIGHT( onto them (the same rewrite the disguised-dialect oracles use). Keeps the
# test portable across SQLite builds while still exercising the real left_right rung.
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t7 (v TEXT)")
con.execute("INSERT INTO t7 VALUES ('Zagreb-42')")
con.commit()
@@ -1029,6 +1061,7 @@ class TestEsperanto(unittest.TestCase):
seed = ('CREATE TABLE q (id INTEGER, "order" TEXT, "group" TEXT)',
"INSERT INTO q VALUES (1, ?, ?)")
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute(seed[0]); con.execute(seed[1], ("a,b", "x'y")); con.commit()
blk = re.compile(r'"|`')
@@ -1050,6 +1083,7 @@ class TestEsperanto(unittest.TestCase):
# a first-byte charcode fn (MySQL-style ASCII) is lossy for non-ASCII; the
# engine must detect that and escalate to hex, recovering the bytes exactly
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE t (v TEXT)")
con.execute(u"INSERT INTO t VALUES ('café-€')") # cafe-EUR
con.commit()
@@ -1071,6 +1105,7 @@ class TestEsperanto(unittest.TestCase):
# non-ASCII data recovered byte-exact even while the dialect is disguised
# (SUBSTR/MID/CHAR_LENGTH/LENGTH blocked -> SUBSTRING/LEN mapped to SQLite)
con = sqlite3.connect(":memory:")
self.addCleanup(con.close)
con.execute("CREATE TABLE s (v TEXT)")
con.execute(u"INSERT INTO s VALUES ('Zagreb-župa-€42')") # Zagreb-zupa-EUR42
con.commit()
@@ -1091,5 +1126,13 @@ class TestEsperanto(unittest.TestCase):
self.assertEqual(esp.extractText("(SELECT v FROM s)"), u"Zagreb-župa-€42")
def tearDownModule():
while _OPEN_CONNECTIONS:
try:
_OPEN_CONNECTIONS.pop().close()
except Exception:
pass
if __name__ == "__main__":
unittest.main(verbosity=2)
+59 -25
View File
@@ -21,8 +21,10 @@ prompts non-interactive, and conf.dumper is a recording stub. Every test restore
all touched conf.* / kb.* / patched module attributes in tearDown so nothing leaks.
"""
import io
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -39,6 +41,7 @@ import plugins.generic.entries as emod
import plugins.generic.custom as cmod
import plugins.generic.misc as mmod
import plugins.generic.takeover as tmod
import lib.core.common as common
from plugins.generic.custom import Custom
from plugins.generic.misc import Miscellaneous
@@ -250,38 +253,69 @@ class TestCustomSqlQuery(_GenericBase):
class TestCustomSqlFile(_GenericBase):
def test_sqlfile_select_snippets(self):
set_dbms("MySQL")
c = Custom()
cmod.inject.getValue = lambda query, **k: "r"
def _runSqlFile(self, content, suffix=".sql"):
handle, filename = tempfile.mkstemp(suffix=suffix)
os.close(handle)
self.addCleanup(os.unlink, filename)
with io.open(filename, "w", encoding="utf-8", newline="") as f:
f.write(content)
# getSQLSnippet reads from disk; patch it to return inline SQL.
saved = cmod.getSQLSnippet
try:
cmod.getSQLSnippet = lambda dbms, filename, **kw: "SELECT 1;SELECT 2"
conf.sqlFile = "dummy.sql"
c.sqlFile()
# two SELECT statements => two recorded dumper.sqlQuery calls
self.assertEqual(len(conf.dumper.sqlQueries), 2)
finally:
cmod.getSQLSnippet = saved
set_dbms("MySQL")
cmod.inject.getValue = lambda query, **k: "r"
conf.sqlFile = filename
Custom().sqlFile()
return [query for query, output in conf.dumper.sqlQueries]
def test_sqlfile_select_snippets(self):
self.assertEqual(self._runSqlFile(u"SELECT 1; SELECT 2;\n"), ["SELECT 1", "SELECT 2"])
def test_sqlfile_nonselect_snippet(self):
set_dbms("MySQL")
conf.direct = True
c = Custom()
cmod.inject.goStacked = lambda *a, **k: None
self.assertEqual(self._runSqlFile(u"DROP TABLE x"), ["DROP TABLE x"])
saved = cmod.getSQLSnippet
def test_sqlfile_preserves_percent_tokens_without_prompting(self):
saved = common.readInput
try:
cmod.getSQLSnippet = lambda dbms, filename, **kw: "DROP TABLE x"
conf.sqlFile = "dummy.sql"
c.sqlFile()
# non-SELECT => single recorded call with the whole snippet
self.assertEqual(len(conf.dumper.sqlQueries), 1)
self.assertEqual(conf.dumper.sqlQueries[0][0], "DROP TABLE x")
common.readInput = lambda *a, **k: self.fail("user SQL must not request template substitutions")
query = u"SELECT DATE_FORMAT(NOW(), '%m%m'), '%RANDSTR1%', '%RANDINT1%'"
self.assertEqual(self._runSqlFile(query), [query])
finally:
cmod.getSQLSnippet = saved
common.readInput = saved
def test_sqlfile_preserves_hash_and_quoted_delimiters(self):
first = u"SELECT '#keep; value', 'it''s; quoted', \"semi; colon\""
self.assertEqual(self._runSqlFile(first + u"; SELECT 2;"), [first, "SELECT 2"])
def test_sqlfile_preserves_multiline_literal(self):
query = u"SELECT 'a;\n b'"
self.assertEqual(self._runSqlFile(query), [query])
def test_sqlfile_preserves_crlf_literal(self):
query = u"SELECT 'a;\r\n b'"
self.assertEqual(self._runSqlFile(query + u";\r\nSELECT 2;"), [query, "SELECT 2"])
def test_sqlfile_newline_separated_selects(self):
self.assertEqual(self._runSqlFile(u"SELECT 'a;b'\nSELECT 2\n"), ["SELECT 'a;b'", "SELECT 2"])
def test_sqlfile_multiline_select(self):
query = u"SELECT name\nFROM users"
self.assertEqual(self._runSqlFile(query), [query])
def test_sqlfile_preserves_mixed_batch(self):
batch = u"UPDATE users SET name='#keep; value';\nSELECT name FROM users;"
executed = []
cmod.inject.goStacked = lambda query, **k: executed.append(query)
self.assertEqual(self._runSqlFile(batch), [batch])
self.assertEqual(executed, [batch.rstrip(';')])
def test_sqlfile_arbitrary_extension(self):
self.assertEqual(self._runSqlFile(u"SELECT 1", suffix=".txt"), ["SELECT 1"])
def test_sqlfile_without_extension(self):
self.assertEqual(self._runSqlFile(u"SELECT 1", suffix=""), ["SELECT 1"])
def test_sqlfile_empty(self):
self.assertEqual(self._runSqlFile(u" \n;\n "), [])
# --------------------------------------------------------------------------- #
+16
View File
@@ -87,6 +87,22 @@ class TestDbmsSpecificVectors(unittest.TestCase):
self.assertEqual(H.oracle_old_passwd("tiger", "scott", uppercase=True), "F894844C34402B67")
class TestCryptGenericBytesInput(unittest.TestCase):
"""
The dictionary-attack workers (_bruteProcessVariantA/B in lib/utils/hash.py) always pass
'password' as bytes (getBytes(word)), regardless of which hash function gets dispatched. Every
other function here hashes via hashlib (bytes-friendly), but crypt_generic_passwd forwards
straight to the stdlib crypt.crypt(), which requires str - raising 'TypeError: crypt() argument
1 must be str, not bytes' on Linux/macOS (reported via a user's cracked-hash session).
"""
def test_bytes_password_does_not_raise(self):
self.assertEqual(H.crypt_generic_passwd(password=b"rasmuslerdorf", salt="rl", uppercase=False), "rl.3StKT.4T8M")
def test_str_password_unaffected(self):
self.assertEqual(H.crypt_generic_passwd(password="rasmuslerdorf", salt="rl", uppercase=False), "rl.3StKT.4T8M")
class TestMssqlUnicodePassword(unittest.TestCase):
"""MSSQL hashes the password as UCS-2/UTF-16LE. A per-char 'utf-8 + NUL' approximation is only
correct for ASCII, so non-ASCII passwords (cafe, etc.) hashed WRONG and were uncrackable. The
+21
View File
@@ -26,6 +26,7 @@ stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x.
import os
import re
import sys
import time
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -223,6 +224,26 @@ class HeuristicSignatureTest(unittest.TestCase):
self.assertEqual(fired, (),
msg="%s output suggests %s: %r" % (backend, '/'.join("'--%s'" % _ for _ in fired), text))
def test_no_signature_has_an_unbounded_gap(self):
# an unbounded '.*' between a literal and a keyword is quadratic on a long line (minified
# JS/JSON, a one-line JSON error body), which is how a stray 'xpath' in a 400KB response
# cost ~25s per parameter - and made the regex engine itself blow up (#5994, #6105).
# The bounded form ('[^\n]{0,100}?') matches the same real errors in constant work
for name, regex in ENGINES:
found = re.search(r"(?<!\\)\.[*+]", regex)
self.assertIsNone(found, msg="'--%s' signatures carry an unbounded '%s' gap" % (name, found.group(0) if found else ""))
def test_signatures_stay_linear_on_a_long_line(self):
# the same invariant, measured: every engine has to survive a single-line response that
# carries the words its signatures start with, without any error actually being present
page = ("<div class=\"x\">lorem ipsum dolor sit amet consectetur adipiscing elit</div>" + ''.join("%s " % _ for _ in ("xpath", "HQL", "ParseException", "NameError", "Entity x", "handlebars", "no such file", "Twig"))) * 2000
for name, regex in ENGINES:
start = time.time()
re.search(regex, page)
elapsed = time.time() - start
self.assertLess(elapsed, 2, msg="'--%s' signatures took %.1fs on a %dKB single-line response" % (name, elapsed, len(page) // 1024))
def test_every_engine_is_covered(self):
# a new switch must arrive here with its own errors, or the matrix above proves nothing about it
owners = set(owner for owner, _, _ in CORPUS)
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.core.data import kb
from lib.core.enums import DBMS
from lib.core.enums import FORK
from lib.parse.html import htmlParser
class TestHtmlParserForks(unittest.TestCase):
def setUp(self):
kb.forkNote = None
kb.cache.parsedDbms.clear()
# NOTE: these forks all speak the PostgreSQL wire protocol and inherit its error wording, so
# the brand name itself never appears next to 'ERROR:' - only a leaked driver package or an
# engine-internal source reference tells them apart from stock PostgreSQL
def test_cockroachdb_error_fingerprint(self):
for page in (
"ERROR: relation \"crdb_internal.tables\" does not exist",
"DETAIL: stack trace: github.com/cockroachdb/cockroach/pkg/sql/conn_executor.go:733",
):
kb.forkNote = None
kb.cache.parsedDbms.clear()
self.assertEqual(htmlParser(page), DBMS.PGSQL)
self.assertEqual(kb.forkNote, FORK.COCKROACHDB)
def test_yugabytedb_error_fingerprint(self):
for page in (
"com.yugabyte.util.PSQLException: ERROR: syntax error at or near \"'\"",
"ERROR: Query error: [Query error (yb/tserver/read_query.cc:265): MISMATCHED_SCHEMA]",
):
kb.forkNote = None
kb.cache.parsedDbms.clear()
self.assertEqual(htmlParser(page), DBMS.PGSQL)
self.assertEqual(kb.forkNote, FORK.YUGABYTEDB)
def test_opengauss_error_fingerprint(self):
page = "org.opengauss.util.PSQLException: ERROR: syntax error at or near \"'\""
dbms = htmlParser(page)
self.assertEqual(dbms, DBMS.PGSQL)
self.assertEqual(kb.forkNote, FORK.OPENGAUSS)
def test_duckdb_error_fingerprint(self):
# Messages below are verbatim from DuckDB 1.1.3/1.5.5 (the module was renamed
#'duckdb.duckdb' -> '_duckdb')
for page in (
"_duckdb.ParserException: Parser Error: unterminated quoted string at or near \"'''\"",
"duckdb.duckdb.CatalogException: Catalog Error: Table with name t does not exist!",
"_duckdb.BinderException: Binder Error: Referenced column \"x\" not found in FROM clause!",
"at org.duckdb.DuckDBPreparedStatement.execute(DuckDBPreparedStatement.java:180)",
):
kb.forkNote = None
kb.cache.parsedDbms.clear()
self.assertEqual(htmlParser(page), DBMS.PGSQL)
self.assertEqual(kb.forkNote, FORK.DUCKDB)
def test_trino_error_fingerprint(self):
# the generic Presto 'mismatched input' entry also matches, so this pins the ordering
# that keeps the Trino fork note from being swallowed by it
page = "io.trino.jdbc.TrinoSQLException: line 1:15: mismatched input 'FROM'. Expecting: <expression>"
dbms = htmlParser(page)
self.assertEqual(dbms, DBMS.PRESTO)
self.assertEqual(kb.forkNote, FORK.TRINO)
def test_doris_error_fingerprint(self):
# Doris speaks the MySQL wire protocol, so the client-side exception is MySQL's own -
# only the leaked FE package name tells the fork apart
page = "com.mysql.jdbc.exceptions.MySQLSyntaxErrorException at org.apache.doris.qe.ConnectProcessor"
dbms = htmlParser(page)
self.assertEqual(dbms, DBMS.MYSQL)
self.assertEqual(kb.forkNote, FORK.DORIS)
def test_starrocks_error_fingerprint(self):
for page in (
"com.mysql.jdbc.exceptions.MySQLSyntaxErrorException at com.starrocks.qe.ConnectProcessor",
"(1064, 'Getting syntax error from line 1, column 7 to line 1, column 62. Detail message: x.')",
):
kb.forkNote = None
kb.cache.parsedDbms.clear()
self.assertEqual(htmlParser(page), DBMS.MYSQL)
self.assertEqual(kb.forkNote, FORK.STARROCKS)
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -699,6 +699,17 @@ class TestRedirectHandling(unittest.TestCase):
self.assertEqual(calls[0][1], 8443)
self.assertEqual(calls[0][4], "xn--tst-qla.example:8443")
def test_cross_origin_redirect_strips_custom_auth_headers(self):
responses = [(307, [(b"location", b"https://other.test/final")], b""), (200, [], b"ok")]
_, calls = self._run_with_fake_exchange(
responses, url="https://example.test/start",
headers={"X-Auth-Token": "secret", "X-Keep": "yes"},
sensitive_headers=("X-Auth-Token",),
)
names = [key.lower() for key, _ in calls[1][5]]
self.assertNotIn("x-auth-token", names)
self.assertIn("x-keep", names)
class TestPoolRetries(unittest.TestCase):
class _Connection(object):
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Unit tests for lib/request/httpshandler.py, exercising the SSL protocol
candidate selection.
No network is involved: the module is reimported with chosen ssl.PROTOCOL_*
constants removed, mimicking OpenSSL builds that have those protocol versions
compiled out (e.g. OPENSSL_NO_TLS1), as the constants are conditional there.
"""
import os
import ssl
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.core.data import conf
import lib.request.httpshandler as hmod
from thirdparty.six.moves import reload_module as _reload_module
class TestProtocolSelection(unittest.TestCase):
def tearDown(self):
hmod.socket.create_connection = self._createConnection
hmod.ssl.SSLContext = self._sslContext
hmod._contexts.clear()
for name, value in self._removed.items():
setattr(ssl, name, value)
_reload_module(hmod)
def _reload_without(self, *names):
"""Reloads the module with `names` missing from ssl (as on builds lacking those protocols)"""
self._removed = {}
self._createConnection = hmod.socket.create_connection
self._sslContext = hmod.ssl.SSLContext
for name in names:
if hasattr(ssl, name):
self._removed[name] = getattr(ssl, name)
delattr(ssl, name)
_reload_module(hmod)
return [hmod._lut.get(_, _) for _ in hmod._protocols if _ in hmod._tlsProtocols]
def test_only_tls_protocols_are_used_with_sni(self):
# Note: SSL era protocols stay in _protocols for the non-SNI fallback, but carry no server_hostname
selected = self._reload_without()
self.assertTrue(selected)
for name in ("PROTOCOL_SSLv3", "PROTOCOL_SSLv23", "PROTOCOL_SSLv2"):
if hasattr(ssl, name):
self.assertIn(getattr(ssl, name), hmod._protocols)
self.assertNotIn(getattr(ssl, name), hmod._tlsProtocols)
def test_missing_tlsv1_does_not_raise(self):
# Note: ssl.PROTOCOL_TLSv1 is compiled in only '#ifndef OPENSSL_NO_TLS1' (used to raise an AttributeError here)
selected = self._reload_without("PROTOCOL_TLSv1")
self.assertTrue(selected)
self.assertNotIn("PROTOCOL_TLSv1", selected)
def test_missing_all_legacy_tls_does_not_raise(self):
selected = self._reload_without("PROTOCOL_TLSv1", "PROTOCOL_TLSv1_1")
self.assertTrue(selected)
self.assertNotIn("PROTOCOL_TLSv1_1", selected)
def test_connect_without_tlsv1(self):
"""Drives the real connect() loop, which used to reference ssl.PROTOCOL_TLSv1 directly"""
selected = self._reload_without("PROTOCOL_TLSv1")
conf.host = None
conf.httpHeaders = []
class _Context(object):
def __init__(self, protocol):
self.protocol = protocol
def set_ciphers(self, _):
pass
def wrap_socket(self, sock, **kwargs):
return "wrapped:%s" % hmod._lut.get(self.protocol, self.protocol)
connection = hmod.HTTPSConnection("example.com", 443)
# Note: stubbed only after the instantiation, as that one resolves the default context through ssl itself
hmod.socket.create_connection = lambda *args, **kwargs: _Socket()
hmod.ssl.SSLContext = _Context
hmod._contexts.clear()
connection.connect()
self.assertEqual(connection.sock, "wrapped:%s" % selected[0])
class _Socket(object):
def close(self):
pass
if __name__ == "__main__":
unittest.main()
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
The last branch of main()'s unhandled-exception chain in sqlmap.py: when the
experimental Python JIT is turned on, a crash gets the tier-2 advisory (Reference:
'https://github.com/python/cpython/issues/156319') instead of the automatic
issue-creation prompt - but the traceback is still printed, so a genuine sqlmap
bug is not swallowed.
Driven through a subprocess because it is main()'s own except chain under test.
"""
import os
import subprocess
import sys
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Forces a crash inside init(), i.e. the same place #6117 landed, without needing a target.
# NOTE: the injected fault must raise on Python 2 as well - 'a' < 128 does NOT (Python 2 orders
# mismatched types by type name and quietly returns a bool), which let a broken run reach the
# network on the PyPy-2.7 job instead of the exception handler under test.
# codeIsModified() is neutralized because its branch sits earlier in the chain and would
# otherwise win on any working tree with uncommitted edits (or a stale txt/checksum.md5).
DRIVER = """
import sys
sys.path.insert(0, %r)
sys.argv = ["sqlmap.py", "-u", "http://127.0.0.1/?id=1", "--batch"]
import sqlmap
import lib.core.option
sqlmap.codeIsModified = lambda: False
lib.core.option.loadPayloads = lambda: iter([])()
sqlmap.main()
""" % ROOT
ADVISORY = "experimental Python JIT compiler is turned on"
TRACEBACK = "TypeError"
PROMPT = "automatically create a new (anonymized) issue"
def _run(jit):
env = dict(os.environ)
env["PYTHON_JIT"] = jit
proc = subprocess.Popen([sys.executable, "-c", DRIVER], cwd=ROOT, env=env,
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
out = proc.communicate(input=b"")[0]
return out.decode("utf-8", "replace")
def _jitEnabled(jit):
"""Whether the guard in sqlmap.py would consider the JIT enabled (mirrors its condition)."""
code = ("import sys, os; print(sys._jit.is_enabled() if hasattr(sys, '_jit') "
"else (sys.version_info >= (3, 13) and os.environ.get('PYTHON_JIT') == '1'))")
env = dict(os.environ)
env["PYTHON_JIT"] = jit
return subprocess.check_output([sys.executable, "-c", code], env=env).strip() == b"True"
class TestJitGuard(unittest.TestCase):
def test_advisory_replaces_issue_prompt_but_keeps_traceback(self):
if not _jitEnabled('1'):
self.skipTest("interpreter does not report the JIT as enabled")
out = _run('1')
self.assertIn(ADVISORY, out)
self.assertIn(TRACEBACK, out) # a real bug must still be visible in full
self.assertNotIn(PROMPT, out) # ... but not auto-filed while the JIT is on
def test_normal_path_is_untouched_without_jit(self):
if _jitEnabled('0'):
self.skipTest("JIT stays enabled with PYTHON_JIT=0 on this build")
out = _run('0')
self.assertNotIn(ADVISORY, out)
self.assertIn(TRACEBACK, out)
self.assertIn(PROMPT, out)
# Tool id 3 is unused by CPython's own well-known ids (DEBUGGER=0, COVERAGE=1, PROFILER=2, OPTIMIZER=5)
_TEST_TOOL_ID = 3
_MONITORING_DRIVER = """
import sys
sys.path.insert(0, %r)
sys.monitoring.use_tool_id(%d, "test-tool")
sys.monitoring.set_events(%d, sys.monitoring.events.PY_START)
import sqlmap
print(sys.monitoring.get_tool(%d))
print(sys.monitoring.get_events(%d))
""" % (ROOT, _TEST_TOOL_ID, _TEST_TOOL_ID, _TEST_TOOL_ID, _TEST_TOOL_ID)
def _monitoringStateAfterImport(jit):
env = dict(os.environ)
env["PYTHON_JIT"] = jit
out = subprocess.check_output([sys.executable, "-c", _MONITORING_DRIVER], cwd=ROOT, env=env)
tool, events = out.decode("utf-8").strip().splitlines()
return tool, int(events)
class TestMonitoringGuard(unittest.TestCase):
"""
The other half of the cpython#156319 mitigation: the tier-2 corruption needs BOTH the JIT and
an active sys.monitoring tool (debugger/profiler/coverage) at once - neither alone triggers it.
So sqlmap only silences an already-registered tool's events when the JIT is also on, and leaves
it alone otherwise. A plain `coverage run sqlmap.py ...` (JIT off) must keep working - which is
exactly what broke CI the first time this guard shipped unconditionally.
"""
def test_active_tool_is_silenced_when_jit_is_on(self):
if not hasattr(sys, "monitoring"):
self.skipTest("interpreter has no sys.monitoring (needs 3.12+)")
if not _jitEnabled('1'):
self.skipTest("interpreter does not report the JIT as enabled")
tool, events = _monitoringStateAfterImport('1')
self.assertEqual(tool, "test-tool") # still registered - its own owner can still free it
self.assertEqual(events, 0) # but events cleared to NO_EVENTS
def test_active_tool_is_left_alone_without_jit(self):
if not hasattr(sys, "monitoring"):
self.skipTest("interpreter has no sys.monitoring (needs 3.12+)")
if _jitEnabled('0'):
self.skipTest("JIT stays enabled with PYTHON_JIT=0 on this build")
tool, events = _monitoringStateAfterImport('0')
self.assertEqual(tool, "test-tool")
self.assertNotEqual(events, 0) # untouched - e.g. a real `coverage run` must keep working
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -1646,5 +1646,50 @@ class TestNonSqlTechniqueRegistry(unittest.TestCase):
self.assertEqual(optDict["Techniques"].get(name), "boolean", name)
class TestSetHTTPHandlersProxyCredentials(unittest.TestCase):
"""_setHTTPHandlers(): credentials embedded in the proxy URL (dropped for HTTP(S) and left
URL-encoded for SOCKS ever since the fix for #5875). Global state (shared proxy handler,
installed opener, SOCKS hooks) is pinned, so nothing leaks into other modules."""
_KEYS = ("proxy", "proxyCred", "proxyList", "hostname", "ignoreProxy", "cj", "keepAlive")
def setUp(self):
from _testutils import save_attrs
self._socks = []
self._saved = save_attrs(option.socks, "setdefaultproxy", "wrapmodule", "unwrapmodule")
self._saved += save_attrs(option.proxyHandler, "proxies", "http_open", "https_open")
self._saved += save_attrs(option._urllib.request, "install_opener")
option.socks.setdefaultproxy = lambda *args, **kwargs: self._socks.append((kwargs.get("username"), kwargs.get("password")))
option.socks.wrapmodule = option.socks.unwrapmodule = lambda *args: None
option._urllib.request.install_opener = lambda *args: None
def tearDown(self):
from _testutils import restore_attrs
restore_attrs(self._saved)
def _run(self, proxy, proxyCred=None):
with _preserve(conf, *self._KEYS), _preserve(kb, "proxies"):
conf.proxy, conf.proxyCred, conf.proxyList = proxy, proxyCred, None
option._setHTTPHandlers()
return (kb.proxies or {}).get("http")
def test_http_url_credentials(self):
# Note: kept URL-encoded, as urllib decodes them itself when building 'Proxy-Authorization'
self.assertEqual(self._run("http://user:p%40s[email protected]:8080"), "user:p%40s[email protected]:8080")
def test_proxy_cred_takes_precedence(self):
self.assertEqual(self._run("http://user:p%40s[email protected]:8080", "other:pw"), "other:[email protected]:8080")
def test_http_without_credentials(self):
self.assertEqual(self._run("http://127.0.0.1:8080"), "127.0.0.1:8080")
def test_socks_url_credentials_decoded(self):
self._run("socks5://user:p%40s[email protected]:1080")
self.assertEqual(self._socks, [("user", "p@ss")])
if __name__ == "__main__":
unittest.main(verbosity=2)
+104
View File
@@ -222,5 +222,109 @@ class TestWriteReportJson(_CollectorCase):
os.remove(path)
class TestMultiTargetTaskId(_CollectorCase):
"""
Regression coverage for issue #6123: a '-m' bulk-file run shares ONE process (and thus one
report collector) across many targets. _storeData()'s COMPLETE-status branch deletes any
existing row for a (taskid, content_type) key before inserting the new one, so if every
target reused the same fixed REPORT_TASKID, a later target's TARGET/TECHNIQUES write would
silently delete an earlier target's row of the same content_type. The fix keys each target's
writes by kb.reportTaskId instead.
"""
def setUp(self):
super(TestMultiTargetTaskId, self).setUp()
from lib.core.dump import Dump
self._had_reportTaskId = "reportTaskId" in kb
self._saved_reportTaskId = kb.get("reportTaskId")
self._saved_dumper = conf.get("dumper")
self._saved_reportCollector = conf.get("reportCollector")
conf.dumper = Dump()
conf.reportCollector = self.c
def tearDown(self):
# restore to the PRIOR STATE exactly - if the key was absent before (the common case, since
# kb.reportTaskId is normally set lazily by a multi-target run), setting it to the saved None
# would instead leave it explicitly present with value None, which breaks the
# kb.get("reportTaskId", REPORT_TASKID) fallback used elsewhere (e.g. ReportErrorRecorder.emit())
# for the rest of the test process - order-dependent flakiness, since Python 2's unittest
# discover() does not sort tests/ (unlike Python 3's), so this class can run before
# TestReportErrorCapture depending on the checkout's raw directory order
if self._had_reportTaskId:
kb.reportTaskId = self._saved_reportTaskId
else:
del kb.reportTaskId
conf.dumper = self._saved_dumper
conf.reportCollector = self._saved_reportCollector
super(TestMultiTargetTaskId, self).tearDown()
def test_second_target_does_not_overwrite_first(self):
kb.reportTaskId = 1
conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET)
kb.reportTaskId = 2
conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET)
first = api._assembleData(self.c, 1)["data"]
second = api._assembleData(self.c, 2)["data"]
self.assertEqual(first[0]["value"]["url"], "http://host1/?id=1") # not clobbered by target #2
self.assertEqual(second[0]["value"]["url"], "http://host2/?id=1")
def test_write_report_json_wraps_multiple_targets(self):
kb.reportTaskId = 1
conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET)
kb.reportTaskId = 2
conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET)
fd, path = tempfile.mkstemp(suffix=".json")
os.close(fd)
try:
api.writeReportJson(self.c, path)
with io.open(path, encoding="utf-8") as f:
loaded = json.load(f)
self.assertIn("targets", loaded)
self.assertEqual(len(loaded["targets"]), 2)
urls = [t["data"][0]["value"]["url"] for t in loaded["targets"]]
self.assertEqual(urls, ["http://host1/?id=1", "http://host2/?id=1"])
finally:
os.remove(path)
def test_single_target_report_keeps_flat_shape(self):
# backward compatibility: exactly one taskid -> no 'targets' wrapper, same shape as before
kb.reportTaskId = 1
conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET)
fd, path = tempfile.mkstemp(suffix=".json")
os.close(fd)
try:
api.writeReportJson(self.c, path)
with io.open(path, encoding="utf-8") as f:
loaded = json.load(f)
self.assertNotIn("targets", loaded)
self.assertEqual(loaded["data"][0]["value"]["url"], "http://host1/?id=1")
finally:
os.remove(path)
def test_error_recorded_under_active_target(self):
import logging
from lib.core.data import logger
saved_level = logger.level
logger.setLevel(logging.ERROR)
# mute pre-existing handlers (e.g. console) but not the ReportErrorRecorder added by setUp
muted = [(handler, handler.level) for handler in logger.handlers if not isinstance(handler, api.ReportErrorRecorder)]
for handler, _ in muted:
handler.setLevel(logging.CRITICAL + 1)
try:
kb.reportTaskId = 2
logger.error("boom for target 2")
self.assertTrue(any("boom for target 2" in _ for _ in api._assembleData(self.c, 2)["error"]))
self.assertEqual(api._assembleData(self.c, 1)["error"], []) # not attributed to target #1
finally:
logger.setLevel(saved_level)
for handler, level in muted:
handler.setLevel(level)
if __name__ == "__main__":
unittest.main(verbosity=2)
+4
View File
@@ -827,3 +827,7 @@ class TestStruts2Header(unittest.TestCase):
# so no start/end marker is found -> no fabricated 'output'
ssti._s2045Send = lambda url, action: "reflected: %s" % action
self.assertIsNone(ssti._executeStruts2Header("http://target", "id"))
if __name__ == "__main__":
unittest.main(verbosity=2)
+38
View File
@@ -51,6 +51,7 @@ from lib.core.settings import RESTORE_MERGED_OPTIONS
from lib.core.settings import UNENCODED_ORIGINAL_VALUE
from lib.core.threads import getCurrentThreadData
from lib.utils.hashdb import HashDB
import lib.core.target as target
from lib.core.target import _createDumpDir
from lib.core.target import _createFilesDir
from lib.core.target import _createTargetDirs
@@ -354,6 +355,31 @@ class TestResumeDBMS(_TargetTestBase):
with self.assertRaises(SqlmapNoneDataException):
_resumeDBMS()
def test_survives_broken_re_module(self):
# a Python 3.13/3.14 're' module regression can make re.search() raise TypeError on an
# otherwise-valid pattern (#6128, #6137) - must fall back to "no match" (the stored value
# used as-is) instead of crashing the whole resume
self._new_hashdb()
conf.dbms = None
conf.offline = False
hashDBWrite(HASHDB_KEYS.DBMS, "MySQL 5.0")
originalRe = target.re
class _BrokenRe(object):
I = originalRe.I
def search(self, *args, **kwargs):
raise TypeError("list.append() takes exactly one argument (2 given)")
target.re = _BrokenRe()
try:
_resumeDBMS()
finally:
target.re = originalRe
self.assertIsNone(Backend.getIdentifiedDbms())
class TestResumeOS(_TargetTestBase):
def test_resumes_os(self):
@@ -483,6 +509,18 @@ class TestCreateDirs(_TargetTestBase):
self.assertTrue(os.path.isdir(conf.filePath))
self.assertIn("example.com", conf.filePath)
def test_files_dir_survives_percent_in_output_dir(self):
# --output-dir carries a literal '%' straight into SQLMAP_FILES_PATH (e.g. an unexpanded
# '$(date +%Y%m%d)' on a shell without command substitution, as in #6133) - the '%' operator
# would try to parse '%Y' as a format directive and crash; safeStringFormat() must not
self._outdir("f_out_%Y%m%d_%H%M%S")
conf.hostname = "example.com"
conf.fileRead = "/etc/passwd"
conf.commonFiles = None
_createFilesDir()
self.assertTrue(os.path.isdir(conf.filePath))
self.assertIn("example.com", conf.filePath)
def test_target_dir_and_target_txt(self):
self._outdir("t_out")
conf.hostname = "example.com"
+28
View File
@@ -112,6 +112,34 @@ class TestOrderByColumnCount(unittest.TestCase):
self.assertEqual(self._detect(25), 25)
class TestNestedOrderBy(unittest.TestCase):
def test_union_preserves_ordering_inside_limited_subquery_and_literals(self):
original_init = uu.initTechnique
original_use = uu._oneShotUnionUse
saved_json = kb.get('jsonAggMode')
saved_run = kb.get('partRun')
set_dbms('Oracle')
captured = []
def capture(query, *args, **kwargs):
captured.append(query)
raise RuntimeError('stop after query construction')
uu.initTechnique = lambda *args: None
uu._oneShotUnionUse = capture
try:
for inner, outer in (('ORDER BY', 'ORDER BY'), ('Order By', 'order by')):
query = "SELECT a,b FROM (SELECT a,'ORDER BY literal' AS b FROM t %s a,b) x WHERE ROWNUM<=20 %s a" % (inner, outer)
with self.assertRaises(RuntimeError):
uu.unionUse(query)
self.assertIn('FROM t %s a,b)' % inner, captured[-1])
self.assertIn("'ORDER BY literal'", captured[-1])
self.assertFalse(captured[-1].rstrip().endswith('%s a' % outer))
finally:
uu.initTechnique = original_init
uu._oneShotUnionUse = original_use
kb.jsonAggMode = saved_json
kb.partRun = saved_run
class TestMssqlJsonAggFalsyValues(unittest.TestCase):
"""Regression: MSSQL UNION dumps use FOR JSON (jsonAggMode), whose output carries native JSON
types. A real 0 / '' / false is a value; only JSON null is SQL NULL. The old `row.get(field) or
+4
View File
@@ -557,3 +557,7 @@ class TestRealXPathSyntax(unittest.TestCase):
self.fail("Boundary '%s' in '%s' with orig='%s' invalid: %s\n payload: %s" % (bk, tkey, original, e, payload))
self.assertIsInstance(count, int,
"Boundary '%s' in '%s' produced no count" % (bk, tkey))
if __name__ == "__main__":
unittest.main(verbosity=2)