Compare commits

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

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

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

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

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

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

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

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

Add the missing entrypoint to both so they behave like the rest of the
suite. No test logic changed (62 and 48 tests respectively still pass).
2026-10-05 23:54:39 +02:00
79 changed files with 2750 additions and 136 deletions

No files matched your search

@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT name AS audit_key,CASE WHEN value_in_use IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),value_in_use) END AS audit_value
FROM master.sys.configurations
WHERE name IN ('xp_cmdshell','clr enabled','Ole Automation Procedures','Ad Hoc Distributed Queries')
) audit_rows WHERE 1=1 ORDER BY audit_key
+11
View File
@@ -0,0 +1,11 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective database user' AS audit_key,CASE WHEN CURRENT_USER IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),CURRENT_USER) END AS audit_value
UNION ALL
SELECT 'effective login' AS audit_key,CASE WHEN SUSER_SNAME() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),SUSER_SNAME()) END AS audit_value
UNION ALL
SELECT 'original login' AS audit_key,CASE WHEN ORIGINAL_LOGIN() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),ORIGINAL_LOGIN()) END AS audit_value
UNION ALL
SELECT 'database' AS audit_key,CASE WHEN DB_NAME() IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),DB_NAME()) END AS audit_value
UNION ALL
SELECT 'sysadmin' AS audit_key,CASE WHEN IS_SRVROLEMEMBER('sysadmin') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),IS_SRVROLEMEMBER('sysadmin')) END AS audit_value
) audit_rows WHERE 1=1 ORDER BY audit_key
+6
View File
@@ -0,0 +1,6 @@
SELECT audit_key,audit_value FROM (
SELECT N'visible linked servers' AS audit_key,N'V'+CONVERT(NVARCHAR(MAX),COUNT(*)) AS audit_value FROM master.sys.servers WHERE is_linked=1
UNION ALL
SELECT N'link '+CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT AS audit_key,N'V'+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'<unknown>')+N' (data access: '+RTRIM(CASE is_data_access_enabled WHEN 1 THEN N'enabled' WHEN 0 THEN N'disabled' ELSE N'unavailable' END)+N'; RPC out: '+RTRIM(CASE is_rpc_out_enabled WHEN 1 THEN N'enabled' WHEN 0 THEN N'disabled' ELSE N'unavailable' END)+N')' AS audit_value
FROM master.sys.servers v WHERE is_linked=1 AND (SELECT COUNT(*) FROM master.sys.servers s WHERE s.is_linked=1 AND s.server_id<=v.server_id)<=%LIMIT%
) audit_rows WHERE 1=1 ORDER BY audit_key
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'execute xp_cmdshell' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.dbo.xp_cmdshell','OBJECT','EXECUTE') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.dbo.xp_cmdshell','OBJECT','EXECUTE')) END AS audit_value
UNION ALL
SELECT 'read configuration metadata' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.sys.configurations','OBJECT','SELECT') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.sys.configurations','OBJECT','SELECT')) END AS audit_value
UNION ALL
SELECT 'read linked-server metadata' AS audit_key,CASE WHEN HAS_PERMS_BY_NAME('master.sys.servers','OBJECT','SELECT') IS NULL THEN N'N' ELSE N'V'+CONVERT(NVARCHAR(MAX),HAS_PERMS_BY_NAME('master.sys.servers','OBJECT','SELECT')) END AS audit_value
) audit_rows WHERE 1=1 ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT LOWER(VARIABLE_NAME) AS audit_key,CASE WHEN VARIABLE_VALUE IS NULL THEN 'N' ELSE CONCAT('V',VARIABLE_VALUE) END AS audit_value
FROM performance_schema.global_variables
WHERE LOWER(VARIABLE_NAME) IN ('local_infile','require_secure_transport','read_only','super_read_only','general_log','log_bin','skip_networking','skip_grant_tables')
) audit_rows ORDER BY audit_key
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT LOWER(VARIABLE_NAME) AS audit_key,CASE WHEN VARIABLE_VALUE IS NULL THEN 'N' ELSE CONCAT('V',VARIABLE_VALUE) END AS audit_value
FROM information_schema.GLOBAL_VARIABLES
WHERE LOWER(VARIABLE_NAME) IN ('local_infile','require_secure_transport','read_only','super_read_only','general_log','log_bin','skip_networking','skip_grant_tables')
) audit_rows ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT 'secure_file_priv' AS audit_key,CASE WHEN @@GLOBAL.secure_file_priv IS NULL THEN 'N' ELSE CONCAT('V',CAST(@@GLOBAL.secure_file_priv AS CHAR)) END AS audit_value
UNION ALL
SELECT 'max_allowed_packet' AS audit_key,CASE WHEN @@GLOBAL.max_allowed_packet IS NULL THEN 'N' ELSE CONCAT('V',CAST(@@GLOBAL.max_allowed_packet AS CHAR)) END AS audit_value
) audit_rows ORDER BY audit_key
+7
View File
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective account' AS audit_key,CASE WHEN CURRENT_USER() IS NULL THEN 'N' ELSE CONCAT('V',CAST(CURRENT_USER() AS CHAR)) END AS audit_value
UNION ALL
SELECT 'login identity' AS audit_key,CASE WHEN USER() IS NULL THEN 'N' ELSE CONCAT('V',CAST(USER() AS CHAR)) END AS audit_value
UNION ALL
SELECT 'server version' AS audit_key,CASE WHEN VERSION() IS NULL THEN 'N' ELSE CONCAT('V',CAST(VERSION() AS CHAR)) END AS audit_value
) audit_rows ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT 'direct FILE grant' AS audit_key,CONCAT('V',CAST(COUNT(*) AS CHAR)) AS audit_value
FROM information_schema.USER_PRIVILEGES
WHERE PRIVILEGE_TYPE='FILE' AND GRANTEE=CONCAT(CHAR(39),LEFT(CURRENT_USER(),LENGTH(CURRENT_USER())-LENGTH(SUBSTRING_INDEX(CURRENT_USER(),'@',-1))-1),CHAR(39),'@',CHAR(39),SUBSTRING_INDEX(CURRENT_USER(),'@',-1),CHAR(39))
) audit_rows ORDER BY audit_key
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT name AS audit_key,CASE WHEN value IS NULL THEN 'N' ELSE 'V'||value END AS audit_value
FROM v$parameter
WHERE name IN ('remote_os_authent','remote_login_passwordfile','sql92_security','audit_trail','os_authent_prefix')
) audit_rows ORDER BY audit_key
+12
View File
@@ -0,0 +1,12 @@
SELECT audit_key,audit_value FROM (
SELECT 'visible directory grants' AS audit_key,'V'||TO_CHAR(COUNT(*)) AS audit_value
FROM all_directories d JOIN all_tab_privs p ON p.table_schema=d.owner AND p.table_name=d.directory_name
WHERE p.type='DIRECTORY' AND p.privilege IN ('READ','WRITE') AND (p.grantee=SYS_CONTEXT('USERENV','CURRENT_USER') OR p.grantee='PUBLIC' OR p.grantee IN (SELECT role FROM session_roles))
UNION ALL
SELECT audit_key,audit_value FROM (
SELECT d.directory_name||' ['||p.privilege||'] (via '||p.grantee||')' AS audit_key,'V'||d.directory_path AS audit_value
FROM all_directories d JOIN all_tab_privs p ON p.table_schema=d.owner AND p.table_name=d.directory_name
WHERE p.type='DIRECTORY' AND p.privilege IN ('READ','WRITE') AND (p.grantee=SYS_CONTEXT('USERENV','CURRENT_USER') OR p.grantee='PUBLIC' OR p.grantee IN (SELECT role FROM session_roles))
ORDER BY d.directory_name,p.privilege,p.grantee
) audit_details WHERE ROWNUM<=%LIMIT%
) audit_rows ORDER BY audit_key
+9
View File
@@ -0,0 +1,9 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective account' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','CURRENT_USER') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','CURRENT_USER')) END AS audit_value FROM DUAL
UNION ALL
SELECT 'login identity' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','SESSION_USER') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','SESSION_USER')) END AS audit_value FROM DUAL
UNION ALL
SELECT 'database' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','DB_NAME') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','DB_NAME')) END AS audit_value FROM DUAL
UNION ALL
SELECT 'SYSDBA session' AS audit_key,CASE WHEN SYS_CONTEXT('USERENV','ISDBA') IS NULL THEN 'N' ELSE 'V'||TO_CHAR(SYS_CONTEXT('USERENV','ISDBA')) END AS audit_value FROM DUAL
) audit_rows ORDER BY audit_key
+5
View File
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT 'DBA role active' AS audit_key,'V'||CASE WHEN COUNT(*)>0 THEN 'yes' ELSE 'no' END AS audit_value FROM session_roles WHERE role='DBA'
UNION ALL
SELECT privilege,'Vgranted' FROM session_privs WHERE privilege IN ('CREATE ANY DIRECTORY','CREATE EXTERNAL JOB','EXECUTE ANY PROCEDURE','ALTER SYSTEM','SELECT ANY DICTIONARY')
) audit_rows ORDER BY audit_key
@@ -0,0 +1,5 @@
SELECT audit_key,audit_value FROM (
SELECT name AS audit_key,CASE WHEN setting IS NULL THEN 'N' ELSE 'V'||setting END AS audit_value
FROM pg_catalog.pg_settings
WHERE name IN ('ssl','password_encryption','row_security','logging_collector','log_statement','log_connections','log_disconnections')
) audit_rows ORDER BY audit_key
+7
View File
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'effective account' AS audit_key,CASE WHEN CURRENT_USER IS NULL THEN 'N' ELSE 'V'||CAST(CURRENT_USER AS TEXT) END AS audit_value
UNION ALL
SELECT 'login identity' AS audit_key,CASE WHEN SESSION_USER IS NULL THEN 'N' ELSE 'V'||CAST(SESSION_USER AS TEXT) END AS audit_value
UNION ALL
SELECT 'database' AS audit_key,CASE WHEN CURRENT_DATABASE() IS NULL THEN 'N' ELSE 'V'||CAST(CURRENT_DATABASE() AS TEXT) END AS audit_value
) audit_rows ORDER BY audit_key
@@ -0,0 +1,7 @@
SELECT audit_key,audit_value FROM (
SELECT 'superuser' AS audit_key,'V'||CASE WHEN rolsuper THEN '1' ELSE '0' END AS audit_value FROM pg_catalog.pg_roles WHERE rolname=CURRENT_USER
UNION ALL
SELECT 'bypass RLS','V'||CASE WHEN rolbypassrls THEN '1' ELSE '0' END FROM pg_catalog.pg_roles WHERE rolname=CURRENT_USER
UNION ALL
SELECT rolname,'V'||CASE WHEN pg_has_role(oid,'USAGE') THEN '1' ELSE '0' END FROM pg_catalog.pg_roles WHERE rolname IN ('pg_read_server_files','pg_write_server_files','pg_execute_server_program')
) audit_rows ORDER BY audit_key
+21
View File
@@ -1365,6 +1365,27 @@ Tag: <test>
</details>
</test>
<!-- No parentheses or functions (e.g. value-wide character whitelists, #6145): TRUE keeps the original
sort column, FALSE raises a runtime regular expression error ('(' pattern) -->
<test>
<title>MySQL boolean-based blind - ORDER BY clause (RLIKE)</title>
<stype>1</stype>
<level>3</level>
<risk>1</risk>
<clause>3</clause>
<where>3</where>
<vector>CASE WHEN [INFERENCE] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</vector>
<request>
<payload>CASE WHEN [RANDNUM]=[RANDNUM] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</payload>
</request>
<response>
<comparison>CASE WHEN [RANDNUM]=[RANDNUM1] THEN [ORIGVALUE] ELSE 1 RLIKE 0x28 END</comparison>
</response>
<details>
<dbms>MySQL</dbms>
</details>
</test>
<!-- Works in MySQL, Oracle, etc. -->
<test>
<title>HAVING boolean-based blind - WHERE, GROUP BY clause</title>
+9 -1
View File
@@ -173,6 +173,10 @@
</dbms>
<dbms value="Microsoft SQL Server">
<links>
<inband query="SELECT CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT+N' -> '+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'&lt;unknown&gt;') FROM master.sys.servers WHERE is_linked=1 ORDER BY server_id"/>
<blind query="SELECT link_name FROM (SELECT CAST(name AS NVARCHAR(MAX)) COLLATE DATABASE_DEFAULT+N' -> '+ISNULL(data_source COLLATE DATABASE_DEFAULT,N'&lt;unknown&gt;') AS link_name,ROW_NUMBER() OVER (ORDER BY server_id) AS rn FROM master.sys.servers WHERE is_linked=1) AS links WHERE rn=%d+1" count="SELECT COUNT(*) FROM master.sys.servers WHERE is_linked=1"/>
</links>
<cast query="CAST(%s AS NVARCHAR(MAX))"/>
<length query="LTRIM(STR(LEN(%s+'.')-1))"/>
<isnull query="ISNULL(%s,' ')"/>
@@ -253,6 +257,10 @@
</dbms>
<dbms value="Oracle">
<links>
<inband query="SELECT OWNER||'.'||DB_LINK||' -> '||NVL(HOST,'&lt;unknown&gt;') FROM ALL_DB_LINKS ORDER BY OWNER,DB_LINK"/>
<blind query="SELECT link_name FROM (SELECT OWNER||'.'||DB_LINK||' -> '||NVL(HOST,'&lt;unknown&gt;') AS link_name,ROW_NUMBER() OVER (ORDER BY OWNER,DB_LINK) AS rn FROM ALL_DB_LINKS) WHERE rn=%d+1" count="SELECT COUNT(*) FROM ALL_DB_LINKS"/>
</links>
<cast query="CAST(%s AS VARCHAR(4000))"/>
<length query="LENGTH(%s)"/>
<isnull query="NVL(%s,' ')"/>
@@ -1931,7 +1939,7 @@
</dbs>
<tables>
<inband query="SELECT CASE table_schema WHEN '' THEN 'default' ELSE table_schema END,table_name FROM INFORMATION_SCHEMA.TABLES" condition="table_schema"/>
<blind query="SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END ORDER BY table_name" count="SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END"/>
<blind query="SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END ORDER BY table_name LIMIT 1 OFFSET %d" count="SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END"/>
</tables>
<columns>
<inband query="SELECT column_name,spanner_type FROM INFORMATION_SCHEMA.COLUMNS WHERE table_name='%s' AND table_schema=CASE '%s' WHEN 'default' THEN '' ELSE '%s' END" condition="column_name"/>
+3 -2
View File
@@ -18,13 +18,14 @@ declare -x SCRIPTPATH="${0}"
PROJECT_FULLPATH=${SCRIPTPATH%/*}/$PROJECT
SETTINGS_FULLPATH=${SCRIPTPATH%/*}/$SETTINGS
git diff $SETTINGS_FULLPATH | grep "VERSION =" > /dev/null && exit 0
git diff HEAD -- $SETTINGS_FULLPATH | grep "^+VERSION =" > /dev/null && exit 0 # Note: manually set VERSION (staged or not, e.g. "git commit -a") is kept as is
if [ -f $SETTINGS_FULLPATH ]
then
LINE=$(grep -o ${SETTINGS_FULLPATH} -e '^VERSION = "[0-9.]*"')
# Note: a new month restarts the counter (i.e. '.0' gets tagged by post-commit hook), while a version already a month ahead (early release) just keeps counting
declare -a LINE
INCREMENTED=$(python -c "import re, sys, time; version = re.search('\"([0-9.]*)\"', sys.argv[1]).group(1); _ = version.split('.'); _.extend([0] * (4 - len(_))); _[-1] = str(int(_[-1]) + 1); month = str(time.gmtime().tm_mon); _[-1] = '0' if _[-2] != month else _[-1]; _[-2] = month; print sys.argv[1].replace(version, '.'.join(_))" "$LINE")
INCREMENTED=$(python -c "import re, sys, time; version = re.search('\"([0-9.]*)\"', sys.argv[1]).group(1); _ = version.split('.'); _.extend([0] * (4 - len(_))); _[-1] = str(int(_[-1]) + 1); month = time.gmtime().tm_mon; _[-2:] = _[-2:] if (int(_[-2]) - month) % 12 in (0, 1) else [str(month), '0']; print sys.argv[1].replace(version, '.'.join(_))" "$LINE")
if [ -n "$INCREMENTED" ]
then
sed -i "s/${LINE}/${INCREMENTED}/" $SETTINGS_FULLPATH
+12
View File
@@ -134,6 +134,18 @@ def action():
if conf.getProcs:
conf.dumper.procedures(conf.dbmsHandler.getProcedures())
if conf.getLinks:
try:
conf.dumper.links(conf.dbmsHandler.getLinks())
except SqlmapNoneDataException as ex:
logger.critical(ex)
if conf.getAudit:
try:
conf.dumper.audit(conf.dbmsHandler.getAudit())
except SqlmapNoneDataException as ex:
logger.critical(ex)
if conf.getPasswordHashes:
try:
conf.dumper.userSettings("database management system users password hashes", conf.dbmsHandler.getPasswordHashes(), "password hash", CONTENT_TYPE.PASSWORDS)
+4 -4
View File
@@ -403,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
@@ -517,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
@@ -627,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
+14 -7
View File
@@ -223,6 +223,9 @@ def _autoWafBypass(place, parameter, value):
return retVal
def _targetInfo():
return {"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}
def _showInjections():
if conf.wizard and kb.wizardMode:
kb.wizardMode = False
@@ -234,13 +237,12 @@ def _showInjections():
header = "sqlmap resumed the following injection point(s) from stored session"
if conf.api:
conf.dumper.string("", {"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, content_type=CONTENT_TYPE.TARGET)
conf.dumper.string("", _targetInfo(), content_type=CONTENT_TYPE.TARGET)
conf.dumper.string("", kb.injections, content_type=CONTENT_TYPE.TECHNIQUES)
else:
# --report-json: capture the same TARGET/TECHNIQUES structures the API emits, without
# printing them (the human-readable injection points are rendered just below)
# --report-json: capture the same TECHNIQUES structure the API emits, without printing it (the
# human-readable injection points are rendered just below); TARGET is reported per target in start()
if conf.reportJson:
conf.dumper._reportData({"url": conf.url, "query": conf.parameters.get(PLACE.GET), "data": conf.parameters.get(PLACE.POST)}, CONTENT_TYPE.TARGET)
conf.dumper._reportData(kb.injections, CONTENT_TYPE.TECHNIQUES)
data = "".join(set(_formatInjection(_) for _ in kb.injections)).rstrip("\n")
@@ -376,9 +378,9 @@ def start():
for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets:
targetCount += 1
# --report-json: 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)
if conf.reportJson:
# --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:
@@ -522,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
+37 -10
View File
@@ -61,6 +61,31 @@ from lib.core.settings import UNICODE_ENCODING
from lib.core.unescaper import unescaper
from thirdparty import six
def _isGroupOrderByClause(clause):
"""
Returns True if (test's/injection's) clause is exclusively GROUP BY and/or ORDER BY
>>> _isGroupOrderByClause([PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY])
True
>>> _isGroupOrderByClause([PAYLOAD.CLAUSE.WHERE, PAYLOAD.CLAUSE.ORDER_BY])
False
"""
return bool(clause) and set(clause) <= set((PAYLOAD.CLAUSE.GROUP_BY, PAYLOAD.CLAUSE.ORDER_BY))
def _markerSafeRandom(generator):
"""
Returns a random value (e.g. for [RANDNUM]) not containing the --string/--not-string page marker,
as a reflected one (e.g. echoed in a DBMS error message) would otherwise fake the comparison
"""
markers = filterNone((conf.get("string"), conf.get("notString")))
while True:
retVal = getUnicode(generator())
if not any(_ in retVal for _ in markers):
return retVal
class Agent(object):
"""
This class defines the SQL agent methods.
@@ -184,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
@@ -296,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
@@ -356,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
@@ -375,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)
@@ -426,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)
@@ -631,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
+44 -6
View File
@@ -4108,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
@@ -4116,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
@@ -4123,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))
@@ -5659,16 +5693,20 @@ def parseRequestFile(reqFile, checkParams=True):
if re.search(BURP_XML_HISTORY_REGEX, content, re.I | re.S):
reqResList = []
for match in re.finditer(BURP_XML_HISTORY_REGEX, content, re.I | re.S):
port, request = match.groups()
port, protocol, request = match.groups()
protocol = (protocol or "").lower()
try:
request = decodeBase64(request, binary=False)
except (binascii.Error, TypeError):
continue
_ = re.search(r"%s:.+" % re.escape(HTTP_HEADER.HOST), request)
if _:
host = _.group(0).strip()
if not re.search(r":\d+\Z", host) and int(port) != 80:
request = request.replace(host, "%s:%d" % (host, int(port)))
host = value = _.group(0).strip()
if not re.search(r":\d+\Z", host) and int(port) != (443 if protocol == "https" else 80):
value = "%s:%d" % (value, int(port))
if protocol and "://" not in value: # Note: item's <protocol> (as the port alone can't tell e.g. HTTPS on 8443)
value = re.sub(r"\A([^:]+:\s*)", r"\g<1>%s://" % protocol, value)
request = request.replace(host, value)
reqResList.append(request)
else:
reqResList = [content]
+37 -2
View File
@@ -9,8 +9,40 @@ import copy
import threading
from collections import OrderedDict
from thirdparty import six
from thirdparty.six.moves import collections_abc as _collections
class NoLock(object):
"""
No-op stand-in for a lock, used where the guarded operations are already atomic on their own
>>> with NoLock():
... True
True
"""
def __enter__(self):
return self
def __exit__(self, *args):
return False
def cacheLock(reentrant=False):
"""
Returns a lock for guarding the (OrderedDict based) caches. Only Python 2 needs a real one, as its
OrderedDict is implemented in Python (#5619), while on Python 3 each operation is atomic. Moreover,
since Python 3.14 a Ctrl-C landing right after a lock got acquired by the 'with' statement leaves it
acquired forever (i.e. '__exit__' never gets called), hanging every thread that touches the cache
>>> isinstance(cacheLock(), NoLock) == six.PY3
True
"""
if six.PY2:
return threading.RLock() if reentrant else threading.Lock()
else:
return NoLock()
class AttribDict(dict):
"""
This class defines the dictionary with added capability to access members as attributes
@@ -133,7 +165,7 @@ class LRUDict(object):
def __init__(self, capacity):
self.capacity = capacity
self.cache = OrderedDict()
self.__lock = threading.Lock()
self.__lock = cacheLock()
def __len__(self):
return len(self.cache)
@@ -159,7 +191,10 @@ class LRUDict(object):
self.cache.pop(key)
except KeyError:
if len(self.cache) >= self.capacity:
self.cache.popitem(last=False)
try:
self.cache.popitem(last=False)
except KeyError: # Note: concurrently emptied by another thread (unlocked on Python 3)
pass
self.cache[key] = value
def set(self, key, value):
+10 -3
View File
@@ -9,6 +9,7 @@ import functools
import threading
from lib.core.datatype import LRUDict
from lib.core.datatype import cacheLock
from lib.core.settings import MAX_CACHE_ITEMS
from lib.core.threads import getCurrentThreadData
@@ -40,7 +41,7 @@ def cachedmethod(f):
"""
_cache[f] = LRUDict(capacity=MAX_CACHE_ITEMS)
_method_locks[f] = threading.RLock()
_method_locks[f] = cacheLock(reentrant=True)
def _freeze(val):
if isinstance(val, (list, set, tuple)):
@@ -61,7 +62,10 @@ def cachedmethod(f):
with lock:
if key in cache:
return cache[key]
try:
return cache[key]
except KeyError: # Note: evicted in between (unlocked on Python 3)
pass
except TypeError:
# Note: fallback (slow-path) for unhashable arguments
@@ -73,7 +77,10 @@ def cachedmethod(f):
with lock:
if key in cache:
return cache[key]
try:
return cache[key]
except KeyError:
pass
except TypeError:
# Note: genuinely uncacheable arguments; skip caching altogether
return f(*args, **kwargs)
+6
View File
@@ -224,6 +224,12 @@ class Dump(object):
def procedures(self, procedures):
self.lister("stored procedures", procedures, content_type=CONTENT_TYPE.PROCEDURES)
def links(self, links):
self.lister("database links", links, content_type=CONTENT_TYPE.LINKS)
def audit(self, audit):
self.lister("database security audit", audit, content_type=CONTENT_TYPE.AUDIT)
def userSettings(self, header, userSettings, subHeader, content_type=None):
self._areAdmins = set()
+14 -12
View File
@@ -352,18 +352,17 @@ class PAYLOAD(object):
3: "High risk",
}
CLAUSE = {
0: "Always",
1: "WHERE",
2: "GROUP BY",
3: "ORDER BY",
4: "LIMIT",
5: "OFFSET",
6: "TOP",
7: "Table name",
8: "Column name",
9: "Pre-WHERE (non-query)",
}
class CLAUSE(object):
ALWAYS = 0
WHERE = 1
GROUP_BY = 2
ORDER_BY = 3
LIMIT = 4
OFFSET = 5
TOP = 6
TABLE_NAME = 7
COLUMN_NAME = 8
PRE_WHERE = 9
class METHOD(object):
COMPARISON = "comparison"
@@ -430,6 +429,8 @@ class CONTENT_TYPE(object):
REG_READ = 25
STATEMENTS = 26
PROCEDURES = 27
LINKS = 28
AUDIT = 29
class CONTENT_STATUS(object):
IN_PROGRESS = 0
@@ -463,6 +464,7 @@ class MKSTEMP_PREFIX(object):
BIG_ARRAY = "sqlmapbigarray-"
SPECIFIC_RESPONSE = "sqlmapresponse-"
PREPROCESS = "sqlmappreprocess-"
AUTH_REFRESH = "sqlmapauthrefresh-"
class TIMEOUT_STATE(object):
NORMAL = 0
+48 -2
View File
@@ -142,6 +142,7 @@ from lib.parse.configfile import configFileParser
from lib.parse.payloads import loadBoundaries
from lib.parse.payloads import loadPayloads
from lib.request.basic import checkCharEncoding
from lib.request import authrefresh
from lib.request.basicauthhandler import SmartHTTPBasicAuthHandler
from lib.request.chunkedhandler import ChunkedHandler
from lib.request.connect import Connect as Request
@@ -1152,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
@@ -1293,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
@@ -1338,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 = ""
@@ -2317,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
@@ -2427,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 = []
@@ -3171,6 +3216,7 @@ def init():
_setTamperingFunctions()
_setPreprocessFunctions()
_setPostprocessFunctions()
_setAuthRefreshFunction()
_setTrafficOutputFP()
_setupHTTPCollector()
_setHttpOptions()
+4 -1
View File
@@ -163,6 +163,8 @@ optDict = {
"getPasswordHashes": ("boolean", "Passwords"),
"getPrivileges": ("boolean", "Privileges"),
"getRoles": ("boolean", "Roles"),
"getLinks": "boolean",
"getAudit": "boolean",
"getDbs": ("boolean", "Databases"),
"getTables": ("boolean", "Tables"),
"getColumns": ("boolean", "Columns"),
@@ -256,8 +258,9 @@ optDict = {
"hexConvert": "boolean",
"outputDir": "string",
"parseErrors": "boolean",
"postprocess": "string",
"authRefresh": "string",
"preprocess": "string",
"postprocess": "string",
"repair": "boolean",
"reportJson": "string",
"saveConfig": "string",
+9 -10
View File
@@ -12,7 +12,6 @@ import random
import re
import string
import sys
import time
from lib.core.enums import DBMS
from lib.core.enums import DBMS_DIRECTORY_NAME
@@ -20,7 +19,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.10.0"
VERSION = "1.10.11.0"
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
@@ -204,17 +203,14 @@ JITTER_JUNK_RESPONSES = (
("", 200),
)
# Regular expression used for extracting results from Google search
GOOGLE_REGEX = r"webcache\.googleusercontent\.com/search\?q=cache:[^:]+:([^+]+)\+&amp;cd=|url\?\w+=((?![^>]+webcache\.googleusercontent\.com)http[^>]+)&(sa=U|rct=j)"
# Google Search consent cookie
GOOGLE_CONSENT_COOKIE = "CONSENT=YES+shp.gws-%s-0-RC1.%s+FX+740" % (time.strftime("%Y%m%d"), "".join(random.sample(string.ascii_lowercase, 2)))
# Regular expression used for extracting results from DuckDuckGo search
DUCKDUCKGO_REGEX = r'<a class="result__url" href="(htt[^"]+)'
# Regular expression used for extracting results from Bing search
BING_REGEX = r'<h2><a href="([^"]+)" h='
BING_REGEX = r'<h2[^>]*><a [^>]*href="(?:https?://www\.bing\.com/ck/a\?[^"]*?[?;]u=a1([^&"]+)[^"]*|([^"]+))"'
# Markers of search engine abuse/captcha pages (i.e. used IP address got blocked)
SEARCH_ABUSE_MARKERS = ("detected unusual traffic", "issue with the Tor Exit Node you are currently using", "anomaly.js", "Verifying your browser", "automated queries")
# Dummy user agent for search (if default one returns different results)
DUMMY_SEARCH_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0"
@@ -586,7 +582,7 @@ WEBSCARAB_SPLITTER = "### Conversation"
BURP_REQUEST_REGEX = r"={10,}\s+([A-Z]{3,} .+?)\s+(={10,}|\Z)"
# Regex used for parsing XML Burp saved history items
BURP_XML_HISTORY_REGEX = r'<port>(\d+)</port>.*?<request base64="true"><!\[CDATA\[([^]]+)'
BURP_XML_HISTORY_REGEX = r'<port>(\d+)</port>\s*(?:<protocol>\s*(https?)\s*</protocol>)?.*?<request base64="true"><!\[CDATA\[([^]]+)'
# Encoding used for Unicode data
UNICODE_ENCODING = "utf8"
@@ -612,6 +608,9 @@ MYSQL_UNION_VALUE_CAST = "CONVERT(%s USING utf8mb4) COLLATE utf8mb4_bin"
# Row count at/above which keyset (seek) pagination is used automatically for table dumps when a usable integer-key cursor exists (smaller tables keep the plain LIMIT/OFFSET path; '--keyset' forces it regardless of size)
KEYSET_MIN_ROWS = 1000
# Maximum detailed metadata rows per variable-size security audit listing
AUDIT_MAX_ROWS = 20
# Number of Huffman (set-membership) character attempts made before their escape ratio is judged; at/above it, escapes reaching half of all attempts latch the technique off (safety against trimmed/blocked long IN() payloads)
HUFFMAN_PROBE_LIMIT = 8
+6 -3
View File
@@ -113,6 +113,8 @@ def vulnTest(tests=None, label="vuln"):
("-u \"<url>&query=*\" --flush-session --technique=Q --banner", ("Title: SQLite inline queries", "banner: '3.")),
("-d \"<direct>\" --flush-session --dump -T creds --dump-format=SQLITE --binary-fields=password_hash --where \"user_id=5\"", ("3137396164343563366365326362393763663130323965323132303436653831", "dumped to SQLITE database")),
("-d \"<direct>\" --flush-session --banner --schema --sql-query=\"UPDATE users SET name='foobar' WHERE id=4; SELECT * FROM users; SELECT 987654321\"", ("banner: '3.", "INTEGER", "TEXT", "id", "name", "surname", "4,foobar,nameisnull", "'987654321'",)),
("-d \"<direct>\" --links", ("back-end DBMS: SQLite", "enumeration of database links is not supported on SQLite", "~unhandled exception")),
("-d \"<direct>\" --audit", ("back-end DBMS: SQLite", "security configuration audit is not supported on SQLite", "~unhandled exception")),
("-u <base>csrf --data=\"id=1&csrf_token=1\" --banner --answers=\"update=y\" --flush-session --technique=B", ("back-end DBMS: SQLite", "banner: '3.")),
("--purge -v 3", ("~ERROR", "~CRITICAL", "deleting the whole directory tree")),
)
@@ -268,9 +270,10 @@ def vulnTest(tests=None, label="vuln"):
os.environ["SQLMAP_UNSAFE_EVAL"] = '1'
# bounded well above the slowest known entry (GraphQL, ~96s) - a hung entry fails fast and
# visibly instead of silently burning the whole CI job's timeout (see #6129 CI investigation)
output = shellExec(cmd, timeout=180)
# GraphQL blind-dumps every table, including the wide LDAP directory fixture. It takes
# ~124s locally on PyPy and can exceed 180s on CI; give that entry more room without
# weakening its checks or changing the watchdog for the remaining entries (see #6129).
output = shellExec(cmd, timeout=300 if "--graphql" in options else 180)
if not all((check in output if not check.startswith('~') else check[1:] not in output) for check in checks) or "unhandled exception" in output:
dataToStdout("---\n\n$ %s\n" % cmd)
+1
View File
@@ -60,6 +60,7 @@ class _ThreadData(threading.local):
self.lastHTTPError = None
self.lastRedirectMsg = None
self.lastQueryDuration = 0
self.authRefreshTime = 0
self.lastPage = None
self.lastRequestMsg = None
self.lastRequestUID = 0
+13 -4
View File
@@ -138,8 +138,8 @@ def cmdLineParser(argv=None):
target.add_argument("-r", dest="requestFile",
help="Load HTTP request from a file")
target.add_argument("-g", dest="googleDork",
help="Process Google dork results as target URLs")
target.add_argument("-g", "--dork", dest="googleDork", metavar="DORK",
help="Process search engine dork results as target URLs")
target.add_argument("-c", dest="configFile",
help="Load options from a configuration INI file")
@@ -484,6 +484,9 @@ def cmdLineParser(argv=None):
enumeration.add_argument("--hostname", dest="getHostname", action="store_true",
help="Retrieve DBMS server hostname")
enumeration.add_argument("--audit", dest="getAudit", action="store_true",
help="Audit DBMS security configuration and capabilities")
enumeration.add_argument("--is-dba", dest="isDba", action="store_true",
help="Detect if the DBMS current user is DBA")
@@ -499,6 +502,9 @@ def cmdLineParser(argv=None):
enumeration.add_argument("--roles", dest="getRoles", action="store_true",
help="Enumerate DBMS users roles")
enumeration.add_argument("--links", dest="getLinks", action="store_true",
help="Enumerate DBMS links")
enumeration.add_argument("--dbs", dest="getDbs", action="store_true",
help="Enumerate DBMS databases")
@@ -730,8 +736,8 @@ def cmdLineParser(argv=None):
general.add_argument("--fresh-queries", dest="freshQueries", action="store_true",
help="Ignore query results stored in session file")
general.add_argument("--gpage", dest="googlePage", type=int,
help="Use Google dork results from specified page number")
general.add_argument("--gpage", dest="googlePage", type=int, metavar="PAGE",
help="Use dork results from specified page number")
general.add_argument("--har", dest="harFile",
help="Log all HTTP traffic into a HAR file")
@@ -745,6 +751,9 @@ def cmdLineParser(argv=None):
general.add_argument("--parse-errors", dest="parseErrors", action="store_true",
help="Parse and display DBMS error messages from responses")
general.add_argument("--auth-refresh", dest="authRefresh",
help="Run script to refresh auth on HTTP 401")
general.add_argument("--preprocess", dest="preprocess",
help="Use given script(s) for preprocessing (request)")
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
import os
import re
import tempfile
from lib.core.common import openFile
from lib.core.data import conf
from lib.core.data import kb
from lib.core.data import logger
from lib.core.enums import MKSTEMP_PREFIX
from lib.core.exception import SqlmapConnectionException
from lib.core.exception import SqlmapGenericException
from thirdparty import six
from thirdparty.six.moves import urllib as _urllib
def createTemplate():
handle, filename = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.AUTH_REFRESH, suffix=".py")
os.close(handle)
with openFile(filename, "w+") as f:
f.write("#!/usr/bin/env\n\ndef refresh():\n # Obtain a fresh token here (use a timeout for login requests).\n # Custom headers also work, e.g. {'X-Auth-Token': token}.\n return {'Authorization': 'Bearer <fresh token>'}\n")
with openFile(os.path.join(os.path.dirname(filename), "__init__.py"), "w+") as f:
f.write("pass")
return filename
def sameOrigin(first, second):
# checkSameHost deliberately ignores schemes and ports, which is unsuitable for credentials.
def origin(url):
parts = _urllib.parse.urlsplit(url)
return parts.scheme.lower(), parts.hostname, parts.port or (443 if parts.scheme.lower() == "https" else 80)
return origin(first) == origin(second)
def apply(req):
"""Overlay refreshed headers after preprocess hooks; return the credential generation sent."""
if not conf.authRefresh or not sameOrigin(req.get_full_url(), conf.url):
return None
with kb.locks.authRefresh:
if kb.authRefreshFailed:
raise SqlmapGenericException("authentication refresh failed; cannot continue with expired credentials")
for name, value in kb.authRefreshHeaders.items():
# Hooks/raw request files can leave differently-cased copies of the same header.
for headers in (req.headers, req.unredirected_hdrs):
for key in list(headers):
if key.lower() == name.lower():
del headers[key]
req.add_header(name, value)
return kb.authRefreshVersion
def refresh(version):
"""Refresh once for all workers that sent the same expired credentials."""
with kb.locks.authRefresh:
if kb.authRefreshFailed:
raise SqlmapConnectionException("authentication refresh failed; cannot continue with expired credentials")
if version != kb.authRefreshVersion:
return
logger.info("refreshing authentication after HTTP 401")
try:
headers = kb.authRefreshFunction()
if not isinstance(headers, dict) or not headers:
raise ValueError()
for name, value in headers.items():
if not isinstance(name, six.string_types) or not re.match(r"\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z", name):
raise ValueError()
if not isinstance(value, six.string_types) or not value or re.search(r"[\x00-\x1f\x7f]", value):
raise ValueError()
value.encode("latin-1")
if name.lower() in ("host", "content-length", "transfer-encoding", "connection", "proxy-authorization"):
raise ValueError()
if len(set(_.lower() for _ in headers)) != len(headers):
raise ValueError()
except Exception:
kb.authRefreshFailed = True
# Hook exceptions can contain tokens/passwords; do not include their text.
raise SqlmapConnectionException("auth refresh hook failed or returned invalid headers. Expected a non-empty dictionary of authentication headers. You can find a template script at '%s'" % createTemplate())
kb.authRefreshHeaders = dict(headers)
kb.authRefreshVersion += 1
def stripHeaders(headers):
"""Remove refreshed credentials before following a redirect to another origin."""
names = set(_.lower() for _ in kb.authRefreshHeaders)
return dict((name, value) for name, value in headers.items() if name.lower() not in names)
+30 -7
View File
@@ -22,6 +22,7 @@ import time
import traceback
from lib.request import websocket
from lib.request import authrefresh
from lib.request.websocket import WebSocketException
from lib.core.agent import agent
@@ -469,6 +470,8 @@ class Connect(object):
responseHeaders = None
logHeaders = u""
skipLogTraffic = False
authRetry = False
authVersion = None
raise404 = raise404 and not kb.ignoreNotFound
@@ -683,6 +686,9 @@ class Connect(object):
else:
post, headers = req.data, req.headers
authVersion = authrefresh.apply(req)
headers = req.headers
requestHeaders += "\r\n".join(["%s: %s" % (u"-".join(_.capitalize() for _ in getUnicode(key).split(u'-')) if hasattr(key, "capitalize") else getUnicode(key), getUnicode(value)) for (key, value) in req.header_items()])
if not getRequestHeader(req, HTTP_HEADER.COOKIE) and conf.cj:
@@ -730,12 +736,12 @@ class Connect(object):
raise SqlmapMissingDependence("native HTTP/2 client does not support SOCKS proxies (omit '--http2' or use an HTTP proxy)")
try:
conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy)
conn = http2OpenUrl(url, method or (HTTPMETHOD.POST if post is not None else HTTPMETHOD.GET), headers, post, timeout, follow_redirects=kb.choices.redirect != REDIRECTION.NO, proxy=h2proxy, sensitive_headers=tuple(kb.authRefreshHeaders) if conf.authRefresh else ())
except IOError as ex:
raise _http_client.HTTPException(getSafeExString(ex))
else:
if conn.code >= 400:
raise _urllib.error.HTTPError(url, conn.code, conn.msg, conn.info(), io.BytesIO(conn.read()))
raise _urllib.error.HTTPError(conn.geturl(), conn.code, conn.msg, conn.info(), io.BytesIO(conn.read()))
requestMsg = re.sub(r" HTTP/[0-9.]+\r\n", " %s\r\n" % conn.http_version, requestMsg, count=1)
@@ -863,7 +869,7 @@ class Connect(object):
page = None
responseHeaders = None
if checking:
if checking and not (ex.code == _http_client.UNAUTHORIZED and authVersion is not None):
return None, None, None
try:
@@ -887,7 +893,8 @@ class Connect(object):
code = ex.code
status = getUnicode(getattr(ex, "reason", None) or getSafeExString(ex).split(": ", 1)[-1])
kb.originalCode = kb.originalCode or code
if not (code == _http_client.UNAUTHORIZED and authVersion is not None):
kb.originalCode = kb.originalCode or code
threadData.lastHTTPError = (threadData.lastRequestUID, code, status)
kb.httpErrorCodes[code] = kb.httpErrorCodes.get(code, 0) + 1
@@ -913,7 +920,15 @@ class Connect(object):
singleTimeLogMessage(errMsg, logging.CRITICAL)
raise SystemExit
if ex.code not in (conf.ignoreCode or []):
if ex.code == _http_client.UNAUTHORIZED and authVersion is not None and authrefresh.sameOrigin(ex.geturl(), url):
if kwargs.get("authRetried"):
with kb.locks.authRefresh:
if authVersion == kb.authRefreshVersion:
kb.authRefreshFailed = True
raise SqlmapConnectionException("authentication refresh did not authorize the retried request (401)")
authrefresh.refresh(authVersion)
authRetry = True
elif ex.code not in (conf.ignoreCode or []):
if ex.code == TOO_MANY_REQUESTS_HTTP_CODE or (ex.code == _http_client.SERVICE_UNAVAILABLE and Connect._parseRetryAfter(responseHeaders) is not None):
retried = Connect._rateLimitRetry(responseHeaders, ex.code, **kwargs)
if retried is not None:
@@ -1099,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):
@@ -1638,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:
@@ -1751,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
@@ -1806,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
+2 -2
View File
@@ -1576,7 +1576,7 @@ def proxy_tuple(proxy, cred=None):
def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redirects=True,
max_redirects=10, proxy=None, verify=False, ssl_context=None):
max_redirects=10, proxy=None, verify=False, ssl_context=None, sensitive_headers=()):
req_headers = headers or {}
current_method = method
current_body = body
@@ -1614,7 +1614,7 @@ def open_url(url, method="GET", headers=None, body=None, timeout=30, follow_redi
old_parts = parts
new_parts = urlsplit(new_url)
if _origin_tuple(old_parts) != _origin_tuple(new_parts):
req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS)
req_headers = _headers_without(req_headers, _SENSITIVE_REDIRECT_FIELDS.union(_as_bytes(_).lower() for _ in sensitive_headers))
method_bytes = _as_bytes(current_method)
change_to_get = status == 303 and method_bytes != b"HEAD"
+3 -1
View File
@@ -28,6 +28,8 @@ except ImportError:
pass
_protocols = filterNone(getattr(ssl, _, None) for _ in ("PROTOCOL_TLS_CLIENT", "PROTOCOL_TLSv1_2", "PROTOCOL_TLSv1_1", "PROTOCOL_TLSv1", "PROTOCOL_SSLv3", "PROTOCOL_SSLv23", "PROTOCOL_SSLv2"))
# Note: SNI (i.e. server_hostname) is a TLS extension, hence only those are used in here (Note: PROTOCOL_TLSv1 is not compiled in on OpenSSL builds without TLS 1.0 support)
_tlsProtocols = filterNone(getattr(ssl, _, None) for _ in ("PROTOCOL_TLS_CLIENT", "PROTOCOL_TLSv1_2", "PROTOCOL_TLSv1_1", "PROTOCOL_TLSv1"))
_lut = dict((getattr(ssl, _), _) for _ in dir(ssl) if _.startswith("PROTOCOL_"))
_contexts = {}
@@ -64,7 +66,7 @@ class HTTPSConnection(_http_client.HTTPSConnection):
# Reference(s): https://docs.python.org/2/library/ssl.html#ssl.SSLContext
# https://www.mnot.net/blog/2014/12/27/python_2_and_tls_sni
if hasattr(ssl, "SSLContext"):
for protocol in (_ for _ in _protocols if _ >= ssl.PROTOCOL_TLSv1):
for protocol in (_ for _ in _protocols if _ in _tlsProtocols):
sock = None
try:
sock = create_sock()
+4
View File
@@ -32,6 +32,7 @@ from lib.core.settings import MAX_TOTAL_REDIRECTIONS
from lib.core.threads import getCurrentThreadData
from lib.request.basic import decodePage
from lib.request.basic import parseResponse
from lib.request import authrefresh
from thirdparty import six
from thirdparty.six.moves import http_client as _http_client
from thirdparty.six.moves import urllib as _urllib
@@ -176,6 +177,9 @@ class SmartRedirectHandler(_urllib.request.HTTPRedirectHandler):
req.headers[HTTP_HEADER.COOKIE] = delimiter.join("%s=%s" % (key, cookies[key]) for key in cookies)
try:
if conf.authRefresh and not authrefresh.sameOrigin(req.get_full_url(), redurl):
req.headers = authrefresh.stripHeaders(req.headers)
req.unredirected_hdrs = authrefresh.stripHeaders(req.unredirected_hdrs)
result = _urllib.request.HTTPRedirectHandler.http_error_302(self, req, fp, code, msg, headers)
except _urllib.error.HTTPError as ex:
result = error = ex
+21 -8
View File
@@ -109,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
@@ -350,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()
@@ -509,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())
@@ -530,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))
@@ -668,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:
@@ -739,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())
@@ -963,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())
@@ -975,7 +988,7 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
bit += 1
if candidates:
forgedPayload = safeStringFormat(payload.replace(INFERENCE_GREATER_CHAR, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, candidates[0]))
forgedPayload = safeStringFormat(replaceInferenceOperator(payload, INFERENCE_EQUALS_CHAR), (expressionUnescaped, idx, candidates[0]))
result = Request.queryPage(forgedPayload, timeBasedCompare=timeBasedCompare, raise404=False)
incrementCounter(getTechnique())
+4 -1
View File
@@ -74,7 +74,10 @@ def dnsUse(payload, expression):
if Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.PGSQL):
query = agent.prefixQuery("; %s" % expressionUnescaped)
query = "%s%s" % (query, queries[Backend.getIdentifiedDbms()].comment.query)
if conf.suffix is not None:
query = agent.suffixQuery(query, comment="", suffix=conf.suffix)
else:
query = "%s%s" % (query, queries[Backend.getIdentifiedDbms()].comment.query)
forgedPayload = agent.payload(newValue=query)
else:
forgedPayload = safeStringFormat(payload, (expressionUnescaped, randomInt(1), randomInt(3)))
+5 -2
View File
@@ -37,6 +37,7 @@ from lib.core.common import singleTimeDebugMessage
from lib.core.common import singleTimeWarnMessage
from lib.core.common import unArrayizeValue
from lib.core.common import wasLastResponseDBMSError
from lib.core.common import zeroDepthSearch
from lib.core.compat import xrange
from lib.core.convert import decodeBase64
from lib.core.convert import getUnicode
@@ -344,8 +345,10 @@ def unionUse(expression, unpack=True, dump=False):
kb.partRun = getPartRun(alias=False) if (conf.api or conf.reportJson) else None
if expressionFieldsList and len(expressionFieldsList) > 1 and "ORDER BY" in expression.upper():
# Removed ORDER BY clause because UNION does not play well with it
expression = re.sub(r"(?i)\s*ORDER BY\s+[\w,]+", "", expression)
# Only remove outer ordering. Nested ORDER BY clauses can select which
# rows survive a ROWNUM/TOP limit and must remain intact.
positions = set(index for keyword in set(re.findall(r"(?i)ORDER BY", expression)) for index in zeroDepthSearch(expression, keyword))
expression = re.sub(r"(?i)ORDER BY\s+[\w,]+", lambda match: "" if match.start() in positions else match.group(0), expression)
debugMsg = "stripping ORDER BY clause from statement because "
debugMsg += "it does not play well with UNION query SQL injection"
singleTimeDebugMessage(debugMsg)
+9 -1
View File
@@ -444,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 ""))
@@ -818,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)
+11
View File
@@ -79,6 +79,7 @@ from lib.core.enums import HASH
from lib.core.enums import MKSTEMP_PREFIX
from lib.core.exception import SqlmapDataException
from lib.core.exception import SqlmapUserQuitException
from lib.core.patch import dirtyPatches
from lib.core.settings import COMMON_PASSWORD_SUFFIXES
from lib.core.settings import COMMON_PASSWORDS
from lib.core.settings import COMMON_USER_COLUMNS
@@ -101,6 +102,16 @@ from thirdparty import six
from thirdparty.colorama.initialise import init as coloramainit
from thirdparty.six.moves import queue as _queue
# On Windows, multiprocessing's 'spawn' start method boots a fresh interpreter per cracking
# worker (_bruteProcessVariantA/B/Salted below) to unpickle its target function, which re-imports
# this module from scratch without ever running the main process's startup dirtyPatches() - so
# process-wide monkeypatches it installs (e.g. the 'reversible' codec error handler used by
# getUnicode()/openFile()) are missing in the worker, raising LookupError on every hashed entry
# (#6143). Placing the (idempotent) call here means that very re-import applies it. POSIX fork()
# workers inherit the parent's patches already, so this only needs to run on Windows.
if IS_WIN:
dirtyPatches()
def mysql_passwd(password, uppercase=True):
"""
Reference(s):
+9 -8
View File
@@ -12,6 +12,7 @@ from lib.core.common import getSafeExString
from lib.core.common import popValue
from lib.core.common import pushValue
from lib.core.common import urlencode
from lib.core.convert import decodeBase64
from lib.core.convert import getBytes
from lib.core.convert import getUnicode
from lib.core.data import conf
@@ -25,9 +26,8 @@ from lib.core.exception import SqlmapBaseException
from lib.core.settings import BING_REGEX
from lib.core.settings import DUCKDUCKGO_REGEX
from lib.core.settings import DUMMY_SEARCH_USER_AGENT
from lib.core.settings import GOOGLE_CONSENT_COOKIE
from lib.core.settings import GOOGLE_REGEX
from lib.core.settings import HTTP_ACCEPT_ENCODING_HEADER_VALUE
from lib.core.settings import SEARCH_ABUSE_MARKERS
from lib.core.settings import UNICODE_ENCODING
from lib.request.basic import decodePage
from thirdparty.six.moves import http_client as _http_client
@@ -89,7 +89,6 @@ def _search(dork):
requestHeaders = {
HTTP_HEADER.USER_AGENT: dict(conf.httpHeaders).get(HTTP_HEADER.USER_AGENT, DUMMY_SEARCH_USER_AGENT),
HTTP_HEADER.ACCEPT_ENCODING: HTTP_ACCEPT_ENCODING_HEADER_VALUE,
HTTP_HEADER.COOKIE: GOOGLE_CONSENT_COOKIE,
}
gpage = conf.googlePage if conf.googlePage > 1 else 1
@@ -97,11 +96,13 @@ def _search(dork):
encoded = urlencode(dork, convall=True)
# Note: (name, url, POST data, regex, regex flags, match->link). Ordered by current scraping reliability; tried in turn until one yields results (DuckDuckGo currently being the only consistently scrapeable one)
# Note: DuckDuckGo's first result page holds 10 entries, while each subsequent one holds 15 (hence the non-linear offset)
ddgOffset = 0 if gpage == 1 else 10 + (gpage - 2) * 15
# Note: (name, url, POST data, regex, regex flags, match->link). Ordered by current scraping reliability; tried in turn until one yields results (Google is not in here as it serves results only to JavaScript capable clients)
engines = (
("DuckDuckGo", "https://html.duckduckgo.com/html/", "q=%s&s=%d" % (encoded, (gpage - 1) * 30), DUCKDUCKGO_REGEX, re.I | re.S, lambda match: match.group(1).replace("&amp;", "&")),
("Bing", "https://www.bing.com/search?q=%s&first=%d" % (encoded, (gpage - 1) * 10 + 1), None, BING_REGEX, re.I | re.S, lambda match: match.group(1)),
("Google", "https://www.google.com/search?q=%s&num=100&hl=en&complete=0&safe=off&filter=0&btnG=Search&start=%d" % (encoded, (gpage - 1) * 100), None, GOOGLE_REGEX, re.I, lambda match: match.group(1) or match.group(2)),
("DuckDuckGo", "https://html.duckduckgo.com/html/", "q=%s&s=%d" % (encoded, ddgOffset), DUCKDUCKGO_REGEX, re.I | re.S, lambda match: match.group(1).replace("&amp;", "&")),
("Bing", "https://www.bing.com/search?q=%s&first=%d" % (encoded, (gpage - 1) * 10 + 1), None, BING_REGEX, re.I | re.S, lambda match: decodeBase64(match.group(1), binary=False) if match.group(1) else match.group(2).replace("&amp;", "&")),
)
for name, url, data, regex, flags, extract in engines:
@@ -123,7 +124,7 @@ def _search(dork):
break # Note: stop at the first engine that actually returns results (others are only fallbacks)
# Note: switch proxy (if available) when an abuse/captcha page was served (instead of pointlessly falling through to the next engine from the same blocked IP)
if conf.proxyList and (("detected unusual traffic" in page) or ("issue with the Tor Exit Node you are currently using" in page)):
if conf.proxyList and any(marker in page for marker in SEARCH_ABUSE_MARKERS):
warnMsg = "%s has detected 'unusual' traffic from the used IP address" % name
raise SqlmapBaseException(warnMsg)
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
from lib.core.enums import DBMS
from lib.core.settings import AUDIT_MAX_ROWS
from plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.MSSQL
# Snippets keep an outer WHERE for the existing blind TOP/NOT IN limiter.
queries = (("identity", ("audit_identity",)), ("permissions", ("audit_permissions",)),
("configuration", ("audit_configuration",)), ("linked servers", ("audit_links",)))
flags = dict((key, ("enabled", "disabled")) for key in ("xp_cmdshell", "clr enabled", "Ole Automation Procedures", "Ad Hoc Distributed Queries"))
flags.update(dict((key, ("yes", "no")) for key in ("sysadmin", "execute xp_cmdshell", "read configuration metadata", "read linked-server metadata")))
def failure(self, scope, groups):
permissions = groups.get("permissions") or {}
for key, name, target in (("read configuration metadata", "configuration", "master.sys.configurations"),
("read linked-server metadata", "linked servers", "master.sys.servers")):
if scope == name and permissions.get(key) == "0":
return "%s: SELECT permission missing on %s" % (scope, target)
return GenericAudit.failure(self, scope, groups)
def explain(self, groups):
configuration = groups.get("configuration") or {}
permissions = groups.get("permissions") or {}
identity = groups.get("identity") or {}
result = []
if identity.get("sysadmin") == "1" and configuration.get("xp_cmdshell") == "0":
if self._auditCanExecute():
result.append("Command execution: sysadmin can enable xp_cmdshell; use --os-cmd or --os-shell")
else:
result.append("Command execution: sysadmin can enable xp_cmdshell; no statement execution route identified")
elif configuration.get("xp_cmdshell") == "1" and permissions.get("execute xp_cmdshell") == "1":
if self._auditCanExecute():
result.append("Command execution: xp_cmdshell enabled and EXECUTE granted; use --os-cmd or --os-shell")
else:
result.append("Command execution: xp_cmdshell enabled and EXECUTE granted; no statement execution route identified")
elif permissions.get("execute xp_cmdshell") == "0":
result.append("Command execution: EXECUTE permission missing on master.dbo.xp_cmdshell")
elif configuration.get("xp_cmdshell") == "0":
result.append("Command execution: xp_cmdshell is disabled")
links = groups.get("linked servers") or {}
count = links.get("visible linked servers")
if count and count.isdigit() and int(count) > AUDIT_MAX_ROWS:
shown = sum(key.startswith("link ") for key in links)
result.append("Linked servers: showing %d of %s; use --links for the full list" % (shown, count))
return result
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
from lib.core.common import Backend
from lib.core.enums import DBMS, FORK
from plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.MYSQL
flags = dict((key, ("enabled", "disabled")) for key in ("local_infile", "require_secure_transport", "read_only", "super_read_only", "general_log", "log_bin", "skip_networking", "skip_grant_tables"))
flags["direct FILE grant"] = ("present", "absent")
def __init__(self):
configuration = ("audit_configuration_mariadb", "audit_configuration") if Backend.isFork(FORK.MARIADB) else ("audit_configuration", "audit_configuration_mariadb")
self.queries = (("identity", ("audit_identity",)), ("configuration", configuration),
("file access", ("audit_file_access",)), ("privileges", ("audit_privileges",)))
def explain(self, groups):
facts = groups.get("file access") or {}
privileges = groups.get("privileges") or {}
result = []
disabled = False
if "secure_file_priv" in facts:
value = facts["secure_file_priv"]
# MariaDB can expose an unrestricted value as SQL NULL; MySQL can expose
# its disabled value as the literal string "NULL". Keep the raw fact.
disabled = (value is None or value.upper() == "NULL") and not Backend.isFork(FORK.MARIADB)
if disabled:
result.append("File operations: secure_file_priv=NULL blocks --file-read and --file-write")
elif not value:
result.append("File operations: no directory restriction from secure_file_priv")
else:
result.append("File operations: --file-read and --file-dest paths must be inside '%s'" % value)
if privileges.get("direct FILE grant") == "1" and not disabled:
message = "File read: FILE granted; use --file-read with a server-side path"
if facts.get("max_allowed_packet"):
message += " (file size < %s bytes)" % facts["max_allowed_packet"]
result.append(message)
return result
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
from lib.core.enums import DBMS
from lib.core.settings import AUDIT_MAX_ROWS
from plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.ORACLE
queries = (("identity", ("audit_identity",)), ("configuration", ("audit_configuration",)),
("privileges", ("audit_privileges",)), ("directory grants", ("audit_directories",)))
def explain(self, groups):
grants = groups.get("directory grants") or {}
count = grants.get("visible directory grants")
if count and count.isdigit() and int(count) > AUDIT_MAX_ROWS:
return ["Directory grants: showing %d of %s" % (len(grants) - 1, count)]
return []
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
from lib.core.enums import DBMS
from plugins.generic.audit import Audit as GenericAudit
class Audit(GenericAudit):
dbms = DBMS.PGSQL
queries = (("identity", ("audit_identity",)), ("configuration", ("audit_configuration",)),
("privileges", ("audit_privileges",)))
flags = dict((key, ("yes", "no")) for key in ("superuser", "bypass RLS", "pg_read_server_files", "pg_write_server_files", "pg_execute_server_program"))
def explain(self, groups):
privileges = groups.get("privileges") or {}
superuser = privileges.get("superuser") == "1"
result = []
if superuser or privileges.get("pg_read_server_files") == "1":
result.append("File read: server-file privilege granted; use --file-read with a server-side path")
if superuser and self._auditCanExecute():
result.append("File write: superuser and statement execution available; use --file-write and --file-dest")
if superuser or privileges.get("pg_execute_server_program") == "1":
if self._auditCanExecute():
result.append("Command execution: COPY FROM PROGRAM privilege granted; use --os-cmd or --os-shell")
else:
result.append("Command execution: COPY FROM PROGRAM privilege granted; no statement execution route identified")
if superuser or privileges.get("bypass RLS") == "1":
result.append("Row security: current role bypasses row-level security")
return result
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
from lib.core.common import arrayizeValue
from lib.core.common import getSQLSnippet
from lib.core.common import isListLike
from lib.core.common import isNoneValue
from lib.core.common import isStackingAvailable
from lib.core.common import unArrayizeValue
from lib.core.convert import getUnicode
from lib.core.data import conf
from lib.core.data import kb
from lib.core.data import logger
from lib.core.enums import CHARSET_TYPE
from lib.core.enums import EXPECTED
from lib.core.exception import SqlmapNoneDataException
from lib.core.settings import AUDIT_MAX_ROWS
from lib.utils.safe2bin import safecharencode
from lib.request import inject
from thirdparty import six
class Audit(object):
# Each snippet returns (audit_key, audit_value). Values are tagged N (SQL NULL) or V<text>
# so extraction's normal NULL/empty-string handling cannot silently change their meaning.
dbms = None
queries = ()
flags = {}
def _auditFetch(self, snippet):
# Extraction locates fields and FROM clauses on one line.
query = getSQLSnippet(self.dbms, snippet, LIMIT=str(AUDIT_MAX_ROWS)).replace("\r", " ").replace("\n", " ")
try:
values = inject.getValue(query, fromUser=True, batch=True, safeCharEncode=False)
except SqlmapNoneDataException:
values = None
if isNoneValue(values):
# Extraction can return None for both zero rows and a failed query.
# Confirm emptiness rather than inferring a database error from it.
countQuery = "SELECT COUNT(*) FROM (%s) audit_count" % query.rsplit(" ORDER BY ", 1)[0]
try:
count = inject.getValue(countQuery, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
except SqlmapNoneDataException:
return None
return {} if getUnicode(unArrayizeValue(count)) == "0" else None
if isListLike(values) and len(values) == 2 and not any(isListLike(_) for _ in values):
values = [values]
facts = {}
for row in arrayizeValue(values):
if not isListLike(row) or len(row) != 2:
return None
key, value = row
if isNoneValue(key) or not isinstance(value, six.string_types):
return None
if value == "N":
value = None
elif value.startswith("V"):
value = value[1:]
else:
return None
# DBMS CHAR expressions can pad the keys; preserve value whitespace.
facts[getUnicode(key).rstrip(" ")] = value
return facts
def failure(self, scope, groups):
return "%s: retrieval failed" % scope
def _auditCanExecute(self):
# Use known execution routes; audit must not probe takeover gadgets.
return bool(conf.direct or isStackingAvailable() or kb.get("gadget"))
def explain(self, groups):
return []
def getAudit(self):
if kb.data.cachedAudit:
return kb.data.cachedAudit
logger.info("fetching security configuration and session capabilities")
groups = {}
result = []
for scope, snippets in self.queries:
facts = None
for snippet in snippets:
facts = self._auditFetch(snippet)
if facts is not None:
break
groups[scope] = facts
if facts is None:
result.append(self.failure(scope, groups))
continue
if not facts:
result.append("%s: no entries" % scope)
for key in sorted(facts):
if facts[key] is None:
value = "<NULL>"
elif facts[key] == "":
value = "<empty>"
else:
value = facts[key]
if key in self.flags:
if value.lower() in ("1", "on", "true", "yes"):
value = self.flags[key][0]
elif value.lower() in ("0", "off", "false", "no"):
value = self.flags[key][1]
result.append("%s / %s: %s" % (scope, key, value))
result.extend(self.explain(groups))
result = [safecharencode(_) for _ in result]
kb.data.cachedAudit = result
return result
+10 -8
View File
@@ -12,10 +12,12 @@ import sys
from lib.core.common import Backend
from lib.core.common import dataToStdout
from lib.core.common import getSQLSnippet
from lib.core.common import isListLike
from lib.core.common import isStackingAvailable
from lib.core.common import joinValue
from lib.core.common import readCachedFileContent
from lib.core.common import splitFields
from lib.core.common import zeroDepthSearch
from lib.core.compat import xrange
from lib.core.convert import getUnicode
from lib.core.data import conf
@@ -148,12 +150,12 @@ class Custom(object):
if not filename:
continue
snippet = getSQLSnippet(Backend.getDbms(), filename)
# User SQL is not a bundled procedure template: preserve literals and percent tokens.
snippet = readCachedFileContent(filename)
queries = [_.strip() for _ in splitFields(snippet, delimiter=';' if zeroDepthSearch(snippet, ';') else '\n') if _.strip()]
if snippet and all(query.strip().upper().startswith("SELECT") for query in (_ for _ in snippet.split(';' if ';' in snippet else '\n') if _)):
for query in (_ for _ in snippet.split(';' if ';' in snippet else '\n') if _):
query = query.strip()
if query:
conf.dumper.sqlQuery(query, self.sqlQuery(query))
else:
if queries and all(query.upper().startswith("SELECT") for query in queries):
for query in queries:
conf.dumper.sqlQuery(query, self.sqlQuery(query))
elif queries:
conf.dumper.sqlQuery(snippet, self.sqlQuery(snippet))
+49
View File
@@ -72,6 +72,8 @@ class Databases(object):
kb.data.dumpedTable = {}
kb.data.cachedStatements = []
kb.data.cachedProcedures = []
kb.data.cachedLinks = []
kb.data.cachedAudit = []
def getCurrentDb(self):
infoMsg = "fetching current database"
@@ -1181,6 +1183,53 @@ class Databases(object):
return kb.data.cachedStatements
def getLinks(self):
dbms = Backend.getIdentifiedDbms()
if dbms not in queries or "links" not in queries[dbms]:
logger.warning("enumeration of database links is not supported on %s" % dbms)
return []
if kb.data.cachedLinks:
return kb.data.cachedLinks
logger.info("fetching database links visible to the current user")
rootQuery = queries[dbms].links
if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct:
values = inject.getValue(rootQuery.inband.query, blind=False, time=False)
if not isNoneValue(values):
for value in arrayizeValue(values):
value = unArrayizeValue(value)
if not isNoneValue(value):
kb.data.cachedLinks.append(value.strip())
if not kb.data.cachedLinks and isInferenceAvailable() and not conf.direct:
count = inject.getValue(rootQuery.blind.count, union=False, error=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS)
if count in (0, "0"):
return []
elif not isNumPosStrValue(count):
logger.warning("unable to retrieve the number of database links")
return []
for index in getLimitRange(count):
value = unArrayizeValue(inject.getValue(rootQuery.blind.query % index, union=False, error=False))
if not isNoneValue(value):
kb.data.cachedLinks.append(value.strip())
return kb.data.cachedLinks
def getAudit(self):
from importlib import import_module
from lib.core.settings import DBMS_DIRECTORY_DICT
dbms = Backend.getIdentifiedDbms()
if dbms not in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE) or Backend.isFork(FORK.DUCKDB):
logger.warning("security configuration audit is not supported on %s" % dbms)
return []
module = import_module("plugins.dbms.%s.audit" % DBMS_DIRECTORY_DICT[dbms])
return module.Audit().getAudit()
def getProcedures(self):
infoMsg = "fetching stored procedures"
logger.info(infoMsg)
+14 -3
View File
@@ -25,8 +25,8 @@ bulkFile =
# Example (file content): POST /login.jsp HTTP/1.1\nHost: example.com\nUser-Agent: Mozilla/4.0\n\nuserid=joe&password=guessme
requestFile =
# Rather than providing a target URL, let Google return target
# hosts as result of your Google dork expression. For a list of Google
# Rather than providing a target URL, let the search engine return target
# hosts as result of your dork expression. For a list of
# dorks see Google Hacking Database at
# https://www.exploit-db.com/google-hacking-database
# Example: +ext:php +inurl:"&id=" +intext:"powered by "
@@ -544,6 +544,14 @@ getComments = False
# Valid: True or False
getStatements = False
# Enumerate database management system links and their destinations.
# Valid: True or False
getLinks = False
# Audit database security configuration and current-session capabilities.
# Valid: True or False
getAudit = False
# Back-end database management system database to enumerate.
db =
@@ -789,7 +797,7 @@ forms = False
# Valid: True or False
freshQueries = False
# Use Google dork results from specified page number.
# Use dork results from specified page number.
# Valid: integer
# Default: 1
googlePage = 1
@@ -805,6 +813,9 @@ outputDir =
# Valid: True or False
parseErrors = False
# Run a Python refresh() script returning replacement headers on HTTP 401.
authRefresh =
# Use given script(s) for preprocessing of request.
preprocess =
+5
View File
@@ -38,6 +38,8 @@ def tamper(payload, **kwargs):
'1 AND A BETWEEN B AND B--'
>>> tamper('1 AND LAST_INSERT_ROWID()=LAST_INSERT_ROWID()')
'1 AND LAST_INSERT_ROWID() BETWEEN LAST_INSERT_ROWID() AND LAST_INSERT_ROWID()'
>>> tamper('CASE WHEN 1=2 THEN id ELSE 0 END')
'CASE WHEN 1 BETWEEN 2 AND 2 THEN id ELSE 0 END'
"""
retVal = payload
@@ -58,4 +60,7 @@ def tamper(payload, **kwargs):
_ = "%s %s BETWEEN %s AND %s" % (match.group(2), match.group(4), match.group(5), match.group(5))
retVal = retVal.replace(match.group(0), _)
# e.g. CASE WHEN 1=1 THEN ... (expression-position payloads with no AND/OR to anchor on)
retVal = re.sub(r"(?i)(\bWHEN\s+)([\w.]+)\s*(?<![<>!])=(?!=)\s*([\w.]+)(?=\s)", r"\g<1>\g<2> BETWEEN \g<3> AND \g<3>", retVal)
return retVal
+5
View File
@@ -0,0 +1,5 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir flask==3.1.3 pymysql==1.2.3 'psycopg[binary]==3.3.6' python-tds==1.17.1 oracledb==26.0.1
WORKDIR /lab
COPY server.py db.py seed.py probe.py /lab/
CMD ["python", "/lab/server.py"]
+42
View File
@@ -0,0 +1,42 @@
# Disposable security audit matrix
Run from the repository root on a local Docker host:
```sh
python3 tests/integration/audit/run.py
```
Requires Python 3, Docker Compose, Internet access for images and roughly 12 GB
available memory. The SQL Server image requires an x86-64 host. Oracle image
support also depends on host architecture and page size. Application drivers are
installed only inside the fixture image; sqlmap gains no dependencies.
The runner creates an isolated `sqlmap-audit-<pid>` Compose project. Database ports
are internal; deliberately injectable HTTP endpoints bind to random localhost
ports. Credentials are synthetic and fixed for these disposable databases. The
runner does not access the remote sqlmap testbed or existing database services.
It removes its own containers, networks and volumes on completion, including a
failed assertion. `--keep` retains them for investigation; remove the retained
project with `docker compose -p <project> -f tests/integration/audit/compose.yml down --volumes`.
The matrix covers MySQL 8, MariaDB 10.11, PostgreSQL 16, SQL Server 2022 and
Oracle Free 23, with administrator, application and restricted accounts. Two
additional MySQL instances exercise unrestricted and disabled file operations.
It compares native metadata results with real UNION SQLi for every account and
boolean blind SQLi for each engine's application account, using five threads.
Variable listings retain total counts and show at most 20 details. Additional
scenarios cover oversized listings, escaped control characters, confirmed empty
queries, RLS enforcement and command advice with and without stacked execution.
It also checks that audits leave configuration unchanged, that revoking grants
and changing settings changes the result, and that database root with FILE
privilege still cannot read a root-only fixture file. Filesystem probes and
configuration changes belong to the fixture provisioner; `--audit` performs
metadata reads only.
Use `--native-only` for database queries and permission/configuration/filesystem
checks without the HTTP scans. Logs and native reference results go to a printed
temporary directory. For a previously seeded retained project:
```sh
python3 tests/integration/audit/run.py --project sqlmap-audit-<id> --reuse --keep
```
+82
View File
@@ -0,0 +1,82 @@
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: AuditOnly!2026
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: auditlab
command: ['--secure-file-priv=/var/lib/mysql-files', '--local-infile=OFF']
mem_limit: 1g
mariadb:
image: mariadb:10.11
environment:
MARIADB_ROOT_PASSWORD: AuditOnly!2026
MARIADB_ROOT_HOST: '%'
MARIADB_DATABASE: auditlab
command: ['--secure-file-priv=', '--local-infile=ON']
mem_limit: 1g
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: AuditOnly!2026
POSTGRES_DB: auditlab
mem_limit: 1g
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: Y
MSSQL_PID: Developer
MSSQL_SA_PASSWORD: AuditOnly!2026
mem_limit: 2g
oracle:
image: gvenzl/oracle-free:23.26.0-slim-faststart
environment:
ORACLE_PASSWORD: AuditOnly!2026
APP_USER: audit_owner
APP_USER_PASSWORD: AuditOnly!2026
mem_limit: 4g
web-mysql:
build: .
image: sqlmap-audit-fixture:local
environment: {ENGINE: mysql}
ports: ['127.0.0.1::8080']
web-mariadb:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mariadb}
ports: ['127.0.0.1::8080']
web-postgres:
image: sqlmap-audit-fixture:local
environment: {ENGINE: postgres}
ports: ['127.0.0.1::8080']
web-mssql:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mssql}
ports: ['127.0.0.1::8080']
web-oracle:
image: sqlmap-audit-fixture:local
environment: {ENGINE: oracle}
ports: ['127.0.0.1::8080']
mysql-open:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: AuditOnly!2026
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: auditlab
command: ['--secure-file-priv=', '--local-infile=ON']
mem_limit: 1g
mysql-disabled:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: AuditOnly!2026
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: auditlab
command: ['--secure-file-priv=NULL', '--local-infile=OFF']
mem_limit: 1g
web-mysql-open:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mysql, DB_HOST: mysql-open}
ports: ['127.0.0.1::8080']
web-mysql-disabled:
image: sqlmap-audit-fixture:local
environment: {ENGINE: mysql, DB_HOST: mysql-disabled}
ports: ['127.0.0.1::8080']
+27
View File
@@ -0,0 +1,27 @@
import os
import threading
engine = os.environ['ENGINE']
password = 'AuditOnly!2026'
host = os.environ.get('DB_HOST', engine)
pools = {}
poolLock = threading.Lock()
def connect(account, pooled=False):
if engine in ('mysql', 'mariadb'):
import pymysql
return pymysql.connect(host=host, user=account, password=password, database='auditlab', autocommit=True)
if engine == 'postgres':
import psycopg
return psycopg.connect(host=host, user=account, password=password, dbname='auditlab', autocommit=True)
if engine == 'mssql':
import pytds
return pytds.connect(host, user=account, password=password, database='master', autocommit=True)
import oracledb
if pooled:
with poolLock:
if account not in pools:
pools[account] = oracledb.create_pool(user=account, password=password, dsn=host+'/FREEPDB1', min=1, max=10, increment=1)
return pools[account].acquire()
return oracledb.connect(user=account, password=password, dsn=host+'/FREEPDB1', mode=oracledb.AUTH_MODE_SYSDBA if account == 'sys' else oracledb.AUTH_MODE_DEFAULT)
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env python
"""Execute fixture SQL over a real DB-API connection for ground-truth checks."""
import json
import sys
from db import connect
conn = connect(sys.argv[1])
try:
cursor = conn.cursor()
cursor.execute(sys.stdin.read())
print(json.dumps(cursor.fetchall() if cursor.description else [], default=str))
finally:
conn.close()
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Opt-in Docker audit matrix. Runtime sqlmap itself acquires metadata only.
This fixture provisioner deliberately changes settings, grants and synthetic files inside
its own disposable containers, then verifies the audit over native SQL and real SQLi.
"""
from __future__ import print_function
import argparse
import concurrent.futures
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[3]
LAB = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / 'tests'))
from _testutils import bootstrap, set_dbms
bootstrap()
from lib.core.enums import DBMS, FORK, EXPECTED
from lib.core.common import Backend, getSQLSnippet, unArrayizeValue
from lib.core.data import conf
from lib.core.settings import AUDIT_MAX_ROWS
from lib.request import inject
from plugins.generic.databases import Databases
ENGINES = {'mysql': (DBMS.MYSQL, 'root'), 'mariadb': (DBMS.MYSQL, 'root'),
'postgres': (DBMS.PGSQL, 'postgres'), 'mssql': (DBMS.MSSQL, 'sa'),
'oracle': (DBMS.ORACLE, 'audit_owner'),
'mysql-open': (DBMS.MYSQL, 'root'), 'mysql-disabled': (DBMS.MYSQL, 'root')}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--project', default='sqlmap-audit-%d' % os.getpid())
parser.add_argument('--reuse', action='store_true', help='Use an already seeded audit project')
parser.add_argument('--keep', action='store_true', help='Keep this disposable project for debugging')
parser.add_argument('--native-only', action='store_true', help='Skip the HTTP SQLi matrix')
args = parser.parse_args()
if not re.match(r'^sqlmap-audit-[a-z0-9-]+$', args.project):
parser.error('project must have the sqlmap-audit- prefix')
compose = ['docker', 'compose', '-p', args.project, '-f', str(LAB / 'compose.yml')]
logs = Path(tempfile.mkdtemp(prefix='sqlmap-audit-matrix-'))
print('Logs: %s' % logs, flush=True)
def command(argv, data=None, check=True, timeout=240):
result = subprocess.run(argv, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout)
if check and result.returncode:
raise AssertionError('%s\n%s\n%s' % (' '.join(argv), result.stdout, result.stderr))
return result
def sql(engine, account, query, check=True):
result = command(compose + ['exec', '-T', 'web-' + engine, 'python', '/lab/probe.py', account], query, check)
return json.loads(result.stdout) if result.returncode == 0 else None
def native(engine, account, statement=False):
dbms = ENGINES[engine][0]
set_dbms(dbms)
# MariaDB is a MySQL plugin fork; native probes don't run the normal fingerprint.
original_fork = Backend.isFork
Backend.isFork = staticmethod(lambda fork: engine == 'mariadb' and fork == FORK.MARIADB)
original_direct = conf.direct
conf.direct = statement
original = inject.getValue
calls = []
def getValue(query, **kwargs):
assert query.startswith('SELECT ')
calls.append(query)
rows = sql(engine, account, query, check=False)
return unArrayizeValue(rows) if kwargs.get("expected") == EXPECTED.INT else rows
inject.getValue = getValue
try:
result = Databases().getAudit()
assert calls
assert not any(line.startswith('OS boundary:') for line in result)
return sorted(set(result))
finally:
conf.direct = original_direct
inject.getValue = original
Backend.isFork = staticmethod(original_fork)
def scan(engine, account, technique, expected):
address = command(compose + ['port', 'web-' + engine, '8080']).stdout.strip()
url = 'http://%s/%s/get_int?id=1' % (address, account)
label = '%s-%s-%s' % (engine, account, technique)
cmd = [sys.executable, str(ROOT / 'sqlmap.py'), '-u', url,
'--dbms=' + ENGINES[engine][0], '--batch', '--technique=' + technique,
'--threads=5', '--audit', '--skip-waf', '--disable-precon', '--flush-session',
'--output-dir=' + str(logs / label)]
result = command(cmd, check=False, timeout=900)
(logs / (label + '.log')).write_text(result.stdout + result.stderr)
assert result.returncode == 0, label
audit_output = re.search(r'^database security audit[^\n]*\n((?:\[\*\] [^\n]*\n)+)', result.stdout, re.M)
assert audit_output, label
actual = sorted(set(re.findall(r'^\[\*\] (.*)$', audit_output.group(1), re.M)))
assert actual == expected, '%s: missing=%r extra=%r' % (label, sorted(set(expected)-set(actual)), sorted(set(actual)-set(expected)))
print('SQLi %s PASS' % label, flush=True)
try:
if not args.reuse:
command(compose + ['build', 'web-mysql'])
command(compose + ['up', '-d'])
with concurrent.futures.ThreadPoolExecutor(max_workers=7) as pool:
futures = [pool.submit(command, compose + ['exec', '-T', 'web-' + engine, 'python', '/lab/seed.py']) for engine in ENGINES]
for future in futures:
future.result()
expected = {}
for engine, (dbms, admin) in ENGINES.items():
for alias, account in (('admin', admin), ('app', 'audit_app'), ('limited', 'audit_limited')):
snapshot_query = getSQLSnippet(dbms, 'audit_configuration_mariadb' if engine == 'mariadb' else 'audit_configuration', LIMIT=str(AUDIT_MAX_ROWS))
before = sql(engine, admin, snapshot_query) if alias == 'admin' else None
result = native(engine, account)
if alias == 'admin':
assert sql(engine, admin, snapshot_query) == before, 'audit changed configuration'
expected[engine, alias] = result
(logs / ('%s-%s-native.json' % (engine, alias))).write_text(json.dumps(result, indent=2))
assert any('/ effective account:' in line or '/ effective login:' in line for line in result)
if alias == 'admin':
assert not any(re.match(r'^(identity|configuration|privileges|file access): retrieval failed', line) for line in result), (engine,result)
if engine == 'postgres' and alias == 'app':
assert 'privileges / pg_read_server_files: yes' in result
assert 'privileges / pg_execute_server_program: no' in result
if engine == 'mssql' and alias == 'limited':
assert 'configuration: SELECT permission missing on master.sys.configurations' in result
assert 'linked servers: SELECT permission missing on master.sys.servers' in result
if engine == 'oracle' and alias == 'app':
assert any('AUDIT_FILES [READ]' in line for line in result)
if engine == 'oracle' and alias == 'limited':
assert 'directory grants / visible directory grants: 0' in result
assert any(line.startswith('configuration: retrieval failed') for line in result)
print('Native %s/%s PASS' % (engine,alias), flush=True)
# Database root with FILE still cannot read files denied to the service OS user.
for engine in ('mysql', 'mariadb', 'mysql-open', 'mysql-disabled'):
container = command(compose + ['ps', '-q', engine]).stdout.strip()
command(['docker','exec','-u','0',container,'sh','-c',
'mkdir -p /var/lib/mysql-files; printf AUDIT_READABLE > /var/lib/mysql-files/readable.txt; '
'printf AUDIT_PRIVATE > /var/lib/mysql-files/root-only.txt; '
'printf AUDIT_OUTSIDE > /tmp/audit-outside.txt; '
'chmod 644 /var/lib/mysql-files/readable.txt /tmp/audit-outside.txt; '
'chmod 600 /var/lib/mysql-files/root-only.txt'])
readable = sql(engine, 'root', "SELECT LOAD_FILE('/var/lib/mysql-files/readable.txt')")[0][0]
private = sql(engine, 'root', "SELECT LOAD_FILE('/var/lib/mysql-files/root-only.txt')")[0][0]
outside = sql(engine, 'root', "SELECT LOAD_FILE('/tmp/audit-outside.txt')")[0][0]
assert private is None
assert command(['docker','exec','-u','mysql',container,'test','-r','/var/lib/mysql-files/root-only.txt'],check=False).returncode != 0
if engine == 'mysql-disabled':
assert readable is None and outside is None
assert any(line in expected[engine,'admin'] for line in ('file access / secure_file_priv: <NULL>', 'file access / secure_file_priv: NULL'))
assert any('secure_file_priv=NULL blocks' in line for line in expected[engine,'admin'])
elif engine == 'mysql':
assert readable is not None and outside is None
else:
assert readable is not None and outside is not None
assert ('file access / secure_file_priv: <NULL>' if engine == 'mariadb' else 'file access / secure_file_priv: <empty>') in expected[engine,'admin']
assert any('no directory restriction' in line for line in expected[engine,'admin'])
assert not any('OS boundary:' in line for line in expected[engine,'admin'])
print('Filesystem boundary %s PASS' % engine, flush=True)
# Changes are made by this fixture provisioner, never by --audit.
sql('postgres','postgres','REVOKE pg_read_server_files FROM audit_app')
changed = native('postgres','audit_app')
assert 'privileges / pg_read_server_files: no' in changed
sql('postgres','postgres','GRANT pg_read_server_files TO audit_app')
sql('mssql','sa',"EXEC sp_configure 'clr enabled',0; RECONFIGURE")
assert 'configuration / clr enabled: disabled' in native('mssql','audit_app')
sql('mssql','sa',"EXEC sp_configure 'clr enabled',1; RECONFIGURE")
sql('oracle','sys','REVOKE SELECT ON sys.v_$parameter FROM audit_app')
assert any(line.startswith('configuration: retrieval failed') for line in native('oracle','audit_app'))
sql('oracle','sys','GRANT SELECT ON sys.v_$parameter TO audit_app')
print('Grant/configuration changes reflected by audit PASS', flush=True)
# A successful zero-row SELECT must not be reported as a failed query.
from plugins.generic import audit as auditModule
original_snippet = auditModule.getSQLSnippet
auditModule.getSQLSnippet = lambda *args, **kwargs: "SELECT audit_key,audit_value FROM (SELECT 'empty' AS audit_key,'Vempty' AS audit_value) audit_rows WHERE 1=0"
try:
assert native('mysql','root') == ['configuration: no entries', 'file access: no entries', 'identity: no entries', 'privileges: no entries']
finally:
auditModule.getSQLSnippet = original_snippet
print('Confirmed empty metadata query PASS', flush=True)
# Independent semantics: row_security=off must fail, not bypass a policy.
sql('postgres','postgres', 'CREATE TABLE audit_rls (id INT)')
sql('postgres','postgres', 'INSERT INTO audit_rls VALUES (1),(2)')
sql('postgres','postgres', 'GRANT SELECT ON audit_rls TO audit_app')
sql('postgres','postgres', 'ALTER TABLE audit_rls ENABLE ROW LEVEL SECURITY')
sql('postgres','postgres', 'CREATE POLICY audit_policy ON audit_rls USING (id=1)')
assert sql('postgres','audit_app','SELECT * FROM audit_rls',check=False) is None
assert not any('bypasses row-level security' in line for line in native('postgres','audit_app'))
sql('postgres','postgres','ALTER ROLE audit_app BYPASSRLS')
assert sql('postgres','audit_app','SELECT COUNT(*) FROM audit_rls')[0][0] == 2
assert any('bypasses row-level security' in line for line in native('postgres','audit_app'))
sql('postgres','postgres','ALTER ROLE audit_app NOBYPASSRLS')
sql('postgres','postgres','DROP TABLE audit_rls')
sql('postgres','postgres','GRANT pg_execute_server_program TO audit_app')
no_route = native('postgres','audit_app')
assert any('COPY FROM PROGRAM privilege granted; no statement execution route identified' in line for line in no_route)
assert not any('--os-cmd' in line for line in no_route)
with_route = native('postgres','audit_app', statement=True)
assert any('COPY FROM PROGRAM privilege granted; use --os-cmd' in line for line in with_route)
if not args.native_only:
scan('postgres','app','U',no_route)
scan('postgres','app','BS',with_route)
sql('postgres','postgres','REVOKE pg_execute_server_program FROM audit_app')
print('Independent RLS and execution-route scenarios PASS', flush=True)
# The fixture creates real metadata beyond the limit and verifies exact totals.
for index in range(AUDIT_MAX_ROWS + 5):
sql('mssql','sa', "EXEC sp_addlinkedserver @server=N'audit_bound_%02d',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'bound.invalid'" % index)
hostile = "audit_control\n[*] forged\r\x1b[2J"
sql('mssql','sa', "EXEC sp_addlinkedserver @server=N'%s',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'control.invalid'" % hostile)
bounded = native('mssql','sa')
assert 'linked servers / visible linked servers: 27' in bounded
assert sum(line.startswith('linked servers / link ') for line in bounded) == AUDIT_MAX_ROWS
assert 'Linked servers: showing 20 of 27; use --links for the full list' in bounded
# The control-character name sorts outside the first details by server_id; test it separately below.
if not args.native_only:
scan('mssql','admin','U',bounded)
for index in range(AUDIT_MAX_ROWS + 5):
sql('mssql','sa', "EXEC sp_dropserver @server=N'audit_bound_%02d',@droplogins='droplogins'" % index)
hostile_result = native('mssql','sa')
assert any('audit_control\\n[*] forged\\r\\x1b[2J' in line for line in hostile_result)
assert not any('\n' in line or '\r' in line or '\x1b' in line for line in hostile_result)
if not args.native_only:
scan('mssql','admin','U',hostile_result)
sql('mssql','sa', "EXEC sp_dropserver @server=N'%s',@droplogins='droplogins'" % hostile)
for index in range(AUDIT_MAX_ROWS + 5):
path = "/tmp/audit-bound-%02d" % index if index else "/tmp/audit\n[*] forged\x1b[2J"
sql('oracle','sys', "CREATE DIRECTORY AUDIT_BOUND_%02d AS '%s'" % (index,path))
sql('oracle','sys', 'GRANT READ ON DIRECTORY AUDIT_BOUND_%02d TO audit_owner' % index)
bounded = native('oracle','audit_owner')
assert sum(line.startswith('directory grants / ') and '/ visible directory grants:' not in line for line in bounded) == AUDIT_MAX_ROWS
assert any(line.startswith('Directory grants: showing 20 of ') for line in bounded)
assert any('audit\\n[*] forged\\x1b[2J' in line for line in bounded)
if not args.native_only:
scan('oracle','admin','U',bounded)
for index in range(AUDIT_MAX_ROWS + 5):
sql('oracle','sys','DROP DIRECTORY AUDIT_BOUND_%02d' % index)
print('Bounded listings and control-character scenarios PASS', flush=True)
if not args.native_only:
jobs = []
for engine in ENGINES:
for alias in ('admin','app','limited'):
jobs.append((engine,alias,'U'))
if engine not in ('mysql-open','mysql-disabled'):
jobs.append((engine,'app','B'))
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
futures = [pool.submit(scan,engine,alias,technique,expected[engine,alias]) for engine,alias,technique in jobs]
for future in futures:
future.result()
print('Audit matrix PASS; logs: %s' % logs, flush=True)
finally:
if not args.keep:
command(compose + ['down','--volumes','--remove-orphans'])
if __name__ == '__main__':
main()
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python
"""Create test accounts and known grants only in the disposable audit databases."""
import time
from db import connect, engine
admin = {'mysql': 'root', 'mariadb': 'root', 'postgres': 'postgres', 'mssql': 'sa', 'oracle': 'sys'}[engine]
for attempt in range(120):
try:
conn = connect(admin)
break
except Exception:
if attempt == 119:
raise
time.sleep(1)
cursor = conn.cursor()
def execute(sql):
cursor.execute(sql)
if engine in ('mysql', 'mariadb'):
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
for user in ('audit_app', 'audit_limited'):
execute("CREATE USER '%s'@'%%' IDENTIFIED BY 'AuditOnly!2026'" % user)
execute("GRANT SELECT ON auditlab.* TO '%s'@'%%'" % user)
execute("GRANT SELECT ON performance_schema.* TO 'audit_app'@'%'")
execute("GRANT FILE ON *.* TO 'audit_app'@'%'")
elif engine == 'postgres':
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
for user in ('audit_app', 'audit_limited'):
execute("CREATE USER %s WITH PASSWORD 'AuditOnly!2026'" % user)
execute('GRANT SELECT ON users TO %s' % user)
execute('GRANT pg_read_server_files TO audit_app')
execute('GRANT pg_write_server_files TO audit_app')
execute("ALTER ROLE audit_app SET row_security=off")
elif engine == 'mssql':
execute('CREATE TABLE users (id INT PRIMARY KEY,name VARCHAR(100),surname VARCHAR(100))')
for user in ('audit_app', 'audit_limited'):
execute("CREATE LOGIN %s WITH PASSWORD='AuditOnly!2026',CHECK_POLICY=OFF" % user)
execute('CREATE USER %s FOR LOGIN %s' % (user,user))
execute('GRANT SELECT ON users TO %s' % user)
execute('DENY SELECT ON OBJECT::sys.configurations TO audit_limited')
execute('DENY SELECT ON OBJECT::sys.servers TO audit_limited')
execute("EXEC sp_configure 'show advanced options',1")
execute('RECONFIGURE')
execute("EXEC sp_configure 'clr enabled',1")
execute('RECONFIGURE')
execute("EXEC sp_addlinkedserver @server=N'audit_reporting',@srvproduct=N'',@provider=N'MSOLEDBSQL',@datasrc=N'reporting.invalid'")
execute("EXEC sp_serveroption @server=N'audit_reporting',@optname=N'rpc out',@optvalue=N'true'")
else:
execute('GRANT DBA TO audit_owner')
for user in ('audit_app', 'audit_limited'):
execute('CREATE USER %s IDENTIFIED BY "AuditOnly!2026"' % user)
execute('GRANT CREATE SESSION TO %s' % user)
execute('CREATE TABLE audit_owner.users (id NUMBER PRIMARY KEY,name VARCHAR2(100),surname VARCHAR2(100))')
for user in ('audit_app', 'audit_limited'):
execute('GRANT SELECT ON audit_owner.users TO %s' % user)
execute('CREATE SYNONYM %s.users FOR audit_owner.users' % user)
execute('CREATE SYNONYM system.users FOR audit_owner.users')
execute('GRANT SELECT ON sys.v_$parameter TO audit_app')
execute("CREATE DIRECTORY AUDIT_FILES AS '/tmp/audit-files'")
execute('GRANT READ ON DIRECTORY AUDIT_FILES TO audit_app')
execute('CREATE SYNONYM audit_owner.audit_fixture_users FOR audit_owner.users')
# Insert into the application schema; the SYS/SYSTEM account is only the fixture provisioner.
execute("INSERT INTO audit_owner.users VALUES (1,'luther','blisset')")
execute("INSERT INTO audit_owner.users VALUES (2,'fluffy','bunny')")
conn.commit()
if engine != 'oracle':
execute("INSERT INTO users VALUES (1,'luther','blisset')")
execute("INSERT INTO users VALUES (2,'fluffy','bunny')")
conn.close()
print('%s fixture seeded' % engine)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python
"""Deliberately injectable HTTP fixture. Run only on the isolated audit lab network."""
import html
from flask import Flask, request
from db import connect, engine
app = Flask(__name__)
@app.get('/<account>/get_int')
def query(account):
accounts = {'admin': {'mysql': 'root', 'mariadb': 'root', 'postgres': 'postgres', 'mssql': 'sa', 'oracle': 'audit_owner'},
'app': dict.fromkeys(('mysql', 'mariadb', 'postgres', 'mssql', 'oracle'), 'audit_app'),
'limited': dict.fromkeys(('mysql', 'mariadb', 'postgres', 'mssql', 'oracle'), 'audit_limited')}
if account not in accounts:
return 'unknown account', 404
conn = None
try:
conn = connect(accounts[account][engine], pooled=True)
cursor = conn.cursor()
cursor.execute('SELECT id,name,surname FROM users WHERE id=%s' % request.args.get('id', '1'))
rows = cursor.fetchall()
body = '<html><body><table>'
for row in rows:
body += '<tr>' + ''.join('<td>%s</td>' % html.escape('' if value is None else str(value)) for value in row) + '</tr>'
return body + '</table></body></html>'
except Exception as ex:
# Render the driver's message, not Python's repr of its argument tuple.
message = next((value for value in ex.args if isinstance(value, str)), str(ex))
return '<html><body>%s</body></html>' % html.escape(message)
finally:
if conn is not None:
conn.close()
app.run(host='0.0.0.0', port=8080, threaded=True)
+21
View File
@@ -186,6 +186,12 @@ class TestPrefixSuffix(unittest.TestCase):
class TestGetFields(unittest.TestCase):
def test_multiline_scalar_literal(self):
field = "HEX('a;\n b')"
result = agent.getFields("SELECT %s" % field)
self.assertEqual(result[5], [field])
self.assertEqual(result[6], field)
def test_extracts_select_list(self):
# getFields(query) returns an 8-tuple; the fields-bearing slots are:
# [0],[1] = regex match objects for the SELECT/expression (must be found, not None)
@@ -556,6 +562,21 @@ class TestCleanupPayloadOrigValue(DbmsStateMixin, unittest.TestCase):
out = agent.cleanupPayload("p=[ORIGINAL]", origValue="raw")
self.assertEqual(out, "p=raw")
def test_origvalue_identifier_raw_for_group_order_by(self):
self.assertEqual(agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[2, 3]), "p.name")
self.assertIn("'p.name'", agent.cleanupPayload("[ORIGVALUE]", origValue="p.name", clause=[1, 2, 3]))
def test_random_values_avoid_string_marker(self):
# a reflected [RANDNUM] (e.g. quoted back in a DBMS error message) containing the --string
# marker would fake a True response (e.g. --string=98 vs. 8988)
saved = conf.string
conf.string = "9"
try:
for _ in range(200):
self.assertNotIn("9", agent.cleanupPayload("[RANDNUM] [RANDNUM1]"))
finally:
conf.string = saved
def test_space_replace_marker(self):
out = agent.cleanupPayload("a[SPACE_REPLACE]b")
self.assertEqual(out, "a%sb" % kb.chars.space)
+78
View File
@@ -30,6 +30,7 @@ bootstrap()
import lib.utils.api as api
from lib.core.data import conf
from lib.core.data import kb
from lib.core.convert import encodeBase64
from lib.core.enums import CONTENT_STATUS, CONTENT_TYPE
from thirdparty.bottle.bottle import default_app
@@ -308,6 +309,63 @@ class TestStdDbOutAndLogRecorder(unittest.TestCase):
self.assertEqual(rows[0][2], "hello world")
class TestStdDbOutMultiTarget(unittest.TestCase):
"""
Regression coverage for issue #2503: a multi-target scan (crawl or '-m' bulk
file) inside a single REST API task runs every target through one StdDbOut
instance. StdDbOut.write() keys each target's data rows by kb.reportTaskId
('<taskid>-<n>'), so a later target's COMPLETE row cannot delete an earlier
target's rows of the same content_type in _storeData() - mirroring the
--report-json fix for #6123 (see test_report.py's TestMultiTargetTaskId).
"""
def setUp(self):
self.db = api.Database(":memory:")
self.db.connect("client")
self.db.init()
self._saved_stdout = sys.stdout
self._saved_cursor = conf.get("databaseCursor")
self._saved_partRun = kb.get("partRun")
self._had_reportTaskId = "reportTaskId" in kb
self._saved_reportTaskId = kb.get("reportTaskId")
conf.databaseCursor = self.db
kb.partRun = None
def tearDown(self):
sys.stdout = self._saved_stdout
conf.databaseCursor = self._saved_cursor
kb.partRun = self._saved_partRun
if self._had_reportTaskId:
kb.reportTaskId = self._saved_reportTaskId
elif "reportTaskId" in kb:
del kb.reportTaskId
self.db.disconnect()
def test_second_target_does_not_overwrite_first(self):
std = api.StdDbOut("t1", messagetype="stdout")
sys.stdout = self._saved_stdout # StdDbOut hijacked it; call write() directly
kb.reportTaskId = 1
std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
kb.reportTaskId = 2
std.write({"url": "http://host2/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
rows = self.db.execute("SELECT taskid FROM data ORDER BY id")
self.assertEqual([row[0] for row in rows], ["t1-1", "t1-2"]) # neither target's row was deleted
def test_without_report_task_id_keeps_plain_taskid(self):
# e.g. output written before controller.start() reaches its target loop
if "reportTaskId" in kb:
del kb.reportTaskId
std = api.StdDbOut("t1", messagetype="stdout")
sys.stdout = self._saved_stdout
std.write({"url": "http://host1/?id=1"}, status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.TARGET)
rows = self.db.execute("SELECT taskid FROM data")
self.assertEqual([row[0] for row in rows], ["t1"])
# ---------------------------------------------------------------------------
# HTTP routes (WSGI test client)
# ---------------------------------------------------------------------------
@@ -485,6 +543,26 @@ class TestScanQueryRoutes(_ApiServerCase):
self.assertFalse(parsed["success"])
class TestScanDataMultiTarget(_ApiServerCase):
"""scan_data() folds the per-target '<taskid>-<n>' rows (see StdDbOut.write) back into the task's flat result, in target order."""
def test_data_merges_per_target_rows(self):
taskid = self._new_task()
api._storeData(self.db, "%s-2" % taskid, {"url": "http://host2/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
api._storeData(self.db, "%s-1" % taskid, {"url": "http://host1/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid)
self.assertEqual(code, 200)
self.assertTrue(parsed["success"])
self.assertEqual([d["value"]["url"] for d in parsed["data"]], ["http://host1/?id=1", "http://host2/?id=1"])
def test_data_ignores_other_tasks_rows(self):
taskid = self._new_task()
api._storeData(self.db, "othertask-1", {"url": "http://other/?id=1"}, CONTENT_STATUS.COMPLETE, CONTENT_TYPE.TARGET)
code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid)
self.assertTrue(parsed["success"])
self.assertEqual(parsed["data"], [])
class TestScanStart(_ApiServerCase):
"""scan_start, with the subprocess-spawning seam (engine_start) monkeypatched."""
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Audit acquisition preserves NULL/empty/unknown states and interpretation boundaries.
Real DBMS/SQLi coverage is opt-in under tests/integration/audit/.
"""
import importlib
import os
import subprocess
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap, set_dbms, reset_dbms
bootstrap()
from lib.core.common import getSQLSnippet
from lib.core.data import conf, kb, paths, queries
from lib.core.enums import DBMS, FORK, EXPECTED, PAYLOAD
from lib.core.settings import DBMS_DIRECTORY_DICT
from lib.request import inject
from plugins.generic.audit import Audit
from plugins.generic.databases import Databases
tearDownModule = reset_dbms
class TestAudit(unittest.TestCase):
def setUp(self):
self._saved = dict(conf)
self._cache = kb.data.get('cachedAudit')
self._getValue = inject.getValue
self._forced = kb.get('forcedDbms')
self._injection = kb.injection.data
self._gadget = kb.get("gadget")
kb.injection.data = {}
kb.gadget = None
conf.direct = False
kb.data.cachedAudit = []
set_dbms(DBMS.MYSQL)
def tearDown(self):
kb.injection.data = self._injection
kb.gadget = self._gadget
inject.getValue = self._getValue
kb.data.cachedAudit = self._cache
kb.forcedDbms = self._forced
conf.clear()
conf.update(self._saved)
def test_preserves_null_empty_and_marker_like_values(self):
inject.getValue = lambda *a, **k: [['null', 'N'], ['empty', 'V'], ['text', 'VNULL'], ['prefix', 'VVvalue'], ['padded ', 'Vkeep '], ['trailing\n', 'Vvalue']]
audit = Audit()
audit.dbms = DBMS.MYSQL
self.assertEqual(audit._auditFetch('audit_identity'), {'null': None, 'empty': '', 'text': 'NULL', 'prefix': 'Vvalue', 'padded': 'keep ', 'trailing\n': 'value'})
def test_single_row_and_failed_results(self):
audit = Audit()
audit.dbms = DBMS.MYSQL
for value, expected in ((['name', 'Vvalue'], {'name': 'value'}), (None, None), ([], None), (['truncated'], None), ([['key', 'invalid']], None)):
inject.getValue = lambda *a, **k: value
self.assertEqual(audit._auditFetch('audit_identity'), expected)
def test_major_dbmses_dispatch_without_xml_audit_blocks(self):
inject.getValue = lambda *a, **k: [['effective account', 'Vtest']]
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
d = Databases()
self.assertNotIn('audit', queries[dbms])
result = d.getAudit()
self.assertTrue(any('test' in item for item in result))
self.assertFalse(any('OS boundary:' in item for item in result))
def test_unsupported_dbmses_do_not_query(self):
inject.getValue = lambda *a, **k: self.fail('unsupported DBMS must not query')
for dbms in queries:
if dbms not in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
self.assertEqual(Databases().getAudit(), [])
def test_failures_are_not_reported_as_disabled(self):
inject.getValue = lambda *a, **k: None
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
result = Databases().getAudit()
self.assertTrue(any('retrieval failed' in item for item in result))
self.assertFalse(any(item.endswith(': disabled') for item in result))
def test_cache_and_bulk_queries_use_existing_extraction(self):
calls = []
def getValue(query, **kwargs):
self.assertTrue(kwargs.get('fromUser'))
self.assertTrue(kwargs.get('batch'))
self.assertNotIn('\n', query)
self.assertNotIn('\r', query)
calls.append(query)
return [['effective account', 'Vtest']]
inject.getValue = getValue
d = Databases()
result = d.getAudit()
count = len(calls)
self.assertEqual(count, 4)
self.assertEqual(d.getAudit(), result)
self.assertEqual(len(calls), count)
def test_mysql_directory_null_and_empty_are_different(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
audit = MySQLAudit()
cases = ((None, 'blocks'), ('NULL', 'blocks'), ('', 'no directory restriction'), ('/var/lib/mysql-files/', 'must be inside'))
for value, expected in cases:
result = audit.explain({'file access': {'secure_file_priv': value}})
self.assertTrue(any(expected in item for item in result))
self.assertFalse(any('must be inside' in item for item in audit.explain({})))
def test_mariadb_selects_fork_specific_configuration(self):
from lib.core.common import Backend
from plugins.dbms.mysql.audit import Audit as MySQLAudit
original = Backend.isFork
Backend.isFork = staticmethod(lambda fork: fork == FORK.MARIADB)
try:
self.assertEqual(MySQLAudit().queries[1][1][0], 'audit_configuration_mariadb')
self.assertFalse(any('blocks' in item for item in MySQLAudit().explain({'file access': {'secure_file_priv': None}})))
finally:
Backend.isFork = staticmethod(original)
def test_mssql_explicit_permission_denial(self):
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
audit = MSSQLAudit()
groups = {'permissions': {'read configuration metadata': '0'}}
self.assertEqual(audit.failure('configuration', groups), 'configuration: SELECT permission missing on master.sys.configurations')
self.assertEqual(audit.failure('linked servers', groups), 'linked servers: retrieval failed')
def test_guidance_requires_retrieved_capabilities(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
from plugins.dbms.oracle.audit import Audit as OracleAudit
for audit in (MySQLAudit(), PostgreSQLAudit(), MSSQLAudit(), OracleAudit()):
self.assertEqual(audit.explain({}), [])
self.assertEqual(MySQLAudit().explain({'privileges': {'direct FILE grant': '0'}}), [])
result = MySQLAudit().explain({'file access': {'secure_file_priv': '/allowed/', 'max_allowed_packet': '1024'}, 'privileges': {'direct FILE grant': '1'}})
self.assertTrue(any('must be inside' in item for item in result))
self.assertTrue(any('file size < 1024 bytes' in item for item in result))
result = MySQLAudit().explain({'file access': {'secure_file_priv': None}, 'privileges': {'direct FILE grant': '1'}})
self.assertFalse(any('FILE granted; use' in item for item in result))
result = PostgreSQLAudit().explain({'privileges': {'superuser': '0', 'pg_read_server_files': '1', 'pg_execute_server_program': '0'}})
self.assertTrue(any('--file-read' in item for item in result))
self.assertFalse(any('--os-cmd' in item for item in result))
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '0'}})
self.assertTrue(any('EXECUTE permission missing' in item for item in result))
self.assertFalse(any('--os-cmd' in item for item in result))
conf.direct = True
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '0'}, 'identity': {'sysadmin': '1'}})
self.assertTrue(any('can enable xp_cmdshell' in item for item in result))
result = MSSQLAudit().explain({'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '1'}})
self.assertTrue(any('--os-cmd' in item for item in result))
result = PostgreSQLAudit().explain({'privileges': {'pg_execute_server_program': '1'}})
self.assertTrue(any('--os-cmd' in item for item in result))
def test_execution_advice_uses_known_routes_without_probing(self):
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
inject.getValue = lambda *a, **k: self.fail('guidance must not send probes')
pg = {'privileges': {'pg_execute_server_program': '1'}}
ms = {'configuration': {'xp_cmdshell': '1'}, 'permissions': {'execute xp_cmdshell': '1'}}
for audit, groups in ((PostgreSQLAudit(), pg), (MSSQLAudit(), ms)):
for route in ('none', 'stacked', 'gadget', 'direct'):
kb.injection.data = {PAYLOAD.TECHNIQUE.STACKED: {'title': 'stacked queries'}} if route == 'stacked' else {}
kb.gadget = {'command': 'known gadget'} if route == 'gadget' else None
conf.direct = route == 'direct'
result = audit.explain(groups)
self.assertEqual(any('--os-cmd' in item for item in result), route != 'none')
self.assertEqual(any('no statement execution route identified' in item for item in result), route == 'none')
def test_rls_setting_does_not_grant_bypass(self):
from plugins.dbms.postgresql.audit import Audit as PostgreSQLAudit
result = PostgreSQLAudit().explain({'configuration': {'row_security': 'off'}, 'privileges': {'superuser': '0', 'bypass RLS': '0'}})
self.assertFalse(any('bypasses' in item for item in result))
result = PostgreSQLAudit().explain({'privileges': {'bypass RLS': '1'}})
self.assertTrue(any('bypasses' in item for item in result))
def test_empty_result_requires_confirmed_zero_count(self):
audit = Audit()
audit.dbms = DBMS.MYSQL
for count, expected in ((0, {}), ('0', {}), ([[0]], {}), (None, None), ('1', None)):
calls = []
def getValue(query, **kwargs):
calls.append(query)
return count if kwargs.get('expected') == EXPECTED.INT else None
inject.getValue = getValue
self.assertEqual(audit._auditFetch('audit_identity'), expected)
self.assertEqual(len(calls), 2)
audit.queries = (('empty scope', ('audit_identity',)),)
inject.getValue = lambda query, **kwargs: 0 if kwargs.get('expected') == EXPECTED.INT else None
self.assertEqual(audit.getAudit(), ['empty scope: no entries'])
def test_control_characters_are_escaped_in_facts_and_guidance(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
value = "/allowed/\n[*] forged\r\x1b[2J"
inject.getValue = lambda query, **kwargs: [['secure_file_priv', 'V' + value]] if '@@GLOBAL.secure_file_priv' in query else [['name\nforged', 'Vsafe']]
result = MySQLAudit().getAudit()
self.assertTrue(any('\\n[*] forged' in item for item in result))
self.assertFalse(any(any(char in item for char in ('\n', '\r', '\x1b')) for item in result))
def test_listing_summary_reports_displayed_and_total(self):
from plugins.dbms.mssqlserver.audit import Audit as MSSQLAudit
from plugins.dbms.oracle.audit import Audit as OracleAudit
links = {'visible linked servers': '200', 'link one': 'destination'}
self.assertIn('Linked servers: showing 1 of 200; use --links for the full list', MSSQLAudit().explain({'linked servers': links}))
grants = {'visible directory grants': '200', 'ONE [READ]': '/one'}
self.assertEqual(OracleAudit().explain({'directory grants': grants}), ['Directory grants: showing 1 of 200'])
def test_only_observed_settings_are_listed_once(self):
from plugins.dbms.mysql.audit import Audit as MySQLAudit
def getValue(query, **kwargs):
if '@@GLOBAL.secure_file_priv' in query:
return [['secure_file_priv', 'V/allowed/']]
return [['local_infile', 'VOFF']]
inject.getValue = getValue
result = MySQLAudit().getAudit()
self.assertFalse(any('super_read_only' in item for item in result))
self.assertEqual(sum('/ secure_file_priv:' in item for item in result), 1)
self.assertFalse(any('not established' in item or 'does not establish' in item for item in result))
def test_snippets_are_single_read_only_statements(self):
for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE):
set_dbms(dbms)
audit = importlib.import_module('plugins.dbms.%s.audit' % DBMS_DIRECTORY_DICT[dbms]).Audit()
for scope, snippets in audit.queries:
for snippet in snippets:
sql = getSQLSnippet(dbms, snippet, LIMIT="20")
self.assertTrue(sql.startswith('SELECT '))
self.assertNotIn(';', sql.replace('; RPC out: ', ''))
self.assertIn('audit_value', sql)
def test_help_is_one_line(self):
output = subprocess.check_output([sys.executable, os.path.join(paths.SQLMAP_ROOT_PATH, 'sqlmap.py'), '-hh'])
lines = [line for line in output.decode('utf-8').splitlines() if '--audit' in line]
self.assertEqual(len(lines), 1)
self.assertLessEqual(len(lines[0]), 78)
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Authentication refresh hook loading and replay against a local HTTP server.
"""
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.core.data import conf, kb, paths, cmdLineOptions
from lib.core.enums import MKSTEMP_PREFIX, REDIRECTION
from lib.core.exception import SqlmapConnectionException, SqlmapFilePathException, SqlmapSyntaxException
from lib.core.option import _setAuthRefreshFunction, _setKnowledgeBaseAttributes
from lib.core.threads import getCurrentThreadData
from lib.request import authrefresh
from lib.request.connect import Connect
from lib.request.redirecthandler import SmartRedirectHandler
from thirdparty.six.moves import BaseHTTPServer, socketserver
from thirdparty.six.moves import urllib as _urllib
_GET_PAGE = Connect.__dict__['getPage']
_QUERY_PAGE = Connect.__dict__['queryPage']
def _assertTemplate(testCase, exception):
match = re.search(r"template script at '([^']+)'", str(exception))
testCase.assertIsNotNone(match)
path = match.group(1)
try:
testCase.assertTrue(os.path.basename(path).startswith(MKSTEMP_PREFIX.AUTH_REFRESH))
testCase.assertTrue(os.path.isfile(os.path.join(os.path.dirname(path), '__init__.py')))
with open(path) as stream:
source = stream.read()
namespace = {}
eval(compile(source, path, 'exec'), namespace)
testCase.assertEqual(namespace['refresh'](), {'Authorization': 'Bearer <fresh token>'})
testCase.assertNotIn(paths.SQLMAP_EXTRAS_PATH, path)
finally:
os.unlink(path)
class _Server(socketserver.ThreadingMixIn, BaseHTTPServer.HTTPServer):
daemon_threads = True
class _Handler(BaseHTTPServer.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
self.do_POST()
def do_POST(self):
body = self.rfile.read(int(self.headers.get('Content-Length', '0')))
token = self.headers.get('X-Auth-Token')
if self.path == '/refresh':
self.server.refreshes += 1
code, body = 200, b'{"token":"fresh"}'
else:
with self.server.lock:
self.server.requests.append((self.path, body, token))
if token != self.server.token:
self.server.expired += 1
if self.server.expired >= 2:
self.server.bothExpired.set()
if token != self.server.token and self.server.syncExpired:
self.server.bothExpired.wait(3)
code = 200 if token == self.server.token else 401
body = b'ok' if code == 200 else b'expired'
self.send_response(302 if self.path == '/redirect' and code == 200 else code)
if self.path == '/redirect' and code == 200:
self.send_header('Location', self.server.redirect)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
class TestAuthRefresh(unittest.TestCase):
def setUp(self):
self.conf = dict(conf)
self.cmdLineOptions = dict(cmdLineOptions)
cmdLineOptions.method = None
self.kb = dict(kb)
self.threadData = dict(getCurrentThreadData().__dict__)
getCurrentThreadData().__dict__.clear()
getCurrentThreadData().reset()
self.timeout = socket.getdefaulttimeout()
self.urlopen = _urllib.request.urlopen
# Other engine tests replace these descriptors; use the real methods even when an
# earlier Python 2 fixture restored a bare function instead of a staticmethod.
self.methods = Connect.__dict__['getPage'], Connect.__dict__['queryPage']
Connect.getPage, Connect.queryPage = _GET_PAGE, _QUERY_PAGE
_setKnowledgeBaseAttributes(False)
kb.wizardMode = True
self.server = _Server(('127.0.0.1', 0), _Handler)
self.server.requests = []
self.server.refreshes = 0
self.server.token = 'fresh'
self.server.expired = 0
self.server.syncExpired = False
self.server.bothExpired = threading.Event()
self.server.lock = threading.Lock()
self.thread = threading.Thread(target=self.server.serve_forever)
self.thread.daemon = True
self.thread.start()
conf.url = 'http://127.0.0.1:%d/test' % self.server.server_port
conf.hostname, conf.scheme, conf.port = '127.0.0.1', 'http', self.server.server_port
conf.path = '/test'
conf.httpHeaders = [('X-Auth-Token', 'expired')]
conf.parameters = {}
conf.data = None
conf.abortCode = []
conf.ignoreCode = []
conf.authRefresh = 'test_refresh.py'
conf.batch = True
conf.keepAlive = False
conf.retryOn = None
conf.timeout = 5
conf.ignoreRedirects = False
conf.scope = None
kb.choices.redirect = REDIRECTION.YES
_urllib.request.urlopen = _urllib.request.build_opener(_urllib.request.ProxyHandler({}), SmartRedirectHandler()).open
def refresh():
# A real, application-specific token exchange outside sqlmap's request engine.
response = _urllib.request.urlopen(conf.url.replace('/test', '/refresh'), timeout=3)
try:
return {'X-Auth-Token': json.loads(response.read().decode('ascii'))['token']}
finally:
response.close()
kb.authRefreshFunction = refresh
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(5)
_urllib.request.urlopen = self.urlopen
Connect.getPage, Connect.queryPage = self.methods
socket.setdefaulttimeout(self.timeout)
conf.clear()
conf.update(self.conf)
cmdLineOptions.clear()
cmdLineOptions.update(self.cmdLineOptions)
kb.clear()
kb.update(self.kb)
getCurrentThreadData().__dict__.clear()
getCurrentThreadData().__dict__.update(self.threadData)
def test_expiry_mid_scan_preserves_payload_and_reuses_headers(self):
self.server.token = 'expired'
self.assertEqual(Connect.getPage()[2], 200)
self.assertEqual(self.server.refreshes, 0)
self.server.token = 'fresh'
payload = b'id=1%20AND%201%3D1&other=keep'
self.assertEqual(Connect.getPage(get='marker=123', post=payload)[2], 200)
self.assertEqual(self.server.requests[-2:], [('/test?marker=123', payload, 'expired'), ('/test?marker=123', payload, 'fresh')])
self.assertEqual(Connect.getPage()[2], 200)
self.assertEqual(self.server.refreshes, 1)
self.assertEqual(getCurrentThreadData().lastCode, 200)
self.assertEqual(getCurrentThreadData().lastPage, 'ok')
self.assertNotEqual(kb.originalCode, 401)
def test_second_401_stops_after_one_replay(self):
kb.authRefreshFunction = lambda: {'X-Auth-Token': 'still-expired'}
with self.assertRaises(SqlmapConnectionException):
Connect.getPage()
self.assertEqual(len(self.server.requests), 2)
self.assertTrue(kb.authRefreshFailed)
def test_abort_code_takes_precedence(self):
conf.abortCode = [401]
with self.assertRaises(SystemExit):
Connect.getPage()
self.assertEqual(self.server.refreshes, 0)
self.assertEqual(len(self.server.requests), 1)
def test_without_option_preserves_401_behavior(self):
conf.authRefresh = None
with self.assertRaises(SqlmapConnectionException):
Connect.getPage()
self.assertEqual(self.server.refreshes, 0)
def test_concurrent_401s_share_one_refresh(self):
self.server.syncExpired = True
results, errors = [], []
def worker():
try:
results.append(Connect.getPage()[2])
except Exception as ex:
errors.append(ex)
workers = [threading.Thread(target=worker) for _ in range(2)]
for workerThread in workers:
workerThread.start()
for workerThread in workers:
workerThread.join(10)
self.assertFalse(workerThread.is_alive())
self.assertEqual(errors, [])
self.assertEqual(results, [200, 200])
self.assertEqual(self.server.refreshes, 1)
self.assertEqual(self.server.expired, 2)
def test_invalid_hook_results_are_rejected_without_leaking_secrets(self):
for result in (None, {}, 'secret-token', {'X-Token': 'secret\r\nInjected: value'}, {'Host': 'elsewhere'}, {'X-Token': 42}, {'X-Token': 'secret', 'x-token': 'other'}):
kb.authRefreshFailed = False
kb.authRefreshFunction = lambda: result
with self.assertRaises(SqlmapConnectionException) as caught:
authrefresh.refresh(0)
self.assertNotIn('secret', str(caught.exception))
_assertTemplate(self, caught.exception)
self.assertEqual(kb.authRefreshVersion, 0)
def test_hook_exception_is_sanitized(self):
def refresh():
raise ValueError('password=secret')
kb.authRefreshFunction = refresh
with self.assertRaises(SqlmapConnectionException) as caught:
Connect.getPage()
self.assertNotIn('secret', str(caught.exception))
_assertTemplate(self, caught.exception)
self.assertEqual(len(self.server.requests), 1)
def test_credentials_are_scoped_to_scheme_host_and_port(self):
authrefresh.refresh(0)
for url in ('http://elsewhere/test', 'https://127.0.0.1/test', 'http://127.0.0.1:1/test'):
req = _urllib.request.Request(url)
self.assertIsNone(authrefresh.apply(req))
self.assertEqual(req.headers, {})
self.assertTrue(authrefresh.sameOrigin('https://example.com', 'https://example.com:443/path'))
def test_refresh_overrides_stale_preprocess_headers(self):
kb.preprocessFunctions = [lambda req: req.add_header('X-Auth-Token', 'expired')]
self.assertEqual(Connect.getPage()[2], 200)
self.assertEqual(self.server.refreshes, 1)
def test_redirect_to_another_port_does_not_receive_refreshed_token(self):
authrefresh.refresh(0)
other = _Server(('127.0.0.1', 0), _Handler)
other.requests, other.token, other.expired = [], None, 0
other.lock = threading.Lock()
other.syncExpired = False
thread = threading.Thread(target=other.serve_forever)
thread.daemon = True
thread.start()
try:
self.server.redirect = 'http://127.0.0.1:%d/landing' % other.server_port
Connect.getPage(url=conf.url.replace('/test', '/redirect'))
self.assertEqual(other.requests, [('/landing', b'', None)])
self.assertEqual(self.server.refreshes, 1)
finally:
other.shutdown()
other.server_close()
thread.join(5)
def test_auth_refresh_latency_is_excluded_from_query_timing(self):
import lib.request.connect as connect
originalPage, originalDelta = Connect.getPage, connect.calculateDeltaSeconds
def getPage(**kwargs):
getCurrentThreadData().authRefreshTime += 12
return 'ok', None, 200
Connect.getPage = staticmethod(getPage)
connect.calculateDeltaSeconds = lambda start: 15
try:
Connect.queryPage(content=True)
self.assertEqual(getCurrentThreadData().lastQueryDuration, 3)
finally:
Connect.getPage = staticmethod(originalPage)
connect.calculateDeltaSeconds = originalDelta
class TestAuthRefreshLoader(unittest.TestCase):
def setUp(self):
self.directory = tempfile.mkdtemp(prefix='sqlmap-authrefresh-')
self.oldOption = conf.authRefresh
self.oldFunction = kb.authRefreshFunction
self.oldPath = list(sys.path)
def tearDown(self):
conf.authRefresh = self.oldOption
kb.authRefreshFunction = self.oldFunction
sys.path[:] = self.oldPath
for name in list(sys.modules):
if name.startswith('authrefresh_test_'):
del sys.modules[name]
shutil.rmtree(self.directory)
def script(self, name, content):
path = os.path.join(self.directory, 'authrefresh_test_%s.py' % name)
with open(path, 'w') as stream:
stream.write(content)
conf.authRefresh = path
def test_load_does_not_call_refresh(self):
self.script('valid', "def refresh():\n raise AssertionError('must only run on 401')\n")
_setAuthRefreshFunction()
self.assertTrue(callable(kb.authRefreshFunction))
def test_missing_invalid_and_empty_scripts_point_to_template(self):
for name, source in (('empty', ''), ('wrong', 'def other():\n pass\n'), ('args', 'def refresh(req):\n pass\n'), ('syntax', 'def refresh(:')):
self.script(name, source)
with self.assertRaises(SqlmapSyntaxException) as caught:
_setAuthRefreshFunction()
_assertTemplate(self, caught.exception)
conf.authRefresh = os.path.join(self.directory, 'missing.py')
with self.assertRaises(SqlmapFilePathException) as caught:
_setAuthRefreshFunction()
_assertTemplate(self, caught.exception)
def test_help_is_one_line(self):
output = subprocess.check_output([sys.executable, os.path.join(paths.SQLMAP_ROOT_PATH, 'sqlmap.py'), '-hh'])
lines = output.decode('utf-8').splitlines()
matches = [i for i, line in enumerate(lines) if '--auth-refresh' in line]
self.assertEqual(len(matches), 1)
self.assertIn('HTTP 401', lines[matches[0]])
self.assertLessEqual(len(lines[matches[0]]), 78)
for option in ('--preprocess', '--postprocess'):
self.assertLess(matches[0], next(i for i, line in enumerate(lines) if option in line))
if __name__ == '__main__':
unittest.main()
+13
View File
@@ -524,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"
+9
View File
@@ -113,14 +113,23 @@ class TestConfigFileRoundTrip(unittest.TestCase):
try:
conf.url = "http://127.0.0.1:1/?id=1"
conf.commonTables = True
conf.getLinks = True
conf.getAudit = True
conf.authRefresh = "refresh_auth.py"
conf.tablePrefix = "zzz"
saveConfig(conf, path)
conf.url, conf.commonTables, conf.tablePrefix = None, False, "overwritten"
conf.getLinks = False
conf.getAudit = False
conf.authRefresh = None
configFileParser(path)
self.assertEqual(conf.url, "http://127.0.0.1:1/?id=1")
self.assertTrue(conf.commonTables)
self.assertTrue(conf.getLinks)
self.assertTrue(conf.getAudit)
self.assertEqual(conf.authRefresh, "refresh_auth.py")
self.assertEqual(conf.tablePrefix, "zzz")
finally:
conf.clear()
+129 -1
View File
@@ -17,6 +17,8 @@ the suite.
"""
import os
import sqlite3
import subprocess
import sys
import unittest
@@ -25,7 +27,7 @@ from _testutils import bootstrap, set_dbms, reset_dbms
bootstrap()
from lib.core.data import conf, kb
from lib.core.data import conf, kb, queries
from lib.core.enums import EXPECTED, PAYLOAD
import plugins.generic.databases as dbmod
from plugins.generic.databases import Databases
@@ -284,6 +286,28 @@ class TestGetTables(_BaseEnumTest):
self.assertIn("testdb", result)
self.assertEqual(sorted(result["testdb"]), sorted(tables))
def test_get_tables_inference_spanner(self):
# Spanner's blind template must page per row (it used to crash with "not all arguments converted", #6151)
set_dbms("Spanner")
self._enable_inference()
d = self._fresh()
conf.db = "testdb"
conf.tbl = None
tables = ["t_a", "t_b"]
queries_ = []
def gv(query, *a, **k):
if k.get("expected") == EXPECTED.INT:
return str(len(tables))
queries_.append(query)
return [tables[len(queries_) - 1]]
dbmod.inject.getValue = gv
result = d.getTables()
self.assertEqual(sorted(result["testdb"]), sorted(tables))
self.assertEqual([_.endswith("OFFSET %d" % i) for i, _ in enumerate(queries_)], [True, True])
class TestGetColumns(_BaseEnumTest):
def _run_direct(self, dbms, db, tbl, rows):
@@ -500,6 +524,110 @@ class TestGetSchema(_BaseEnumTest):
self.assertEqual(result["testdb"]["users"].get("id"), "int")
class TestGetLinks(_BaseEnumTest):
def setUp(self):
_BaseEnumTest.setUp(self)
self._saved_links = kb.data.get("cachedLinks")
def tearDown(self):
kb.data.cachedLinks = self._saved_links
_BaseEnumTest.tearDown(self)
def test_unsupported_dbms_does_not_query(self):
d = self._fresh()
dbmod.inject.getValue = lambda *a, **k: self.fail("unsupported DBMS must not query links")
saved = dbmod.logger.warning
warnings = []
try:
dbmod.logger.warning = warnings.append
for dbms in queries:
if "links" not in queries[dbms]:
set_dbms(dbms)
self.assertEqual(d.getLinks(), [])
self.assertIn(dbms, warnings[-1])
finally:
dbmod.logger.warning = saved
def test_direct_links_and_cache(self):
conf.direct = True
for dbms in ("Microsoft SQL Server", "Oracle"):
set_dbms(dbms)
d = self._fresh()
calls = []
def gv(query, **kwargs):
calls.append(query)
return [["remote -> db.example"], ["other -> <unknown>"]]
dbmod.inject.getValue = gv
expected = ["remote -> db.example", "other -> <unknown>"]
self.assertEqual(d.getLinks(), expected)
self.assertEqual(d.getLinks(), expected)
self.assertEqual(calls, [queries[dbms].links.inband.query])
def test_blind_links_use_zero_based_paging(self):
self._enable_inference()
for dbms in ("Microsoft SQL Server", "Oracle"):
set_dbms(dbms)
d = self._fresh()
calls = []
values = iter(["2", ["first -> host1"], ["second -> host2"]])
def gv(query, **kwargs):
calls.append(query)
return next(values)
dbmod.inject.getValue = gv
self.assertEqual(d.getLinks(), ["first -> host1", "second -> host2"])
root = queries[dbms].links
self.assertEqual(calls, [root.blind.count, root.blind.query % 0, root.blind.query % 1])
def test_empty_direct_results(self):
set_dbms("Microsoft SQL Server")
conf.direct = True
d = self._fresh()
dbmod.inject.getValue = lambda *a, **k: None
self.assertEqual(d.getLinks(), [])
def test_oracle_query_paging_and_null_destination(self):
# Evaluate the catalog/paging SQL with compatible SQLite expressions; live Oracle is
# not required for this check of owner-qualified names, NULL handling and row offsets.
conn = sqlite3.connect(":memory:")
try:
conn.create_function("NVL", 2, lambda value, default: default if value is None else value)
conn.execute("CREATE TABLE ALL_DB_LINKS (OWNER TEXT, DB_LINK TEXT, HOST TEXT)")
conn.executemany("INSERT INTO ALL_DB_LINKS VALUES (?,?,?)", [("PUBLIC", "REMOTE", "db.example"), ("APP", "REMOTE", None)])
root = queries["Oracle"].links
expected = ["APP.REMOTE -> <unknown>", "PUBLIC.REMOTE -> db.example"]
self.assertEqual([row[0] for row in conn.execute(root.inband.query)], expected)
self.assertEqual(conn.execute(root.blind.count).fetchone()[0], 2)
self.assertEqual([conn.execute(root.blind.query % i).fetchone()[0] for i in range(2)], expected)
self.assertIsNone(conn.execute(root.blind.query % 2).fetchone())
finally:
conn.close()
def test_empty_or_unavailable_blind_count_does_not_fetch_rows(self):
set_dbms("Oracle")
self._enable_inference()
for count in (0, "0", None, "unavailable"):
d = self._fresh()
def gv(query, **kwargs):
self.assertEqual(kwargs.get("expected"), EXPECTED.INT)
return count
dbmod.inject.getValue = gv
self.assertEqual(d.getLinks(), [])
def test_help_is_one_line(self):
output = subprocess.check_output([sys.executable, os.path.join(os.path.dirname(__file__), "..", "sqlmap.py"), "-hh"])
lines = output.decode("utf-8").splitlines()
helpLines = [line for line in lines if "--links" in line]
self.assertEqual(len(helpLines), 1)
self.assertIn("Enumerate DBMS links", helpLines[0])
self.assertLessEqual(len(helpLines[0]), 78)
class TestGetProcedures(_BaseEnumTest):
def test_procedures_direct_pgsql(self):
set_dbms("PostgreSQL")
+74 -2
View File
@@ -43,9 +43,13 @@ bootstrap()
from lib.core.agent import agent
from lib.core.common import Backend
from lib.core.data import conf, kb
from lib.core.data import conf, kb, queries
from lib.core.datatype import AttribDict
from lib.core.datatype import InjectionDict
from lib.core.threads import getCurrentThreadData
from lib.core.enums import DBMS
from lib.core.enums import PAYLOAD
from lib.core.enums import PLACE
from lib.core.exception import SqlmapNotVulnerableException
from lib.core.settings import DNS_BOUNDARIES_ALPHABET
from lib.core.settings import MAX_DNS_LABEL
@@ -80,7 +84,8 @@ class _HighPortDNSServer(DNSServer):
except socket.error:
pass
_CONF = {"dnsDomain": "exfil.test", "hexConvert": False, "api": False, "verbose": 0, "forceDns": False}
_CONF = {"dnsDomain": "exfil.test", "hexConvert": False, "api": False, "verbose": 0, "forceDns": False,
"suffix": None, "direct": False}
_KB = {"dnsTest": True, "dnsMode": False, "bruteMode": False, "safeCharEncode": False}
@@ -252,6 +257,73 @@ class TestDnsExfilEngineMssql(TestDnsExfilEngine):
DBMS_NAME = "Microsoft SQL Server"
class TestDnsStackedSuffix(_DnsCase):
def setUp(self):
_DnsCase.setUp(self)
restore_attrs(self._s_agent)
self._saved_target = {k: conf.get(k) for k in ("parameters", "paramDict", "prefix")}
self._saved_injection = {k: kb.get(k) for k in ("injection", "technique", "forceWhere")}
conf.parameters = {PLACE.GET: "id=1"}
conf.paramDict = {PLACE.GET: {"id": "1"}}
conf.prefix = " /* custom prefix */"
kb.technique = PAYLOAD.TECHNIQUE.BOOLEAN
kb.forceWhere = None
kb.injection = InjectionDict()
kb.injection.place = PLACE.GET
kb.injection.parameter = "id"
kb.injection.prefix = conf.prefix
kb.injection.suffix = "; SELECT 999-- inferred suffix"
kb.injection.clause = [1]
kb.injection.data[PAYLOAD.TECHNIQUE.BOOLEAN] = AttribDict({"where": PAYLOAD.WHERE.ORIGINAL, "comment": "-- inferred comment"})
def tearDown(self):
for k, v in self._saved_target.items():
conf[k] = v
for k, v in self._saved_injection.items():
kb[k] = v
_DnsCase.tearDown(self)
def _extract_with_suffix(self, suffix):
secret = "The quick brown fox jumps over the lazy dog 0123456789"
conf.suffix = suffix
for dbms in (DBMS.MSSQL, DBMS.PGSQL):
set_dbms(dbms)
self._install_oracle(secret)
oracle = Connect.queryPage
payloads = []
def require_suffix(payload, **kwargs):
sql = agent.removePayloadDelimiters(payload)
payloads.append(sql)
# Model an endpoint that accepts the DNS payload only with its required ending.
if suffix and not sql.endswith(suffix):
return None
return oracle(payload, **kwargs)
Connect.queryPage = staticmethod(require_suffix)
self.assertEqual(dnsmod.dnsUse("unused", "SELECT secret"), secret, dbms)
self.assertGreater(len(payloads), 1, dbms)
for payload in payloads:
self.assertTrue(payload.startswith("id=1%s; " % conf.prefix), dbms)
self.assertNotIn("-- inferred", payload)
if suffix is None:
self.assertTrue(payload.endswith(queries[dbms].comment.query), dbms)
elif suffix:
self.assertTrue(payload.endswith(suffix), dbms)
else:
self.assertFalse(payload.endswith(queries[dbms].comment.query), dbms)
def test_explicit_suffix_survives_every_chunk(self):
self._extract_with_suffix("; SELECT 4973-- required suffix")
def test_default_comment_without_explicit_suffix(self):
self._extract_with_suffix(None)
def test_explicit_empty_suffix_does_not_add_comment(self):
self._extract_with_suffix("")
class TestDnsLabelInvariant(_DnsCase):
"""The exfil chunk is hex-encoded into ONE DNS label, so the label dnsUse emits must never
exceed the 63-octet DNS label limit - otherwise the query carries an invalid (over-long) label
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Unit tests for lib/utils/search.py (dork search), exercising the engine loop by
MOCKING the network layer (_fetch).
No network is involved: _fetch is patched to return canned search engine result
pages, so we can assert both on the links extracted per engine and on the
abuse/captcha detection that triggers proxy rotation (SEARCH_ABUSE_MARKERS).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.core.data import conf
from lib.core.exception import SqlmapBaseException
from lib.core.settings import SEARCH_ABUSE_MARKERS
import lib.utils.search as smod
BING_PAGE = '<h2><a target="_blank" href="https://www.bing.com/ck/a?!&amp;&amp;p=1&amp;u=a1aHR0cHM6Ly93d3cuZXhhbXBsZS5jb20vYS5waHA_aWQ9MQ&amp;ntb=1">a</a></h2>'
DUCKDUCKGO_PAGE = '<a class="result__url" href="https://www.example.com/b.php?id=2">b</a>'
class TestDorkSearch(unittest.TestCase):
def setUp(self):
conf.googlePage = 1
conf.proxyList = None
self._fetch = smod._fetch
def tearDown(self):
smod._fetch = self._fetch
def _pages(self, *pages):
"""Patches _fetch to serve `pages` in engine order (None meaning no reply)"""
served = list(pages)
self.requested = []
def fetch(url, headers, data=None):
self.requested.append((url, data))
return served.pop(0) if served else None
smod._fetch = fetch
def test_duckduckgo_results(self):
self._pages(DUCKDUCKGO_PAGE)
self.assertEqual(smod._search("dork"), ["https://www.example.com/b.php?id=2"])
def test_bing_fallback_decodes_redirect(self):
self._pages(None, BING_PAGE)
self.assertEqual(smod._search("dork"), ["https://www.example.com/a.php?id=1"])
def test_duckduckgo_paging_offset(self):
# Note: first result page holds 10 entries, each subsequent one 15 (and not the uniform 30 as previously used)
for gpage, offset in ((1, 0), (2, 10), (3, 25), (4, 40)):
conf.googlePage = gpage
self._pages(DUCKDUCKGO_PAGE)
smod._search("dork")
self.assertEqual(self.requested[0][1], "q=dork&s=%d" % offset)
def test_abuse_page_switches_proxy(self):
# Note: without '--proxy-list' there is nothing to switch to, hence no exception
for marker in SEARCH_ABUSE_MARKERS:
self._pages("<html>%s</html>" % marker)
conf.proxyList = None
self.assertEqual(smod._search("dork"), [])
self._pages("<html>%s</html>" % marker)
conf.proxyList = ["http://127.0.0.1:8080"]
self.assertRaises(SqlmapBaseException, smod._search, "dork")
def test_results_page_is_not_mistaken_for_abuse(self):
self._pages(DUCKDUCKGO_PAGE)
conf.proxyList = ["http://127.0.0.1:8080"]
self.assertEqual(smod._search("dork"), ["https://www.example.com/b.php?id=2"])
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -76,6 +76,17 @@ class _CaptureCase(unittest.TestCase):
class TestStringAndLister(_CaptureCase):
def test_audit_output(self):
self.d.audit(["xp_cmdshell: disabled", "CLR integration: unavailable"])
self.assertIn("database security audit [2]", self.text())
self.assertIn("xp_cmdshell: disabled", self.text())
self.assertIn("CLR integration: unavailable", self.text())
def test_links_output(self):
self.d.links(["remote -> db.example"])
self.assertIn("database links [1]", self.text())
self.assertIn("remote -> db.example", self.text())
def test_string_scalar_quoted(self):
# a plain string fact is rendered as "header: 'value'"
self.d.string("current user", "root@localhost")
+59 -25
View File
@@ -21,8 +21,10 @@ prompts non-interactive, and conf.dumper is a recording stub. Every test restore
all touched conf.* / kb.* / patched module attributes in tearDown so nothing leaks.
"""
import io
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -39,6 +41,7 @@ import plugins.generic.entries as emod
import plugins.generic.custom as cmod
import plugins.generic.misc as mmod
import plugins.generic.takeover as tmod
import lib.core.common as common
from plugins.generic.custom import Custom
from plugins.generic.misc import Miscellaneous
@@ -250,38 +253,69 @@ class TestCustomSqlQuery(_GenericBase):
class TestCustomSqlFile(_GenericBase):
def test_sqlfile_select_snippets(self):
set_dbms("MySQL")
c = Custom()
cmod.inject.getValue = lambda query, **k: "r"
def _runSqlFile(self, content, suffix=".sql"):
handle, filename = tempfile.mkstemp(suffix=suffix)
os.close(handle)
self.addCleanup(os.unlink, filename)
with io.open(filename, "w", encoding="utf-8", newline="") as f:
f.write(content)
# getSQLSnippet reads from disk; patch it to return inline SQL.
saved = cmod.getSQLSnippet
try:
cmod.getSQLSnippet = lambda dbms, filename, **kw: "SELECT 1;SELECT 2"
conf.sqlFile = "dummy.sql"
c.sqlFile()
# two SELECT statements => two recorded dumper.sqlQuery calls
self.assertEqual(len(conf.dumper.sqlQueries), 2)
finally:
cmod.getSQLSnippet = saved
set_dbms("MySQL")
cmod.inject.getValue = lambda query, **k: "r"
conf.sqlFile = filename
Custom().sqlFile()
return [query for query, output in conf.dumper.sqlQueries]
def test_sqlfile_select_snippets(self):
self.assertEqual(self._runSqlFile(u"SELECT 1; SELECT 2;\n"), ["SELECT 1", "SELECT 2"])
def test_sqlfile_nonselect_snippet(self):
set_dbms("MySQL")
conf.direct = True
c = Custom()
cmod.inject.goStacked = lambda *a, **k: None
self.assertEqual(self._runSqlFile(u"DROP TABLE x"), ["DROP TABLE x"])
saved = cmod.getSQLSnippet
def test_sqlfile_preserves_percent_tokens_without_prompting(self):
saved = common.readInput
try:
cmod.getSQLSnippet = lambda dbms, filename, **kw: "DROP TABLE x"
conf.sqlFile = "dummy.sql"
c.sqlFile()
# non-SELECT => single recorded call with the whole snippet
self.assertEqual(len(conf.dumper.sqlQueries), 1)
self.assertEqual(conf.dumper.sqlQueries[0][0], "DROP TABLE x")
common.readInput = lambda *a, **k: self.fail("user SQL must not request template substitutions")
query = u"SELECT DATE_FORMAT(NOW(), '%m%m'), '%RANDSTR1%', '%RANDINT1%'"
self.assertEqual(self._runSqlFile(query), [query])
finally:
cmod.getSQLSnippet = saved
common.readInput = saved
def test_sqlfile_preserves_hash_and_quoted_delimiters(self):
first = u"SELECT '#keep; value', 'it''s; quoted', \"semi; colon\""
self.assertEqual(self._runSqlFile(first + u"; SELECT 2;"), [first, "SELECT 2"])
def test_sqlfile_preserves_multiline_literal(self):
query = u"SELECT 'a;\n b'"
self.assertEqual(self._runSqlFile(query), [query])
def test_sqlfile_preserves_crlf_literal(self):
query = u"SELECT 'a;\r\n b'"
self.assertEqual(self._runSqlFile(query + u";\r\nSELECT 2;"), [query, "SELECT 2"])
def test_sqlfile_newline_separated_selects(self):
self.assertEqual(self._runSqlFile(u"SELECT 'a;b'\nSELECT 2\n"), ["SELECT 'a;b'", "SELECT 2"])
def test_sqlfile_multiline_select(self):
query = u"SELECT name\nFROM users"
self.assertEqual(self._runSqlFile(query), [query])
def test_sqlfile_preserves_mixed_batch(self):
batch = u"UPDATE users SET name='#keep; value';\nSELECT name FROM users;"
executed = []
cmod.inject.goStacked = lambda query, **k: executed.append(query)
self.assertEqual(self._runSqlFile(batch), [batch])
self.assertEqual(executed, [batch.rstrip(';')])
def test_sqlfile_arbitrary_extension(self):
self.assertEqual(self._runSqlFile(u"SELECT 1", suffix=".txt"), ["SELECT 1"])
def test_sqlfile_without_extension(self):
self.assertEqual(self._runSqlFile(u"SELECT 1", suffix=""), ["SELECT 1"])
def test_sqlfile_empty(self):
self.assertEqual(self._runSqlFile(u" \n;\n "), [])
# --------------------------------------------------------------------------- #
+11
View File
@@ -699,6 +699,17 @@ class TestRedirectHandling(unittest.TestCase):
self.assertEqual(calls[0][1], 8443)
self.assertEqual(calls[0][4], "xn--tst-qla.example:8443")
def test_cross_origin_redirect_strips_custom_auth_headers(self):
responses = [(307, [(b"location", b"https://other.test/final")], b""), (200, [], b"ok")]
_, calls = self._run_with_fake_exchange(
responses, url="https://example.test/start",
headers={"X-Auth-Token": "secret", "X-Keep": "yes"},
sensitive_headers=("X-Auth-Token",),
)
names = [key.lower() for key, _ in calls[1][5]]
self.assertNotIn("x-auth-token", names)
self.assertIn("x-keep", names)
class TestPoolRetries(unittest.TestCase):
class _Connection(object):
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
Unit tests for lib/request/httpshandler.py, exercising the SSL protocol
candidate selection.
No network is involved: the module is reimported with chosen ssl.PROTOCOL_*
constants removed, mimicking OpenSSL builds that have those protocol versions
compiled out (e.g. OPENSSL_NO_TLS1), as the constants are conditional there.
"""
import os
import ssl
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _testutils import bootstrap
bootstrap()
from lib.core.data import conf
import lib.request.httpshandler as hmod
from thirdparty.six.moves import reload_module as _reload_module
class TestProtocolSelection(unittest.TestCase):
def tearDown(self):
hmod.socket.create_connection = self._createConnection
hmod.ssl.SSLContext = self._sslContext
hmod._contexts.clear()
for name, value in self._removed.items():
setattr(ssl, name, value)
_reload_module(hmod)
def _reload_without(self, *names):
"""Reloads the module with `names` missing from ssl (as on builds lacking those protocols)"""
self._removed = {}
self._createConnection = hmod.socket.create_connection
self._sslContext = hmod.ssl.SSLContext
for name in names:
if hasattr(ssl, name):
self._removed[name] = getattr(ssl, name)
delattr(ssl, name)
_reload_module(hmod)
return [hmod._lut.get(_, _) for _ in hmod._protocols if _ in hmod._tlsProtocols]
def test_only_tls_protocols_are_used_with_sni(self):
# Note: SSL era protocols stay in _protocols for the non-SNI fallback, but carry no server_hostname
selected = self._reload_without()
self.assertTrue(selected)
for name in ("PROTOCOL_SSLv3", "PROTOCOL_SSLv23", "PROTOCOL_SSLv2"):
if hasattr(ssl, name):
self.assertIn(getattr(ssl, name), hmod._protocols)
self.assertNotIn(getattr(ssl, name), hmod._tlsProtocols)
def test_missing_tlsv1_does_not_raise(self):
# Note: ssl.PROTOCOL_TLSv1 is compiled in only '#ifndef OPENSSL_NO_TLS1' (used to raise an AttributeError here)
selected = self._reload_without("PROTOCOL_TLSv1")
self.assertTrue(selected)
self.assertNotIn("PROTOCOL_TLSv1", selected)
def test_missing_all_legacy_tls_does_not_raise(self):
selected = self._reload_without("PROTOCOL_TLSv1", "PROTOCOL_TLSv1_1")
self.assertTrue(selected)
self.assertNotIn("PROTOCOL_TLSv1_1", selected)
def test_connect_without_tlsv1(self):
"""Drives the real connect() loop, which used to reference ssl.PROTOCOL_TLSv1 directly"""
selected = self._reload_without("PROTOCOL_TLSv1")
conf.host = None
conf.httpHeaders = []
class _Context(object):
def __init__(self, protocol):
self.protocol = protocol
def set_ciphers(self, _):
pass
def wrap_socket(self, sock, **kwargs):
return "wrapped:%s" % hmod._lut.get(self.protocol, self.protocol)
connection = hmod.HTTPSConnection("example.com", 443)
# Note: stubbed only after the instantiation, as that one resolves the default context through ssl itself
hmod.socket.create_connection = lambda *args, **kwargs: _Socket()
hmod.ssl.SSLContext = _Context
hmod._contexts.clear()
connection.connect()
self.assertEqual(connection.sock, "wrapped:%s" % selected[0])
class _Socket(object):
def close(self):
pass
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -1646,5 +1646,50 @@ class TestNonSqlTechniqueRegistry(unittest.TestCase):
self.assertEqual(optDict["Techniques"].get(name), "boolean", name)
class TestSetHTTPHandlersProxyCredentials(unittest.TestCase):
"""_setHTTPHandlers(): credentials embedded in the proxy URL (dropped for HTTP(S) and left
URL-encoded for SOCKS ever since the fix for #5875). Global state (shared proxy handler,
installed opener, SOCKS hooks) is pinned, so nothing leaks into other modules."""
_KEYS = ("proxy", "proxyCred", "proxyList", "hostname", "ignoreProxy", "cj", "keepAlive")
def setUp(self):
from _testutils import save_attrs
self._socks = []
self._saved = save_attrs(option.socks, "setdefaultproxy", "wrapmodule", "unwrapmodule")
self._saved += save_attrs(option.proxyHandler, "proxies", "http_open", "https_open")
self._saved += save_attrs(option._urllib.request, "install_opener")
option.socks.setdefaultproxy = lambda *args, **kwargs: self._socks.append((kwargs.get("username"), kwargs.get("password")))
option.socks.wrapmodule = option.socks.unwrapmodule = lambda *args: None
option._urllib.request.install_opener = lambda *args: None
def tearDown(self):
from _testutils import restore_attrs
restore_attrs(self._saved)
def _run(self, proxy, proxyCred=None):
with _preserve(conf, *self._KEYS), _preserve(kb, "proxies"):
conf.proxy, conf.proxyCred, conf.proxyList = proxy, proxyCred, None
option._setHTTPHandlers()
return (kb.proxies or {}).get("http")
def test_http_url_credentials(self):
# Note: kept URL-encoded, as urllib decodes them itself when building 'Proxy-Authorization'
self.assertEqual(self._run("http://user:p%40s[email protected]:8080"), "user:p%40s[email protected]:8080")
def test_proxy_cred_takes_precedence(self):
self.assertEqual(self._run("http://user:p%40s[email protected]:8080", "other:pw"), "other:[email protected]:8080")
def test_http_without_credentials(self):
self.assertEqual(self._run("http://127.0.0.1:8080"), "127.0.0.1:8080")
def test_socks_url_credentials_decoded(self):
self._run("socks5://user:p%40s[email protected]:1080")
self.assertEqual(self._socks, [("user", "p@ss")])
if __name__ == "__main__":
unittest.main(verbosity=2)
+4
View File
@@ -827,3 +827,7 @@ class TestStruts2Header(unittest.TestCase):
# so no start/end marker is found -> no fabricated 'output'
ssti._s2045Send = lambda url, action: "reflected: %s" % action
self.assertIsNone(ssti._executeStruts2Header("http://target", "id"))
if __name__ == "__main__":
unittest.main(verbosity=2)
+28
View File
@@ -112,6 +112,34 @@ class TestOrderByColumnCount(unittest.TestCase):
self.assertEqual(self._detect(25), 25)
class TestNestedOrderBy(unittest.TestCase):
def test_union_preserves_ordering_inside_limited_subquery_and_literals(self):
original_init = uu.initTechnique
original_use = uu._oneShotUnionUse
saved_json = kb.get('jsonAggMode')
saved_run = kb.get('partRun')
set_dbms('Oracle')
captured = []
def capture(query, *args, **kwargs):
captured.append(query)
raise RuntimeError('stop after query construction')
uu.initTechnique = lambda *args: None
uu._oneShotUnionUse = capture
try:
for inner, outer in (('ORDER BY', 'ORDER BY'), ('Order By', 'order by')):
query = "SELECT a,b FROM (SELECT a,'ORDER BY literal' AS b FROM t %s a,b) x WHERE ROWNUM<=20 %s a" % (inner, outer)
with self.assertRaises(RuntimeError):
uu.unionUse(query)
self.assertIn('FROM t %s a,b)' % inner, captured[-1])
self.assertIn("'ORDER BY literal'", captured[-1])
self.assertFalse(captured[-1].rstrip().endswith('%s a' % outer))
finally:
uu.initTechnique = original_init
uu._oneShotUnionUse = original_use
kb.jsonAggMode = saved_json
kb.partRun = saved_run
class TestMssqlJsonAggFalsyValues(unittest.TestCase):
"""Regression: MSSQL UNION dumps use FOR JSON (jsonAggMode), whose output carries native JSON
types. A real 0 / '' / false is a value; only JSON null is SQL NULL. The old `row.get(field) or
+4
View File
@@ -557,3 +557,7 @@ class TestRealXPathSyntax(unittest.TestCase):
self.fail("Boundary '%s' in '%s' with orig='%s' invalid: %s\n payload: %s" % (bk, tkey, original, e, payload))
self.assertIsInstance(count, int,
"Boundary '%s' in '%s' produced no count" % (bk, tkey))
if __name__ == "__main__":
unittest.main(verbosity=2)