fix(taint): a filter returns its value argument; a source is the access, not a call around it

The apply_filters class from the second PMPro adjudication (2 of 3
remaining false positives): `apply_filters('pmpro_random_code', $code,
$this)` returned whatever $this carried, and a report's `$type` reached SQL
only as an extra filter argument. Two causes, both fixed generally:

- The dispatch fact now says what an apply call RETURNS: `returns` names
  the calls whose result is the dispatched value and `value_arg` its
  position (WordPress: apply_filters/apply_filters_ref_array return
  argument 2; do_action returns nothing). A path through such a call must
  ENTER by that argument -- the first of the run of the call's arguments
  before it, because the engine hops between sibling arguments
  ($_GET["c"] -> md5("x") -> apply_filters). Loaded and validated as data;
  the query knows no framework.
- A call was a source when the pattern appeared anywhere in its text, so
  `apply_filters(..., $_GET["c"])` and `sanitize_text_field($_POST[..])`
  were sources themselves -- the "a token matches the thing it names"
  invariant. A call is now a source when the pattern names its callee,
  receiver or the access it is; the inner access stays a source.

Controls: dispatch facts load/validate; a real-Joern case with request
data as filter context (quiet) and as the filtered value (found). 1,453
host and 18 query tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-24 08:36:28 +02:00
co-authored by Claude Opus 5.5
parent f1e43d3646
commit 7b8ff3cf44
8 changed files with 182 additions and 6 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ FRONTEND_BUILD_ATTEMPTS = 4
# across 2,500 requests made a 42.5MB request file of which 41.7MB was the same string over and over. Joern # across 2,500 requests made a 42.5MB request file of which 41.7MB was the same string over and over. Joern
# parsed that with ujson inside a 2GB heap and the whole batch died in ForkJoinPool, which the driver # parsed that with ujson inside a 2GB heap and the whole batch died in ForkJoinPool, which the driver
# correctly reported as `query_failed` for all 2,500 regions -- a whole-repository scan that decided nothing. # correctly reported as `query_failed` for all 2,500 regions -- a whole-repository scan that decided nothing.
_SHARED_REQUEST_FIELDS = ("hookCallbacks", "dispatchApply", "contextFilter", "contextPaths") _SHARED_REQUEST_FIELDS = ("hookCallbacks", "dispatchApply", "dispatchValue", "contextFilter", "contextPaths")
Runner = Callable[..., subprocess.CompletedProcess[str]] Runner = Callable[..., subprocess.CompletedProcess[str]]
+46 -5
View File
@@ -52,6 +52,7 @@
evidenceOnly: String = "false", evidenceOnly: String = "false",
hookCallbacks: String = "", hookCallbacks: String = "",
dispatchApply: String = "", dispatchApply: String = "",
dispatchValue: String = "",
contextFilter: String = "", contextFilter: String = "",
contextPaths: String = "", contextPaths: String = "",
requests: String = "", requests: String = "",
@@ -91,6 +92,7 @@
sanitizersS: String, sanitizersS: String,
hookCallbacksS: String, hookCallbacksS: String,
dispatchApplyS: String, dispatchApplyS: String,
dispatchValueS: String,
functionS: String, functionS: String,
paramSrc: String, paramSrc: String,
fieldParamSrc: String, fieldParamSrc: String,
@@ -137,7 +139,17 @@
// Framework sources: a call whose code contains one of the patterns. Matching on `code` rather than a // Framework sources: a call whose code contains one of the patterns. Matching on `code` rather than a
// method name keeps `request.args["x"]` (an indexAccess over a fieldAccess) and `req.body.name` both // method name keeps `request.args["x"]` (an indexAccess over a fieldAccess) and `req.body.name` both
// reachable without a per-framework rule for each. // reachable without a per-framework rule for each.
def frameworkSources = cpg.call.filter(c => sourcePatterns.exists(p => c.code.contains(p))) // A call IS a source when the pattern names the call itself -- its callee or receiver, or the access it is
// (`$_GET["c"]`, `req.body.x`) -- not when the pattern merely appears inside an argument. Matched on the
// whole text, `apply_filters("h", md5("x"), $_GET["c"])` and `sanitize_text_field($_POST["x"])` were
// sources themselves, so a filter's return was request data whatever it returned. The access inside the
// argument is a source in its own right; nothing that reaches a sink through it is lost.
def isSourceCall(c: io.shiftleft.codepropertygraph.generated.nodes.Call): Boolean = {
val named = if (c.name.startsWith("<operator")) c.code else c.code.takeWhile(_ != '(')
sourcePatterns.exists(p => named.contains(p))
}
def frameworkSources = cpg.call.filter(isSourceCall)
// The labeled methods, and the region they lexically own. A closure defined inside the labeled function -- // The labeled methods, and the region they lexically own. A closure defined inside the labeled function --
// the callback passed to `fs.stat`, say -- has its own synthetic method (`<lambda>0`) whose astParentFullName // the callback passed to `fs.stat`, say -- has its own synthetic method (`<lambda>0`) whose astParentFullName
@@ -341,6 +353,34 @@
// path's calls afresh cost one outlier request 101 s -> 160 s. // path's calls afresh cost one outlier request 101 s -> 160 s.
val familySinkMemo = scala.collection.mutable.Map.empty[Long, Boolean] val familySinkMemo = scala.collection.mutable.Map.empty[Long, Boolean]
// A registry read returns ONE of its arguments -- the dispatched value -- or nothing at all, per the facts'
// `returns`/`value_arg`. A path that enters such a call through any other argument has used the engine's
// default "every argument reaches the return", which is how `$this`, passed as filter context, turned a
// random md5 fragment into an SQL injection.
val dispatchValues: Map[String, Int] = split(dispatchValueS).flatMap { entry =>
entry.split(":") match {
case Array(name, position) => scala.util.Try(name.trim -> position.trim.toInt).toOption
case _ => None
}
}.toMap
def offValueDispatch(elements: List[io.shiftleft.codepropertygraph.generated.nodes.AstNode]): Boolean =
dispatchValues.nonEmpty && elements.indices.exists { k =>
elements(k) match {
case call: io.shiftleft.codepropertygraph.generated.nodes.Call if k > 0 && dispatchValues.contains(call.name) =>
val position = dispatchValues(call.name)
// The argument the path ENTERED by, which is the first of the run of this call's arguments right
// before it: the engine hops between sibling arguments (`$_GET["c"]` -> `md5("x")` -> the call),
// so the one immediately before the call can be the value argument when the data came in as context.
val arguments = call.argument.l.map(a => a.id -> a).toMap
var j = k - 1
while (j >= 0 && arguments.contains(elements(j).id)) j -= 1
if (j == k - 1) false
else position == 0 || arguments(elements(j + 1).id).argumentIndex != position
case _ => false
}
}
def plausible(flow: io.joern.dataflowengineoss.language.Path): Boolean = { def plausible(flow: io.joern.dataflowengineoss.language.Path): Boolean = {
val elements = flow.elements.l val elements = flow.elements.l
val throughSink = elements.dropRight(1).exists { val throughSink = elements.dropRight(1).exists {
@@ -348,7 +388,7 @@
familySinkMemo.getOrElseUpdate(c.id, familySinkCall(c)) familySinkMemo.getOrElseUpdate(c.id, familySinkCall(c))
case _ => false case _ => false
} }
!throughSink && !(elements.exists { !throughSink && !offValueDispatch(elements) && !(elements.exists {
case c: io.shiftleft.codepropertygraph.generated.nodes.Call => c.name == "<operator>.fieldAccess" case c: io.shiftleft.codepropertygraph.generated.nodes.Call => c.name == "<operator>.fieldAccess"
case _ => false case _ => false
} && absorbedByObject(elements)) } && absorbedByObject(elements))
@@ -362,7 +402,7 @@
(sourcesS, fieldParamSrc, fileName), { (sourcesS, fieldParamSrc, fileName), {
val methods = methodsIn(fileName) val methods = methodsIn(fileName)
val framework: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] = val framework: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] =
methods.flatMap(_.ast.isCall.filter(c => sourcePatterns.exists(p => c.code.contains(p))).l) methods.flatMap(_.ast.isCall.filter(isSourceCall).l)
val params: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] = val params: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] =
if (fieldParamSrc == "true") methods.flatMap(_.parameter.l) else Nil if (fieldParamSrc == "true") methods.flatMap(_.parameter.l) else Nil
framework ++ params framework ++ params
@@ -466,7 +506,7 @@
// and only the sink is elsewhere. Task 5.13 has the numbers. // and only the sink is elsewhere. Task 5.13 has the numbers.
def hookSeedsOf(callback: io.shiftleft.codepropertygraph.generated.nodes.Method) = { def hookSeedsOf(callback: io.shiftleft.codepropertygraph.generated.nodes.Method) = {
val framework: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] = val framework: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] =
callback.ast.isCall.filter(c => sourcePatterns.exists(p => c.code.contains(p))).l callback.ast.isCall.filter(isSourceCall).l
val fields: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] = val fields: List[io.shiftleft.codepropertygraph.generated.nodes.CfgNode] =
callback.ast.isCall.nameExact(FIELD_ACCESS).l.filter(r => taintedPrefixes(r.code.trim, callback.filename)) callback.ast.isCall.nameExact(FIELD_ACCESS).l.filter(r => taintedPrefixes(r.code.trim, callback.filename))
framework ++ fields framework ++ fields
@@ -1039,6 +1079,7 @@
// -region path. // -region path.
if (field("hookCallbacks").nonEmpty) field("hookCallbacks") else hookCallbacks, if (field("hookCallbacks").nonEmpty) field("hookCallbacks") else hookCallbacks,
if (field("dispatchApply").nonEmpty) field("dispatchApply") else dispatchApply, if (field("dispatchApply").nonEmpty) field("dispatchApply") else dispatchApply,
if (field("dispatchValue").nonEmpty) field("dispatchValue") else dispatchValue,
field("function"), field("function"),
paramSrc, paramSrc,
fieldParamSrc, fieldParamSrc,
@@ -1063,7 +1104,7 @@
} else { } else {
println("---OUSAST-CPG-BEGIN---") println("---OUSAST-CPG-BEGIN---")
println( println(
ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, hookCallbacks, dispatchApply, function, parameterSources, parameterSources, fieldSources, evidenceOnly, file, callDepth, boundedSinks): _*)) ujson.write(ujson.Arr(rowsFor(sources, sinks, sanitizers, hookCallbacks, dispatchApply, dispatchValue, function, parameterSources, parameterSources, fieldSources, evidenceOnly, file, callDepth, boundedSinks): _*))
) )
} }
println("---OUSAST-CPG-END---") println("---OUSAST-CPG-END---")
+9
View File
@@ -115,6 +115,9 @@ class TaintSpec:
# The calls that read a value back out of a string-keyed registry (`apply_filters`, `do_action`). Facts, # The calls that read a value back out of a string-keyed registry (`apply_filters`, `do_action`). Facts,
# not constants, so a second framework is a row in a TOML rather than an edit to the query. # not constants, so a second framework is a row in a TOML rather than an edit to the query.
dispatch_apply: tuple[str, ...] = () dispatch_apply: tuple[str, ...] = ()
# Which argument of each apply call its RESULT comes from, as `call:position`; position 0 means the call
# returns nothing a flow can carry. Only calls a fact describes appear here.
dispatch_value: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -243,6 +246,11 @@ def taint_specs(
return bool(getattr(fact, "parameterized", False)) or getattr(fact, "literal_format_arg", None) is not None return bool(getattr(fact, "parameterized", False)) or getattr(fact, "literal_format_arg", None) is not None
dispatch_apply = tuple(sorted({call for fact in scoped.dispatches for call in fact.apply})) dispatch_apply = tuple(sorted({call for fact in scoped.dispatches for call in fact.apply}))
dispatch_value = tuple(
sorted(
{f"{call}:{fact.value_arg if call in fact.returns else 0}" for fact in scoped.dispatches if fact.returns for call in fact.apply}
)
)
sanitizers = tuple(sorted({call for fact in scoped.sanitizers if not _is_shape(fact) for call in fact.calls})) sanitizers = tuple(sorted({call for fact in scoped.sanitizers if not _is_shape(fact) for call in fact.calls}))
safe_shapes = tuple(sorted({call for fact in scoped.sanitizers if _is_shape(fact) for call in fact.calls})) safe_shapes = tuple(sorted({call for fact in scoped.sanitizers if _is_shape(fact) for call in fact.calls}))
by_family: dict[str, set[str]] = {} by_family: dict[str, set[str]] = {}
@@ -266,6 +274,7 @@ def taint_specs(
bounded_sinks=tuple(sorted(bounded.get(family_id, ()))), bounded_sinks=tuple(sorted(bounded.get(family_id, ()))),
safe_shape_sinks=safe_shapes, safe_shape_sinks=safe_shapes,
dispatch_apply=dispatch_apply, dispatch_apply=dispatch_apply,
dispatch_value=dispatch_value,
) )
for family_id, calls in sorted(by_family.items()) for family_id, calls in sorted(by_family.items())
} }
+1
View File
@@ -59,6 +59,7 @@ def request_params(
# The registry's own vocabulary, from the fact tables. Without it the query would have to know what # The registry's own vocabulary, from the fact tables. Without it the query would have to know what
# `apply_filters` is, which makes a general mechanism into a WordPress feature. # `apply_filters` is, which makes a general mechanism into a WordPress feature.
"dispatchApply": spec.dispatch_apply, "dispatchApply": spec.dispatch_apply,
"dispatchValue": spec.dispatch_value,
} }
@@ -208,6 +208,11 @@ parameterized = true
id = "wordpress_hooks" id = "wordpress_hooks"
register = ["add_action", "add_filter", "add_shortcode"] register = ["add_action", "add_filter", "add_shortcode"]
apply = ["apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array"] apply = ["apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array"]
# A filter returns its VALUE argument, filtered; its further arguments are context and never the result, and
# an action returns nothing. Without this, `apply_filters('pmpro_random_code', $code, $this)` returned
# whatever `$this` carried, and a random md5 fragment became an SQL injection.
returns = ["apply_filters", "apply_filters_ref_array"]
value_arg = 2
# What a PHP repository's paths say about scope. `vendored` trees are out of the scan and out of the graph # What a PHP repository's paths say about scope. `vendored` trees are out of the scan and out of the graph
# (a bundled library is a separate unit); `tests` paths stay in scope but are not product, so their # (a bundled library is a separate unit); `tests` paths stay in scope but are not product, so their
+27
View File
@@ -72,6 +72,14 @@ class DispatchFact:
register: tuple[str, ...] register: tuple[str, ...]
apply: tuple[str, ...] apply: tuple[str, ...]
language: str language: str
# What an apply call RETURNS. `returns` names the apply calls whose result is the dispatched value, and
# `value_arg` is that value's argument position (1-based, receiver excluded): WordPress's
# `apply_filters('hook', $value, ...$context)` returns the filtered `$value`, and its context arguments
# never become the result. An apply call not named in `returns` returns nothing a flow can carry
# (`do_action`). Without this the engine let EVERY argument reach the return: PMPro's random order code
# came out "tainted" because `$this` was passed as filter context. Empty keeps the engine's default.
returns: tuple[str, ...] = ()
value_arg: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -164,11 +172,30 @@ def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]:
register=_strings(item.get("register"), "register", path), register=_strings(item.get("register"), "register", path),
apply=_strings(item.get("apply"), "apply", path), apply=_strings(item.get("apply"), "apply", path),
language=language, language=language,
returns=_returning(item, path),
value_arg=_value_arg(item, path),
) )
) )
return facts return facts
def _returning(item: dict[str, object], path: Path) -> tuple[str, ...]:
returns = _strings(item.get("returns", []), "returns", path)
unknown = set(returns) - set(_strings(item.get("apply"), "apply", path))
if unknown:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: returns names calls that are not apply calls: {sorted(unknown)}")
return returns
def _value_arg(item: dict[str, object], path: Path) -> int:
value = item.get("value_arg", 0)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: value_arg must be a non-negative integer")
if item.get("returns") and value < 1:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: returns needs the value's argument position")
return value
def _layouts(value: object, language: str, path: Path) -> list[LayoutFact]: def _layouts(value: object, language: str, path: Path) -> list[LayoutFact]:
facts: list[LayoutFact] = [] facts: list[LayoutFact] = []
for item in _items(value): for item in _items(value):
+57
View File
@@ -745,3 +745,60 @@ def test_a_path_through_a_query_result_or_a_whole_object_is_not_an_injection(tmp
assert lines["c"] == [], f"an argument into an object's method, its field read: {lines['c']}" assert lines["c"] == [], f"an argument into an object's method, its field read: {lines['c']}"
assert lines["d"] == [18], f"a member of a decoded request value must still be found: {lines['d']}" assert lines["d"] == [18], f"a member of a decoded request value must still be found: {lines['d']}"
assert lines["e"] == [20], f"the field that was written, read back, must still be found: {lines['e']}" assert lines["e"] == [20], f"the field that was written, read back, must still be found: {lines['e']}"
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_a_filter_returns_its_value_argument_not_its_context(tmp_path: Path) -> None:
"""`apply_filters('h', $value, ...$context)` returns the filtered value. Request data passed as context
made PMPro's random md5 order code an SQL injection; request data passed as the value must still flow."""
from openultrasast.cpg.backend import extract_payload
frontend = __import__("shutil").which("php2cpg")
if frontend is None:
pytest.skip("php2cpg is not installed")
src = tmp_path / "src"
src.mkdir()
(src / "app.php").write_text(
"<?php\n"
"function context_handler() { global $wpdb; $code = apply_filters('h', md5('x'), $_GET['c']);\n"
' return $wpdb->query("DELETE FROM t WHERE code = \'" . $code . "\'"); }\n'
"function value_handler() { global $wpdb; $v = apply_filters('h', $_GET['v'], 'context');\n"
' return $wpdb->query("DELETE FROM t WHERE code = \'" . $v . "\'"); }\n'
)
cpg = tmp_path / "cpg.bin"
built = subprocess.run([frontend, str(src), "-o", str(cpg)], capture_output=True, text=True, timeout=600, check=False)
if not cpg.is_file():
pytest.skip(f"could not build a sample cpg: {(built.stderr or '')[-200:]}")
common = {
"sources": "$_GET",
"sinks": "$wpdb->query",
"file": "app.php",
"dispatchApply": "apply_filters,do_action",
"dispatchValue": "apply_filters:2,do_action:0",
}
requests = tmp_path / "requests.json"
requests.write_text(
json.dumps({"context": {**common, "function": "context_handler"}, "value": {**common, "function": "value_handler"}})
)
done = subprocess.run(
[
_joern() or "joern",
"--script",
str((QUERIES / "taint.sc").resolve()),
"--param",
f"cpgFile={cpg}",
"--param",
f"requestsFile={requests}",
],
capture_output=True,
text=True,
timeout=900,
check=False,
cwd=str(tmp_path),
)
assert done.returncode == 0, (done.stderr or done.stdout or "")[-500:]
payload = extract_payload(done.stdout or "")
assert isinstance(payload, dict)
flows = {rid: [r for r in payload[rid] if isinstance(r, dict) and r.get("sourceKind")] for rid in ("context", "value")}
assert not flows["context"], f"filter context became its result: {flows['context']}"
assert flows["value"], "the filtered value no longer carries request data"
+36
View File
@@ -0,0 +1,36 @@
"""A registry read returns its VALUE argument, not its context: a fact, loaded and checked."""
import pytest
from openultrasast.model.specs import taint_specs
from openultrasast.semantic.facts import FactLoadError, load_facts
def _write(tmp_path, dispatch: str) -> None:
(tmp_path / "php.toml").write_text('language = "php"\n[[source]]\nid = "g"\npatterns = ["$_GET"]\n' + dispatch)
def test_the_shipped_facts_say_which_argument_a_filter_returns() -> None:
spec = taint_specs(language="php")["injection"]
assert "apply_filters:2" in spec.dispatch_value and "do_action:0" in spec.dispatch_value
def test_a_dispatch_row_declares_returns_with_a_position(tmp_path) -> None:
row = '[[dispatch]]\nid = "hooks"\nregister = ["add_filter"]\napply = ["apply_filters", "do_action"]\n'
_write(tmp_path, row + 'returns = ["apply_filters"]\nvalue_arg = 2\n')
(fact,) = load_facts(tmp_path).dispatches
assert fact.returns == ("apply_filters",) and fact.value_arg == 2
@pytest.mark.parametrize(
"row",
[
'returns = ["apply_filters"]\n', # a returning call without the value's position
'returns = ["not_an_apply_call"]\nvalue_arg = 2\n',
"value_arg = -1\n",
],
)
def test_a_malformed_dispatch_row_is_refused(tmp_path, row: str) -> None:
_write(tmp_path, '[[dispatch]]\nid = "hooks"\nregister = ["add_filter"]\napply = ["apply_filters"]\n' + row)
with pytest.raises(FactLoadError):
load_facts(tmp_path)