BOLA Behind a gzip-Compressed Request Body
A POST endpoint with Content-Encoding: gzip renders as a block of
unreadable bytes in Burp's inspector. Testers see it and move on. There's nothing
to click, nothing that looks like a parameter. That instinct is exactly wrong. The
compressed body is where the authorization parameter was hiding, and it took one
decompression step to turn an "untestable" endpoint into a confirmed BOLA.
The request that looks untestable
Any POST endpoint can carry its parameters inside a compressed body. On the wire it looks unremarkable: standard headers, an ordinary session cookie, and a body that Burp renders as binary noise. Nothing to click, nothing that reads as a parameter:
POST /api/report HTTP/1.1
Host: api.acme-lab.test
Cookie: session=<session>
Content-Type: application/json
Content-Encoding: gzip
Content-Length: 48
(body: raw bytes, gzip stream, not rendered by Burp)
That's exactly where most testers stop. It's also exactly where the object-reference parameter is hiding.
What the body actually is, byte for byte
Don't take "it's binary" on faith. The body's first two bytes are the gzip magic
number 1f 8b, and everything after them is DEFLATE-compressed JSON:
00000000 1f8b 0800 0000 0000 02ff ab56 ca4c 51b2 ...........V.LQ.
00000010 5232 3450 d251 2a2e 492c 292d 06f2 824b R24P.Q*.I,)-...K
00000020 9393 538b 8b95 6a01 cd02 322a 1e00 0000 ..S...j...2*....
It's not obfuscation or encryption. It's standard DEFLATE-compressed JSON. Reversing it takes one library call.
Steps 01–07: recovering the plaintext and confirming BOLA
In Burp: right-click the request → Save item(s) (or Copy as Base64). This captures the exact bytes that went on the wire, headers and compressed body, with no rendering transformation applied.
HTTP always separates headers from body with
\r\n\r\n. Everything after that boundary is the still-compressed body.
import base64
data = base64.b64decode(open("request_b64.txt").read())
idx = data.find(b"\r\n\r\n")
headers, body = data[:idx], data[idx+4:]
Don't trust the Content-Encoding header alone;
check the first two bytes of the body against the gzip signature before
assuming the encoding.
| Offset | Bytes | Meaning |
|---|---|---|
| 0x00 | 1f 8b | gzip magic number |
| 0x02 | 08 | compression method: DEFLATE |
| 0x03 | 00 | flags |
gzip.decompress() handles the standard case.
If the stream was corrupted in transit and throws a CRC error, fall back to
zlib.decompressobj(-zlib.MAX_WBITS), which inflates the raw DEFLATE
stream without checking the trailer checksum.
import gzip, json
result = gzip.decompress(body)
obj = json.loads(result)
print(json.dumps(obj, indent=2))
The decompressed output reveals an id
parameter that was never visible in the URL path, cookies, or Burp's rendered
view. It existed only inside the compressed body.
{
"id": "10",
"status": "Success"
}
Skip decompression and you'd report this endpoint as "not testable," which is the exact opposite of the truth.
Change "id": "10" to
"11", drop the Content-Encoding: gzip header
(send the edited body as plain JSON so header and payload agree),
and replay in Burp Repeater with the original session cookie unchanged.
The server returns another user's record. The path, session cookie, and all other context stayed identical; only the compressed body's object reference changed.
The BOLA test, before and after
Same session, same path, same everything except the object reference inside the
compressed body. Swapping id returned a record the caller had no
authorization to see, with no server-side re-validation. Confirms OWASP API1:2023.
Why this class of bug stays hidden
Any SPA that gzips its request bodies client-side, common with Angular and Vue
front ends talking to a JSON API, can hide identical bugs. The tester
who stops at "body is binary, move on" misses the entire authorization surface of
that endpoint. The compression isn't an obstacle; it's a single import gzip
away from plain text.
The same principle applies to other content encodings: Brotli (br),
Zstandard (zstd), and even base64-wrapped blobs. The shape of the
body's encoding is irrelevant. What matters is whether an object reference is
inside it and whether the server validates that reference against the caller's
actual authorization.
Fixing it
- Re-validate the object reference server-side on every request.
The server must confirm that the authenticated session user is authorized to
access the requested
id, not just that a valid session token is present. Compression of the request body is irrelevant to this check. - Never trust client-supplied object identifiers for scoping. Derive the record owner from the verified session, not from the request body, unless the caller holds an explicit delegation that is also validated server-side.
- Audit all endpoints that accept
Content-Encoding. Each one should be inspected as if the body is plaintext, because under test conditions it is. The encoding is a transport convenience, not a security control. - Treat any privilege flag in a client-controlled body as a separate finding.
A boolean that requests elevated behaviour belongs to the server's decision, derived
from the session role, never a field the client can set to
true.
The methodology, generalized
- Never conclude "not testable" from a body that fails to render as text; verify what it is first.
- Export the raw item from Burp (base64/file), not a screenshot; screenshots of rendered bytes can't be reversed.
- Check the magic number before assuming the encoding:
1f 8bfor gzip, don't just trust the header. - Inflate, parse, then re-read the JSON as if seeing the endpoint for the first time; object references hide in the fields nobody expected to be editable.
- Any identifier found this way is a BOLA candidate the moment the server doesn't re-validate it against the caller's authorization.