attackmap-analyzer-c 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attackmap_analyzer_c/__init__.py +3 -0
- attackmap_analyzer_c/analyzer.py +431 -0
- attackmap_analyzer_c/native_deps.py +524 -0
- attackmap_analyzer_c/native_sinks.py +1737 -0
- attackmap_analyzer_c-0.2.0.dist-info/METADATA +167 -0
- attackmap_analyzer_c-0.2.0.dist-info/RECORD +10 -0
- attackmap_analyzer_c-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_c-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_c-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_c-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1737 @@
|
|
|
1
|
+
"""Native (C/C++) memory-safety and command-exec sinks (AttackMap#189).
|
|
2
|
+
|
|
3
|
+
Emits ``CodeWeakness`` records for five kinds, decided inside one function:
|
|
4
|
+
|
|
5
|
+
- ``unbounded_copy``: ``gets``, ``scanf``-family ``%s``/``%[`` without a field
|
|
6
|
+
width, and ``strcpy``/``strcat``/``sprintf`` whose source (or ``%s``
|
|
7
|
+
argument) is traced to an input in the same function.
|
|
8
|
+
- ``format_string``: a printf-family call whose format is not a constant.
|
|
9
|
+
- ``native_command_exec``: ``system``/``popen`` with a non-constant command,
|
|
10
|
+
``exec*`` with an input-derived argument or a non-constant ``sh -c`` line.
|
|
11
|
+
- ``tainted_copy_length``: ``memcpy``/``memmove``/``strncpy``/``strncat``
|
|
12
|
+
whose length is traced to an input and not bounds-checked first.
|
|
13
|
+
- ``alloc_size_overflow``: ``malloc``/``realloc``/``alloca``/``new T[]`` of a
|
|
14
|
+
product with an input-derived factor and no overflow check.
|
|
15
|
+
|
|
16
|
+
Sources (per function): request accessors of the supported web frameworks
|
|
17
|
+
(civetweb, mongoose, libmicrohttpd, cpp-httplib, Crow, Drogon, Pistache,
|
|
18
|
+
Boost.Beast, oat++, restbed) and parameters of their request types; socket
|
|
19
|
+
reads (``recv``, ``SSL_read``, ...); stdin (``fgets(..., stdin)``,
|
|
20
|
+
``std::cin``); ``argv``/``optarg`` in a function that takes ``argv``;
|
|
21
|
+
``getenv``; and file reads. Taint follows assignments, out-parameters and
|
|
22
|
+
copy functions in textual order; it is never cleared. A comparison
|
|
23
|
+
(``<``/``>``) on the value, a ``MIN``/``std::min`` clamp, or an allocation
|
|
24
|
+
sized from the same value counts as a bound.
|
|
25
|
+
|
|
26
|
+
Severity and confidence come from the traced source: request/network input
|
|
27
|
+
-> high/high, local input (stdin, argv, env, file) -> medium/medium, nothing
|
|
28
|
+
traced -> low/low (reported only for non-constant formats and commands).
|
|
29
|
+
Code under example/demo/sample directories is capped at low/low. Test and
|
|
30
|
+
fuzz code is skipped (``ATTACKMAP_INCLUDE_TESTS`` opts back in).
|
|
31
|
+
|
|
32
|
+
This module is mirrored verbatim in attackmap-analyzer-c and
|
|
33
|
+
attackmap-analyzer-cpp. Change both together.
|
|
34
|
+
|
|
35
|
+
Every scanner here is linear or bounded: comments/strings are masked with one
|
|
36
|
+
regex pass, brace and paren matching are capped, and all patterns are
|
|
37
|
+
module-level with no nested quantifiers.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import bisect
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from pathlib import PurePosixPath
|
|
47
|
+
|
|
48
|
+
from attackmap.sdk import line_of
|
|
49
|
+
|
|
50
|
+
try: # attackmap >= the release that exports CodeWeakness (AttackMap#189)
|
|
51
|
+
from attackmap.sdk import CodeWeakness
|
|
52
|
+
except ImportError: # older core: the plugin still loads, emits no weaknesses
|
|
53
|
+
CodeWeakness = None # type: ignore[assignment,misc]
|
|
54
|
+
|
|
55
|
+
_SNIPPET_MAX_CHARS = 160
|
|
56
|
+
# Bounds for the hand-written scanners. A call or parameter list longer than
|
|
57
|
+
# this is treated as unparseable rather than scanned further.
|
|
58
|
+
_MAX_ARGS_SPAN = 4000
|
|
59
|
+
_MAX_PARAMS_SPAN = 2000
|
|
60
|
+
_MAX_RHS_SPAN = 1500
|
|
61
|
+
_COMPARE_WINDOW = 120
|
|
62
|
+
_MAX_OWNER_WALK = 2000
|
|
63
|
+
_MAX_INHERIT_DEPTH = 8
|
|
64
|
+
|
|
65
|
+
# ---------- Masking ----------
|
|
66
|
+
|
|
67
|
+
# One left-to-right pass over the source: comments, preprocessor directives
|
|
68
|
+
# (with continuation lines), ``#if 0`` blocks and string/char/raw-string
|
|
69
|
+
# literals. Comments and directives become spaces; literals keep their quotes
|
|
70
|
+
# and lose their contents. Newlines are preserved so offsets and line numbers
|
|
71
|
+
# don't move. Each construct is consumed up to its terminator, or to the end of
|
|
72
|
+
# the file when it has none, so the scan is linear even on unterminated input.
|
|
73
|
+
_MASK_START_RE = re.compile(
|
|
74
|
+
r"(?P<if0>^[ \t]*\#[ \t]*if[ \t]+0\b)"
|
|
75
|
+
r"|(?P<pp>^[ \t]*\#)"
|
|
76
|
+
r"|(?P<line>//)"
|
|
77
|
+
r"|(?P<block>/\*)"
|
|
78
|
+
r"|(?P<raw>(?<![\w])(?:u8|[uUL])?R\"(?P<delim>[^()\\\s\"]{0,16})\()"
|
|
79
|
+
r"|(?P<str>(?<![\w])(?:u8|[uUL])?\"|\")"
|
|
80
|
+
r"|(?P<chr>')",
|
|
81
|
+
re.MULTILINE,
|
|
82
|
+
)
|
|
83
|
+
_IF0_END_RE = re.compile(r"^[ \t]*\#[ \t]*(?:endif|else|elif)\b[^\n]*", re.MULTILINE)
|
|
84
|
+
_STRING_BODY_RE = re.compile(r"(?:[^\"\\\n]|\\[\s\S])*")
|
|
85
|
+
_CHAR_BODY_RE = re.compile(r"(?:[^'\\\n]|\\[\s\S]){0,8}'")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _blank(text: str) -> str:
|
|
89
|
+
return re.sub(r"[^\n]", " ", text)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def mask_source(text: str) -> str:
|
|
93
|
+
"""Same-length copy of ``text`` with comments, directives and literal
|
|
94
|
+
contents blanked (quotes kept, newlines kept)."""
|
|
95
|
+
out: list[str] = []
|
|
96
|
+
pos = 0
|
|
97
|
+
n = len(text)
|
|
98
|
+
while pos < n:
|
|
99
|
+
match = _MASK_START_RE.search(text, pos)
|
|
100
|
+
if match is None:
|
|
101
|
+
break
|
|
102
|
+
start = match.start()
|
|
103
|
+
out.append(text[pos:start])
|
|
104
|
+
kind = match.lastgroup if match.lastgroup != "delim" else "raw"
|
|
105
|
+
if match.group("raw") is not None:
|
|
106
|
+
kind = "raw"
|
|
107
|
+
if kind == "if0":
|
|
108
|
+
end_match = _IF0_END_RE.search(text, match.end())
|
|
109
|
+
end = end_match.end() if end_match else n
|
|
110
|
+
out.append(_blank(text[start:end]))
|
|
111
|
+
elif kind == "pp":
|
|
112
|
+
end = match.end()
|
|
113
|
+
while True:
|
|
114
|
+
newline = text.find("\n", end)
|
|
115
|
+
if newline == -1:
|
|
116
|
+
end = n
|
|
117
|
+
break
|
|
118
|
+
if text[newline - 1] == "\\":
|
|
119
|
+
end = newline + 1
|
|
120
|
+
continue
|
|
121
|
+
end = newline
|
|
122
|
+
break
|
|
123
|
+
out.append(_blank(text[start:end]))
|
|
124
|
+
elif kind == "line":
|
|
125
|
+
newline = text.find("\n", start)
|
|
126
|
+
end = n if newline == -1 else newline
|
|
127
|
+
out.append(_blank(text[start:end]))
|
|
128
|
+
elif kind == "block":
|
|
129
|
+
close = text.find("*/", match.end())
|
|
130
|
+
end = n if close == -1 else close + 2
|
|
131
|
+
out.append(_blank(text[start:end]))
|
|
132
|
+
elif kind == "raw":
|
|
133
|
+
terminator = ")" + match.group("delim") + '"'
|
|
134
|
+
close = text.find(terminator, match.end())
|
|
135
|
+
if close == -1:
|
|
136
|
+
out.append(text[start : match.end()] + _blank(text[match.end() :]))
|
|
137
|
+
end = n
|
|
138
|
+
else:
|
|
139
|
+
out.append(text[start : match.end()] + _blank(text[match.end() : close + len(terminator) - 1]) + '"')
|
|
140
|
+
end = close + len(terminator)
|
|
141
|
+
elif kind == "str":
|
|
142
|
+
body = _STRING_BODY_RE.match(text, match.end())
|
|
143
|
+
body_end = body.end() if body else match.end()
|
|
144
|
+
out.append(text[start : match.end()] + _blank(text[match.end() : body_end]))
|
|
145
|
+
end = body_end
|
|
146
|
+
if end < n and text[end] == '"':
|
|
147
|
+
out.append('"')
|
|
148
|
+
end += 1
|
|
149
|
+
else: # char literal
|
|
150
|
+
body = _CHAR_BODY_RE.match(text, match.end())
|
|
151
|
+
if body is None:
|
|
152
|
+
out.append("'")
|
|
153
|
+
end = match.end()
|
|
154
|
+
else:
|
|
155
|
+
out.append("'" + _blank(text[match.end() : body.end() - 1]) + "'")
|
|
156
|
+
end = body.end()
|
|
157
|
+
pos = end
|
|
158
|
+
out.append(text[pos:])
|
|
159
|
+
return "".join(out)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------- Calls, functions, arguments ----------
|
|
163
|
+
|
|
164
|
+
_CONTROL_WORDS = frozenset(
|
|
165
|
+
{
|
|
166
|
+
"if", "for", "while", "switch", "catch", "return", "sizeof", "alignof", "decltype",
|
|
167
|
+
"else", "do", "case", "typeof", "__typeof__", "defined", "_Generic", "static_assert",
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
_TRAILING_QUALIFIERS = frozenset({"const", "noexcept", "override", "final", "mutable", "volatile", "throw"})
|
|
171
|
+
_BRACE_RE = re.compile(r"[{}]")
|
|
172
|
+
_WORD_BEFORE_RE = re.compile(r"(?<!\w)([A-Za-z_]\w*)\s*$")
|
|
173
|
+
_TRAILING_RETURN_RE = re.compile(r"->\s*[\w:<>,\s*&]{1,80}$")
|
|
174
|
+
_IDENT_RE = re.compile(r"[A-Za-z_]\w*")
|
|
175
|
+
_SIZEOF_RE = re.compile(r"\bsizeof\s*(?:\([^()]{0,200}\)|[A-Za-z_]\w*)")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass
|
|
179
|
+
class _Scope:
|
|
180
|
+
start: int # offset of the opening brace
|
|
181
|
+
end: int # offset of the closing brace
|
|
182
|
+
name: str
|
|
183
|
+
params: str # masked parameter text
|
|
184
|
+
params_orig: str
|
|
185
|
+
param_names: set[str] = field(default_factory=set)
|
|
186
|
+
parent: int | None = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _skip_ws_back(text: str, i: int) -> int:
|
|
190
|
+
while i >= 0 and text[i] in " \t\r\n":
|
|
191
|
+
i -= 1
|
|
192
|
+
return i
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _match_open_paren_back(text: str, close: int, limit: int) -> int:
|
|
196
|
+
"""Index of the ``(`` matching the ``)`` at ``close``, or -1."""
|
|
197
|
+
depth = 0
|
|
198
|
+
lo = max(-1, close - limit)
|
|
199
|
+
i = close
|
|
200
|
+
while i > lo:
|
|
201
|
+
c = text[i]
|
|
202
|
+
if c == ")":
|
|
203
|
+
depth += 1
|
|
204
|
+
elif c == "(":
|
|
205
|
+
depth -= 1
|
|
206
|
+
if depth == 0:
|
|
207
|
+
return i
|
|
208
|
+
elif c in "{};":
|
|
209
|
+
return -1
|
|
210
|
+
i -= 1
|
|
211
|
+
return -1
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _function_header(masked: str, brace: int) -> tuple[str, int, int] | None:
|
|
215
|
+
"""If the ``{`` at ``brace`` opens a function or lambda body, return
|
|
216
|
+
``(name, params_open, params_close)``."""
|
|
217
|
+
i = _skip_ws_back(masked, brace - 1)
|
|
218
|
+
# Trailing qualifiers / trailing return type.
|
|
219
|
+
for _ in range(4):
|
|
220
|
+
if i < 0:
|
|
221
|
+
return None
|
|
222
|
+
if masked[i] == ")":
|
|
223
|
+
break
|
|
224
|
+
tail = masked[max(0, i - 100) : i + 1]
|
|
225
|
+
arrow = _TRAILING_RETURN_RE.search(tail)
|
|
226
|
+
if arrow is not None:
|
|
227
|
+
i = _skip_ws_back(masked, i - (len(tail) - arrow.start()))
|
|
228
|
+
continue
|
|
229
|
+
word = _WORD_BEFORE_RE.search(tail)
|
|
230
|
+
if word is None or word.group(1) not in _TRAILING_QUALIFIERS:
|
|
231
|
+
return None
|
|
232
|
+
i = _skip_ws_back(masked, i - len(word.group(0)))
|
|
233
|
+
if i < 0 or masked[i] != ")":
|
|
234
|
+
return None
|
|
235
|
+
open_paren = _match_open_paren_back(masked, i, _MAX_PARAMS_SPAN)
|
|
236
|
+
if open_paren < 0:
|
|
237
|
+
return None
|
|
238
|
+
j = _skip_ws_back(masked, open_paren - 1)
|
|
239
|
+
if j < 0:
|
|
240
|
+
return None
|
|
241
|
+
if masked[j] == "]":
|
|
242
|
+
return "<lambda>", open_paren, i
|
|
243
|
+
word = _WORD_BEFORE_RE.search(masked[max(0, j - 80) : j + 1])
|
|
244
|
+
if word is None or word.group(1) in _CONTROL_WORDS:
|
|
245
|
+
return None
|
|
246
|
+
return word.group(1), open_paren, i
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _scopes(masked: str, orig: str) -> list[_Scope]:
|
|
250
|
+
scopes: list[_Scope] = []
|
|
251
|
+
stack: list[int] = []
|
|
252
|
+
for match in _BRACE_RE.finditer(masked):
|
|
253
|
+
if match.group(0) == "{":
|
|
254
|
+
stack.append(match.start())
|
|
255
|
+
continue
|
|
256
|
+
if not stack:
|
|
257
|
+
continue
|
|
258
|
+
start = stack.pop()
|
|
259
|
+
header = _function_header(masked, start)
|
|
260
|
+
if header is None:
|
|
261
|
+
continue
|
|
262
|
+
name, p_open, p_close = header
|
|
263
|
+
params = masked[p_open + 1 : p_close]
|
|
264
|
+
scopes.append(
|
|
265
|
+
_Scope(
|
|
266
|
+
start=start,
|
|
267
|
+
end=match.start(),
|
|
268
|
+
name=name,
|
|
269
|
+
params=params,
|
|
270
|
+
params_orig=orig[p_open + 1 : p_close],
|
|
271
|
+
param_names=_param_names(params),
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
scopes.sort(key=lambda s: s.start)
|
|
275
|
+
# Parent links (the enclosing function of a lambda).
|
|
276
|
+
stack: list[int] = []
|
|
277
|
+
for index, scope in enumerate(scopes):
|
|
278
|
+
while stack and scopes[stack[-1]].end < scope.start:
|
|
279
|
+
stack.pop()
|
|
280
|
+
scope.parent = stack[-1] if stack else None
|
|
281
|
+
stack.append(index)
|
|
282
|
+
return scopes
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _param_names(params: str) -> set[str]:
|
|
286
|
+
names: set[str] = set()
|
|
287
|
+
for piece in _split_top(params, 0, len(params)):
|
|
288
|
+
text = params[piece[0] : piece[1]].split("=", 1)[0]
|
|
289
|
+
text = re.sub(r"\[[^\]]*\]", "", text)
|
|
290
|
+
idents = _IDENT_RE.findall(text)
|
|
291
|
+
if idents:
|
|
292
|
+
names.add(idents[-1])
|
|
293
|
+
return names
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _split_top(text: str, lo: int, hi: int) -> list[tuple[int, int]]:
|
|
297
|
+
"""Top-level comma-separated spans of ``text[lo:hi]``."""
|
|
298
|
+
spans: list[tuple[int, int]] = []
|
|
299
|
+
depth = 0
|
|
300
|
+
start = lo
|
|
301
|
+
for i in range(lo, hi):
|
|
302
|
+
c = text[i]
|
|
303
|
+
if c in "([{":
|
|
304
|
+
depth += 1
|
|
305
|
+
elif c in ")]}":
|
|
306
|
+
depth -= 1
|
|
307
|
+
elif c == "," and depth == 0:
|
|
308
|
+
spans.append((start, i))
|
|
309
|
+
start = i + 1
|
|
310
|
+
if hi > start or spans:
|
|
311
|
+
spans.append((start, hi))
|
|
312
|
+
return spans
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _call_args(masked: str, open_paren: int) -> tuple[list[tuple[int, int]], int] | None:
|
|
316
|
+
"""Argument spans of the call whose ``(`` is at ``open_paren`` and the
|
|
317
|
+
index of its ``)``; None when unbalanced within the bound."""
|
|
318
|
+
depth = 0
|
|
319
|
+
hi = min(len(masked), open_paren + _MAX_ARGS_SPAN)
|
|
320
|
+
for i in range(open_paren, hi):
|
|
321
|
+
c = masked[i]
|
|
322
|
+
if c in "([{":
|
|
323
|
+
depth += 1
|
|
324
|
+
elif c in ")]}":
|
|
325
|
+
depth -= 1
|
|
326
|
+
if depth == 0:
|
|
327
|
+
if c != ")":
|
|
328
|
+
return None
|
|
329
|
+
if i == open_paren + 1 or not masked[open_paren + 1 : i].strip():
|
|
330
|
+
return [], i
|
|
331
|
+
return _split_top(masked, open_paren + 1, i), i
|
|
332
|
+
elif c == ";" and depth == 1:
|
|
333
|
+
return None
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ---------- Catalogs ----------
|
|
338
|
+
|
|
339
|
+
# Sinks, by family. Values are argument indexes.
|
|
340
|
+
_UNBOUNDED_COPY = {"strcpy": (0, 1), "strcat": (0, 1), "wcscpy": (0, 1), "wcscat": (0, 1), "stpcpy": (0, 1),
|
|
341
|
+
"lstrcpy": (0, 1), "lstrcat": (0, 1), "lstrcpyA": (0, 1), "lstrcatA": (0, 1)}
|
|
342
|
+
_SPRINTF = {"sprintf": 1, "vsprintf": 1}
|
|
343
|
+
# Format argument index for printf-family calls.
|
|
344
|
+
_FORMAT_FUNCS = {
|
|
345
|
+
"printf": 0, "vprintf": 0, "wprintf": 0, "vwprintf": 0,
|
|
346
|
+
"fprintf": 1, "vfprintf": 1, "fwprintf": 1,
|
|
347
|
+
"sprintf": 1, "vsprintf": 1, "asprintf": 1, "vasprintf": 1,
|
|
348
|
+
"snprintf": 2, "vsnprintf": 2, "swprintf": 2,
|
|
349
|
+
"syslog": 1, "vsyslog": 1,
|
|
350
|
+
# civetweb / mongoose response writers take a printf format.
|
|
351
|
+
"mg_printf": 1, "mg_http_reply": 3,
|
|
352
|
+
}
|
|
353
|
+
_VA_FORMAT_FUNCS = frozenset(n for n in _FORMAT_FUNCS if n.startswith("v"))
|
|
354
|
+
_SCANF = {"scanf": 0, "fscanf": 1, "sscanf": 1, "wscanf": 0, "fwscanf": 1, "swscanf": 1}
|
|
355
|
+
_SHELL_COMMAND = {"system": 0, "popen": 0, "_popen": 0, "_wsystem": 0, "_wpopen": 0}
|
|
356
|
+
_EXEC = frozenset({"execl", "execlp", "execle", "execv", "execvp", "execvpe", "execve", "posix_spawn", "posix_spawnp"})
|
|
357
|
+
# (dst, src, length) for bounded copies whose length is the risk.
|
|
358
|
+
_LENGTH_COPY = {"memcpy": (0, 1, 2), "memmove": (0, 1, 2), "strncpy": (0, 1, 2), "strncat": (0, 1, 2),
|
|
359
|
+
"wmemcpy": (0, 1, 2), "wmemmove": (0, 1, 2), "bcopy": (1, 0, 2), "CopyMemory": (0, 1, 2),
|
|
360
|
+
"RtlCopyMemory": (0, 1, 2)}
|
|
361
|
+
_ALLOC = {"malloc": 0, "alloca": 0, "_alloca": 0, "realloc": 1, "xmalloc": 0, "xrealloc": 1}
|
|
362
|
+
|
|
363
|
+
# Out-parameter sources: function -> (dst arg index, source kind or a
|
|
364
|
+
# resolver keyword). "stream:<n>" means the kind follows the stream at
|
|
365
|
+
# argument n (stdin vs file); "fd:<n>" the descriptor at argument n.
|
|
366
|
+
_OUT_PARAM_SOURCES: dict[str, tuple[int, str]] = {
|
|
367
|
+
"recv": (1, "network"), "recvfrom": (1, "network"), "SSL_read": (1, "network"),
|
|
368
|
+
"SSL_read_ex": (1, "network"), "BIO_read": (1, "network"), "mbedtls_ssl_read": (1, "network"),
|
|
369
|
+
"gnutls_record_recv": (1, "network"), "WSARecv": (1, "network"),
|
|
370
|
+
"read": (1, "fd:0"), "pread": (1, "fd:0"),
|
|
371
|
+
"fgets": (0, "stream:2"), "fgetws": (0, "stream:2"), "fread": (0, "stream:3"),
|
|
372
|
+
"getline": (0, "stream:2"), "getdelim": (0, "stream:3"), "gets": (0, "stdin"),
|
|
373
|
+
# civetweb
|
|
374
|
+
"mg_read": (1, "request"), "mg_get_var": (3, "request"), "mg_get_var2": (3, "request"),
|
|
375
|
+
"mg_get_cookie": (2, "request"), "mg_get_form_value": (2, "request"),
|
|
376
|
+
# mongoose
|
|
377
|
+
"mg_http_get_var": (2, "request"),
|
|
378
|
+
}
|
|
379
|
+
# Calls that copy taint from source argument(s) to a destination argument.
|
|
380
|
+
# Value: (dst index, first source index, all-following?)
|
|
381
|
+
_PROPAGATORS: dict[str, tuple[int, int, bool]] = {
|
|
382
|
+
"strcpy": (0, 1, False), "strncpy": (0, 1, False), "strcat": (0, 1, False), "strncat": (0, 1, False),
|
|
383
|
+
"stpcpy": (0, 1, False), "strlcpy": (0, 1, False), "strlcat": (0, 1, False),
|
|
384
|
+
"wcscpy": (0, 1, False), "wcscat": (0, 1, False),
|
|
385
|
+
"memcpy": (0, 1, False), "memmove": (0, 1, False),
|
|
386
|
+
"sprintf": (0, 1, True), "vsprintf": (0, 1, True), "snprintf": (0, 2, True), "vsnprintf": (0, 2, True),
|
|
387
|
+
"mg_url_decode": (2, 0, False), "mg_snprintf": (2, 3, True),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# Expressions that are a source wherever they appear (masked text).
|
|
391
|
+
_DIRECT_SOURCES: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
392
|
+
("env", re.compile(r"(?<![\w.>])(?:std::)?(?:secure_getenv|getenv|_wgetenv)\s*\(")),
|
|
393
|
+
# civetweb / mongoose / libmicrohttpd request accessors
|
|
394
|
+
("request", re.compile(r"\bmg_get_(?:header|request_info|request_link)\s*\(")),
|
|
395
|
+
("request", re.compile(r"\bmg_http_(?:get_header|get_header_var|var)\s*\(")),
|
|
396
|
+
("request", re.compile(r"\bMHD_lookup_connection_value(?:_n)?\s*\(")),
|
|
397
|
+
# cpp-httplib / Crow / Drogon / Pistache / restbed accessors
|
|
398
|
+
("request", re.compile(r"\.\s*get_(?:param|header|file)_value\s*\(")),
|
|
399
|
+
("request", re.compile(r"\.\s*url_params\b")),
|
|
400
|
+
)
|
|
401
|
+
# Parameter types whose variable is request data. Qualified framework types
|
|
402
|
+
# always count; bare names only in a file that uses that framework (gates).
|
|
403
|
+
_REQUEST_TYPE_RE = re.compile(
|
|
404
|
+
r"\bhttplib::Request\b|\bcrow::request\b|\b(?:drogon::)?HttpRequestPtr\b|\bdrogon::HttpRequest\b"
|
|
405
|
+
r"|\bPistache::(?:Rest|Http)::Request\b|\b(?:boost::)?beast::http::request\s*<"
|
|
406
|
+
r"|\brestbed::(?:Session|Request)\b"
|
|
407
|
+
)
|
|
408
|
+
_MG_REQUEST_TYPE_RE = re.compile(r"\bstruct\s+(?:mg_http_message|mg_request_info)\b")
|
|
409
|
+
_STRUCT_REQUEST_RE = re.compile(r"\bstruct\s+\w+")
|
|
410
|
+
_FRAMEWORK_GATES: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
411
|
+
("httplib", re.compile(r"\bhttplib\b")),
|
|
412
|
+
("pistache", re.compile(r"\b[Pp]istache\b")),
|
|
413
|
+
("beast", re.compile(r"\bbeast\b")),
|
|
414
|
+
("restbed", re.compile(r"\brestbed\b")),
|
|
415
|
+
)
|
|
416
|
+
_GATED_REQUEST_TYPES: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
417
|
+
("httplib", re.compile(r"(?<![\w:])Request\b")),
|
|
418
|
+
("pistache", re.compile(r"\b(?:Rest|Http)::Request\b")),
|
|
419
|
+
("beast", re.compile(r"\b(?:http::)?request\s*<")),
|
|
420
|
+
("restbed", re.compile(r"\bshared_ptr\s*<\s*(?:const\s+)?(?:restbed::)?(?:Session|Request)\s*>")),
|
|
421
|
+
)
|
|
422
|
+
_MG_LOCAL_DECL_RE = re.compile(r"\bstruct\s+(?:mg_http_message|mg_request_info)\s*\*\s*(?:const\s+)?([A-Za-z_]\w*)")
|
|
423
|
+
# libmicrohttpd access-handler parameters that carry the request.
|
|
424
|
+
_MHD_HANDLER_RE = re.compile(r"\bstruct\s+MHD_Connection\b")
|
|
425
|
+
_MHD_REQUEST_PARAMS = frozenset({"url", "method", "version", "upload_data", "upload_data_size"})
|
|
426
|
+
# oat++ ENDPOINT parameter macros.
|
|
427
|
+
_OATPP_PARAM_RE = re.compile(r"\b(?:QUERY|PATH|HEADER|BODY_STRING|BODY_DTO)\s*\(\s*[^,()]{1,120},\s*([A-Za-z_]\w*)")
|
|
428
|
+
_ARGV_PARAM_RE = re.compile(r"\bargv\b")
|
|
429
|
+
_ARGV_USE_RE = re.compile(r"\bargv\s*\[|\boptarg\b")
|
|
430
|
+
# `std::string t(req.target());` / `std::string s{buf};`: direct-initialized
|
|
431
|
+
# strings take their argument's data like an assignment.
|
|
432
|
+
_STRING_CTOR_DECL_RE = re.compile(
|
|
433
|
+
r"\b(?:std::)?(?:string|wstring|string_view|basic_string\s*<[^<>;\n]{0,40}>)\s+([A-Za-z_]\w*)\s*[({]"
|
|
434
|
+
)
|
|
435
|
+
_CIN_RE = re.compile(r"\b(?:std::)?(?:cin|wcin)\s*>>\s*([A-Za-z_]\w*)")
|
|
436
|
+
_GETLINE_CIN_RE = re.compile(r"\b(?:std::)?getline\s*\(\s*(?:std::)?(?:cin|wcin)\s*,\s*([A-Za-z_]\w*)")
|
|
437
|
+
|
|
438
|
+
_STDIN_ARG_RE = re.compile(r"^\s*(?:stdin|0|STDIN_FILENO|std::cin)\s*$")
|
|
439
|
+
_SOCKET_NAME_RE = re.compile(r"(?i)sock|conn|client|peer|remote|accept")
|
|
440
|
+
|
|
441
|
+
# Conversions that make a value numeric (taint kept, string-ness dropped).
|
|
442
|
+
_NUMERIC_RHS_RE = re.compile(
|
|
443
|
+
r"^\s*(?:\(\s*(?:unsigned\s+|signed\s+)?[A-Za-z_][\w:]*\s*\)\s*)?"
|
|
444
|
+
r"(?:(?:std::)?(?:atoi|atol|atoll|atof|strtol|strtoul|strtoll|strtoull|strtoumax|strtoimax|strtod|"
|
|
445
|
+
r"stoi|stol|stoul|stoll|stoull|stod)|ntohs|ntohl|be16toh|be32toh|be64toh|le16toh|le32toh|le64toh|"
|
|
446
|
+
r"strlen|wcslen|strnlen)\s*\("
|
|
447
|
+
)
|
|
448
|
+
_INT_DECL_RE = re.compile(
|
|
449
|
+
r"\b(?:(?:unsigned|signed|long|short|int|size_t|ssize_t|off_t|u?int(?:8|16|32|64)_t|DWORD|uint|ulong|"
|
|
450
|
+
r"std::size_t|socklen_t)\s+){1,4}(?:const\s+)?([A-Za-z_]\w*)\s*[=;,\[)]"
|
|
451
|
+
)
|
|
452
|
+
_CLAMP_RE = re.compile(r"\b(?:MIN|min|std::min|MG_MIN|g_min|qMin)\s*[(<]")
|
|
453
|
+
_ALLOC_ASSIGN_RE = re.compile(
|
|
454
|
+
r"(?<!\w)([A-Za-z_]\w*)\s*(?:\[[^\]\n]{0,80}\])?\s*=\s*(?:\([^()\n]{1,60}\)\s*)?"
|
|
455
|
+
r"(?:(?:std::)?(?:malloc|calloc|realloc|alloca|_alloca|xmalloc|xcalloc|xrealloc|mg_malloc|mg_calloc|"
|
|
456
|
+
r"mg_realloc|mg_malloc_ctx|mg_calloc_ctx|g_malloc|g_malloc0|OPENSSL_malloc)\s*\("
|
|
457
|
+
r"|new\s+[\w:]+(?:\s*<[^<>;\n]{0,60}>)?\s*\[)"
|
|
458
|
+
)
|
|
459
|
+
_RESIZE_RE = re.compile(r"(?<!\w)([A-Za-z_]\w*)\s*(?:\.|->)\s*(?:resize|reserve|assign)\s*\(")
|
|
460
|
+
_ASSIGN_RE = re.compile(
|
|
461
|
+
r"(?<![\w.>])([A-Za-z_]\w*(?:\s*(?:\.|->)\s*[A-Za-z_]\w*){0,4})\s*(?:\[[^\]\n]{0,80}\])?\s*"
|
|
462
|
+
r"(?:[+\-*/|&^%]|<<|>>)?=(?!=)"
|
|
463
|
+
)
|
|
464
|
+
_DECL_STRING_LITERAL_RE = re.compile(
|
|
465
|
+
r"\b(?:const\s+)?(?:char|wchar_t|TCHAR|std::string|string)\s*(?:const\s*)?\*?\s*(?:const\s+)?"
|
|
466
|
+
r"([A-Za-z_]\w*)\s*(?:\[\s*\w*\s*\])?\s*(?:=\s*\{?|\{)\s*(?:u8|[uUL])?\"[^\"\n]*\"\s*[;,)}]"
|
|
467
|
+
)
|
|
468
|
+
# `char buf[256], small[16];`: declared byte/char arrays with a literal size.
|
|
469
|
+
_ARRAY_DECL_START_RE = re.compile(r"\b(?:char|wchar_t|TCHAR|u?int8_t|BYTE|uchar)\s+(?=[*\s]*[A-Za-z_])")
|
|
470
|
+
_ARRAY_DECLARATOR_RE = re.compile(r"(?:^|,)\s*(?:\*+\s*)?([A-Za-z_]\w*)\s*\[\s*(\d{1,9})\s*\]")
|
|
471
|
+
# `x.c_str()` / `p->data()`: a call that only exposes a variable's data.
|
|
472
|
+
_DATA_CALL_RE = re.compile(r"(?:\.|->)\s*(?:c_str|data|get|str|toStdString|constData|toUtf8)\s*\(\s*\)\s*$")
|
|
473
|
+
# A call to a free function (not a method): `f(`, `ns::f(`, `f<T>(`.
|
|
474
|
+
_FREE_CALL_RE = re.compile(r"(?<![\w.>:])((?:[A-Za-z_]\w*::){0,4}[A-Za-z_]\w*)\s*(?:<[^<>;()\n]{0,60}>\s*)?\(")
|
|
475
|
+
# Free functions whose result still carries their argument's data: sources,
|
|
476
|
+
# string views/copies/searches, numeric conversions and casts.
|
|
477
|
+
_PASSTHROUGH_CALLS = frozenset(
|
|
478
|
+
{
|
|
479
|
+
"getenv", "secure_getenv", "_wgetenv", "mg_get_header", "mg_get_request_info", "mg_get_request_link",
|
|
480
|
+
"mg_http_get_header", "mg_http_get_header_var", "mg_http_var", "MHD_lookup_connection_value",
|
|
481
|
+
"MHD_lookup_connection_value_n",
|
|
482
|
+
"strdup", "strndup", "_strdup", "wcsdup", "mg_strdup", "mg_strndup", "mg_strdup_ctx", "mg_strndup_ctx",
|
|
483
|
+
"strchr", "strrchr", "strstr", "strcasestr", "strpbrk", "strtok", "strtok_r", "strsep", "memchr",
|
|
484
|
+
"mg_str", "mg_str_n", "mg_strdup", "basename", "dirname", "realpath",
|
|
485
|
+
"string", "string_view", "basic_string", "move", "forward", "data", "c_str",
|
|
486
|
+
"atoi", "atol", "atoll", "atof", "strtol", "strtoul", "strtoll", "strtoull", "strtoumax", "strtoimax",
|
|
487
|
+
"strtod", "stoi", "stol", "stoul", "stoll", "stoull", "stod", "ntohs", "ntohl", "htons", "htonl",
|
|
488
|
+
"be16toh", "be32toh", "be64toh", "le16toh", "le32toh", "le64toh", "strlen", "wcslen", "strnlen",
|
|
489
|
+
"static_cast", "reinterpret_cast", "const_cast", "size_t", "int", "long", "unsigned",
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
_LEADING_CAST_RE = re.compile(r"^\(\s*(?:unsigned\s+|signed\s+|const\s+)?[A-Za-z_][\w:]*\s*\**\s*\)\s*")
|
|
493
|
+
_INDEXED_IDENT_RE = re.compile(r"(?<!\w)([A-Za-z_]\w*)\s*\[[^\]]{0,60}\]")
|
|
494
|
+
# Copies that bound the destination (its content stays input, its length
|
|
495
|
+
# doesn't grow past the destination).
|
|
496
|
+
_BOUNDED_PROPAGATORS = frozenset(
|
|
497
|
+
{"strncpy", "strlcpy", "strlcat", "strncat", "snprintf", "vsnprintf", "memcpy", "memmove", "mg_url_decode",
|
|
498
|
+
"mg_snprintf"}
|
|
499
|
+
)
|
|
500
|
+
_DEFINE_STRING_RE = re.compile(r"^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)[ \t]+(?:u8|[uUL])?\"", re.MULTILINE)
|
|
501
|
+
_COMPARE_OP_RE = re.compile(r"(?<![-<>=!])(?:<=|>=|<|>)(?![<>=])")
|
|
502
|
+
_OVERFLOW_GUARD_RE = re.compile(r"\b(?:SIZE_MAX|INT_MAX|UINT_MAX|LONG_MAX|SSIZE_MAX|PTRDIFF_MAX|__builtin_mul_overflow|"
|
|
503
|
+
r"ckd_mul|std::numeric_limits)\b")
|
|
504
|
+
# A binary multiplication or left shift (an operand before it, so `*p` and
|
|
505
|
+
# `(char *)` casts don't count).
|
|
506
|
+
_MUL_RE = re.compile(r"[\w)\]]\s*(?:\*(?![*/=)])|<<(?!=))")
|
|
507
|
+
_GETTEXT_RE = re.compile(r"^\s*(?:_|N_|gettext|dgettext|_T|TEXT|L|tr|QObject::tr)\s*\(")
|
|
508
|
+
_ALL_CAPS_RE = re.compile(r"^\s*[A-Z][A-Z0-9_]*\s*$")
|
|
509
|
+
# Literals only, possibly concatenated with macros (`"%" PRIu64 "\n"`);
|
|
510
|
+
# `"ping " + host` is not constant. A macro name is matched possessively and
|
|
511
|
+
# never as a string prefix (`u8""` is a literal, not the macro `u8` then `""`),
|
|
512
|
+
# so no input can be split more than one way.
|
|
513
|
+
_LITERAL_ONLY_RE = re.compile(r"^\s*(?:u8|[uUL])?\"[^\"\n]*\"(?:\s*(?:(?:u8|[uUL])?\"[^\"\n]*\"|\b(?!(?:u8|[uUL])\")[A-Za-z_]\w*+))*\s*$")
|
|
514
|
+
_LITERAL_START_RE = re.compile(r"^\s*(?:u8|[uUL])?\"")
|
|
515
|
+
_CONVERSION_RE = re.compile(
|
|
516
|
+
r"%(?:%|(?P<flags>[-+ #0']*)(?P<width>\*|\d+)?(?:\.(?P<prec>\*|\d+))?"
|
|
517
|
+
r"(?P<len>hh|h|ll|l|L|q|j|z|Z|t|I64|I32)?(?P<conv>[diouxXeEfFgGaAcspnCS]|\[))"
|
|
518
|
+
)
|
|
519
|
+
_SCANF_CONVERSION_RE = re.compile(
|
|
520
|
+
r"%(?:%|(?P<suppress>\*)?(?P<width>\d+)?(?P<alloc>m)?(?P<len>hh|h|ll|l|L|q|j|z|t)?"
|
|
521
|
+
r"(?P<conv>[diouxXeEfFgGaAcspn]|\[\^?\]?[^\]]{0,64}\]))"
|
|
522
|
+
)
|
|
523
|
+
_SH_C_RE = re.compile(r"^\s*\"-c\"\s*$")
|
|
524
|
+
|
|
525
|
+
_ALL_CALLS = sorted(
|
|
526
|
+
set(_UNBOUNDED_COPY) | set(_FORMAT_FUNCS) | set(_SCANF) | set(_SHELL_COMMAND) | _EXEC
|
|
527
|
+
| set(_LENGTH_COPY) | set(_ALLOC) | set(_OUT_PARAM_SOURCES) | set(_PROPAGATORS) | {"gets", "getline"},
|
|
528
|
+
key=lambda n: (-len(n), n),
|
|
529
|
+
)
|
|
530
|
+
_SINK_CALLS = frozenset(
|
|
531
|
+
set(_UNBOUNDED_COPY) | set(_FORMAT_FUNCS) | set(_SCANF) | set(_SHELL_COMMAND) | _EXEC | set(_LENGTH_COPY)
|
|
532
|
+
| set(_ALLOC) | {"gets"}
|
|
533
|
+
)
|
|
534
|
+
_CALL_RE = re.compile(r"(?<![\w.>:$])(?:(?:std)?::)?(" + "|".join(_ALL_CALLS) + r")\s*\(")
|
|
535
|
+
_NEW_ARRAY_RE = re.compile(r"\bnew\s+(?:[\w:]+(?:\s*<[^<>;\n]{0,60}>)?)\s*\[")
|
|
536
|
+
|
|
537
|
+
_REMOTE = frozenset({"request", "network"})
|
|
538
|
+
_DOWNRANK_SEGMENTS = frozenset({"example", "examples", "sample", "samples", "demo", "demos", "contrib", "doc", "docs",
|
|
539
|
+
"tutorial", "tutorials"})
|
|
540
|
+
_TEST_SEGMENTS = frozenset({"test", "tests", "testing", "unittest", "unittests", "unit_test", "unit_tests", "fuzz",
|
|
541
|
+
"fuzztest", "fuzzing", "fuzzer", "fuzzers", "bench", "benchmark", "benchmarks", "mock",
|
|
542
|
+
"mocks", "spec", "specs", "__tests__", "fixtures", "gtest", "googletest"})
|
|
543
|
+
_TEST_FILE_RE = re.compile(r"(?i)(?:^test_|_test\.|_tests\.|_unittest\.|_fuzzer\.|^fuzz_|\.test\.)")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def is_test_path(relative: str) -> bool:
|
|
547
|
+
"""Test, fuzz and benchmark code: skipped (``ATTACKMAP_INCLUDE_TESTS`` opts in)."""
|
|
548
|
+
if os.environ.get("ATTACKMAP_INCLUDE_TESTS"):
|
|
549
|
+
return False
|
|
550
|
+
parts = PurePosixPath(relative.replace("\\", "/")).parts
|
|
551
|
+
if any(p.lower() in _TEST_SEGMENTS for p in parts[:-1]):
|
|
552
|
+
return True
|
|
553
|
+
return bool(parts) and _TEST_FILE_RE.search(parts[-1]) is not None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _is_downranked(relative: str) -> bool:
|
|
557
|
+
parts = PurePosixPath(relative.replace("\\", "/")).parts
|
|
558
|
+
return any(p.lower() in _DOWNRANK_SEGMENTS for p in parts[:-1])
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------- Taint state ----------
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass(frozen=True)
|
|
565
|
+
class _Taint:
|
|
566
|
+
kind: str
|
|
567
|
+
line: int
|
|
568
|
+
numeric: bool = False
|
|
569
|
+
# A string whose length is capped (a sized read, a width-limited %s);
|
|
570
|
+
# `max_len` is the cap when known (the destination array's size).
|
|
571
|
+
bounded: bool = False
|
|
572
|
+
max_len: int | None = None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
class _State:
|
|
576
|
+
"""Per-function taint state. Facts are kept with prefix-best arrays so a
|
|
577
|
+
lookup is a bisect, not a scan of every earlier fact."""
|
|
578
|
+
|
|
579
|
+
def __init__(self) -> None:
|
|
580
|
+
# name -> (positions, best taint up to each position, best string taint)
|
|
581
|
+
self._facts: dict[str, tuple[list[int], list[_Taint | None], list[_Taint | None]]] = {}
|
|
582
|
+
self.constant_assigned: dict[str, bool] = {}
|
|
583
|
+
self.allocs: dict[str, list[tuple[int, str]]] = {}
|
|
584
|
+
self.resized: dict[str, list[int]] = {}
|
|
585
|
+
# `n = strlen(s)` / `n = s.size()`: n measures s, so checking n bounds s.
|
|
586
|
+
self.length_of: dict[str, set[str]] = {}
|
|
587
|
+
self.measured_by: dict[str, set[str]] = {}
|
|
588
|
+
# First offset where a call wrote into a variable (out-parameter, copy).
|
|
589
|
+
self.written_first: dict[str, int] = {}
|
|
590
|
+
# Offsets where a variable was clamped (`m = MIN(n, cap)`).
|
|
591
|
+
self.clamped: dict[str, list[int]] = {}
|
|
592
|
+
self._raw: dict[str, list[object]] = {}
|
|
593
|
+
|
|
594
|
+
def write(self, name: str, pos: int) -> None:
|
|
595
|
+
if pos < self.written_first.get(name, pos + 1):
|
|
596
|
+
self.written_first[name] = pos
|
|
597
|
+
|
|
598
|
+
def written_before(self, name: str, pos: int) -> bool:
|
|
599
|
+
return self.written_first.get(name, pos) < pos
|
|
600
|
+
|
|
601
|
+
def measure(self, alias: str, measured: set[str]) -> None:
|
|
602
|
+
self.length_of.setdefault(alias, set()).update(measured)
|
|
603
|
+
for name in measured:
|
|
604
|
+
self.measured_by.setdefault(name, set()).add(alias)
|
|
605
|
+
|
|
606
|
+
def add(self, name: str, pos: int, taint: _Taint) -> None:
|
|
607
|
+
self._record(name, pos, taint)
|
|
608
|
+
|
|
609
|
+
def clear(self, name: str, pos: int) -> None:
|
|
610
|
+
"""A reassignment to a constant drops the taint from here on."""
|
|
611
|
+
if name in self._facts:
|
|
612
|
+
self._record(name, pos, _CLEAR)
|
|
613
|
+
|
|
614
|
+
def _record(self, name: str, pos: int, fact: object) -> None:
|
|
615
|
+
positions, best_any, best_str = self._facts.setdefault(name, ([], [], []))
|
|
616
|
+
raw = self._raw.setdefault(name, [])
|
|
617
|
+
index = bisect.bisect_right(positions, pos)
|
|
618
|
+
positions.insert(index, pos)
|
|
619
|
+
raw.insert(index, fact)
|
|
620
|
+
best_any.insert(index, None)
|
|
621
|
+
best_str.insert(index, None)
|
|
622
|
+
for i in range(index, len(positions)):
|
|
623
|
+
prev_any = best_any[i - 1] if i > 0 else None
|
|
624
|
+
prev_str = best_str[i - 1] if i > 0 else None
|
|
625
|
+
item = raw[i]
|
|
626
|
+
if item is _CLEAR:
|
|
627
|
+
best_any[i], best_str[i] = None, None
|
|
628
|
+
else:
|
|
629
|
+
best_any[i] = _better(prev_any, item)
|
|
630
|
+
best_str[i] = prev_str if item.numeric else _better(prev_str, item)
|
|
631
|
+
|
|
632
|
+
def get(self, name: str, before: int, *, want_string: bool) -> _Taint | None:
|
|
633
|
+
entry = self._facts.get(name)
|
|
634
|
+
if entry is None:
|
|
635
|
+
return None
|
|
636
|
+
index = bisect.bisect_left(entry[0], before) - 1
|
|
637
|
+
if index < 0:
|
|
638
|
+
return None
|
|
639
|
+
return (entry[2] if want_string else entry[1])[index]
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
_CLEAR = object()
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _rank(taint: _Taint) -> int:
|
|
646
|
+
"""Remote input beats local; an unbounded string beats a bounded one."""
|
|
647
|
+
return (4 if taint.kind in _REMOTE else 2) + (0 if taint.bounded else 1)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _better(a: _Taint | None, b: _Taint | None) -> _Taint | None:
|
|
651
|
+
if a is None:
|
|
652
|
+
return b
|
|
653
|
+
if b is None:
|
|
654
|
+
return a
|
|
655
|
+
return a if _rank(a) >= _rank(b) else b
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _base_ident(expr: str) -> str | None:
|
|
659
|
+
"""The variable an lvalue/argument expression names: ``&buf[2]`` ->
|
|
660
|
+
``buf``, ``(char *) p + 1`` -> ``p``, ``s.data()`` -> ``s``."""
|
|
661
|
+
text = expr.strip()
|
|
662
|
+
text = re.sub(r"^\(\s*[^()]{0,60}\)\s*", "", text) # leading cast
|
|
663
|
+
text = text.lstrip("&*( ")
|
|
664
|
+
match = _IDENT_RE.match(text)
|
|
665
|
+
if match is None:
|
|
666
|
+
return None
|
|
667
|
+
name = match.group(0)
|
|
668
|
+
if name in ("std", "static_cast", "reinterpret_cast", "const_cast"):
|
|
669
|
+
inner = re.search(r"\(\s*&?\s*([A-Za-z_]\w*)", text)
|
|
670
|
+
return inner.group(1) if inner else None
|
|
671
|
+
return name
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@dataclass
|
|
675
|
+
class _Ctx:
|
|
676
|
+
masked: str
|
|
677
|
+
orig: str
|
|
678
|
+
scope: _Scope
|
|
679
|
+
state: _State
|
|
680
|
+
request_vars: dict[str, int] # name -> line where it enters the function
|
|
681
|
+
argv_scope: bool
|
|
682
|
+
int_vars: set[str]
|
|
683
|
+
constants: set[str]
|
|
684
|
+
int_decl_positions: dict[str, int]
|
|
685
|
+
array_sizes: dict[str, int] = field(default_factory=dict)
|
|
686
|
+
# name -> sorted offsets of an upper-bound check on it (precomputed).
|
|
687
|
+
bounds: dict[str, list[int]] = field(default_factory=dict)
|
|
688
|
+
overflow_guards: list[int] = field(default_factory=list)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _opaque_calls_blanked(text: str) -> str:
|
|
692
|
+
"""``text`` with the arguments of calls to unknown free functions blanked:
|
|
693
|
+
``gethostbyname(argv[1])`` or ``sanitize(name)`` returns something else,
|
|
694
|
+
so its argument's taint doesn't carry over. Method calls, sources and the
|
|
695
|
+
known pass-through functions (``strdup``, ``strchr``, ``atoi``, casts...)
|
|
696
|
+
keep theirs."""
|
|
697
|
+
if "(" not in text:
|
|
698
|
+
return text
|
|
699
|
+
out = list(text)
|
|
700
|
+
for match in _FREE_CALL_RE.finditer(text):
|
|
701
|
+
name = match.group(1).rsplit("::", 1)[-1]
|
|
702
|
+
if name in _PASSTHROUGH_CALLS or name in _CONTROL_WORDS:
|
|
703
|
+
continue
|
|
704
|
+
depth = 0
|
|
705
|
+
for i in range(match.end() - 1, min(len(text), match.end() - 1 + _MAX_ARGS_SPAN)):
|
|
706
|
+
c = text[i]
|
|
707
|
+
if c == "(":
|
|
708
|
+
depth += 1
|
|
709
|
+
elif c == ")":
|
|
710
|
+
depth -= 1
|
|
711
|
+
if depth == 0:
|
|
712
|
+
for j in range(match.end(), i):
|
|
713
|
+
if out[j] != "\n":
|
|
714
|
+
out[j] = " "
|
|
715
|
+
break
|
|
716
|
+
return "".join(out)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _expr_taint(ctx: _Ctx, lo: int, hi: int, *, want_string: bool) -> _Taint | None:
|
|
720
|
+
"""Taint of the expression ``masked[lo:hi]`` as of offset ``lo``."""
|
|
721
|
+
text = _opaque_calls_blanked(ctx.masked[lo:hi])
|
|
722
|
+
if not text.strip():
|
|
723
|
+
return None
|
|
724
|
+
found: _Taint | None = None
|
|
725
|
+
line = line_of(ctx.orig, lo)
|
|
726
|
+
for kind, pattern in _DIRECT_SOURCES:
|
|
727
|
+
if pattern.search(text):
|
|
728
|
+
found = _better(found, _Taint(kind, line))
|
|
729
|
+
if ctx.argv_scope and _ARGV_USE_RE.search(text):
|
|
730
|
+
found = _better(found, _Taint("argv", line))
|
|
731
|
+
stripped = _SIZEOF_RE.sub(" ", text)
|
|
732
|
+
for match in _IDENT_RE.finditer(stripped):
|
|
733
|
+
start = match.start()
|
|
734
|
+
before = stripped[max(0, start - 2) : start]
|
|
735
|
+
if before.endswith(".") or before == "->":
|
|
736
|
+
continue
|
|
737
|
+
name = match.group(0)
|
|
738
|
+
if name in ctx.request_vars:
|
|
739
|
+
found = _better(found, _Taint("request", ctx.request_vars[name]))
|
|
740
|
+
continue
|
|
741
|
+
taint = ctx.state.get(name, lo, want_string=want_string)
|
|
742
|
+
if taint is not None:
|
|
743
|
+
found = _better(found, taint)
|
|
744
|
+
return found
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _is_numeric_expr(ctx: _Ctx, text: str) -> bool:
|
|
748
|
+
if _NUMERIC_RHS_RE.match(text):
|
|
749
|
+
return True
|
|
750
|
+
stripped = text.strip()
|
|
751
|
+
return stripped.endswith((".size()", ".length()", ".len", "->len", "_len", ".size", "->size"))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _is_constant_expr(ctx: _Ctx, lo: int, hi: int, depth: int = 0) -> bool:
|
|
755
|
+
"""A string literal (possibly concatenated with macros), an ALL_CAPS macro,
|
|
756
|
+
a gettext-wrapped literal, a literal ternary, or a variable that is only
|
|
757
|
+
ever assigned constants."""
|
|
758
|
+
text = ctx.masked[lo:hi].strip()
|
|
759
|
+
if not text or depth > 3:
|
|
760
|
+
return False
|
|
761
|
+
if _LITERAL_ONLY_RE.match(text) or _ALL_CAPS_RE.match(text):
|
|
762
|
+
return True
|
|
763
|
+
if text.startswith("(") and text.endswith(")"):
|
|
764
|
+
inner_lo = ctx.masked.index("(", lo) + 1
|
|
765
|
+
inner_hi = ctx.masked.rindex(")", lo, hi)
|
|
766
|
+
if _is_constant_expr(ctx, inner_lo, inner_hi, depth + 1):
|
|
767
|
+
return True
|
|
768
|
+
gettext = _GETTEXT_RE.match(text)
|
|
769
|
+
if gettext is not None and text.endswith(")"):
|
|
770
|
+
inner_lo = lo + ctx.masked[lo:hi].index("(") + 1
|
|
771
|
+
inner_hi = ctx.masked.rindex(")", lo, hi)
|
|
772
|
+
return _is_constant_expr(ctx, inner_lo, inner_hi, depth + 1)
|
|
773
|
+
question = _top_level_index(text, "?")
|
|
774
|
+
if question >= 0:
|
|
775
|
+
colon = _top_level_index(text, ":", question + 1)
|
|
776
|
+
if colon > question:
|
|
777
|
+
offset = lo + (len(ctx.masked[lo:hi]) - len(ctx.masked[lo:hi].lstrip()))
|
|
778
|
+
return _is_constant_expr(ctx, offset + question + 1, offset + colon, depth + 1) and _is_constant_expr(
|
|
779
|
+
ctx, offset + colon + 1, offset + len(text), depth + 1
|
|
780
|
+
)
|
|
781
|
+
data_call = _DATA_CALL_RE.search(text)
|
|
782
|
+
if data_call is not None and depth < 3:
|
|
783
|
+
# `cmd.c_str()` / `fmt.data()`: as constant as the variable itself.
|
|
784
|
+
base = text[: data_call.start()].strip()
|
|
785
|
+
ident = _IDENT_RE.fullmatch(base)
|
|
786
|
+
if ident is None:
|
|
787
|
+
return False
|
|
788
|
+
text = base
|
|
789
|
+
ident = _IDENT_RE.fullmatch(text) or _INDEXED_IDENT_RE.fullmatch(text)
|
|
790
|
+
if ident is not None:
|
|
791
|
+
name = ident.group(1) if ident.re is _INDEXED_IDENT_RE else ident.group(0)
|
|
792
|
+
if ctx.state.written_before(name, lo):
|
|
793
|
+
return False # `char cmd[64] = ""; sscanf(tag, "%63s", cmd);`
|
|
794
|
+
if ctx.state.constant_assigned.get(name) is not None:
|
|
795
|
+
return ctx.state.constant_assigned[name]
|
|
796
|
+
return name in ctx.constants
|
|
797
|
+
return False
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _top_level_index(text: str, char: str, start: int = 0) -> int:
|
|
801
|
+
depth = 0
|
|
802
|
+
for i in range(start, len(text)):
|
|
803
|
+
c = text[i]
|
|
804
|
+
if c in "([{":
|
|
805
|
+
depth += 1
|
|
806
|
+
elif c in ")]}":
|
|
807
|
+
depth -= 1
|
|
808
|
+
elif c == char and depth == 0:
|
|
809
|
+
if char == ":" and (text[i - 1 : i] == ":" or text[i + 1 : i + 2] == ":"):
|
|
810
|
+
continue
|
|
811
|
+
return i
|
|
812
|
+
return -1
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _measured_names(ctx: _Ctx, names: set[str]) -> set[str]:
|
|
816
|
+
"""``names`` plus the variables that measure them (``n = strlen(s)``)."""
|
|
817
|
+
out = set(names)
|
|
818
|
+
for name in names:
|
|
819
|
+
out |= ctx.state.measured_by.get(name, set())
|
|
820
|
+
return out
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _any_before(index: dict[str, list[int]], names: set[str], before: int) -> bool:
|
|
824
|
+
for name in names:
|
|
825
|
+
positions = index.get(name)
|
|
826
|
+
if positions and positions[0] < before:
|
|
827
|
+
return True
|
|
828
|
+
return False
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _compared_before(ctx: _Ctx, names: set[str], before: int) -> bool:
|
|
832
|
+
"""An upper-bound check (``n < sizeof buf``, ``if (n > cap) return``,
|
|
833
|
+
``ck_assert_int_lt(n, cap)``) on any of ``names``, or on a variable that
|
|
834
|
+
measures one of them, before ``before``. Checks against 0/1/NULL
|
|
835
|
+
(``n < 0``, ``strlen(s) > 0``) don't bound anything."""
|
|
836
|
+
return bool(names) and _any_before(ctx.bounds, _measured_names(ctx, names), before)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _clamped(ctx: _Ctx, names: set[str], before: int) -> bool:
|
|
840
|
+
"""A ``m = MIN(n, cap)`` / ``m = n > cap ? cap : n`` clamp before ``before``."""
|
|
841
|
+
return _any_before(ctx.state.clamped, names, before)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _is_clamp_expr(text: str) -> bool:
|
|
845
|
+
"""``MIN(n, cap)`` or a ternary whose condition compares (``n > cap ? cap : n``)."""
|
|
846
|
+
if _CLAMP_RE.search(text):
|
|
847
|
+
return True
|
|
848
|
+
question = _top_level_index(text, "?")
|
|
849
|
+
return question > 0 and _COMPARE_OP_RE.search(text, 0, question) is not None
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _expr_names(ctx: _Ctx, lo: int, hi: int) -> set[str]:
|
|
853
|
+
stripped = _SIZEOF_RE.sub(" ", ctx.masked[lo:hi])
|
|
854
|
+
out: set[str] = set()
|
|
855
|
+
for match in _IDENT_RE.finditer(stripped):
|
|
856
|
+
before = stripped[max(0, match.start() - 2) : match.start()]
|
|
857
|
+
if before.endswith(".") or before == "->":
|
|
858
|
+
continue
|
|
859
|
+
out.add(match.group(0))
|
|
860
|
+
return out
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _sized_by(ctx: _Ctx, dst: str | None, names: set[str], before: int) -> bool:
|
|
864
|
+
"""``dst`` was allocated or resized from one of ``names`` before
|
|
865
|
+
``before`` (``p = malloc(len + 1); memcpy(p, s, len)``)."""
|
|
866
|
+
if dst is None:
|
|
867
|
+
return False
|
|
868
|
+
measured = _measured_names(ctx, names)
|
|
869
|
+
for pos, expr in ctx.state.allocs.get(dst, ()):
|
|
870
|
+
if pos < before and (_names_in(expr) & measured or "strlen" in expr):
|
|
871
|
+
return True
|
|
872
|
+
return any(pos < before for pos in ctx.state.resized.get(dst, ()))
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _names_in(text: str) -> set[str]:
|
|
876
|
+
return set(_IDENT_RE.findall(_SIZEOF_RE.sub(" ", text)))
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# ---------- Bounds index ----------
|
|
880
|
+
|
|
881
|
+
# Template argument lists (`static_cast<int>(...)`, `std::vector<char>(n)`):
|
|
882
|
+
# their angle brackets are not comparisons.
|
|
883
|
+
_TEMPLATE_RE = re.compile(
|
|
884
|
+
r"\b(?:std::\w+|static_cast|reinterpret_cast|const_cast|dynamic_cast|vector|array|basic_string|unique_ptr|"
|
|
885
|
+
r"shared_ptr|make_shared|make_unique|numeric_limits|map|unordered_map|set|unordered_set|pair|tuple|function|"
|
|
886
|
+
r"optional|span|string_view|list|deque|initializer_list|atomic|request|response)\s*<[^<>;()\n]{0,80}>"
|
|
887
|
+
)
|
|
888
|
+
# Only short operands are tested (`_is_trivial_bound`): a trivial bound is a
|
|
889
|
+
# few characters, and the leading/trailing space runs would otherwise
|
|
890
|
+
# backtrack quadratically on a long one.
|
|
891
|
+
_TRIVIAL_BOUND_MAX = 120
|
|
892
|
+
_TRIVIAL_BOUND_RE = re.compile(
|
|
893
|
+
r"^[\s(]*(?:\(\s*[A-Za-z_][\w:\s*]{0,40}\)\s*)?(?:[-+]?\s*[01](?:[uUlL]{0,3})|0x0+|NULL|nullptr|true|false|'\s*'|EOF)[\s)]*$"
|
|
894
|
+
)
|
|
895
|
+
_BOUND_CHECK_CALL_RE = re.compile(
|
|
896
|
+
r"\b(?:\w*_(?:lt|le|gt|ge)|\w*(?:EXPECT|ASSERT|CHECK|REQUIRE|DCHECK)_(?:LT|LE|GT|GE))\s*\("
|
|
897
|
+
)
|
|
898
|
+
_OPERAND_STOP = frozenset(";,?:{}")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _left_operand(masked: str, op: int, floor: int) -> str:
|
|
902
|
+
depth = 0
|
|
903
|
+
i = op - 1
|
|
904
|
+
lo = max(floor, op - _COMPARE_WINDOW)
|
|
905
|
+
while i >= lo:
|
|
906
|
+
c = masked[i]
|
|
907
|
+
if c in ")]":
|
|
908
|
+
depth += 1
|
|
909
|
+
elif c in "([":
|
|
910
|
+
if depth == 0:
|
|
911
|
+
break
|
|
912
|
+
depth -= 1
|
|
913
|
+
elif depth == 0 and (c in _OPERAND_STOP or masked[i - 1 : i + 1] in ("&&", "||") or (c == "=" and masked[i - 1 : i] not in ("=", "!", "<", ">"))):
|
|
914
|
+
break
|
|
915
|
+
i -= 1
|
|
916
|
+
text = masked[i + 1 : op]
|
|
917
|
+
return text[text.rfind("return") + 6 :] if "return" in text else text
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _right_operand(masked: str, op_end: int, ceiling: int) -> str:
|
|
921
|
+
depth = 0
|
|
922
|
+
i = op_end
|
|
923
|
+
hi = min(ceiling, op_end + _COMPARE_WINDOW)
|
|
924
|
+
while i < hi:
|
|
925
|
+
c = masked[i]
|
|
926
|
+
if c in "([":
|
|
927
|
+
depth += 1
|
|
928
|
+
elif c in ")]":
|
|
929
|
+
if depth == 0:
|
|
930
|
+
break
|
|
931
|
+
depth -= 1
|
|
932
|
+
elif depth == 0 and (c in _OPERAND_STOP or masked[i : i + 2] in ("&&", "||")):
|
|
933
|
+
break
|
|
934
|
+
i += 1
|
|
935
|
+
return masked[op_end:i]
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _operand_names(text: str) -> list[str]:
|
|
939
|
+
stripped = _SIZEOF_RE.sub(" ", text)
|
|
940
|
+
out = []
|
|
941
|
+
for match in _IDENT_RE.finditer(stripped):
|
|
942
|
+
before = stripped[max(0, match.start() - 2) : match.start()]
|
|
943
|
+
if not (before.endswith(".") or before == "->"):
|
|
944
|
+
out.append(match.group(0))
|
|
945
|
+
return out
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _is_trivial_bound(text: str) -> bool:
|
|
949
|
+
return len(text) <= _TRIVIAL_BOUND_MAX and _TRIVIAL_BOUND_RE.match(text) is not None
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _bound_entries(masked: str, op: re.Match[str], floor: int, ceiling: int) -> list[tuple[str, int]]:
|
|
953
|
+
left = _left_operand(masked, op.start(), floor)
|
|
954
|
+
right = _right_operand(masked, op.end(), ceiling)
|
|
955
|
+
out: list[tuple[str, int]] = []
|
|
956
|
+
if right.strip() and not _is_trivial_bound(right):
|
|
957
|
+
out.extend((name, op.start()) for name in _operand_names(left))
|
|
958
|
+
if left.strip() and not _is_trivial_bound(left):
|
|
959
|
+
out.extend((name, op.start()) for name in _operand_names(right))
|
|
960
|
+
return out
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _check_call_entries(masked: str, match: re.Match[str]) -> list[tuple[str, int]]:
|
|
964
|
+
parsed = _call_args(masked, match.end() - 1)
|
|
965
|
+
if parsed is None or len(parsed[0]) < 2:
|
|
966
|
+
return []
|
|
967
|
+
(a_lo, a_hi), (b_lo, b_hi) = parsed[0][0], parsed[0][1]
|
|
968
|
+
first, second = masked[a_lo:a_hi], masked[b_lo:b_hi]
|
|
969
|
+
out: list[tuple[str, int]] = []
|
|
970
|
+
if not _is_trivial_bound(second):
|
|
971
|
+
out.extend((n, match.start()) for n in _operand_names(first))
|
|
972
|
+
if not _is_trivial_bound(first):
|
|
973
|
+
out.extend((n, match.start()) for n in _operand_names(second))
|
|
974
|
+
return out
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
# ---------- Scope analysis ----------
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@dataclass
|
|
981
|
+
class _Hit:
|
|
982
|
+
kind: str
|
|
983
|
+
pos: int
|
|
984
|
+
taint: _Taint | None
|
|
985
|
+
traced_direct: bool = False
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
@dataclass
|
|
989
|
+
class _ScopeData:
|
|
990
|
+
"""Everything one pass over the file found inside one function/lambda
|
|
991
|
+
(its own body only; nested lambdas have their own)."""
|
|
992
|
+
|
|
993
|
+
events: list[tuple[int, int, re.Match[str]]] = field(default_factory=list)
|
|
994
|
+
int_vars: set[str] = field(default_factory=set)
|
|
995
|
+
int_decl_positions: dict[str, int] = field(default_factory=dict)
|
|
996
|
+
constants: set[str] = field(default_factory=set)
|
|
997
|
+
array_sizes: dict[str, int] = field(default_factory=dict)
|
|
998
|
+
mg_locals: dict[str, int] = field(default_factory=dict)
|
|
999
|
+
resized: dict[str, list[int]] = field(default_factory=dict)
|
|
1000
|
+
bounds: dict[str, list[int]] = field(default_factory=dict)
|
|
1001
|
+
overflow_guards: list[int] = field(default_factory=list)
|
|
1002
|
+
has_sink: bool = False
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _request_param(decl: str, gates: frozenset[str]) -> bool:
|
|
1006
|
+
"""A parameter declared with a known framework request type. Bare names
|
|
1007
|
+
(`Request`, `Rest::Request`, `request<...>`, `shared_ptr<Session>`) count
|
|
1008
|
+
only in a file that uses that framework; `struct Request` never does."""
|
|
1009
|
+
if _STRUCT_REQUEST_RE.search(decl):
|
|
1010
|
+
return _MG_REQUEST_TYPE_RE.search(decl) is not None
|
|
1011
|
+
if _REQUEST_TYPE_RE.search(decl):
|
|
1012
|
+
return True
|
|
1013
|
+
return any(gate in gates and pattern.search(decl) for gate, pattern in _GATED_REQUEST_TYPES)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _scope_hits(masked: str, orig: str, scope: _Scope, data: _ScopeData, inherited: _ScopeData,
|
|
1017
|
+
gates: frozenset[str]) -> list[_Hit]:
|
|
1018
|
+
state = _State()
|
|
1019
|
+
request_vars: set[str] = set()
|
|
1020
|
+
for piece in _split_top(scope.params, 0, len(scope.params)):
|
|
1021
|
+
decl = scope.params[piece[0] : piece[1]]
|
|
1022
|
+
if _request_param(decl, gates):
|
|
1023
|
+
names = _IDENT_RE.findall(re.sub(r"<[^<>]*>", " ", decl.split("=", 1)[0]))
|
|
1024
|
+
if names:
|
|
1025
|
+
request_vars.add(names[-1])
|
|
1026
|
+
if _MHD_HANDLER_RE.search(scope.params):
|
|
1027
|
+
request_vars |= scope.param_names & _MHD_REQUEST_PARAMS
|
|
1028
|
+
if scope.name == "ENDPOINT":
|
|
1029
|
+
request_vars |= set(_OATPP_PARAM_RE.findall(scope.params_orig))
|
|
1030
|
+
header_line = line_of(orig, scope.start)
|
|
1031
|
+
request_lines = {name: header_line for name in request_vars}
|
|
1032
|
+
for name, line in data.mg_locals.items():
|
|
1033
|
+
request_lines.setdefault(name, line)
|
|
1034
|
+
|
|
1035
|
+
int_vars = set(_param_int_vars(scope.params)) | data.int_vars | inherited.int_vars
|
|
1036
|
+
state.resized = data.resized
|
|
1037
|
+
ctx = _Ctx(
|
|
1038
|
+
masked=masked,
|
|
1039
|
+
orig=orig,
|
|
1040
|
+
scope=scope,
|
|
1041
|
+
state=state,
|
|
1042
|
+
request_vars=request_lines,
|
|
1043
|
+
argv_scope="argv" in scope.param_names or bool(_ARGV_PARAM_RE.search(scope.params)),
|
|
1044
|
+
int_vars=int_vars,
|
|
1045
|
+
constants=data.constants | inherited.constants,
|
|
1046
|
+
int_decl_positions=data.int_decl_positions,
|
|
1047
|
+
array_sizes={**inherited.array_sizes, **data.array_sizes},
|
|
1048
|
+
bounds=data.bounds,
|
|
1049
|
+
overflow_guards=data.overflow_guards,
|
|
1050
|
+
)
|
|
1051
|
+
hits: list[_Hit] = []
|
|
1052
|
+
for _pos, etype, match in data.events:
|
|
1053
|
+
if etype == 0:
|
|
1054
|
+
_on_assign(ctx, match)
|
|
1055
|
+
elif etype == 1:
|
|
1056
|
+
hits.extend(_on_call(ctx, match))
|
|
1057
|
+
elif etype == 2:
|
|
1058
|
+
state.add(match.group(1), match.end(), _Taint("stdin", line_of(orig, match.start())))
|
|
1059
|
+
elif etype == 4:
|
|
1060
|
+
_on_string_ctor(ctx, match)
|
|
1061
|
+
else:
|
|
1062
|
+
hit = _on_new_array(ctx, match)
|
|
1063
|
+
if hit is not None:
|
|
1064
|
+
hits.append(hit)
|
|
1065
|
+
return hits
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _param_int_vars(params: str) -> list[str]:
|
|
1069
|
+
return [m.group(1) for m in _INT_DECL_RE.finditer(params)]
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _rhs_span(masked: str, lo: int, hi_limit: int) -> int:
|
|
1073
|
+
depth = 0
|
|
1074
|
+
end = min(hi_limit, lo + _MAX_RHS_SPAN)
|
|
1075
|
+
for i in range(lo, end):
|
|
1076
|
+
c = masked[i]
|
|
1077
|
+
if c in "([{":
|
|
1078
|
+
depth += 1
|
|
1079
|
+
elif c in ")]}":
|
|
1080
|
+
if depth == 0:
|
|
1081
|
+
return i
|
|
1082
|
+
depth -= 1
|
|
1083
|
+
elif c in ",;" and depth == 0:
|
|
1084
|
+
return i
|
|
1085
|
+
return end
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _on_assign(ctx: _Ctx, match: re.Match[str]) -> None:
|
|
1089
|
+
lhs = match.group(1)
|
|
1090
|
+
name = _IDENT_RE.match(lhs).group(0) # type: ignore[union-attr]
|
|
1091
|
+
if name in _CONTROL_WORDS or name in ("operator",):
|
|
1092
|
+
return
|
|
1093
|
+
rhs_lo = match.end()
|
|
1094
|
+
rhs_hi = _rhs_span(ctx.masked, rhs_lo, ctx.scope.end)
|
|
1095
|
+
rhs = ctx.masked[rhs_lo:rhs_hi]
|
|
1096
|
+
stripped = rhs.strip()
|
|
1097
|
+
# Constant-only assignment tracking (for format / command arguments). An
|
|
1098
|
+
# element write (`fmt[2] = '0' + n`) doesn't make a literal non-constant.
|
|
1099
|
+
indexed = "[" in ctx.masked[match.end(1) : match.end()]
|
|
1100
|
+
if stripped and not indexed:
|
|
1101
|
+
is_const = _is_constant_expr(ctx, rhs_lo, rhs_hi)
|
|
1102
|
+
previous = ctx.state.constant_assigned.get(name)
|
|
1103
|
+
ctx.state.constant_assigned[name] = is_const if previous is None else (previous and is_const)
|
|
1104
|
+
if is_const:
|
|
1105
|
+
ctx.state.clear(name, rhs_lo) # `u = "default";` drops earlier input
|
|
1106
|
+
return
|
|
1107
|
+
alloc = _ALLOC_ASSIGN_RE.match(ctx.masked, match.start())
|
|
1108
|
+
if alloc is not None:
|
|
1109
|
+
ctx.state.allocs.setdefault(name, []).append((match.end(), rhs))
|
|
1110
|
+
# The return value of a read is a byte count bounded by the caller's
|
|
1111
|
+
# buffer, not attacker data (the buffer itself is tainted by the call).
|
|
1112
|
+
call = _CALL_RE.match(_LEADING_CAST_RE.sub("", stripped, count=1))
|
|
1113
|
+
if call is not None and call.group(1) in _OUT_PARAM_SOURCES and call.group(1) not in ("gets",):
|
|
1114
|
+
return
|
|
1115
|
+
if _is_clamp_expr(rhs):
|
|
1116
|
+
ctx.state.clamped.setdefault(name, []).append(rhs_lo)
|
|
1117
|
+
return
|
|
1118
|
+
numeric_rhs = _is_numeric_expr(ctx, rhs)
|
|
1119
|
+
if numeric_rhs:
|
|
1120
|
+
measured = _expr_names(ctx, rhs_lo, rhs_hi) - {name}
|
|
1121
|
+
if measured:
|
|
1122
|
+
ctx.state.measure(name, measured)
|
|
1123
|
+
taint = _expr_taint(ctx, rhs_lo, rhs_hi, want_string=False)
|
|
1124
|
+
if taint is None:
|
|
1125
|
+
return
|
|
1126
|
+
numeric = taint.numeric or name in ctx.int_vars or numeric_rhs
|
|
1127
|
+
ctx.state.add(name, rhs_lo, _Taint(taint.kind, taint.line, numeric, taint.bounded, taint.max_len))
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _on_string_ctor(ctx: _Ctx, match: re.Match[str]) -> None:
|
|
1131
|
+
open_index = match.end() - 1
|
|
1132
|
+
close_char = ")" if ctx.masked[open_index] == "(" else "}"
|
|
1133
|
+
depth = 0
|
|
1134
|
+
for i in range(open_index, min(len(ctx.masked), open_index + _MAX_RHS_SPAN)):
|
|
1135
|
+
c = ctx.masked[i]
|
|
1136
|
+
if c in "({[":
|
|
1137
|
+
depth += 1
|
|
1138
|
+
elif c in ")}]":
|
|
1139
|
+
depth -= 1
|
|
1140
|
+
if depth == 0:
|
|
1141
|
+
if c != close_char:
|
|
1142
|
+
return
|
|
1143
|
+
taint = _expr_taint(ctx, open_index + 1, i, want_string=True)
|
|
1144
|
+
if taint is not None:
|
|
1145
|
+
ctx.state.add(match.group(1), i, taint)
|
|
1146
|
+
return
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _resolve_kind(spec: str, ctx: _Ctx, args: list[tuple[int, int]]) -> str | None:
|
|
1150
|
+
if ":" not in spec:
|
|
1151
|
+
return spec
|
|
1152
|
+
resolver, index_text = spec.split(":", 1)
|
|
1153
|
+
index = int(index_text)
|
|
1154
|
+
if index >= len(args):
|
|
1155
|
+
return None
|
|
1156
|
+
arg = ctx.masked[args[index][0] : args[index][1]]
|
|
1157
|
+
if _STDIN_ARG_RE.match(arg):
|
|
1158
|
+
return "stdin"
|
|
1159
|
+
if resolver == "fd" and _SOCKET_NAME_RE.search(arg):
|
|
1160
|
+
return "network"
|
|
1161
|
+
return "file"
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _on_call(ctx: _Ctx, match: re.Match[str]) -> list[_Hit]:
|
|
1165
|
+
name = match.group(1)
|
|
1166
|
+
if _is_definition(ctx.masked, match.start()):
|
|
1167
|
+
return []
|
|
1168
|
+
parsed = _call_args(ctx.masked, match.end() - 1)
|
|
1169
|
+
if parsed is None:
|
|
1170
|
+
return []
|
|
1171
|
+
args, _close = parsed
|
|
1172
|
+
pos = match.start()
|
|
1173
|
+
hits: list[_Hit] = []
|
|
1174
|
+
|
|
1175
|
+
if name == "gets" and len(args) == 1:
|
|
1176
|
+
hits.append(_Hit("unbounded_copy", pos, _Taint("stdin", line_of(ctx.orig, pos)), traced_direct=True))
|
|
1177
|
+
if name in _UNBOUNDED_COPY and len(args) == 2:
|
|
1178
|
+
hit = _check_strcpy(ctx, args, pos)
|
|
1179
|
+
if hit is not None:
|
|
1180
|
+
hits.append(hit)
|
|
1181
|
+
if name in _SPRINTF:
|
|
1182
|
+
hit = _check_sprintf(ctx, args, _SPRINTF[name], pos)
|
|
1183
|
+
if hit is not None:
|
|
1184
|
+
hits.append(hit)
|
|
1185
|
+
if name in _SCANF:
|
|
1186
|
+
hit = _check_scanf(ctx, name, args, pos)
|
|
1187
|
+
if hit is not None:
|
|
1188
|
+
hits.append(hit)
|
|
1189
|
+
if name in _FORMAT_FUNCS:
|
|
1190
|
+
hit = _check_format(ctx, name, args, pos)
|
|
1191
|
+
if hit is not None:
|
|
1192
|
+
hits.append(hit)
|
|
1193
|
+
if name in _SHELL_COMMAND:
|
|
1194
|
+
hit = _check_shell(ctx, args, _SHELL_COMMAND[name], pos)
|
|
1195
|
+
if hit is not None:
|
|
1196
|
+
hits.append(hit)
|
|
1197
|
+
if name in _EXEC:
|
|
1198
|
+
hit = _check_exec(ctx, name, args, pos)
|
|
1199
|
+
if hit is not None:
|
|
1200
|
+
hits.append(hit)
|
|
1201
|
+
if name in _LENGTH_COPY:
|
|
1202
|
+
hit = _check_length_copy(ctx, args, _LENGTH_COPY[name], pos)
|
|
1203
|
+
if hit is not None:
|
|
1204
|
+
hits.append(hit)
|
|
1205
|
+
if name in _ALLOC:
|
|
1206
|
+
index = _ALLOC[name]
|
|
1207
|
+
if index < len(args):
|
|
1208
|
+
hit = _check_alloc(ctx, args[index], pos)
|
|
1209
|
+
if hit is not None:
|
|
1210
|
+
hits.append(hit)
|
|
1211
|
+
|
|
1212
|
+
# Taint effects of the call, visible after it.
|
|
1213
|
+
after = match.end()
|
|
1214
|
+
if name in _OUT_PARAM_SOURCES:
|
|
1215
|
+
dst_index, spec = _OUT_PARAM_SOURCES[name]
|
|
1216
|
+
if dst_index < len(args):
|
|
1217
|
+
kind = _resolve_kind(spec, ctx, args)
|
|
1218
|
+
dst = _base_ident(ctx.masked[args[dst_index][0] : args[dst_index][1]])
|
|
1219
|
+
if dst is not None:
|
|
1220
|
+
ctx.state.write(dst, after)
|
|
1221
|
+
if kind is not None and dst is not None:
|
|
1222
|
+
# Every read here but `gets` takes a size: the result is
|
|
1223
|
+
# bounded by the destination.
|
|
1224
|
+
bounded = name != "gets"
|
|
1225
|
+
ctx.state.add(dst, after, _Taint(kind, line_of(ctx.orig, pos), False, bounded, ctx.array_sizes.get(dst)))
|
|
1226
|
+
if name in _PROPAGATORS:
|
|
1227
|
+
dst_index, src_index, rest = _PROPAGATORS[name]
|
|
1228
|
+
if dst_index < len(args) and src_index < len(args):
|
|
1229
|
+
src_spans = args[src_index:] if rest else [args[src_index]]
|
|
1230
|
+
taint: _Taint | None = None
|
|
1231
|
+
for lo, hi in src_spans:
|
|
1232
|
+
taint = _better(taint, _expr_taint(ctx, lo, hi, want_string=True))
|
|
1233
|
+
dst = _base_ident(ctx.masked[args[dst_index][0] : args[dst_index][1]])
|
|
1234
|
+
if dst is not None:
|
|
1235
|
+
ctx.state.write(dst, after)
|
|
1236
|
+
if taint is not None and dst is not None:
|
|
1237
|
+
if name in _BOUNDED_PROPAGATORS:
|
|
1238
|
+
ctx.state.add(dst, after, _Taint(taint.kind, taint.line, False, True, ctx.array_sizes.get(dst)))
|
|
1239
|
+
else:
|
|
1240
|
+
ctx.state.add(dst, after, _Taint(taint.kind, taint.line, False, taint.bounded, taint.max_len))
|
|
1241
|
+
if name in _SCANF and len(args) > _SCANF[name] + 1:
|
|
1242
|
+
for lo, hi in args[_SCANF[name] + 1 :]:
|
|
1243
|
+
dst = _base_ident(ctx.masked[lo:hi])
|
|
1244
|
+
if dst is not None:
|
|
1245
|
+
ctx.state.write(dst, after)
|
|
1246
|
+
if name in ("sscanf", "swscanf"):
|
|
1247
|
+
source = _expr_taint(ctx, args[0][0], args[0][1], want_string=True)
|
|
1248
|
+
else:
|
|
1249
|
+
kind = "stdin" if name in ("scanf", "wscanf") else _resolve_kind("stream:0", ctx, args)
|
|
1250
|
+
source = _Taint(kind, line_of(ctx.orig, pos)) if kind is not None else None
|
|
1251
|
+
if source is not None:
|
|
1252
|
+
_taint_scanf_outputs(ctx, args, _SCANF[name], source, after)
|
|
1253
|
+
return hits
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _taint_scanf_outputs(ctx: _Ctx, args: list[tuple[int, int]], fmt_index: int, source: _Taint, after: int) -> None:
|
|
1257
|
+
"""Taint each scanf destination with the source, numeric for numeric
|
|
1258
|
+
conversions and bounded for a width-limited ``%s``/``%[``."""
|
|
1259
|
+
fmt = _format_literal(ctx, args[fmt_index])
|
|
1260
|
+
convs = [c for c in _SCANF_CONVERSION_RE.finditer(fmt) if c.group(0) != "%%" and not c.group("suppress")] if fmt else []
|
|
1261
|
+
for i, (lo, hi) in enumerate(args[fmt_index + 1 :]):
|
|
1262
|
+
dst = _base_ident(ctx.masked[lo:hi])
|
|
1263
|
+
if dst is None:
|
|
1264
|
+
continue
|
|
1265
|
+
conv = convs[i] if i < len(convs) else None
|
|
1266
|
+
if conv is None:
|
|
1267
|
+
taint = _Taint(source.kind, source.line, dst in ctx.int_vars, source.bounded, source.max_len)
|
|
1268
|
+
elif conv.group("conv") == "s" or conv.group("conv").startswith("["):
|
|
1269
|
+
width = conv.group("width")
|
|
1270
|
+
taint = _Taint(source.kind, source.line, False, width is not None or source.bounded,
|
|
1271
|
+
int(width) + 1 if width else source.max_len)
|
|
1272
|
+
else:
|
|
1273
|
+
taint = _Taint(source.kind, source.line, True)
|
|
1274
|
+
ctx.state.add(dst, after, taint)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _is_definition(masked: str, start: int) -> bool:
|
|
1278
|
+
"""``int printf(const char *fmt, ...)``: a declaration, not a call."""
|
|
1279
|
+
i = _skip_ws_back(masked, start - 1)
|
|
1280
|
+
if i < 0:
|
|
1281
|
+
return False
|
|
1282
|
+
c = masked[i]
|
|
1283
|
+
if c == "*" or c == "&":
|
|
1284
|
+
j = _skip_ws_back(masked, i - 1)
|
|
1285
|
+
return j >= 0 and (masked[j].isalnum() or masked[j] == "_")
|
|
1286
|
+
if c.isalnum() or c == "_":
|
|
1287
|
+
word = _WORD_BEFORE_RE.search(masked[max(0, i - 40) : i + 1])
|
|
1288
|
+
return word is not None and word.group(1) not in _CONTROL_WORDS and word.group(1) not in ("else", "new", "delete", "co_await", "co_return", "throw")
|
|
1289
|
+
return False
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _string_taint(ctx: _Ctx, span: tuple[int, int]) -> _Taint | None:
|
|
1293
|
+
return _expr_taint(ctx, span[0], span[1], want_string=True)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _direct(ctx: _Ctx, span: tuple[int, int]) -> bool:
|
|
1297
|
+
text = ctx.masked[span[0] : span[1]]
|
|
1298
|
+
return any(p.search(text) for _, p in _DIRECT_SOURCES) or (ctx.argv_scope and bool(_ARGV_USE_RE.search(text)))
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _check_strcpy(ctx: _Ctx, args: list[tuple[int, int]], pos: int) -> _Hit | None:
|
|
1302
|
+
if _is_constant_expr(ctx, *args[1]):
|
|
1303
|
+
return None
|
|
1304
|
+
taint = _string_taint(ctx, args[1])
|
|
1305
|
+
if taint is None:
|
|
1306
|
+
return None
|
|
1307
|
+
src_names = _expr_names(ctx, *args[1])
|
|
1308
|
+
dst = _base_ident(ctx.masked[args[0][0] : args[0][1]])
|
|
1309
|
+
if not _overflows(ctx, taint, dst, _base_ident(ctx.masked[args[1][0] : args[1][1]])):
|
|
1310
|
+
return None
|
|
1311
|
+
if _compared_before(ctx, src_names, pos) or _sized_by(ctx, dst, src_names, pos):
|
|
1312
|
+
return None
|
|
1313
|
+
return _Hit("unbounded_copy", pos, taint, _direct(ctx, args[1]))
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _overflows(ctx: _Ctx, taint: _Taint, dst: str | None, src: str | None = None) -> bool:
|
|
1317
|
+
"""Can a copy of ``taint`` overflow ``dst``? Not when both are declared
|
|
1318
|
+
arrays and ``dst`` is at least as large as ``src``. Otherwise an
|
|
1319
|
+
unbounded string can; a bounded one only into a declared array smaller
|
|
1320
|
+
than its cap."""
|
|
1321
|
+
size = ctx.array_sizes.get(dst) if dst else None
|
|
1322
|
+
src_size = ctx.array_sizes.get(src) if src else None
|
|
1323
|
+
if size is not None and src_size is not None and size >= src_size:
|
|
1324
|
+
return False
|
|
1325
|
+
if not taint.bounded:
|
|
1326
|
+
return True
|
|
1327
|
+
return size is not None and taint.max_len is not None and size < taint.max_len
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _format_literal(ctx: _Ctx, span: tuple[int, int]) -> str | None:
|
|
1331
|
+
"""The concatenated literal text of a constant format argument; macro
|
|
1332
|
+
pieces between literals (``"%" PRIu64``) become an integer conversion."""
|
|
1333
|
+
masked = ctx.masked[span[0] : span[1]]
|
|
1334
|
+
if not _LITERAL_START_RE.match(masked):
|
|
1335
|
+
return None
|
|
1336
|
+
pieces: list[str] = []
|
|
1337
|
+
i = 0
|
|
1338
|
+
text = ctx.orig[span[0] : span[1]]
|
|
1339
|
+
while i < len(masked):
|
|
1340
|
+
if masked[i] == '"':
|
|
1341
|
+
j = masked.find('"', i + 1)
|
|
1342
|
+
if j < 0:
|
|
1343
|
+
return None
|
|
1344
|
+
pieces.append(text[i + 1 : j])
|
|
1345
|
+
i = j + 1
|
|
1346
|
+
elif masked[i].isalpha() or masked[i] == "_":
|
|
1347
|
+
ident = _IDENT_RE.match(masked, i)
|
|
1348
|
+
if ident is None:
|
|
1349
|
+
break
|
|
1350
|
+
if ident.group(0) not in ("u8", "u", "U", "L"):
|
|
1351
|
+
pieces.append("d")
|
|
1352
|
+
i = ident.end()
|
|
1353
|
+
else:
|
|
1354
|
+
i += 1
|
|
1355
|
+
return "".join(pieces)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _check_sprintf(ctx: _Ctx, args: list[tuple[int, int]], fmt_index: int, pos: int) -> _Hit | None:
|
|
1359
|
+
if fmt_index >= len(args):
|
|
1360
|
+
return None
|
|
1361
|
+
fmt = _format_literal(ctx, args[fmt_index])
|
|
1362
|
+
if fmt is None:
|
|
1363
|
+
return None # non-constant format: reported by the format-string check
|
|
1364
|
+
dst = _base_ident(ctx.masked[args[0][0] : args[0][1]])
|
|
1365
|
+
arg_index = fmt_index + 1
|
|
1366
|
+
best: _Hit | None = None
|
|
1367
|
+
for conv in _CONVERSION_RE.finditer(fmt):
|
|
1368
|
+
if conv.group(0) == "%%":
|
|
1369
|
+
continue
|
|
1370
|
+
if conv.group("width") == "*":
|
|
1371
|
+
arg_index += 1
|
|
1372
|
+
if conv.group("prec") == "*":
|
|
1373
|
+
arg_index += 1
|
|
1374
|
+
if conv.group("conv") in ("s", "S") and conv.group("prec") is None and arg_index < len(args):
|
|
1375
|
+
span = args[arg_index]
|
|
1376
|
+
taint = _string_taint(ctx, span)
|
|
1377
|
+
names = _expr_names(ctx, *span)
|
|
1378
|
+
if (
|
|
1379
|
+
taint is not None
|
|
1380
|
+
and _overflows(ctx, taint, dst, _base_ident(ctx.masked[span[0] : span[1]]))
|
|
1381
|
+
and not _compared_before(ctx, names, pos)
|
|
1382
|
+
and not _sized_by(ctx, dst, names, pos)
|
|
1383
|
+
and (best is None or _rank(taint) > _rank(best.taint)) # type: ignore[arg-type]
|
|
1384
|
+
):
|
|
1385
|
+
best = _Hit("unbounded_copy", pos, taint, _direct(ctx, span))
|
|
1386
|
+
arg_index += 1
|
|
1387
|
+
return best
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _check_scanf(ctx: _Ctx, name: str, args: list[tuple[int, int]], pos: int) -> _Hit | None:
|
|
1391
|
+
fmt_index = _SCANF[name]
|
|
1392
|
+
if fmt_index >= len(args):
|
|
1393
|
+
return None
|
|
1394
|
+
fmt = _format_literal(ctx, args[fmt_index])
|
|
1395
|
+
if fmt is None:
|
|
1396
|
+
return None
|
|
1397
|
+
unbounded = any(
|
|
1398
|
+
c.group(0) != "%%"
|
|
1399
|
+
and not c.group("suppress")
|
|
1400
|
+
and not c.group("width")
|
|
1401
|
+
and not c.group("alloc")
|
|
1402
|
+
and (c.group("conv") == "s" or c.group("conv").startswith("["))
|
|
1403
|
+
for c in _SCANF_CONVERSION_RE.finditer(fmt)
|
|
1404
|
+
)
|
|
1405
|
+
if not unbounded:
|
|
1406
|
+
return None
|
|
1407
|
+
line = line_of(ctx.orig, pos)
|
|
1408
|
+
if name in ("scanf", "wscanf"):
|
|
1409
|
+
return _Hit("unbounded_copy", pos, _Taint("stdin", line), True)
|
|
1410
|
+
if name in ("fscanf", "fwscanf"):
|
|
1411
|
+
kind = _resolve_kind("stream:0", ctx, args) or "file"
|
|
1412
|
+
return _Hit("unbounded_copy", pos, _Taint(kind, line), True)
|
|
1413
|
+
taint = _string_taint(ctx, args[0])
|
|
1414
|
+
if taint is None:
|
|
1415
|
+
return None
|
|
1416
|
+
return _Hit("unbounded_copy", pos, taint, _direct(ctx, args[0]))
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _check_format(ctx: _Ctx, name: str, args: list[tuple[int, int]], pos: int) -> _Hit | None:
|
|
1420
|
+
index = _FORMAT_FUNCS[name]
|
|
1421
|
+
if index >= len(args):
|
|
1422
|
+
return None
|
|
1423
|
+
span = args[index]
|
|
1424
|
+
if _is_constant_expr(ctx, *span):
|
|
1425
|
+
return None
|
|
1426
|
+
base = _base_ident(ctx.masked[span[0] : span[1]])
|
|
1427
|
+
variadic = "..." in ctx.scope.params or "va_list" in ctx.scope.params
|
|
1428
|
+
if base is not None and base in ctx.scope.param_names and (variadic or name in _VA_FORMAT_FUNCS):
|
|
1429
|
+
return None # a format wrapper forwarding its caller's format
|
|
1430
|
+
if name in _VA_FORMAT_FUNCS and variadic:
|
|
1431
|
+
return None
|
|
1432
|
+
taint = _string_taint(ctx, span)
|
|
1433
|
+
if taint is None and _is_call_result(ctx, span):
|
|
1434
|
+
return None # e.g. a function returning a constant format
|
|
1435
|
+
return _Hit("format_string", pos, taint, taint is not None and _direct(ctx, span))
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _is_call_result(ctx: _Ctx, span: tuple[int, int]) -> bool:
|
|
1439
|
+
text = ctx.masked[span[0] : span[1]].strip()
|
|
1440
|
+
return text.endswith(")") and not text.startswith("(") and _DATA_CALL_RE.search(text) is None
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def _check_shell(ctx: _Ctx, args: list[tuple[int, int]], index: int, pos: int) -> _Hit | None:
|
|
1444
|
+
if index >= len(args) or _is_constant_expr(ctx, *args[index]):
|
|
1445
|
+
return None
|
|
1446
|
+
if not ctx.masked[args[index][0] : args[index][1]].strip():
|
|
1447
|
+
return None
|
|
1448
|
+
taint = _string_taint(ctx, args[index])
|
|
1449
|
+
if taint is None and _is_call_result(ctx, args[index]):
|
|
1450
|
+
return None
|
|
1451
|
+
return _Hit("native_command_exec", pos, taint, taint is not None and _direct(ctx, args[index]))
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _check_exec(ctx: _Ctx, name: str, args: list[tuple[int, int]], pos: int) -> _Hit | None:
|
|
1455
|
+
taint: _Taint | None = None
|
|
1456
|
+
direct = False
|
|
1457
|
+
for span in args:
|
|
1458
|
+
t = _string_taint(ctx, span)
|
|
1459
|
+
if t is not None and _better(taint, t) is t:
|
|
1460
|
+
taint, direct = t, _direct(ctx, span)
|
|
1461
|
+
if taint is not None:
|
|
1462
|
+
return _Hit("native_command_exec", pos, taint, direct)
|
|
1463
|
+
# An untraced but non-constant `sh -c <cmd>` line.
|
|
1464
|
+
if name.startswith("execl"):
|
|
1465
|
+
for i, span in enumerate(args[:-1]):
|
|
1466
|
+
if _SH_C_RE.match(ctx.orig[span[0] : span[1]]):
|
|
1467
|
+
cmd = args[i + 1]
|
|
1468
|
+
if not _is_constant_expr(ctx, *cmd) and ctx.masked[cmd[0] : cmd[1]].strip() not in ("NULL", "0", "nullptr"):
|
|
1469
|
+
return _Hit("native_command_exec", pos, None)
|
|
1470
|
+
return None
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def _check_length_copy(ctx: _Ctx, args: list[tuple[int, int]], spec: tuple[int, int, int], pos: int) -> _Hit | None:
|
|
1474
|
+
dst_index, _src_index, len_index = spec
|
|
1475
|
+
if len_index >= len(args):
|
|
1476
|
+
return None
|
|
1477
|
+
span = args[len_index]
|
|
1478
|
+
text = ctx.masked[span[0] : span[1]]
|
|
1479
|
+
if _is_clamp_expr(text):
|
|
1480
|
+
return None # `n > sizeof b ? sizeof b : n`
|
|
1481
|
+
taint = _expr_taint(ctx, span[0], span[1], want_string=False)
|
|
1482
|
+
if taint is None:
|
|
1483
|
+
return None
|
|
1484
|
+
names = _expr_names(ctx, *span)
|
|
1485
|
+
dst = _base_ident(ctx.masked[args[dst_index][0] : args[dst_index][1]])
|
|
1486
|
+
if _compared_before(ctx, names, pos) or _clamped(ctx, names, pos) or _sized_by(ctx, dst, names, pos):
|
|
1487
|
+
return None
|
|
1488
|
+
return _Hit("tainted_copy_length", pos, taint, _direct(ctx, span))
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _product_terms(masked: str, lo: int, hi: int) -> list[tuple[int, int]]:
|
|
1492
|
+
"""Top-level ``+``/``-`` terms of ``masked[lo:hi]`` that multiply or shift."""
|
|
1493
|
+
terms: list[tuple[int, int]] = []
|
|
1494
|
+
depth = 0
|
|
1495
|
+
start = lo
|
|
1496
|
+
for i in range(lo, hi + 1):
|
|
1497
|
+
c = masked[i] if i < hi else "+"
|
|
1498
|
+
if c in "([{":
|
|
1499
|
+
depth += 1
|
|
1500
|
+
elif c in ")]}":
|
|
1501
|
+
depth -= 1
|
|
1502
|
+
elif depth == 0 and c in "+-" and masked[i + 1 : i + 2] not in ("+", "-", ">", "=") and masked[i - 1 : i] not in ("+", "-"):
|
|
1503
|
+
if _MUL_RE.search(masked, start, i):
|
|
1504
|
+
terms.append((start, i))
|
|
1505
|
+
start = i + 1
|
|
1506
|
+
return terms
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _check_alloc(ctx: _Ctx, span: tuple[int, int], pos: int) -> _Hit | None:
|
|
1510
|
+
terms = _product_terms(ctx.masked, span[0], span[1])
|
|
1511
|
+
if not terms:
|
|
1512
|
+
return None
|
|
1513
|
+
if ctx.overflow_guards and ctx.overflow_guards[0] < pos:
|
|
1514
|
+
return None
|
|
1515
|
+
taint: _Taint | None = None
|
|
1516
|
+
names: set[str] = set()
|
|
1517
|
+
direct = False
|
|
1518
|
+
for lo, hi in terms:
|
|
1519
|
+
t = _expr_taint(ctx, lo, hi, want_string=False)
|
|
1520
|
+
if t is not None:
|
|
1521
|
+
if _better(taint, t) is t:
|
|
1522
|
+
direct = _direct(ctx, (lo, hi))
|
|
1523
|
+
taint = _better(taint, t)
|
|
1524
|
+
names |= _expr_names(ctx, lo, hi)
|
|
1525
|
+
if taint is None:
|
|
1526
|
+
return None
|
|
1527
|
+
if _compared_before(ctx, names, pos) or _clamped(ctx, names, pos):
|
|
1528
|
+
return None
|
|
1529
|
+
return _Hit("alloc_size_overflow", pos, taint, direct)
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def _on_new_array(ctx: _Ctx, match: re.Match[str]) -> _Hit | None:
|
|
1533
|
+
open_bracket = match.end() - 1
|
|
1534
|
+
depth = 0
|
|
1535
|
+
hi = min(len(ctx.masked), open_bracket + 400)
|
|
1536
|
+
for i in range(open_bracket, hi):
|
|
1537
|
+
c = ctx.masked[i]
|
|
1538
|
+
if c == "[":
|
|
1539
|
+
depth += 1
|
|
1540
|
+
elif c == "]":
|
|
1541
|
+
depth -= 1
|
|
1542
|
+
if depth == 0:
|
|
1543
|
+
return _check_alloc(ctx, (open_bracket + 1, i), match.start())
|
|
1544
|
+
return None
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
# ---------- Public entry point ----------
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _severity(taint: _Taint | None, downranked: bool) -> tuple[str, str]:
|
|
1551
|
+
if downranked:
|
|
1552
|
+
return "low", "low"
|
|
1553
|
+
if taint is None:
|
|
1554
|
+
return "low", "low"
|
|
1555
|
+
if taint.kind in _REMOTE:
|
|
1556
|
+
return "high", "high"
|
|
1557
|
+
return "medium", "medium"
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _snippet(content: str, offset: int) -> str:
|
|
1561
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
1562
|
+
line_end = content.find("\n", offset)
|
|
1563
|
+
if line_end == -1:
|
|
1564
|
+
line_end = len(content)
|
|
1565
|
+
line = content[line_start:line_end].strip()
|
|
1566
|
+
if len(line) > _SNIPPET_MAX_CHARS:
|
|
1567
|
+
line = line[: _SNIPPET_MAX_CHARS - 1] + "…"
|
|
1568
|
+
return line
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def _owner_index(scopes: list[_Scope], starts: list[int], pos: int) -> int | None:
|
|
1572
|
+
"""Index of the innermost scope containing ``pos``: walking back from the
|
|
1573
|
+
last scope that starts before it, the first one still open at ``pos``."""
|
|
1574
|
+
i = bisect.bisect_left(starts, pos) - 1
|
|
1575
|
+
for _ in range(_MAX_OWNER_WALK):
|
|
1576
|
+
if i < 0:
|
|
1577
|
+
return None
|
|
1578
|
+
if scopes[i].end > pos:
|
|
1579
|
+
return i
|
|
1580
|
+
i -= 1
|
|
1581
|
+
return None
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _scope_data(masked: str, orig: str, scopes: list[_Scope]) -> list[_ScopeData]:
|
|
1585
|
+
"""One pass per pattern over the whole file, each match filed under its
|
|
1586
|
+
innermost scope, so nested lambdas aren't rescanned by their parents."""
|
|
1587
|
+
starts = [s.start for s in scopes]
|
|
1588
|
+
data = [_ScopeData() for _ in scopes]
|
|
1589
|
+
|
|
1590
|
+
def owner(pos: int) -> _ScopeData | None:
|
|
1591
|
+
index = _owner_index(scopes, starts, pos)
|
|
1592
|
+
return None if index is None else data[index]
|
|
1593
|
+
|
|
1594
|
+
def owner_index(pos: int) -> int | None:
|
|
1595
|
+
return _owner_index(scopes, starts, pos)
|
|
1596
|
+
|
|
1597
|
+
for match in _ASSIGN_RE.finditer(masked):
|
|
1598
|
+
d = owner(match.end() - 1)
|
|
1599
|
+
if d is not None:
|
|
1600
|
+
d.events.append((match.end() - 1, 0, match))
|
|
1601
|
+
for match in _CALL_RE.finditer(masked):
|
|
1602
|
+
d = owner(match.start())
|
|
1603
|
+
if d is not None:
|
|
1604
|
+
d.events.append((match.start(), 1, match))
|
|
1605
|
+
d.has_sink = d.has_sink or match.group(1) in _SINK_CALLS
|
|
1606
|
+
for pattern in (_CIN_RE, _GETLINE_CIN_RE):
|
|
1607
|
+
for match in pattern.finditer(masked):
|
|
1608
|
+
d = owner(match.start())
|
|
1609
|
+
if d is not None:
|
|
1610
|
+
d.events.append((match.start(), 2, match))
|
|
1611
|
+
for match in _NEW_ARRAY_RE.finditer(masked):
|
|
1612
|
+
d = owner(match.start())
|
|
1613
|
+
if d is not None:
|
|
1614
|
+
d.events.append((match.start(), 3, match))
|
|
1615
|
+
d.has_sink = True
|
|
1616
|
+
for match in _STRING_CTOR_DECL_RE.finditer(masked):
|
|
1617
|
+
d = owner(match.end() - 1)
|
|
1618
|
+
if d is not None:
|
|
1619
|
+
d.events.append((match.end() - 1, 4, match))
|
|
1620
|
+
for match in _RESIZE_RE.finditer(masked):
|
|
1621
|
+
d = owner(match.start())
|
|
1622
|
+
if d is not None:
|
|
1623
|
+
d.resized.setdefault(match.group(1), []).append(match.start())
|
|
1624
|
+
for match in _INT_DECL_RE.finditer(masked):
|
|
1625
|
+
d = owner(match.start())
|
|
1626
|
+
if d is not None:
|
|
1627
|
+
d.int_vars.add(match.group(1))
|
|
1628
|
+
d.int_decl_positions.setdefault(match.group(1), match.start())
|
|
1629
|
+
for match in _DECL_STRING_LITERAL_RE.finditer(masked):
|
|
1630
|
+
d = owner(match.start())
|
|
1631
|
+
if d is not None:
|
|
1632
|
+
d.constants.add(match.group(1))
|
|
1633
|
+
for match in _ARRAY_DECL_START_RE.finditer(masked):
|
|
1634
|
+
d = owner(match.start())
|
|
1635
|
+
if d is None:
|
|
1636
|
+
continue
|
|
1637
|
+
end = masked.find(";", match.end(), match.end() + 400)
|
|
1638
|
+
if end > 0:
|
|
1639
|
+
for decl in _ARRAY_DECLARATOR_RE.finditer(masked[match.end() : end]):
|
|
1640
|
+
d.array_sizes.setdefault(decl.group(1), int(decl.group(2)))
|
|
1641
|
+
for match in _MG_LOCAL_DECL_RE.finditer(masked):
|
|
1642
|
+
d = owner(match.start())
|
|
1643
|
+
if d is not None:
|
|
1644
|
+
d.mg_locals.setdefault(match.group(1), line_of(orig, match.start()))
|
|
1645
|
+
for match in _OVERFLOW_GUARD_RE.finditer(masked):
|
|
1646
|
+
d = owner(match.start())
|
|
1647
|
+
if d is not None:
|
|
1648
|
+
d.overflow_guards.append(match.start())
|
|
1649
|
+
template_brackets: set[int] = set()
|
|
1650
|
+
for match in _TEMPLATE_RE.finditer(masked):
|
|
1651
|
+
template_brackets.add(masked.index("<", match.start()))
|
|
1652
|
+
template_brackets.add(match.end() - 1)
|
|
1653
|
+
for match in _COMPARE_OP_RE.finditer(masked):
|
|
1654
|
+
if match.start() in template_brackets:
|
|
1655
|
+
continue
|
|
1656
|
+
index = owner_index(match.start())
|
|
1657
|
+
if index is None:
|
|
1658
|
+
continue
|
|
1659
|
+
scope = scopes[index]
|
|
1660
|
+
for name, pos in _bound_entries(masked, match, scope.start, scope.end):
|
|
1661
|
+
data[index].bounds.setdefault(name, []).append(pos)
|
|
1662
|
+
for match in _BOUND_CHECK_CALL_RE.finditer(masked):
|
|
1663
|
+
d = owner(match.start())
|
|
1664
|
+
if d is not None:
|
|
1665
|
+
for name, pos in _check_call_entries(masked, match):
|
|
1666
|
+
d.bounds.setdefault(name, []).append(pos)
|
|
1667
|
+
for d in data:
|
|
1668
|
+
d.events.sort(key=lambda e: (e[0], e[1]))
|
|
1669
|
+
for positions in d.bounds.values():
|
|
1670
|
+
positions.sort()
|
|
1671
|
+
return data
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def _inherited(scopes: list[_Scope], data: list[_ScopeData], index: int) -> _ScopeData:
|
|
1675
|
+
"""Declarations a lambda sees from its enclosing scopes (a few levels)."""
|
|
1676
|
+
out = _ScopeData()
|
|
1677
|
+
parent = scopes[index].parent
|
|
1678
|
+
for _ in range(_MAX_INHERIT_DEPTH):
|
|
1679
|
+
if parent is None:
|
|
1680
|
+
break
|
|
1681
|
+
d = data[parent]
|
|
1682
|
+
out.int_vars |= d.int_vars
|
|
1683
|
+
out.constants |= d.constants
|
|
1684
|
+
for name, size in d.array_sizes.items():
|
|
1685
|
+
out.array_sizes.setdefault(name, size)
|
|
1686
|
+
parent = scopes[parent].parent
|
|
1687
|
+
return out
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def find_native_weaknesses(content: str, relative: str) -> list[CodeWeakness]:
|
|
1691
|
+
"""C/C++ memory-safety and command-exec weaknesses in one file."""
|
|
1692
|
+
if CodeWeakness is None or is_test_path(relative):
|
|
1693
|
+
return []
|
|
1694
|
+
masked = mask_source(content)
|
|
1695
|
+
if _CALL_RE.search(masked) is None and _NEW_ARRAY_RE.search(masked) is None:
|
|
1696
|
+
return []
|
|
1697
|
+
file_constants = set(_DEFINE_STRING_RE.findall(content))
|
|
1698
|
+
gates = frozenset(gate for gate, pattern in _FRAMEWORK_GATES if pattern.search(content))
|
|
1699
|
+
downranked = _is_downranked(relative)
|
|
1700
|
+
scopes = _scopes(masked, content)
|
|
1701
|
+
data = _scope_data(masked, content, scopes)
|
|
1702
|
+
# File-scope string constants (`static const char *usage = "..."`).
|
|
1703
|
+
starts = [s.start for s in scopes]
|
|
1704
|
+
for match in _DECL_STRING_LITERAL_RE.finditer(masked):
|
|
1705
|
+
if _owner_index(scopes, starts, match.start()) is None:
|
|
1706
|
+
file_constants.add(match.group(1))
|
|
1707
|
+
out: list[CodeWeakness] = []
|
|
1708
|
+
seen: set[tuple[str, int]] = set()
|
|
1709
|
+
for index, scope in enumerate(scopes):
|
|
1710
|
+
if not data[index].has_sink:
|
|
1711
|
+
continue
|
|
1712
|
+
inherited = _inherited(scopes, data, index)
|
|
1713
|
+
inherited.constants |= file_constants
|
|
1714
|
+
for hit in _scope_hits(masked, content, scope, data[index], inherited, gates):
|
|
1715
|
+
line = line_of(content, hit.pos)
|
|
1716
|
+
key = (hit.kind, line)
|
|
1717
|
+
if key in seen:
|
|
1718
|
+
continue
|
|
1719
|
+
seen.add(key)
|
|
1720
|
+
severity, confidence = _severity(hit.taint, downranked)
|
|
1721
|
+
out.append(
|
|
1722
|
+
CodeWeakness(
|
|
1723
|
+
kind=hit.kind, # type: ignore[arg-type]
|
|
1724
|
+
file=relative,
|
|
1725
|
+
line=line,
|
|
1726
|
+
evidence_text=_snippet(content, hit.pos),
|
|
1727
|
+
severity=severity, # type: ignore[arg-type]
|
|
1728
|
+
confidence=confidence, # type: ignore[arg-type]
|
|
1729
|
+
source_kind=hit.taint.kind if hit.taint else None,
|
|
1730
|
+
source_line=hit.taint.line if hit.taint else None,
|
|
1731
|
+
)
|
|
1732
|
+
)
|
|
1733
|
+
out.sort(key=lambda w: (w.line or 0, w.kind))
|
|
1734
|
+
return out
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
__all__ = ["find_native_weaknesses", "is_test_path", "mask_source"]
|