mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 20:19:29 +00:00
feat(cpg): the hook half — CVE-2022-25148 found across an add_filter/apply_filters edge
The last of the three WordPress flaw classes 5.8 measured as missed. The
value crosses a registry inside WordPress core:
add_filter('wp_statistics_current_page', array($this, 'set_current_page'));
return apply_filters('wp_statistics_current_page', $current_page);
php2cpg drops the callback argument entirely -- the CPG holds
`add_filter("wp_statistics_current_page", )` -- so the edge cannot come from
the graph at any price. It comes from the source text instead, where both ends
are literals, via `mapping.php_hook_callbacks`.
vulnerable fixed
wpstatistics 3 entailed, incl. pages.php:225:record 2, that one absent
mwwpform 1 ($this->attachments) 0
pmpro 18, incl. CVE at :936 1
VAmPI 4 unchanged
The correctly-prepared sibling query one line below the CVE is not flagged on
either side.
Four things this cost, each measured rather than guessed:
* Half one must be asked per ARRAY KEY, not per callback. "Does an
unsanitized value reach this callback's return" is TRUE on both sides: the
fix escapes `type` and `id` and leaves `search_query` alone. A
callback-level answer flags the fixed side as readily as the vulnerable
one, which is a leak, not a detection. The key is a literal at both ends,
so it joins the way the hook name and the field name do -- the same idea
for the third time.
* The sanitizer test had to stop crediting a sibling's cleansing. The
vulnerable query is ONE concatenation holding both `esc_sql($page_uri)`
and the injectable `{$current_page['id']}`, so asking whether a
sanitizer's name appears in a path element's text marked the whole flow
sanitized. An element must now BE the cleansing -- a call to it, or its
name as a string literal, which keeps `array_map('esc_sql', ...)` working.
* Hook seeds are repository-wide where field seeds are file-scoped. A hook
registry is global by construction: the callback is in one file and its
`apply_filters` in another. The query returned nothing until those two
scopes were separated.
* `sourceKind` had to be REPORTED, not inferred. By the time a row reaches
Python a hook source reads as `$current_page["id"]` and a parameter as
`$args`, and `_usable_flows` discards parameter flows whenever a modelled
one is present -- so seven sanitized `$_SERVER["REQUEST_URI"]` flows
displaced the CVE between the query and the verdict. That preference then
had to move PER SINK SITE: applied across a region it decides one site's
question with another site's evidence, and PMPro's CVE at :936 vanished
from a region still reporting fifteen other findings.
`verdict` now delegates to `verdicts` rather than running its own selection
over the union, because two copies of "which flow decides" is exactly the
duplication this module has been bitten by before -- and had been again: the
per-site preference did not reach the single-verdict path, so the two entry
points disagreed about the same rows.
841 tests pass; gates byte-identical; VAmPI unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
dcdbe322a1
commit
d6908f18ba
@@ -146,6 +146,12 @@ def scan_repository(
|
||||
# spent in belongs to whoever holds the budget.
|
||||
ordered_regions = sorted(regions, key=lambda r: (not r.shipped, -r.rank, r.path, r.function or ""))
|
||||
|
||||
# The hook link table, built once for the whole scan. It is a property of the repository rather than of
|
||||
# any region, and it is read from the source text because the graph does not carry it: php2cpg drops a
|
||||
# registration's callback argument, so `add_filter("hook", array($this, "m"))` reaches the CPG as
|
||||
# `add_filter("hook", )`. See `mapping.php_hook_callbacks`.
|
||||
hooks = _hook_callbacks(root, regions)
|
||||
|
||||
# Phase 1: collect the whole scan's questions, for at most `max_regions` regions. Nothing is asked yet.
|
||||
work: list[tuple[str, ScanRegion, ArbiterSpec]] = []
|
||||
for region in ordered_regions[: limits.max_regions]:
|
||||
@@ -162,7 +168,7 @@ def scan_repository(
|
||||
per_kind: dict[str, float] = {}
|
||||
census_reported = False
|
||||
query_started = time.monotonic()
|
||||
for kind, requests in _grouped(work).items():
|
||||
for kind, requests in _grouped(work, hook_callbacks=hooks).items():
|
||||
kind_started = time.monotonic()
|
||||
batch = getattr(cpg, "run_batch", None)
|
||||
if callable(batch):
|
||||
@@ -276,7 +282,9 @@ def _prefetched(rows: list[object]): # type: ignore[no-untyped-def]
|
||||
return run
|
||||
|
||||
|
||||
def _grouped(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, dict[str, Mapping[str, object]]]:
|
||||
def _grouped(
|
||||
work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], *, hook_callbacks: str = ""
|
||||
) -> dict[str, dict[str, Mapping[str, object]]]:
|
||||
"""The work, keyed by query kind, with each request built by the arbiter's OWN parameter function.
|
||||
|
||||
Building the parameters here instead would be a second copy that drifts from the arbiter's -- the exact
|
||||
@@ -299,12 +307,32 @@ def _grouped(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, d
|
||||
file=region.path,
|
||||
parameter_sources=entry,
|
||||
call_depth=ENTRY_POINT_CALL_DEPTH if entry else 0,
|
||||
hook_callbacks=hook_callbacks,
|
||||
),
|
||||
)
|
||||
grouped.setdefault(kind, {})[rid] = params
|
||||
return grouped
|
||||
|
||||
|
||||
def _hook_callbacks(root: Path, regions: Sequence[ScanRegion]) -> str:
|
||||
"""``hook:callback;hook:callback`` for the PHP files this scan is about, or ``""``.
|
||||
|
||||
Rendered here rather than in the query because a CPGQL parameter is a string, and rendered from the
|
||||
regions rather than by walking the tree because the regions already name every file in scope.
|
||||
"""
|
||||
paths = sorted({region.path for region in regions if region.language == "php" and region.path})
|
||||
if not paths:
|
||||
return ""
|
||||
from ..mapping import php_hook_callbacks
|
||||
from ..preprocess import FileTarget
|
||||
|
||||
targets = [
|
||||
FileTarget(path=path, absolute_path=str(root / path), language="php", loc=0, tags=[], has_fuzz_entry_point=False) for path in paths
|
||||
]
|
||||
table = php_hook_callbacks(root, targets)
|
||||
return ";".join(f"{hook}:{name}" for hook, names in table.items() for name in names)
|
||||
|
||||
|
||||
def _is_entry_point(region: ScanRegion) -> bool:
|
||||
"""Is this region a place untrusted input actually enters?
|
||||
|
||||
|
||||
@@ -20,7 +20,13 @@ from .specs import TaintSpec
|
||||
|
||||
|
||||
def request_params(
|
||||
spec: TaintSpec, *, function: str = "", file: str = "", parameter_sources: bool = False, call_depth: int = 0
|
||||
spec: TaintSpec,
|
||||
*,
|
||||
function: str = "",
|
||||
file: str = "",
|
||||
parameter_sources: bool = False,
|
||||
call_depth: int = 0,
|
||||
hook_callbacks: str = "",
|
||||
) -> dict[str, object]:
|
||||
"""The query parameters this arbiter sends. Shared with the batcher so there is one definition, not two.
|
||||
|
||||
@@ -41,6 +47,10 @@ def request_params(
|
||||
"parameterSources": "true" if parameter_sources else "false",
|
||||
"callDepth": str(call_depth),
|
||||
"boundedSinks": spec.bounded_sinks,
|
||||
# The hook link table, `hook:callback;hook:callback`. Read out of the source text rather than the
|
||||
# graph, because php2cpg drops a registration's callback argument entirely -- see
|
||||
# `mapping.php_hook_callbacks`. Empty for every language that does not register handlers by string.
|
||||
"hookCallbacks": hook_callbacks,
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +69,11 @@ def verdict(
|
||||
function-local question; above zero is what lets a handler's parameter reach a sink in another module,
|
||||
which is the shape VAmPI's SQL injection has and the shape a function-scoped question cannot see.
|
||||
"""
|
||||
considered = _usable_flows(cpg, spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth)
|
||||
if not considered:
|
||||
return None
|
||||
# The strongest of the per-site verdicts, which is what this always meant. It used to run its own
|
||||
# selection over the union of flows, and two copies of "which flow decides" is the duplication this
|
||||
# module has been bitten by before: the per-site preference added to `verdicts` did not reach here, so
|
||||
# the two entry points disagreed about the same rows.
|
||||
#
|
||||
# A flow the graph cannot see a discharge for is the finding; a sanitized or BOUNDED one is a question.
|
||||
# libpng bounds `strcpy(outname+len, ".png")` with `(len = strlen(inname)) > 250` against a char[256],
|
||||
# and entailing it is a claim the code contradicts. But the bound reaches the sink through an `error`
|
||||
@@ -69,10 +81,8 @@ def verdict(
|
||||
# bound is no proof of safety in any case, off-by-one being the classic way they fail. Corroborated is
|
||||
# the honest rung: the model cannot assert this, so the judge is asked instead of the site being
|
||||
# silenced.
|
||||
open_flows = [flow for flow in considered if not flow["sanitized"] and not flow["bounded"]]
|
||||
# Deterministic by construction: a total order, then take the first. Nothing depends on engine ordering.
|
||||
chosen = min(open_flows or considered, key=lambda flow: _rank(flow, spec))
|
||||
return _verdict_for(chosen, spec)
|
||||
answers = verdicts(cpg, spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth)
|
||||
return answers[0] if answers else None
|
||||
|
||||
|
||||
def _usable_flows(
|
||||
@@ -100,9 +110,7 @@ def _usable_flows(
|
||||
"taint",
|
||||
request_params(spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth),
|
||||
)
|
||||
flows = [flow for flow in _flows(rows, function=function) if not _has_safe_shape(flow, spec)]
|
||||
modelled = [flow for flow in flows if _from_modelled_source(flow, spec)]
|
||||
return modelled or flows
|
||||
return [flow for flow in _flows(rows, function=function) if not _has_safe_shape(flow, spec)]
|
||||
|
||||
|
||||
def verdicts(
|
||||
@@ -134,8 +142,13 @@ def verdicts(
|
||||
|
||||
chosen: list[Mapping[str, object]] = []
|
||||
for site_flows in by_site.values():
|
||||
open_flows = [flow for flow in site_flows if not flow["sanitized"] and not flow["bounded"]]
|
||||
chosen.append(min(open_flows or site_flows, key=lambda flow: _rank(flow, spec)))
|
||||
# Parameter sources are a FALLBACK, never an override, and that preference is PER SINK SITE. Applied
|
||||
# across the whole region it decides one site's question with another site's evidence: on Paid
|
||||
# Memberships Pro, field-sourced flows at lines 671 and 705 displaced the parameter-sourced flow at
|
||||
# 936, and CVE-2023-23488 vanished from a region that still reported fifteen other findings.
|
||||
preferred = [flow for flow in site_flows if _from_modelled_source(flow, spec)] or list(site_flows)
|
||||
open_flows = [flow for flow in preferred if not flow["sanitized"] and not flow["bounded"]]
|
||||
chosen.append(min(open_flows or preferred, key=lambda flow: _rank(flow, spec)))
|
||||
# Same total order the single-verdict path uses, so `verdicts(...)[0] == verdict(...)`.
|
||||
chosen.sort(key=lambda flow: (bool(flow["sanitized"]) or bool(flow["bounded"]), _rank(flow, spec)))
|
||||
return [_verdict_for(flow, spec) for flow in chosen]
|
||||
@@ -170,6 +183,7 @@ def _flows(rows: object, *, function: str) -> list[dict[str, object]]:
|
||||
"bound": str(row.get("bound", "")),
|
||||
"source": str(row.get("source", "")),
|
||||
"sanitized": bool(row.get("sanitized", False)),
|
||||
"sourceKind": str(row.get("sourceKind", "")),
|
||||
"length": _as_int(row.get("length")),
|
||||
}
|
||||
)
|
||||
@@ -215,8 +229,25 @@ def _in_scope(row: Mapping[str, object], function: str) -> bool:
|
||||
return str(row.get("sinkMethod", "")) == function
|
||||
|
||||
|
||||
# A source the model VOUCHES for, as opposed to a bare captured parameter. The two-stage joins produce the
|
||||
# first kind without producing a superglobal: a hook source reads as `$current_page["id"]` and a field source
|
||||
# as `$this->attachments`, neither of which matches a source pattern, yet both were established by a flow the
|
||||
# query resolved from a real one.
|
||||
_MODELLED_KINDS = frozenset({"framework", "field", "hook"})
|
||||
|
||||
|
||||
def _from_modelled_source(flow: Mapping[str, object], spec: TaintSpec) -> bool:
|
||||
"""Does this flow start at something the source model names, rather than a bare captured parameter?"""
|
||||
"""Does this flow start at something the source model names, rather than a bare captured parameter?
|
||||
|
||||
`sourceKind` is the engine's own answer and wins when present. Before it existed this was decided by
|
||||
matching the source's TEXT against the source patterns, which cannot see the difference between a hook
|
||||
source and a parameter -- both are just identifiers by then. On WP Statistics that cost the CVE: seven
|
||||
sanitized `$_SERVER["REQUEST_URI"]` flows counted as modelled, the unsanitized hook flow did not, and
|
||||
`modelled or flows` in `_usable_flows` discarded the finding between the query and the verdict.
|
||||
"""
|
||||
kind = str(flow.get("sourceKind", ""))
|
||||
if kind:
|
||||
return kind in _MODELLED_KINDS
|
||||
source = str(flow["source"])
|
||||
return any(pattern in source for pattern in spec.sources)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user