diff --git a/python/ql/src/experimental/Security/CWE-094/Js2Py.qhelp b/python/ql/src/experimental/Security/CWE-094/Js2Py.qhelp new file mode 100644 index 000000000000..6be0b43d1a1f --- /dev/null +++ b/python/ql/src/experimental/Security/CWE-094/Js2Py.qhelp @@ -0,0 +1,24 @@ + + + +

+ Passing untrusted inputs to a JavaScript interpreter like `Js2Py` can lead to arbitrary + code execution. +

+
+ +

This vulnerability can be prevented either by preventing an untrusted user input to flow + to an eval_js call. Or, the impact of this vulnerability can be + significantly reduced by disabling imports from the interepreted code (note that in a + comment the author of the library highlights that Js2Py is still insecure with this + option).

+
+ +

In the example below, the Javascript code being evaluated is controlled by the user and + hence leads to arbitrary code execution.

+ +

This can be fixed by disabling imports before evaluating the user passed buffer.

+ +
+
diff --git a/python/ql/src/experimental/Security/CWE-094/Js2Py.ql b/python/ql/src/experimental/Security/CWE-094/Js2Py.ql new file mode 100644 index 000000000000..5dc160077873 --- /dev/null +++ b/python/ql/src/experimental/Security/CWE-094/Js2Py.ql @@ -0,0 +1,38 @@ +/** + * @name JavaScript code execution. + * @description Passing user supplied arguments to a Javascript to Python translation engine such as Js2Py can lead to remote code execution. + * @problem.severity error + * @security-severity 9.3 + * @precision high + * @kind path-problem + * @id py/js2py-rce + * @tags security + * experimental + * external/cwe/cwe-94 + */ + +import python +import semmle.python.ApiGraphs +import semmle.python.dataflow.new.TaintTracking +import semmle.python.dataflow.new.RemoteFlowSources +import semmle.python.Concepts + +module Js2PyFlowConfig implements DataFlow::ConfigSig { + predicate isSource(DataFlow::Node node) { node instanceof RemoteFlowSource } + + predicate isSink(DataFlow::Node node) { + API::moduleImport("js2py").getMember(["eval_js", "eval_js6", "EvalJs"]).getACall().getArg(_) = + node + } +} + +module Js2PyFlow = TaintTracking::Global; + +import Js2PyFlow::PathGraph + +from Js2PyFlow::PathNode source, Js2PyFlow::PathNode sink +where + Js2PyFlow::flowPath(source, sink) and + not exists(API::moduleImport("js2py").getMember("disable_pyimport").getACall()) +select sink, source, sink, "This input to Js2Py depends on a $@.", source.getNode(), + "user-provided value" diff --git a/python/ql/src/experimental/Security/CWE-094/Js2pyBad.py b/python/ql/src/experimental/Security/CWE-094/Js2pyBad.py new file mode 100644 index 000000000000..69791a424628 --- /dev/null +++ b/python/ql/src/experimental/Security/CWE-094/Js2pyBad.py @@ -0,0 +1,4 @@ +@bp.route("/bad") +def bad(): + jk = flask.request.form["jk"] + jk = eval_js(f"{jk} f()") diff --git a/python/ql/src/experimental/Security/CWE-094/Js2pyGood.py b/python/ql/src/experimental/Security/CWE-094/Js2pyGood.py new file mode 100644 index 000000000000..dd034d48bb30 --- /dev/null +++ b/python/ql/src/experimental/Security/CWE-094/Js2pyGood.py @@ -0,0 +1,6 @@ +@bp.route("/good") +def good(): + # disable python imports to prevent execution of malicious code + js2py.disable_pyimport() + jk = flask.request.form["jk"] + jk = eval_js(f"{jk} f()") diff --git a/python/ql/test/experimental/query-tests/Security/CWE-094/Js2Py.expected b/python/ql/test/experimental/query-tests/Security/CWE-094/Js2Py.expected new file mode 100644 index 000000000000..7798cdda143c --- /dev/null +++ b/python/ql/test/experimental/query-tests/Security/CWE-094/Js2Py.expected @@ -0,0 +1,10 @@ +edges +| Js2PyTest.py:9:5:9:6 | ControlFlowNode for jk | Js2PyTest.py:10:18:10:28 | ControlFlowNode for Fstring | provenance | | +| Js2PyTest.py:9:10:9:22 | ControlFlowNode for Attribute | Js2PyTest.py:9:5:9:6 | ControlFlowNode for jk | provenance | AdditionalTaintStep | +nodes +| Js2PyTest.py:9:5:9:6 | ControlFlowNode for jk | semmle.label | ControlFlowNode for jk | +| Js2PyTest.py:9:10:9:22 | ControlFlowNode for Attribute | semmle.label | ControlFlowNode for Attribute | +| Js2PyTest.py:10:18:10:28 | ControlFlowNode for Fstring | semmle.label | ControlFlowNode for Fstring | +subpaths +#select +| Js2PyTest.py:10:18:10:28 | ControlFlowNode for Fstring | Js2PyTest.py:9:10:9:22 | ControlFlowNode for Attribute | Js2PyTest.py:10:18:10:28 | ControlFlowNode for Fstring | This input to Js2Py depends on a $@. | Js2PyTest.py:9:10:9:22 | ControlFlowNode for Attribute | user-provided value | diff --git a/python/ql/test/experimental/query-tests/Security/CWE-094/Js2Py.qlref b/python/ql/test/experimental/query-tests/Security/CWE-094/Js2Py.qlref new file mode 100644 index 000000000000..457bfe2aacca --- /dev/null +++ b/python/ql/test/experimental/query-tests/Security/CWE-094/Js2Py.qlref @@ -0,0 +1 @@ +experimental/Security/CWE-094/Js2Py.ql diff --git a/python/ql/test/experimental/query-tests/Security/CWE-094/Js2PyTest.py b/python/ql/test/experimental/query-tests/Security/CWE-094/Js2PyTest.py new file mode 100644 index 000000000000..f7aae16a9eed --- /dev/null +++ b/python/ql/test/experimental/query-tests/Security/CWE-094/Js2PyTest.py @@ -0,0 +1,10 @@ + +import flask +from js2py import eval_js, disable_pyimport + +bp = flask.Blueprint("app", __name__, url_prefix="/") + +@bp.route("/bad") +def bad(): + jk = flask.request.form["jk"] + jk = eval_js(f"{jk} f()") \ No newline at end of file