Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions data/txt/sha256sums.txt
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,10 @@ ca86d61d3349ed2d94a6b164d4648cff9701199b5e32378c3f40fca0f517b128 extra/shutils/
df768bcb9838dc6c46dab9b4a877056cb4742bd6cfaaf438c4a3712c5cc0d264 extra/shutils/recloak.sh
1972990a67caf2d0231eacf60e211acf545d9d0beeb3c145a49ba33d5d491b3f extra/shutils/strip.sh
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 extra/vulnserver/__init__.py
43214ecb0101bce72eb243c91b90db34693ebfd485d6c111a4ae22591ff7800b extra/vulnserver/vulnserver.py
faaaa586baa4df245b8780a1a808ebf07e3027ce4245ded3274d908c49e1eecd extra/vulnserver/vulnserver.py
a2bf70d7f87c3a4e0675c0bad54119a4e04efa6ea2730a8338d5aebcd995630e lib/controller/action.py
0c6433b289094d37f295238699042a34a6ab950bb3d11f74fe9a83d30bb7f4bd lib/controller/checks.py
ea0fdf6bcda59aae4d093bada965654a0cd940227c2dbdf62b6ded79baa8dfad lib/controller/controller.py
284b5b056f048e5951c43605965f6758cb9cefa54ca30d818b2c1d1c6713fb91 lib/controller/checks.py
b1e89bff221cc907f5033bae941bf7929de9490f5dcdf2747cba676acd2da95b lib/controller/controller.py
d69e84f1648cdb907f5d2dd454f03874a4613752b07867510145d51d84b3c56f lib/controller/handler.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/controller/__init__.py
9c5764c92ce536d1f0f96200359ee5ef1f37f9128769bf990cb77f1d1f8e17b1 lib/core/agent.py
Expand All @@ -181,26 +181,26 @@ f8de57606325456928e46ae2896f5f8bbec9ad18b1c644b492a566fa992216f6 lib/core/decor
5387168e5dfedd94ae22af7bb255f27d6baaca50b24179c6b98f4f325f5cc7b4 lib/core/exception.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/core/__init__.py
914a13ee21fd610a6153a37cbe50830fcbd1324c7ebc1e7fc206d5e598b0f7ad lib/core/log.py
056930fba3cf9827f97d280bc38ac785c93108eb84c922f5f39723bb04dcf403 lib/core/optiondict.py
1b03686e1aa916ccad3cd86b8e4e6ea4baca5e30e05bf86a56f8df8dd4f44ba6 lib/core/optiondict.py
4e7f2ad3d2866093aa195616a0e93de1687406edc0b9038fbfa76bf1c9c174b2 lib/core/option.py
ccc4a717e887652b1fcce073d9409d9c59a3b28548c703a9e453d15845f90cd7 lib/core/patch.py
49c0fa7e3814dfda610d665ee02b12df299b28bc0b6773815b4395514ddf8dec lib/core/profiling.py
03db48f02c3d07a047ddb8fe33a757b6238867352d8ddda2a83e4fec09a98d04 lib/core/readlineng.py
9bf174058f15d14e24e94f9aaf42df045119d3617c6c54bd2f3af79b462f331d lib/core/replication.py
0b8c38a01bb01f843d94a6c5f2075ee47520d0c4aa799cecea9c3e2c5a4a23a6 lib/core/revision.py
888daba83fd4a34e9503fe21f01fef4cc730e5cde871b1d40e15d4cbc847d56c lib/core/session.py
ca14e55b4d49a9b9f4e547180828030e4fcc51176dc9036879dbdae05919dd02 lib/core/settings.py
1e5c125c69d2921ed69041a2462f6b41d11f9c1afdfe1987b60657484aa5ccf0 lib/core/settings.py
c7804223319e18eb0b8e2cbf0a8b6896d1cefb7b0b1a2e9f1cf826a8a3b56750 lib/core/shell.py
a2e98a94b231432736d6b304fc75525c8b5fdb4768c418387c5b4c1a610dad64 lib/core/subprocessng.py
19f1e3c5e3ba703d28d510cd7a9ab8284d5fbe9df5ce7e77c86e5931571364b7 lib/core/target.py
e453904a50372216b09146ad9f11cdced2323c10f49c3d866238cc044dcb2cce lib/core/testing.py
5955be979a1d5d3ee221d12e88805f6ef767d43bd4c542e01714cc868c4d020c lib/core/testing.py
95656c44bab1771f4808030dd6a17eae5b129cb1234443f00b19695c7b712b86 lib/core/threads.py
b9aacb840310173202f79c2ba125b0243003ee6b44c92eca50424f2bdfc83c02 lib/core/unescaper.py
53e396902cb2546eaa09e77073fcba8be8827ee9ce055cfc899e81b0e6ad4d6d lib/core/update.py
2400e465fa4d13e4c32795910878c71ff212e4361b46428d57ce43983f5e997c lib/core/wordlist.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/__init__.py
54bfd31ebded3ffa5848df1c644f196eb704116517c7a3d860b5d081e984d821 lib/parse/banner.py
223badcfd102cdf3313411b63d09b6c59599d58dfc40d27409b1bfa2efc1aa8f lib/parse/cmdline.py
c515041ee2d50aded9afa371de47c3c44c81b30546fb1f6f170b2169ae5e64b4 lib/parse/cmdline.py
02d82e4069bd98c52755417f8b8e306d79945672656ac24f1a45e7a6eff4b158 lib/parse/configfile.py
c5b258be7485089fac9d9cd179960e774fbd85e62836dc67cce76cc028bb6aeb lib/parse/handler.py
5c9a9caee948843d5537745640cc7b98d70a0412cc0949f59d4ebe8b2907c06c lib/parse/headers.py
Expand Down Expand Up @@ -239,6 +239,8 @@ a66a4b9df6207dce722c9b71d290ea426723cb4b697b416065dc7dd5db96fe8e lib/techniques
74ca78082dcd20b3faf07cc944cd65ea552996df40e6fb58d0a011b262528456 lib/techniques/dns/use.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/error/__init__.py
5bbef46c16e34fd80e3f9f0e9aa255ce2e39be0d0e57479e25890b041c7efc7d lib/techniques/error/use.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/graphql/__init__.py
a1c5ec208843eb93e0fab40daac090aa3bf914a7dd0afb0f7c55c2db4db8d72b lib/techniques/graphql/inject.py
1966ca704961fb987ab757f0a4afddbf841d1a880631b701487c75cef63d60c3 lib/techniques/__init__.py
44401cad3e39ae9fb899ed5d0e2fdd0879561de05c3117f17f3b0db54f4e3724 lib/techniques/nosql/__init__.py
d62b28bf9f1544e65a1017994402f484166f4d64a1efb724351b15e27b851990 lib/techniques/nosql/inject.py
Expand Down Expand Up @@ -594,6 +596,7 @@ ed5a0e453b811dc3dcc5ca28e14a9d7552aacaa7e316e1bca1b042dc5939e204 tests/test_dns
9cd5841349bc4db818658d12184929a96f7f279eff1f53ad18a54dbefbd6b276 tests/test_dump_jsonl.py
2bbe4b01f79992cfa8884651fc0a28dbd0e3abb0cbea9eb7eadf1f98ca3c3420 tests/test_encoding.py
bb6991260a994fcbe79e05febaa34affd5631d02299fbc626820addd5f6ea4f4 tests/test_error_engine.py
4a5f9392b7fec7b40c4d865b83306b58b76f3423cebc2876e6e75fb91b037202 tests/test_graphql.py
8105de9978fe286a29f6b635a58db1e9998d86e8dded54d7efdfb9d52a121094 tests/test_hashdb.py
c04e8358fb6df45f69f2f26435c971acde280535bf304e84d30cf2681158c6a7 tests/test_hash.py
d539d0ae758b5bb91e314ab82ab4fe03d6fb2f8b377d16aefa6d7d1d77a7d5a9 tests/test_identifiers_output.py
Expand Down
255 changes: 255 additions & 0 deletions extra/vulnserver/vulnserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,232 @@ def waf_score(value, ua=None, level=0):
retVal += WAF_SCANNER_UA_WEIGHT
return retVal

# --- 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, "")
Expand Down Expand Up @@ -339,6 +565,35 @@ def do_REQUEST(self):
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 == '/':
if not any(_ in self.params for _ in ("id", "query")):
self.send_response(OK)
Expand Down
8 changes: 8 additions & 0 deletions lib/controller/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
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 INFERENCE_EQUALS_CHAR
from lib.core.settings import IPS_WAF_CHECK_PAYLOAD
Expand Down Expand Up @@ -1178,6 +1179,13 @@ def _(page):
if conf.beep:
beep()

if not conf.graphql and re.search(GRAPHQL_ERROR_REGEX, page or ""):
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()

kb.disableHtmlDecoding = False
kb.heuristicMode = False

Expand Down
13 changes: 13 additions & 0 deletions lib/controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,8 +504,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 conf.graphql:
from lib.techniques.graphql.inject import graphqlScan
graphqlScan()
continue

if not checkConnection(suppressOutput=conf.forms):
continue

Expand Down
1 change: 1 addition & 0 deletions lib/core/optiondict.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"Techniques": {
"technique": "string",
"nosql": "boolean",
"graphql": "boolean",
"timeSec": "integer",
"uCols": "string",
"uChar": "string",
Expand Down
Loading