Introduction

Trong các kiến trúc microservice, các service thường truyền dữ liệu sử dụng định dạng JSON. Tương tự với HTTP Request Smuggling, nếu các JSON parsers của các services không đồng nhất về cách parse các JSON thì có thể dẫn đến lỗ hổng.

Lý do mà việc parse không đồng nhất là có quá nhiều đặc tả và các implementation không thống nhất sử dụng cùng 1 đặc tả:

  • IETF JSON RFC
  • ECMASCript Standard
  • JSON5
  • HJSON

Việc xử lý các key bị trùng, biểu diễu số và string encoding trong JSON thường được các implementation tự quyết định chứ không được chỉ dẫn rõ bởi các đặc tả, một phần là do các đặc tả ra đời sau các implementation và chúng muốn đảm bảo tính tương thích ngược với các implementation sẵn có.

Json Interoperability Security Risks

Có 5 loại rủi ro liên quan đến bảo mật:

  • Inconsistent Duplicate Key Precedence: Thứ tự ưu tiên của các key bị trùng.
  • Key Collision: character truncation và comments
  • JSON Serialization Quirks: một số điểm kỳ dị trong quá trình serialization.
  • Float and Integer Representation: biểu diễn số chấm động và số nguyên.
  • Permissive Parsing and Other Bugs: parsing “lạc quan” và một số bug khác.

Key Collision: Inconsistent Duplicate Key Precedence

Xét JSON object sau:

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

Giá trị của obj["test"] là 1 hay là 2 thì đều được chấp thuận bởi đặc tả. Thậm chí, có trường hợp developer còn sử dụng duplicated key để comment:

// 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"}

Ví dụ, xét một ứng dụng thương mại điện tử có Cart service giúp verify business logic và forward request đến Payment service để xử lý thanh toán.

Giả sử ta gửi request sau:

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
        }
    ]
}

Đối với Cart service, nó được viết bằng Python Flask và sử dụng thư viện chuẩn của Python mà ưu tiên lấy key cuối cùng để parse 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 <= 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 {} @ ${}/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"

Việc validate bằng jsonschema sẽ thành công vì duplicated key sẽ bị ignored. Sau đó, JSON gốc sẽ được forwarded đến Payment service. Từ góc độ của developer, việc re-serializing sẽ là lãng phí thay vì dùng trực tiếp JSON đã được parsed và validated.

Việc re-serializing cũng mang tới một số rủi ro nhất định.

Đối với Payment service, nó sử dụng thư viện của bên thứ 3 để có performance cao hơn: buger/jsonparser. Tuy nhiên, parser này ưu tiên sử dụng key đầu tiên trong số các key bị trùng.

Service này tính giá tiền cho giỏ hàng như sau:

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

Thay vì nhận được giá tiền là 300 (bằng với 200):

HTTP/1.1 200 OK
...
Content-Type: text/plain
 
Receipt:
5x Product A @ $100/unit
1x Product B @ $200/unit
 
Total Charged: $300
Như vậy, nếu JSON parser của 2 service có thứ tự ưu tiên khi xử lý duplicated field là khác nhau thì có thể bị tấn công key collision như trên.

Key Collision: Character Truncation and Comments

Còn một số cách khác để tấn công key collision là sử dụng tính năng character truncation và comments.

Using Character Truncation

Một số parser sẽ loại bỏ một số ký tự ở trong chuỗi trong khi một số khác thì không. Việc loại bỏ có thể dẫn đến việc các key bị trùng. Ví dụ, các JSON sau có thể có duplicated keys khi được xử lý bởi các parser ưu tiên key cuối cùng:

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

Ví dụ, các ký tự Unicode từ U+D800 đến U+DFFF được xem là các ký tự Unicode không hợp lệ trong các chuỗi UTF-8 (là chuẩn encode dành cho các chuỗi JSON theo đặc tả). Đối với một số parser, nó sẽ loại bỏ các ký tự Unicode không hợp lệ này đi như thư viện ujson trong đoạn code bên dưới:

$ 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}
Để tấn công bằng tính năng character truncation, ta cần phải có khả năng encoding và decode các ký tự Unicode và một database với hệ thống kiểu dữ liệu không quăng exception khi lưu trữ các ký tự này.

Xét ví dụ một ứng dụng multi-tenant mà admin có thể tạo ra các custom user roles. Ngoài ra, users có quyền truy cập tới tổ chức khác được gán cho quyền superadmin. Chúng ta sẽ cố gắng leo quyền.

Thử tạo một user có quyền superadmin thì gặp lỗi:

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"}

Thay vào đó, ta tạo ra custom roles có Unicode character để bypass:

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'"}

Sau đó tạo user với role vừa tạo:

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 sẽ được lưu vào DB với quyền superadmin\ud888 và do nó khác superadmin nên điều này được phép.

Tuy nhiên, khi truy cập đến tổ chức khác sử dụng endpoint /admin, service của endpoint này lại sử dụng thư viện ujson như trên và nó tiến hành truncate ký tự Unicode trong role.

@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

Dẫn đến là ta có thể truy cập được tổ chức khác với quyền superadmin.

Using Comment Truncation

Nhiều thư viện JSON hỗ trợ quotless string làm key và cho phép comment tương tự như trong ngôn ngữ JavaScript:

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

Tuy nhiên, các tính năng này không được mô tả trong đặc tả.

Xét 2 parser đều hỗ trợ quoteless string và một trong số đó hỗ trợ comment, JSON sau sẽ được hiểu theo 2 cách:

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

Cách 1 (từ thư viện GoJay của Golang) sẽ serialize thành:

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

Cách 2(từ thư viện JSON-iterator của Java) sẽ serialize thành:

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

Một ví dụ khác:

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

Đối với thư viện GSON của Java:

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

Đối với thư viện simdjson của Ruby:

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

JSON Serialization Quirks

Inconsistent Precedence: Deserialization vs. Serialization

Xét thư viện JSON-iterator của Java, việc serialize và deserialize của nó cho ra 2 kết quả khác nhau với input là obj = {"test": 1, "test": 2}:

obj["test"] // 1
obj.toString() // {"test": 2}

Generating Documents with Duplicate Keys

Một vài parser còn cho phép serialize duplicated key:

obj["test"] // 2
obj.toString() // {"test": 1, "test": 2}

Float and Integer Representation

Inconsistent Large Number Decoding

Nếu không được decode đúng cách, các số lớn có thể được decoded thành MAX_INT hoặc 0. Ví dụ, với số 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999, nó có thể được decoded thành nhiều dạng biểu diễn bởi các parser như sau:

999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999
9.999999999999999e95
1E+96
0
9223372036854775807

Trong ví dụ của Key Collision Inconsistent Duplicate Key Precedence, ta biết rằng thư viện jsonparser của Payment service sẽ decoded số lớn thành 0 và chúng ta có thể khai thác để mua hàng với $0.

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

Mặc dù Cart service decode đúng.

Inconsistent Type Representation with Infinity

Số dương vô cực và âm vô cực cùng NaN không được hỗ trợ bởi đặc tả của RFC. Tuy nhiên, nhiều parser cho phép sử dụng các giá trị này. Ví dụ, với input là:

{"description": "Big float", "test": 1.0e4096}

Nó có thể được biểu diễn thành:

{"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}

Chú ý rằng việc chuyển đổi từ số sang chuỗi: mặc dù là an toàn trước strict comparison nhưng khi sử dụng loose comparison thì có thể dẫn đến lỗ hổng juggling vulnerability (string được biểu diễn thành số 0):

<b><?php</b>
echo 0 == 1.0e4096 ? "True": "False" . "\n"; # False
echo 0 == "Infinity" ? "True": "False" . "\n"; # True
<b>?></b>

Permissive Parsing and One-off Bugs

Trailing Garbage

Nhiều JSON parser cho phép có ký tự thừa sau JSON:

POST / HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded
 
{"test": 1}=

Attacker đã lợi dụng tính năng này để thực hiện CSRF trong nhiều năm bằng cách thêm dấu bằng ở cuối request body để làm cho nó trông giống như là có dạng x-form-urlencoded. Nếu server không validate Content-Type thì có thể bị tấn công CSRF.

Denial-of-Service: Segmentation Faults

Có một vài parser bị crash khi xử lý các JSON bị lỗi.

How Can We Detect JSON Interoperability Vulnerabilities?

Thường rất khó để phát hiện từ bên ngoài. Nếu chúng ta có source code thì dễ dàng hơn. Có thể làm theo các đề nghị của bài lab: BishopFox/json-interop-vuln-labs: Companion labs to “An Exploration of JSON Interoperability Vulnerabilities”.

Resources