在 JavaScript 和 TypeScript 中分析数据流¶
本主题介绍了 CodeQL 适用于 JavaScript/TypeScript 的库中数据流分析的实现方式,并包含一些示例,可帮助您编写自己的数据流查询。
概述¶
本文的各个部分介绍了如何利用库进行本地数据流、全局数据流和污点跟踪。作为我们的示例,我们将开发一个查询,以识别作为文件路径传递给标准 Node.js readFile
函数的命令行参数。虽然这本身不是一种有问题的模式,但它通常是安全查询中经常使用的推理类型。
有关数据流建模的更一般性介绍,请参阅“关于数据流分析。”
数据流节点¶
本地数据流和全局数据流以及污点跟踪都作用于程序的一种表示,称为 数据流图。数据流图上的节点也可能对应于抽象语法树上的节点,但它们并不相同。虽然 AST 节点属于类 ASTNode
及其子类,但数据流节点属于类 DataFlow::Node
及其子类
DataFlow::ValueNode
:一个值节点,即一个对应于表达式或函数、类、TypeScript 命名空间或 TypeScript 枚举声明的数据流节点。DataFlow::SsaDefinitionNode
:一个对应于 SSA 变量的数据流节点,即一个局部变量,其中包含其他信息,以便更精确地推断对同一变量的不同赋值。这种数据流节点不对应于 AST 节点。DataFlow::PropRef
:一个对应于对对象属性的读取或写入的数据流节点,例如,在赋值、对象文字或解构赋值中。DataFlow::PropRead
、DataFlow::PropWrite
:DataFlow::PropRef
的子类,分别对应于读取和写入。
除了这些相当通用的类之外,还有一些更专门的类
DataFlow::ParameterNode
:一个对应于函数参数的数据流节点。DataFlow::InvokeNode
:一个对应于函数调用的数据流节点;其子类DataFlow::NewNode
和DataFlow::CallNode
分别表示带和不带new
的调用,而DataFlow::MethodCallNode
表示方法调用。请注意,这些类还使用.call
和.apply
来模拟反射调用,它们不对应于任何 AST 节点。DataFlow::ThisNode
:一个对应于函数或顶层中this
值的数据流节点。这种数据流节点也不对应于 AST 节点。DataFlow::GlobalVarRefNode
:一个对应于对全局变量的直接引用。此类很少直接使用,而是通常使用谓词globalVarRef
(在下面介绍),它还会考虑通过window
或全局this
的间接引用。DataFlow::FunctionNode
、DataFlow::ObjectLiteralNode
、DataFlow::ArrayLiteralNode
:一个对应于函数(表达式或声明)、对象文字或数组文字的数据流节点。DataFlow::ClassNode
:对应于类的数据流节点,类可以使用 ECMAScript 2015class
声明或旧式构造函数来定义。DataFlow::ModuleImportNode
:对应于 ECMAScript 2015 导入或 AMD 或 CommonJSrequire
导入的数据流节点。
以下谓词可用于从 AST 节点和其他元素映射到它们对应的数据流节点
DataFlow::valueNode(x)
:将x
(必须是表达式或函数、类、命名空间或枚举的声明)映射到其对应的数据流节点。DataFlow::ssaDefinitionNode(ssa)
:将 SSA 定义ssa
映射到其对应的数据流节点。DataFlow::parameterNode(p)
:将函数参数p
映射到其对应的数据流节点。DataFlow::thisNode(s)
:将函数或顶层s
映射到表示s
中this
值的数据流节点。
类 DataFlow::Node
还具有一个成员谓词 asExpr()
,您可以使用它从数据流节点映射到它所对应的表达式。请注意,此谓词对于其他类型的节点以及不对应于表达式的值节点来说是未定义的。
还有一些其他谓词可用于访问常用数据流节点
DataFlow::globalVarRef(g)
:获取对应于对全局变量g
的访问的数据流节点,无论是直接访问,还是通过window
或(顶层)this
访问。例如,您可以使用DataFlow::globalVarRef("document")
来查找对 DOMdocument
对象的引用。DataFlow::moduleMember(p, m)
:获取引用从路径p
加载的模块的成员m
的数据流节点。例如,您可以使用DataFlow::moduleMember("fs", "readFile")
来查找对来自 Node.js 标准库的fs.readFile
函数的引用。
本地数据流¶
本地数据流是指单个函数内的信息流。不会对函数调用和返回,或者对属性写入和读取进行建模。
本地数据流的计算速度比全局数据流快,使用起来也更简单,但完整性较差。但是,对于许多目的来说,它已经足够了。
要推断本地数据流,请使用 DataFlow::Node
上的成员谓词 getAPredecessor
和 getASuccessor
。对于数据流节点 nd
来说,nd.getAPredecessor()
会返回所有数据从其流向 nd
的数据流节点(以一个本地步骤进行)。相反,nd.getASuccessor()
会返回所有从 nd
流向其的数据节点(以一个本地步骤进行)。
要跟踪一个或多个本地数据流步骤,请使用传递闭包运算符 +
,要跟踪零个或多个步骤,请使用自反传递闭包运算符 *
。
例如,以下查询查找所有数据流节点 source
,其值可能流入对名为 readFile
的方法的调用的第一个参数
import javascript
from DataFlow::MethodCallNode readFile, DataFlow::Node source
where
readFile.getMethodName() = "readFile" and
source.getASuccessor*() = readFile.getArgument(0)
select source
源节点¶
明确推断数据流边可能会很麻烦,而且在实践中很少见。通常,我们并不关心从任意节点发出的流,而是关心从某种意义上来说是某种数据“源”的节点,无论是它们创建新对象(如对象文字或函数),还是它们表示数据进入本地数据流图的点(如参数或属性读取)。
数据流库使用类 DataFlow::SourceNode
来表示这些节点,它提供了用于推断涉及源节点的本地数据流的便捷 API。
默认情况下,以下类型的数据流节点被视为源节点
- 类、函数、对象和数组文字、正则表达式和 JSX 元素
- 属性读取、全局变量引用和
this
节点- 函数参数
- 函数调用
- 导入
您可以通过定义 DataFlow::SourceNode::Range
的其他子类来扩展源节点集。
类 DataFlow::SourceNode
定义了许多成员谓词,可用于跟踪从源节点发出的数据流向何处,以及查找在源节点上访问属性或调用方法的位置。
例如,以下查询查找对 process.argv
属性的所有引用,该数组是 Node.js 应用程序接收其命令行参数的方式。
import javascript
select DataFlow::globalVarRef("process").getAPropertyRead("argv").getAPropertyReference()
首先,我们使用 DataFlow::globalVarRef
(如上所述)查找对全局变量 process
的所有引用。由于全局变量引用是源节点,因此我们可以使用谓词 getAPropertyRead
(在类 DataFlow::SourceNode
中定义)来查找读取该全局变量的 argv
属性的所有位置。此谓词的结果也是源节点,因此我们可以将其与对 getAPropertyReference
的调用链接起来,这是一个谓词,用于查找其基源节点上所有属性(即使是具有计算名称的引用)的所有引用。
请注意,DataFlow::SourceNode
上的许多谓词的结果反过来都是源节点,这允许链接调用以简洁地表达多个数据流节点之间的关系。
最重要的是,像 getAPropertyRead
这样的谓词隐式地遵循局部数据流,因此上面的查询不仅会找到像 process.argv[2]
这样的直接属性引用,还会找到本示例中的更间接的引用。
var args = process.argv;
var firstArg = args[2];
与 getAPropertyRead
类似,还有一个谓词 getAPropertyWrite
用于识别属性写入。
另一个常见任务是查找源节点发出的函数调用。为此,DataFlow::SourceNode
提供了谓词 getACall
、getAnInstantiation
和 getAnInvocation
:第一个只考虑没有 new
的调用,第二个只考虑有 new
的调用,而第三个考虑所有调用。
我们可以将这些谓词与 DataFlow::moduleMember
(如上所述)结合使用,以查找从标准 Node.js fs
库导入的 readFile
函数的调用。
import javascript
select DataFlow::moduleMember("fs", "readFile").getACall()
为了识别方法调用,还有一个谓词 getAMethodCall
,以及更通用的 getAMemberCall
。两者的区别在于,前者只查找具有方法调用语法形状的调用,例如 x.m(...)
,而后者还查找将 x.m
首先存储到局部变量 f
中,然后作为 f(...)
调用的调用。
最后,谓词 flowsTo(nd)
对任何节点 nd
成立,数据可能从源节点流入该节点。相反,DataFlow::Node
提供了一个谓词 getALocalSource()
,它可以用来查找流入它的任何源节点。
将所有这些结合起来,这是一个查询,用于查找从命令行参数到 readFile
调用的(局部)数据流。
import javascript
from DataFlow::SourceNode arg, DataFlow::CallNode call
where
arg = DataFlow::globalVarRef("process").getAPropertyRead("argv").getAPropertyReference() and
call = DataFlow::moduleMember("fs", "readFile").getACall() and
arg.flowsTo(call.getArgument(0))
select arg, call
关于源节点 API,有两点值得注意:
- 所有数据流跟踪都是纯粹局部的,尤其是不会跟踪通过全局变量的流。如果我们上面
process.argv
示例中的args
是一个全局变量,那么查询将不会找到通过args[2]
的引用。- 字符串不是源节点,不能使用此 API 跟踪。但是,您可以使用类
DataFlow::Node
上的mayHaveStringValue
谓词来推断流入数据流节点的可能字符串值。
有关 DataFlow::SourceNode
API 的完整说明,请参见 JavaScript 标准库。
全局数据流¶
全局数据流跟踪整个程序中的数据流,因此比局部数据流更强大。但是,全局数据流不如局部数据流精确。也就是说,分析可能会报告实际上不可能发生的虚假流。此外,全局数据流分析通常需要比局部分析更多的时间和内存。
注意
您可以通过创建路径查询来在 CodeQL 中对数据流路径进行建模。要在 CodeQL for VS Code 中查看路径查询生成的数据流路径,您需要确保它具有正确的元数据和
select
子句。有关详细信息,请参见 创建路径查询。
使用全局数据流¶
出于性能原因,通常无法计算整个程序的全部全局数据流。相反,您可以定义一个数据流 配置,它指定感兴趣的 源 数据流节点和 汇 数据流节点(简称“源”和“汇”)。数据流库提供了一个通用的数据流求解器,它可以检查是否存在从源到汇的(全局)数据流。
可选地,配置可能会指定要添加到数据流图中的额外数据流边,也可能会指定 屏障。屏障是数据流节点或边,数据在分析目的上不应通过这些节点或边进行跟踪。
要定义配置,请扩展类 DataFlow::Configuration
,如下所示:
class MyDataFlowConfiguration extends DataFlow::Configuration {
MyDataFlowConfiguration() { this = "MyDataFlowConfiguration" }
override predicate isSource(DataFlow::Node source) { /* ... */ }
override predicate isSink(DataFlow::Node sink) { /* ... */ }
// optional overrides:
override predicate isBarrier(DataFlow::Node nd) { /* ... */ }
override predicate isBarrierEdge(DataFlow::Node pred, DataFlow::Node succ) { /* ... */ }
override predicate isAdditionalFlowStep(DataFlow::Node pred, DataFlow::Node succ) { /* ... */ }
}
特征谓词 MyDataFlowConfiguration()
定义了配置的名称,因此应将 "MyDataFlowConfiguration"
替换为描述您特定分析配置的合适名称。
数据流分析是使用谓词 hasFlow(source, sink)
执行的。
from MyDataFlowConfiguration dataflow, DataFlow::Node source, DataFlow::Node sink
where dataflow.hasFlow(source, sink)
select source, "Data flow from $@ to $@.", source, source.toString(), sink, sink.toString()
使用全局污点跟踪¶
全局污点跟踪使用其他非值保留步骤扩展全局数据流,例如通过字符串操作的流。要使用它,只需扩展 TaintTracking::Configuration
而不是 DataFlow::Configuration
。
class MyTaintTrackingConfiguration extends TaintTracking::Configuration {
MyTaintTrackingConfiguration() { this = "MyTaintTrackingConfiguration" }
override predicate isSource(DataFlow::Node source) { /* ... */ }
override predicate isSink(DataFlow::Node sink) { /* ... */ }
}
与 isAdditionalFlowStep
类似,还有一个谓词 isAdditionalTaintStep
,您可以覆盖它来指定要在分析中考虑的自定义流步骤。而不是使用 isBarrier
和 isBarrierEdge
谓词,污点跟踪配置包括 isSanitizer
和 isSanitizerEdge
谓词,它们指定充当污点消毒器的数据流节点或边,因此阻止了从源到汇的流。
类似于全局数据流,特征谓词 MyTaintTrackingConfiguration()
定义了配置的唯一名称,因此应将 "MyTaintTrackingConfiguration"
替换为适当的描述性名称。
污点跟踪分析再次使用谓词 hasFlow(source, sink)
执行。
示例¶
以下污点跟踪配置是我们上面示例查询的概括,它跟踪从命令行参数到 readFile
调用的流,这次使用全局污点跟踪。
import javascript
class CommandLineFileNameConfiguration extends TaintTracking::Configuration {
CommandLineFileNameConfiguration() { this = "CommandLineFileNameConfiguration" }
override predicate isSource(DataFlow::Node source) {
DataFlow::globalVarRef("process").getAPropertyRead("argv").getAPropertyRead() = source
}
override predicate isSink(DataFlow::Node sink) {
DataFlow::moduleMember("fs", "readFile").getACall().getArgument(0) = sink
}
}
from CommandLineFileNameConfiguration cfg, DataFlow::Node source, DataFlow::Node sink
where cfg.hasFlow(source, sink)
select source, sink
此查询现在将找到涉及过程间步骤的流,如下面的示例(其中各个步骤已用注释 #1
到 #4
标记)
const fs = require('fs'),
path = require('path');
function readFileHelper(p) { // #2
p = path.resolve(p); // #3
fs.readFile(p, // #4
'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
}
readFileHelper(process.argv[2]); // #1
请注意,对于步骤 #3,我们依赖于污点跟踪库对 Node.js path
库的内置模型,该模型从 p
到 path.resolve(p)
添加了污点步骤。此步骤不是值保留步骤,但它保留污点,因为如果 p
是用户控制的,那么 path.resolve(p)
也是用户控制的(至少部分是)。
其他标准污点步骤包括通过字符串操作的流,例如连接、JSON.parse
和 JSON.stringify
,数组转换,Promise 操作等等。
消毒器¶
上面的 JavaScript 程序允许用户读取任何文件,包括敏感的系统文件,例如 /etc/passwd
。如果程序可能由不受信任的用户调用,这是不可取的,因此我们可能希望限制路径。例如,我们可以实现一个函数 checkPath
,它首先将路径设为绝对路径,然后检查它是否以当前工作目录开头,如果它不以当前工作目录开头,则会中止程序并显示错误。然后,我们可以像这样在 readFileHelper
中使用该函数。
function readFileHelper(p) {
p = checkPath(p);
...
}
在我们的上述分析中,checkPath
是一个 消毒器:它的输出始终是未污染的,即使它的输入是污染的。为了对这一点进行建模,我们可以像这样向我们的污点跟踪配置添加 isSanitizer
的覆盖。
class CommandLineFileNameConfiguration extends TaintTracking::Configuration {
// ...
override predicate isSanitizer(DataFlow::Node nd) {
nd.(DataFlow::CallNode).getCalleeName() = "checkPath"
}
}
这表示任何对名为 checkPath
的函数的调用都将被视为消毒器,因此通过此节点的任何流都将被阻止。特别是,该查询将不再标记我们上面更新示例中从 process.argv[2]
到 fs.readFile
的流。
消毒器防护¶
在我们的示例中实现路径检查的一个更自然的方法可能是让 checkPath
返回一个布尔值,指示路径是否安全读取(而不是如果安全则返回路径,否则中止)。然后,我们可以像这样在 readFileHelper
中使用它。
function readFileHelper(p) {
if (!checkPath(p))
return;
...
}
请注意,checkPath
现在不再是上述意义上的消毒器,因为从 process.argv[2]
到 fs.readFile
的流程不再经过 checkPath
。然而,此流程在 checkPath
的保护之下,因为表达式 checkPath(p)
必须评估为 true
(或更确切地说,为真值)才能发生流程。
这种消毒器防护可以通过定义 TaintTracking::SanitizerGuardNode
的新子类并覆盖污点跟踪配置类中的谓词 isSanitizerGuard
来实现,将此类所有实例添加为配置中的消毒器防护。
对于我们上面的例子,我们将从定义 SanitizerGuardNode
的子类开始,该子类识别 checkPath(...)
形式的防护。
class CheckPathSanitizerGuard extends TaintTracking::SanitizerGuardNode, DataFlow::CallNode {
CheckPathSanitizerGuard() { this.getCalleeName() = "checkPath" }
override predicate sanitizes(boolean outcome, Expr e) {
outcome = true and
e = getArgument(0).asExpr()
}
}
此类的特征谓词检查消毒器防护是否为对名为 checkPath
的函数的调用。对 sanitizes
的覆盖定义表明,如果此类调用评估为 true
(或更确切地说,为真值),则它会对第一个参数(即 getArgument(0)
)进行消毒。
现在我们可以覆盖 isSanitizerGuard
来将这些消毒器防护添加到我们的配置中。
class CommandLineFileNameConfiguration extends TaintTracking::Configuration {
// ...
override predicate isSanitizerGuard(TaintTracking::SanitizerGuardNode nd) {
nd instanceof CheckPathSanitizerGuard
}
}
有了这两个补充,查询会在 return
之后识别 checkPath(p)
检查为对 p
的消毒,因为只有当 checkPath(p)
评估为真值时,执行才能到达那里。因此,不再存在从 process.argv[2]
到 readFile
的路径。
其他污点步骤¶
有时,DataFlow::Configuration
和 TaintTracking::Configuration
提供的默认数据流和污点步骤不足,我们需要在配置中添加其他流或污点步骤,使其找到预期的流。例如,这可能是因为分析的程序使用来自外部库的函数,而该函数的源代码不可用于分析,或者是因为分析该函数过于困难。
在运行示例的上下文中,假设我们要分析的 JavaScript 程序使用一个(虚构的)npm 包 resolve-symlinks
来解析路径 p
中的任何符号链接,然后将其传递给 readFile
。
const resolveSymlinks = require('resolve-symlinks');
function readFileHelper(p) {
p = resolveSymlinks(p);
fs.readFile(p,
...
}
解析符号链接不会使不安全的路径变得更安全,因此我们仍然希望我们的查询对此进行标记,但由于标准库没有 resolve-symlinks
的模型,它将不再返回任何结果。
我们可以通过将 isAdditionalTaintStep
谓词的覆盖定义添加到我们的配置中来轻松修复此问题,从 resolveSymlinks
的第一个参数引入一个额外的污点步骤到其结果。
class CommandLineFileNameConfiguration extends TaintTracking::Configuration {
// ...
override predicate isAdditionalTaintStep(DataFlow::Node pred, DataFlow::Node succ) {
exists(DataFlow::CallNode c |
c = DataFlow::moduleImport("resolve-symlinks").getACall() and
pred = c.getArgument(0) and
succ = c
)
}
}
我们甚至可以考虑将其添加为默认污点步骤,供所有污点跟踪配置使用。为此,我们需要将其包装在 TaintTracking::SharedTaintStep
的新子类中,如下所示。
class StepThroughResolveSymlinks extends TaintTracking::SharedTaintStep {
override predicate step(DataFlow::Node pred, DataFlow::Node succ) {
exists(DataFlow::CallNode c |
c = DataFlow::moduleImport("resolve-symlinks").getACall() and
pred = c.getArgument(0) and
succ = c
)
}
}
如果我们将此定义添加到标准库中,所有污点跟踪配置都会拾取它。显然,在添加此类新的额外污点步骤时,必须小心确保它们确实对所有配置都有意义。
类似于 TaintTracking::SharedTaintStep
,还有一个类 DataFlow::SharedFlowStep
,可以扩展它来将额外步骤添加到所有数据流配置中,因此也添加到所有污点跟踪配置中。
答案¶
练习 1¶
import javascript
from DataFlow::CallNode create, string name
where
create = DataFlow::globalVarRef("document").getAMethodCall("createElement") and
create.getArgument(0).mayHaveStringValue(name)
select name
练习 2¶
import javascript
class HardCodedTagNameConfiguration extends DataFlow::Configuration {
HardCodedTagNameConfiguration() { this = "HardCodedTagNameConfiguration" }
override predicate isSource(DataFlow::Node source) { source.asExpr() instanceof ConstantString }
override predicate isSink(DataFlow::Node sink) {
sink = DataFlow::globalVarRef("document").getAMethodCall("createElement").getArgument(0)
}
}
from HardCodedTagNameConfiguration cfg, DataFlow::Node source, DataFlow::Node sink
where cfg.hasFlow(source, sink)
select source, sink
练习 3¶
import javascript
class ArrayEntryCallResult extends DataFlow::Node {
ArrayEntryCallResult() {
exists(DataFlow::CallNode call, string index |
this = call.getAPropertyRead(index) and
index.regexpMatch("\\d+")
)
}
}
练习 4¶
import javascript
class ArrayEntryCallResult extends DataFlow::Node {
ArrayEntryCallResult() {
exists(DataFlow::CallNode call, string index |
this = call.getAPropertyRead(index) and
index.regexpMatch("\\d+")
)
}
}
class HardCodedTagNameConfiguration extends DataFlow::Configuration {
HardCodedTagNameConfiguration() { this = "HardCodedTagNameConfiguration" }
override predicate isSource(DataFlow::Node source) { source instanceof ArrayEntryCallResult }
override predicate isSink(DataFlow::Node sink) {
sink = DataFlow::globalVarRef("document").getAMethodCall("createElement").getArgument(0)
}
}
from HardCodedTagNameConfiguration cfg, DataFlow::Node source, DataFlow::Node sink
where cfg.hasFlow(source, sink)
select source, sink
进一步阅读¶
- 使用路径查询探索数据流 在 GitHub 文档中。