防止 Cypher 注入
什么是 Cypher 注入?
Cypher 注入是一种通过恶意格式化的输入跳出原有上下文,篡改查询本身,从而劫持查询并在数据库上执行意外操作的方法。
这是 SQL 注入的“表亲”,但它影响的是我们的 Cypher 查询语言。
XKCD 漫画中关于“小鲍比·表”的故事是展示 SQL 注入的一个绝佳案例。
漫画中,一位母亲给孩子起名为 Robert'; DROP TABLE STUDENTS;--,这确保了如果将他的名字通过字符串拼接的方式附加到 SQL 语句中(且没有针对注入攻击的保护),多出来的引号就会闭合应用程序中已打开的引号,分号将结束该语句,而 -- 会将查询的其余部分注释掉,从而避免语法错误。
考虑到鲍比的母亲可能会继续她的“征途”,她的下一个孩子可能会被起名为专门针对领先图形数据库 Neo4j 所使用的 Cypher 语言。我们叫他“小罗比·标签”。
"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; //';
在创建 Robby 节点之后,所有 :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 注入的尝试。
这是因为参数与查询是分离的。查询本身被编译为可执行计划,一旦编译完成,就可以使用任何参数映射来进行执行。
换句话说,一旦查询计划被编译,它就固定了,提交给它的任何数据都无法改变、篡改或劫持它。
这种保护方式是防止字面量(literal)层面的 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;
以逗号分隔的字符串形式返回数据库中所有学生的姓名。为了使此方法成功,客户端应用程序必须既容易受到注入攻击,又会将查询结果返回给用户。
盲注(Blind Injection)
盲注是指攻击者不直接从客户端响应中获取泄露的信息,而是通过其他方式获取。
一种实现方式是通过观察应用程序的行为。假设一个网站根据查询的存在结果加载不同的页面。例如,登录页面首先询问电子邮件,然后显示“登录以继续”页面或“注册以继续”页面。
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;"
^
如果服务器返回原始错误,则整个查询现在都是可见的,从而更容易发送更具体的恶意输入。攻击者现在知道了至少一个标签的名称以及与其关联的变量。
为了防止这种情况,除了使用参数化和清理/验证用户输入外,还要避免向用户返回具体的数据库错误,应选择更通用的错误提示。
查询清理(Query Sanitization)
虽然通过字符串拼接构建查询通常是一个坏主意,但有时无法避免。节点标签、关系类型和属性名称就是无法在 Cypher 中进行参数化的典型示例。
在这些情况下,清理用户输入非常重要。清理是对输入进行修改以确保其有效。对于 Cypher,这通常意味着转义引号或删除会被过早地解释为字符串字面量或标识符结束的分隔符。在接收不受信任的外部输入时,应始终进行清理,有时也可能在其他情况下需要,请参阅二阶注入以获取更多信息。
建议在客户端进行清理,然后再将其传递给数据库。
Cypher 中的转义字符
转义字符会改变序列中后续字符的含义。在 Cypher 中,可以通过开启和关闭某些字符来定义字符串字面量和标识符(如节点标签),这些字符如果被正确转义,也可以在表达式中使用。
在接下来的章节中,我们将解释如何转义不同 Cypher 类型中的分隔符。
| Cypher 类型 | 字符类型 | 字符 | 转义序列 |
|---|---|---|---|
字符串字面量 |
单引号 |
' |
\' 或 \u005c' |
Unicode 单引号 |
\u0027 |
\u005c\u0027 或 \\u0027 |
|
双引号 |
" |
\" 或 \u005c" |
|
Unicode 双引号 |
\u0022 |
\u005c\u0022 或 \\u0022 |
|
标识符 |
反引号 |
` |
`` |
Unicode 标识符 |
\u0060 |
\u0060\u0060 或 `\u0060 |
何时需要清理
节点标签、关系类型和参数可能包含非字母字符,包括数字、符号和空格字符,但必须使用反引号转义。例如: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 是反斜杠 \,\u0075 是 u),然后数据库会将其解析为一个反引号!
编写自己的清理函数可能很棘手。这就是为什么强烈建议避免使用字符串拼接,并以不需要用户输入来动态查询节点标签、关系类型和参数的方式来设计数据库。
验证与清理的常见利用方式
清理也可以作为一种清理用户输入的技术。另一种保持输入安全和干净的方法是使用验证。验证会检查输入并确保其符合一组特定标准,如果不符合则拒绝输入,这与仅清理输入的清理方式不同。验证可以与清理同时使用。请记住,这两种技术都有风险。
空白检查
检查用户输入中的空格听起来是避免注入的好方法,在某些情况下也确实有效,请考虑以下示例:
"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\u006e 是 admin 的 Unicode 编码。
CREATE (n {username: '\u0061\u0064\u006d\u0069\u006e'}) RETURN n.username
| n.username |
|---|
"Admin" |
二阶注入
二阶注入发生在输入在第一次使用时成功过滤和清理,并存储在数据库中。当应用程序在另一处使用该值时,恶意代码被执行。
例如,“小罗比·标签”设置了一个账户,其用户名设置为:
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;
账户创建后,小罗比·表登录并转到设置更改其用户名。数据库检索其当前用户名,并使用客户端字符串拼接构建更新查询。
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;
恶意代码现在运行了,所有用户都被删除了!这就是为什么即使输入看起来不是直接来自用户,也应该继续使用清理的原因。
导入数据
并非所有输入都可以作为参数提交。也许一些恶意输入进入了 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
它会受到“小罗比·标签”的影响吗?
不,不会。即使参数不能用于行数据,Cypher 注入在此处仍然是不可能的。
LOAD 查询独立于要处理的 CSV 文件。这意味着无论每一行的内容如何,这些内容都无法影响或劫持查询本身。
此页面有帮助吗?
