深入考察JSON在互操作性方面的安全漏洞(上)
2021-03-03 11:30:00 Author: www.4hou.com(查看原文) 阅读量:200 收藏

实验表明:同一个JSON文档,对于不同的微服务来说,可能会解析出不同的结果,这会导致多种潜在的安全风险。如果您喜欢动手实验的话,不妨亲自尝试一下,结果肯定会让您大吃一惊的。

简介:解析器越多,问题就越严重

JSON是Web应用通信的基石。同时,JSON的简单性通常被认为是必不可少的。我们通常不会将JSON解析视为我们威胁模型的一部分。然而,在我们现代的多语言微服务架构中,应用程序通常都是借助于多个独立的JSON解析来实现的,而每个JSON解析通常都有自己的“怪癖”。

正如我们在HTTP请求走私等漏洞中所看到的那样,不同解析器之间的差异与多阶段请求处理相结合会导致非常严重的安全漏洞。在这项研究中,我对49个JSON解析器进行了调查,对它们的怪癖进行了分类,并展示了各种攻击场景和Docker Compose实验,以帮助读者充分认识它们的危险性。通过支付处理和用户管理的例子,我们将展示JSON解析的不一致性是如何在原本正常的代码中隐匿严重的业务逻辑漏洞的。

为什么会出现解析不一致的情况?

官方规范与其他规范

即使在最好的情况下,规范的实现也不可避免地存在一些轻微的、非故意的偏差。除此之外,JSON解析器还面临一些额外的挑战:即使在官方的JSON RFC中,也存在关于某些主题的开放式指导,例如如何处理重复的键和表示数字等。虽然这些指导后面有关于互操作性的免责声明,但大多数JSON解析器的用户并不了解这些注意事项。

造成解析器之间不一致的原因之一是存在多种不同的规范。

· IETF JSON RFC (8259及以前的版本):这是互联网工程任务组(IETF)的官方规范。

· ECMAScript标准:对JSON的更改是与RFC版本同步发布的,该标准参考了RFC关于JSON的指导。然而,JavaScript解释器提供的不合规范的便利性,如无引号字符串和注释,则激发了许多解析器的“创造”灵感。

· JSON5:这个超集规范通过明确地添加便利性特征(如注释、备选引号、无引号字符串、尾部逗号)来增强官方规范。

· HJSON:HJSON在思想上与JSON5类似,但在设计上则具有不同的选择。

· 还有更多...

那么,为什么有些解析器会有选择地加入其他解析器忽略的特性,或者在解析器行为上采取矛盾的方法呢?

开放式的指导意见 

正如下面的章节所讨论的那样,关于处理重复键和表示数字的决定往往是开放式的。这可能是由于规范是在实现变得流行之后才发布的所致。同时,也许是设计委员会决定不破坏与规范前JSON解析器的向后兼容性所致,包括最初的JavaScript实现。

然而,这些决定后来继续通过生态系统传播到JSON5和HJSON等超级集规范中,甚至传播到BSON、MessagePack和CBOR等二进制变体中,这些我们将在后面讨论。

进一步的互操作性问题来自于对数字和字符串编码的延迟指导。字符串编码只是在2017年修订的规范中才被明确要求为UTF-8格式。

下面,我们开始讨论在这样的背景下会出现哪些问题。

JSON的互操作性安全风险

我将已经发现的互操作性安全风险分为五类,分别如下所示:

1. 不一致的重复键优先级

2. 键碰撞:字符截断与注释

3. JSON序列化的怪癖

4. 浮点数和整数的表示法

5. 权限解析及其他漏洞

1. 不一致的重复键优先级

1.png 

Creed说他们是不同的。实际上,Creed和Pam都是对的,但这要看你问谁。

我们很多人在开发工作中都遇到过JSON的这个怪癖。如果代码中存在一个重复的键,结果会怎样?

我们不妨考虑一下下面文档中obj["test"]的值:

obj = {"test": 1, "test": 2}

请问:obj["test"]的值是是1还是2,还是会导致出错?

根据官方规范,这些结果都是可以接受的。令人惊讶的是,我甚至遇到过开发者直接利用重复键的优先级来创建自文档化的JSON的情况,下面就是这样的示例:

// For a parser where the last key takes precedence, the first “test” key will be ignored during parsing.
obj = {"test": "this is a description of the test field", "test": "Actual Value"}

你可能会惊讶地发现,该规范的指导意见很大程度上都是描述性的,而不是规定性的。以下内容摘自IETF JSON RFC (8259)的最新版本:

名称都是唯一的对象是可互操作的,因为接收该对象的所有软件实现都会在名称-值映射上达成一致。当一个对象内的名称不唯一时,接收该对象的软件的行为是不可预测的。许多实现只报告最后的名/值对。其他的实现则会报告一个错误或指出无法解析对象,而一些实现会报告所有的名称/值对,包括重复的名称/值对。

据观察,JSON解析库在是否使对象成员的排序对调用软件可见方面存在差异。行为不依赖于成员排序的实现将是可互操作的,因为它们不会受到这些差异的影响。

之所以这么做,可能是该规范不想破坏与之前规范对应的解析器保持向后兼容性。平心而论,这里的确注意到了互操作性的问题。但从实际意义上讲,正如前面所说,到底会有多少开发人员会阅读JSON RFC,或者考虑这样一个简单格式的互操作性问题呢?不用说,上述规范中的语言与RFC中常见的明确而直接的指导是完全不同的。

因此,让我们看一些有关重复键优先级可能出错的示例。

示例:验证代理模式

让我们考虑一个电子商务应用程序,其中有一个Cart服务,该服务强制执行业务逻辑,将请求转发到Payment服务以进行付款处理,并履行订单。下面,让我们尝试免费获得一些东西。本示例将使用下面描述的设计:

 1.png

验证代理模式

假设Cart服务收到的请求如下所示(请注意购物车中重复的qty键):

POST /cart/checkout HTTP/1.1
...
Content-Type: application/json
 
{
    "orderId": 10,
    "paymentInfo": {
        //...
    },
    "shippingInfo": {
        //...
    },
    "cart": [
        {
            "id": 0,
            "qty": 5
        },
        {
            "id": 1,
            "qty": -1,
            "qty": 1
        }
    ]
}

如下图所示,Cart服务在将订单发送到支付服务之前,会强制执行业务逻辑。API是用Python Flask编写的,并通过Python标准库JSON解析器,对重复的键,它们会优先选用最后一个:

@app.route('/cart/checkout', methods=["POST"])
def checkout():
   # 1a: Parse JSON body using Python stdlib parser.
   data = request.get_json(force=True)
 
   # 1b: Validate constraints using jsonschema: id: 0 <= x
   # See the full source code for the schema
   jsonschema.validate(instance=data, schema=schema)
 
   # 2: Process payments
   resp = requests.request(method="POST",
                          url="http://payments:8000/process",
                          data=request.get_data(),
                          )
 
   # 3: Print receipt as a response, or produce generic error message
   if resp.status_code == 200:
       receipt = "Receipt:\n"
       for item in data["cart"]:
           receipt += "{}x {} @ ${}/unit\n".format(
               item["qty"],
               productDB[item["id"]].get("name"),
               productDB[item["id"]].get("price")
           )
      receipt += "\nTotal Charged: ${}\n".format(resp.json()["total"])
      return receipt
   return "Error during payment processing"

JSON主体将成功通过验证,因为重复的键会被忽略,并且所有解析的值都满足约束条件。既然JSON被认为是安全的,那么原始的JSON字符串(request.get_data())就被转发给Payments服务。从开发人员的角度来看,当字符串输入很容易获得时,为什么要通过重新序列化刚刚解析和验证的JSON对象来浪费计算时间呢?这个假设应该是合理的。

接下来,代码将在Payments服务中接收请求。这个Golang服务使用一个高性能的第三方JSON解析器(buger/jsonParser)。但是,这个JSON解析器使用首键优先级(对于ID:1,其qty=-1)。之后,该服务将计算总费用,具体如下所示:

func processPayment(w http.ResponseWriter, r *http.Request) {
   var total int64 = 0
   data, _ := ioutil.ReadAll(r.Body)
   jsonparser.ArrayEach(
           data,
           func(value []byte, dataType jsonparser.ValueType, offset int, err error) {
             // Retrieves first instance of a duplicated key. Including qty = -1
               id, _ := jsonparser.GetInt(value, "id")
               qty, _ := jsonparser.GetInt(value, "qty")
               total = total + productDB[id]["price"].(int64) * qty;
           },
       "cart")
 
   //... Process payment of value 'total'
 
   // Return value of 'total' to Cart service for receipt generation.
   io.WriteString(w, fmt.Sprintf("{\"total\": %d}", total))
}

Cart服务接收来自支付服务收取的总费用,并生成收据作为响应。我们查看了Cart服务的收据,并且发现了一个错误。我们将邮寄价值700美元的六种商品,但只收了300美元:

HTTP/1.1 200 OK
...
Content-Type: text/plain
 
Receipt:
5x Product A @ $100/unit
1x Product B @ $200/unit
 
Total Charged: $300

然而,在这个例子中,经过验证的JSON文档在被解析后并没有被重新string化,而是使用了原始请求中的JSON字符串。在第3节JSON序列化怪癖中,我们将探索重新string化对象的例子。 

在实验1中,我们将尝试这种攻击方法。

2. 键碰撞:字符截断与注释

1.png 

实际上,攻击者也可以通过字符截断和注释来诱发键碰撞,以增加受重复键优先级影响的解析器的范围。

使用字符截断

有些解析器会在字符串中出现特定字符时截断它们,而其他解析器则不会这么做。这可能会导致不同的键在某个解析器子集中被解释为重复键。例如,对于以下文档来说,在某些优先选用最后一个键的解析器中,将会出现重复的键,而在其他解析器中则不会:

{"test": 1, "test\[raw \x0d byte]": 2}
{"test": 1, "test\ud800": 2}
{"test": 1, "test"": 2}
{"test": 1, "te\st": 2}

这些字符串表示形式,在经过多轮反序列化和重新序列化的处理之后,得到的结果往往是不稳定的。例如,对于从U+D800到U+DFFF的Unicode码点,并没有对应的UTF-16码点与之对应,即使这些码点可以编码成UTF-8字节串,但仍被认为是非法的Unicode。

所有这些示例都可以与之前的示例和实验1相似的方式进行使用。然而,允许对非法Unicode进行编码和解码的环境(如Python 2.x)可能容易受到复杂的攻击,这些攻击需要存储(序列化)和检索(反序列化)这些值。

下面,让我们先观察一下Python 2.x中的Unicode编码和解码行为:

$ python2
>>> import json
>>> import ujson
# Serialization into illegal unicode.
>>> u"asdf\ud800".encode("utf-8")
'asdf\xed\xa0\x80'
# Reserializing illegal unicode
>>> json.dumps({"test": "asdf\xed\xa0\x80"})
'{"test": "asdf\\ud800"}'
# Let's observe the third party parser ujson's truncation behavior and how it creates a duplicate key.
>>> ujson.loads('{"test": 1, "test\\ud800": 2}')
{u'test': 2}

正如我们在下一个例子中所看到的,攻击者可以使用这个功能来绕过某些安全检查,例如,创建和存储一个名为superadmin\ud888的角色,这个角色可能会被检索和解析为superadmin。然而,这种技术需要支持对非法Unicode码点的编码和解码(这一点并不难),以及具有不会抛出异常的类型系统的数据库(这个的难度就要大一些了)。

在下面的实验中,我们将使用Python 2.7和MySQL的二进制模式,以使我们能够专注于存储非法Unicode的风险及其对不一致的JSON解析的影响。

示例:验证-存储模式

让我们考虑一个多租户应用程序,组织管理员能够创建自定义用户角色。此外,我们知道,具有跨组织访问权限的用户已经被授予内部角色superadmin的权限。下面,让我们尝试进行提权。本例将使用下图所示的设计:

1.png 

首先,我们尝试强制创建一个具有superadmin权限的用户。

POST /user/create HTTP/1.1
...
Content-Type: application/json
 
{
   "user": "exampleUser",
   "roles": [
       "superadmin"
   ]
}
 
HTTP/1.1 401 Not Authorized
...
Content-Type: application/json
 
{"Error": "Assignment of internal role 'superadmin' is forbidden"}

如上所示,User API具有一个服务器端安全控件,以阻止用户创建具有superadmin角色的新用户。该控件由Roles API共享,以避免覆盖现有的用户定义角色和系统角色。在这里,我们假设User API上的/User/和/role/端点使用的是行为良好的、兼容的解析器。

相反,为了影响下游解析器,我们将创建一个角色,并且其名称在解析器之间是不稳定的,即superadmin\ud888:

POST /role/create HTTP/1.1
...
Content-Type: application/json
 
{
   "name": "superadmin\ud888"
}
 
HTTP/1.1 200 OK
...
Content-type: application/json
 
{"result": "OK: Created role 'superadmin\ud888'"}

接下来,我们创建一个具有用户刚定义的角色的用户:

POST /user/create HTTP/1.1
...
Content-Type: application/json
 
{
   "user": "exampleUser",
   "roles": [
       "superadmin\ud888"
   ]
}
 
HTTP/1.1 200 OK
...
Content-Type: application/json
 
{"result": "OK: Created user 'exampleUser'"}

这样的话,User API会将该用户存储到数据库中。到目前为止,所有的解析器都将用户定义的角色(superadmin\ud888)视为与内部角色superadmin具有不同的名称。

然而,当稍后访问跨组织的/admin端点时,服务器会通过Permissions API请求用户的权限。这时,Permissions API会忠实地对该角色进行编码,具体如下所示:

GET /permissions/exampleUser HTTP/1.1
...
 
HTTP/1.1 200 OK
...
Content-type: application/json
 
{
   "roles": [
       "superadmin\ud888"
   ]
}

但是,这里就会出现问题了:admin API使用了第三方ujson解析器。如前所述,该解析器会截断包含非法码点的所有字节: 

@app.route('/admin')
def admin():
   username = request.cookies.get("username")
   if not username:
      return {"Error": "Specify username in Cookie"}
 
   username = urllib.quote(os.path.basename(username))
 
   url = "http://permissions:5000/permissions/{}".format(username)
   resp = requests.request(method="GET", url=url)
 
   # "superadmin\ud888" will be simplified to "superadmin"
   ret = ujson.loads(resp.text)
 
   if resp.status_code == 200:
       if "superadmin" in ret["roles"]:
           return {"OK": "Superadmin Access granted"}
       else:
           e = u"Access denied. User has following roles: {}".format(ret["roles"])
           return {"Error": e}, 401
   else:
       return {"Error": ret["Error"]}, 500

如上所示,用户定义的角色将被截断为superadmin,并授予对特权API的访问权限。

在实验2中,我们将尝试这种攻击方法。

使用注释截断

许多JSON库支持JavaScript解释器环境中的无引号字符串和注释语法(例如,/*,*/)。然而,这些功能都不是官方规范的一部分。这些特性允许解析器处理如下所示的文档:

obj = {"test": valWithoutQuotes, keyWithoutQuotes: "test" /* Comment support */}

假设有两个支持无引号的字符串的解析器,但是只有一个解析器可以识别注释语法,这种情况下,我们就可以走私重复的键,具体如下例所示:

obj = {"description": "Duplicate with comments", "test": 2, "extra": /*, "test": 1, "extra2": */}

在这里,我们将使用每个解析器中的序列化器来查看其各自的输出。

序列化器1(例如GoLang的GoJay库)的输出如下所示:

· description = "Duplicate with comments"

· test = 2

· extra = ""

序列化器2(例如,Java的JSON-iterator库)将产生如下所示的输出:

· description = "Duplicate with comments"

· extra = "/*"

· extra2 = "*/"

· test = 1

或者,直接使用注释:

obj = {"description": "Comment support", "test": 1, "extra": "a"/*, "test": 2, "extra2": "b"*/}

下面是Java的GSON库生成的解码结果:

{"description":"Comment support","test":1,"extra":"a"}

下面是Java的GSON库生成的解码结果:

{"description":"Comment support","test":2,"extra":"a","extra2":"b"}

这展示了附加的解析特征的不一致是如何导致键碰撞攻击的。

小结

在本文中,我们为读者解释了不同的JSON解析器的解析结果不一致的原因,以及JSON在互操作性方面存在的安全风险,在接下来的文章中,我们将继续与读者一起探索JOSN的互操作性安全漏洞以及防御措施!

(未完待续)

本文翻译自:https://labs.bishopfox.com/tech-blog/an-exploration-of-json-interoperability-vulnerabilities如若转载,请注明原文地址:


文章来源: https://www.4hou.com/posts/4VW0
如有侵权请联系:admin#unsafe.sh