知识库

防止 Cypher 注入

什么是 Cypher 注入?

Cypher 注入是一种方式,恶意格式化的输入可以跳出其上下文,通过更改查询本身,劫持查询并在数据库上执行意外操作。

这是 SQL 注入的近亲,但影响了我们的 Cypher 查询语言。

一个很好的例子,展示了 SQL 注入,来自 XKCD 漫画,以“小鲍比·泰布尔斯”为特色

Little Bobby Tables

漫画中,一位母亲给孩子取名Robert'; DROP TABLE STUDENTS;--,确保如果他的名字被附加到 SQL 语句中,通过字符串连接,没有防止注入攻击的保护措施,额外的引号将关闭来自应用程序的打开的引号,分号将完成语句,而--将注释掉查询的其余部分,从而避免语法错误。

鉴于鲍比的母亲可能会继续她的圣战,她的下一个孩子可能会被命名为针对 Cypher,它被领先的图数据库 Neo4j 使用。让我们称他们为“小罗比·莱布尔斯”。

"Robby' WITH true as ignored MATCH (s:Student) DETACH DELETE s; //"

这是一种 Cypher 注入的尝试,相当于 SQL 注入攻击,但删除了所有 :Student 节点。

如果罗比的学校使用字符串连接来构建他们的查询,这种攻击可能会成功。

String queryString = "CREATE (s:Student) SET s.name = '" + studentName + "'";

Result result = session.run(queryString);

将要运行的查询是

CREATE (s:Student)
SET s.name = 'Robby' WITH true as ignored MATCH (s:Student) DETACH DELETE s; //';

在创建罗比节点后,所有 :Student 节点都被找到并删除。

使用参数防止 Cypher 注入

在接收用户输入时,可以使用参数来防止 Cypher 注入。

在上面的例子中,不要将学生的姓名与CREATE查询连接起来,而应该使用参数。

Map<String,Object> params = new HashMap<>();
params.put( "studentName", studentName );

String query =
"CREATE (s:Student)" + "\n" +
"SET s.name = $studentName";

Result result = transaction.execute( query, params );

参数化的查询看起来像这样

CREATE (s:Student)
SET s.name = $studentName

使用参数时,参数不可能修改原始查询,阻止任何针对 Cypher 注入的尝试。

这是因为参数与查询是分开的。查询本身被编译成可执行计划,并且一旦编译,就可以使用任何参数映射进行执行。

换句话说,一旦查询计划被编译,它就被设置好了,数据中提交给它的任何内容都无法更改它、修改它或劫持它。

这种类型的保护是针对文字防止 Cypher 注入的最简单、最安全的方法。变量和文字可以替换为参数,不幸的是,并非查询中的所有内容都是可参数化的。

同样重要的是要记住,并非所有注入攻击都依赖于字符串文字的引号转义。例如,以下查询,针对数字文字,在不使用参数化的情况下仍然容易受到攻击

query = "MATCH (user) WHERE user.id =" + userid + ";"

其中恶意 userid 输入为:"1 OR 1 = 1 WITH true AS ignored MATCH (all) DETACH DELETE all; //" 这也可以通过使用参数轻松修复

MATCH (user) WHERE user.id = $userid;

参数和 APOC

APOC 是一个广泛使用的插件,可以与 Neo4j 一起安装。提供的过程和函数提供了一些强大的增强功能,在使用 Cypher 时很有用。参数的使用在这里仍然很重要,但重要的是要注意,此级别的字符串连接仍然容易受到 Cypher 注入的攻击。

考虑这个查询

CALL apoc.cypher.doIt("CREATE (s:Student) SET s.name = '" + $studentName + "' RETURN true", {})
YIELD value
RETURN value;

即使studentName作为参数传递,它现在将与CREATE查询连接起来以准备执行。这种连接可能会导致 APOC 执行被劫持的查询。

例如,如果学生姓名为

' MATCH (all) DETACH DELETE all; //

这将由 APOC 作为以下查询执行

CREATE (s:Student) SET s.name = '' MATCH (all) DETACH DELETE all; //' RETURN true

在这种情况下,解决方案是继续将studentName作为参数传递给 APOC 过程。

CALL apoc.cypher.doIt("CREATE (s:Student) SET s.name = $name RETURN true", { name: $studentName })
YIELD value
RETURN value;

小罗比·莱布尔斯再次无能为力!

值得注意的 APOC 过程

apoc.case()
apoc.when()
apoc.cypher.doIt()
apoc.cypher.run()
apoc.cypher.runMany()
apoc.cypher.runManyReadOnly()
apoc.cypher.runSchema()
apoc.cypher.runTimeboxed()
apoc.cypher.runWrite()
apoc.cypher.runFirstColumnMany()
apoc.cypher.runFirstColumnSingle()
apoc.do.case()
apoc.do.when()
apoc.export.csv.query()
apoc.export.cypher.query()
apoc.export.graphml.query()
apoc.export.json.query()
apoc.graph.fromCypher()
apoc.periodic.commit()
apoc.periodic.iterate()
apoc.periodic.repeat()
apoc.periodic.submit()
apoc.trigger.add()

所有上面列出的 APOC 过程都包含传递参数映射到调用的方法,从而防止注入攻击。

注入攻击的类型

在前面的例子中,我们展示了小罗比如何通过删除数据库中的所有数据来破坏你的工作。但这并不是恶意攻击者利用注入攻击查询的唯一方式。

信息泄露

另一个可能的注入媒介是,攻击者使用恶意输入来读取他们不应该访问的信息。

例如;攻击有效载荷为

Robby' OR 1=1 RETURN apoc.text.join(collect(s.name), ','); //

可能会执行为

MATCH (s:Student) WHERE s.name = 'Robby' OR 1=1 RETURN apoc.text.join(collect(s.name), ','); //' RETURN s.name;

以一个逗号分隔的字符串返回数据库中所有学生的姓名。为了使这种方法成功,客户端应用程序需要容易受到注入攻击,以及将查询的结果发送回用户。

盲注

盲注是指攻击者不直接从客户端响应中获取公开的信息,而是以不同的方式获取。

一种方法是根据应用程序的行为做出反应。假设一个网站根据查询结果的存在加载不同的页面。例如,一个登录页面首先要求输入电子邮件,然后显示继续登录页面或继续注册页面。

query = "MATCH (user) WHERE user.email = '" + email + "' RETURN user IS NOT NULL;"

此查询的结果不会返回给用户,而是应用程序使用用户的身份来显示下一页。这样,可能的注入可能会通过有条件地触发不同的响应来利用这一点。

例如,小罗比想看看他哥哥用什么用户名注册的

"bobby@mail.com' RETURN user.username STARTS WITH 'a';//

如果用户名以a开头,则查询解析为 true 并显示登录页面。通过这种方式,罗比可以逐个字符地找出他们哥哥的用户名,通过系统地检查每个字符的响应。

基于错误的 Cypher 注入

另一种获取信息的方法是,恶意攻击者利用客户端应用程序返回的错误消息。这可以通过注入错误的输入来完成,这将输出不同的错误消息,并根据这些消息获取有关数据库的敏感信息。此信息可用于使用下一个有效载荷制作更强大的注入。这可能与向查询添加额外的引号一样简单,以查看服务器是否将整个数据库错误返回。这是一个简单利用输入的另一个例子

输入:' RETURN a//

MATCH (s:Student) WHERE s.name = '' RETURN a//' RETURN s;

这会导致以下数据库错误

Variable `a` not defined (line 1, column 44 (offset: 43))
"MATCH (s:Student) WHERE s.name = '' RETURN a//' RETURN s;"
                                            ^

如果服务器将原始错误返回,则整个查询现在可见,从而更容易发送更具体的恶意输入。攻击者现在至少知道一个标签的名称以及与其关联的变量。

为了防止这种情况,除了使用参数和清理/验证用户输入之外,避免将特定于数据库的错误返回给用户,而选择更通用的错误。

查询清理

虽然字符串连接通常是构建查询的糟糕主意,但并非总能避免它。节点标签、关系类型和属性名称是参数化在 Cypher 中不受支持的显着示例。

在这些情况下,对用户输入进行清理非常重要。清理是指修改输入以确保其有效。在 Cypher 的情况下,通常意味着转义引号或删除分隔符,这些分隔符会被提前解释为字符串字面量或标识符的结束。在接受不可信的外部输入时,始终应进行清理,并且在其他情况下可能需要进行清理,有关详细信息,请参阅二阶注入。

建议在客户端级别进行此清理,然后再将其传递到数据库。

Cypher 中的转义字符

转义字符在序列中的以下字符上调用备用含义。在 Cypher 中,字符串字面量和标识符(如节点标签)的定义可以使用某些字符的打开和关闭来完成,这些字符也可以在表达式中使用,前提是它们已正确转义。

在以下各节中,我们将解释如何转义不同 Cypher 类型的分隔符。

Cypher 类型 字符类型 字符 转义序列

字符串字面量

单引号

'

\' 或 \u005c'

Unicode 单引号

\u0027

\u005c\u0027 或 \\u0027

双引号

"

\" 或 \u005c"

Unicode 双引号

\u0022

\u005c\u0022 或 \\u0022

标识符

反引号

`

``

Unicode 标识符

\u0060

\u0060\u0060 或 `\u0060

字符串字面量

字符串字面量以单引号 ' 或双引号 " 开头和结尾。可以使用反斜杠 \ 对它们进行转义。字符串字面量中的反斜杠使用另一个反斜杠 \ 进行转义。

标识符

节点标签、关系类型、参数、变量、函数名称和映射键遵循一组命名规则。但是,可以使用反引号来使用任意名称。例如,您可以在标识符中使用空格

CREATE (n:`Fancy Name`);

要在 such a name 中使用反引号,必须使用另一个反引号 ` 对其进行转义。

有关转义字符的更多信息,请参阅 Cypher 手册中的 表达式命名规则和建议

何时需要清理

节点标签、关系类型和参数可能包含非字母字符,包括数字、符号和空格字符,但必须使用反引号进行转义。例如:node label with spaces。这意味着,当使用字符串连接动态构建查询时,需要对反引号的转义进行清理。在 Cypher 中,使用另一个反引号 `` 对反引号进行转义。对于其他类型,例如字符串字面量,它们以单引号 ' 或双引号 " 开头和结尾,清理将通过使用反斜杠 \ 对引号字符进行转义来完成。请注意,在可以使用字符串字面量的地方,也可以使用参数,建议始终使用参数化,而不仅仅是清理输入,以避免 Cypher 注入。

这是一个简单的动态标签注入攻击示例

query = "MATCH (s:School)-[:IN]→(c:`" + cityName + "`) RETURN s;

使用此查询,我们要搜索位于特定城市的所有学校,不幸的是,我们的城市名称是节点标签,因此无法对输入进行参数化。

可能的攻击输入将是

Input = `) RETURN 1 as a UNION MATCH (n) RETURN 1 WITH true AS ignored MATCH (n) DETACH DELETE n; //

反引号转义了标签名称上下文,括号关闭了节点。此处的 UNION 然后确保匹配成功,因为如果第一个 MATCH 语句没有返回任何内容,则查询的下一部分将不会运行。WITH 将结果集缩减到一行,然后最后一部分将删除数据库中的所有内容。

这种攻击无法通过参数化来避免。为了避免这种攻击,必须使用清理。

防止 Cypher 注入的最佳保护措施是始终对用户输入进行参数化。如果可能,更新您的数据模型,以避免需要使用动态标签进行查询。在此示例中,重构将是将城市名称移动到参数中。

MATCH (s:School)-[:IN]→(c: City { name: $cityName }) RETURN s;

也可以对用户输入添加验证,在这种情况下,在将城市名称传递到数据库之前,验证城市名称是否是真正的城市名称,否则拒绝该名称。

此查询所需的清理是对其他反引号字符进行转义。

SanitizedInput = ``) RETURN 1 as a UNION MATCH (n) RETURN 1 WITH true AS ignored MATCH (n) DETACH DELETE n; //

现在添加的其他反引号确保整个字符串用作节点标签,并且无法超出该上下文。

反引号的 Unicode 字符;\u0060 也将解析为反引号,需要进行清理。在处理用户输入时,必须考虑编写客户端的编程语言。例如,输入:\u005C\u00750060 可能会在传递到数据库之前解析为 \u0060\u005C 是反斜杠 \\u0075u),然后数据库将解析为反引号!

编写您自己的清理函数可能很棘手。这就是为什么强烈建议避免字符串连接,并以这样一种方式设计您的数据库,即不需要用户输入来根据节点标签、关系类型和参数动态进行查询。

验证和清理常见漏洞

清理也可以用作清理用户输入的技术。另一种使输入安全和干净的方法是使用验证。验证会检查输入并确保其满足一组特定条件,如果不满足,则会拒绝输入,而清理只会清理输入。验证可以与清理一起使用。请记住,这两种技术都存在风险。

空格检查

检查用户输入中是否有空格听起来像是避免注入的好方法,在某些情况下它确实有效,请考虑以下示例

"Robby' MATCH (s:Student) DETACH DELETE s; //"

空格验证检查将标记此查询为无效,但仅检查空格是不够的。在 Cypher 中,使用块注释替换空格也是有效的,因此以下查询将通过空格验证检查

"Robby'/**/MATCH/**/(s:Student)/**/DETACH/**/DELETE/**/s;/**///"

请注意,在这种情况下,过滤 /**/ 仍然不够,因为块注释本身可以包含随机的不可忽略字符:/**thisisacomment**/

检查和清理空格可能对您的应用程序有用,但不应将其视为避免 Cypher 注入的可靠方法。

Unicode 编码

输入验证和清理周围的另一个常见漏洞是 Unicode 编码。Unicode 编码是指将字符编码为其 Unicode 等效项。例如;单引号字符 ' 可以编码为 \u0027。在清理字符串以删除转义引号字符时,重要的是还要检查 Unicode 等效项。以下查询乍一看似乎没有转义字符串

"Robby\u0027 MATCH (s:Student) DETACH DELETE s; //"

但实际上,Cypher 会将 Unicode 解析为单引号,并在查询的编译中将其视为单引号。

在验证输入(如用户名)时,通常会检查是否缺少保留关键字(如 admin)。Unicode 编码可以作为另一种常见的规避方法。例如,用户输入 \u0061\u0064\u006d\u0069\u006eadmin 的 Unicode

CREATE (n {username: '\u0061\u0064\u006d\u0069\u006e'}) RETURN n.username
表 1. 结果
n.username

"Admin"

字符串连接

规避特定关键字验证的另一种方法是在注入中使用字符串连接。例如,可以像这样使用注入规避用户不将其用户名设置为 admin 的验证

"ad' + 'min"

可以通过转义分隔符来避免这种情况。

二阶注入

当输入在第一次使用时成功过滤和清理,然后存储在数据库中时,就会发生二阶注入。当应用程序再次使用该值时,就会执行恶意代码。

例如;Little Robby Labels 使用他们的用户名设置了帐户

LilRob' OR 1=1 WITH true AS hacked MATCH (all) DETACH DELETE all; //

由于用户名直接从用户那里接收,因此我们的应用程序使用参数设置它。

Map<String,Object> params = new HashMap<>();
params.put( "username", username );

String query =
"CREATE (u:User)" + "\n" +
"SET u.username = $username";

Result result = transaction.execute( query, params );

参数化的查询看起来像这样

CREATE (u:User) SET u.username = $username;

现在创建了一个帐户,Little Robby Tables 登录并进入设置更改他们的用户名。数据库检索其当前用户名,并使用客户端字符串连接来构建更新它的查询。

query = "MATCH (u:User) WHERE u.username = '" + username + "' SET u.username = $newUsername;"

此查询执行为

MATCH (u:User) WHERE u.username = 'LilRob' OR 1=1 WITH true AS hacked MATCH (all) DETACH DELETE all; //' SET u.username = $newUsername;

现在执行了恶意代码,所有用户都被删除了!这就是为什么即使输入似乎不是直接来自用户,也应该继续使用清理的原因。

基于角色的权限

最小特权原则

最小特权原则是指程序或用户应具有执行其功能所需的最低特权。例如,如果您的应用程序只读取数据,那么它应该只对该数据具有读取访问权限。这样做的好处是,在发生 Cypher 注入攻击的情况下,注入的查询无法操纵数据,因为执行劫持查询的角色仅限于读取数据。可以利用基于角色的权限 (RBAC) 以及在 配置 中控制可用于执行的操作的 DBMS 限制。使用 Neo4j,企业版中提供了一系列细粒度的访问控制,这可以在发生注入攻击时增加另一层保护。有关 Neo4j 中基于角色的权限的更多信息,请参阅 此处

导入数据

并非所有输入都可以作为参数提交。也许一些恶意输入已进入 CSV 文件以供处理。例如,今年新学生的姓名 CSV。

LOAD CSV WITH HEADERS FROM "file:///students_2021.csv" AS row
CREATE (s:Student)
SET s.year = 2021, s.name = row.student_name

这是否容易受到 Little Robby Labels 的攻击?

不,它不会。即使不能对行数据使用参数,Cypher 注入仍然不可能。

LOAD 查询独立于要处理的 CSV。这意味着,无论每行内容如何,内容都无法影响或劫持查询本身。