One Header Away from 10+ GB of Customer Documents (PII) — $6K Bounty
Press enter or click to view image in full sizeSample Document LeakedAn anonymous attacker could ful 2026-7-27 09:38:35 Author: infosecwriteups.com(查看原文) 阅读量:3 收藏

Alvin Ferdiansyah

Press enter or click to view image in full size

Sample Document Leaked

An anonymous attacker could fully enumerate, read, and overwrite the production customer-service attachment bucket of a major insurer’s China operation.

The bucket backs the file-upload flow of their WeChat customer-service chatbot, held massive customer chat attachments; photos of identity cards, hospital certificates, policy screens, confidential documents, etc. Anyone on the internet could read all of it.

A quick aside, if you’ve never touched a Chinese cloud stack. This bucket lives on Volcengine (火山引擎), ByteDance’s public cloud, same parent company as TikTok and Douyin. Their object storage is called TOS, and it’s S3-compatible in the way that matters: same API verbs, same virtual-hosted URLs, same XML error bodies, its own HMAC-SHA256 signing scheme. Every instinct you have from S3 transfers over.

What doesn’t transfer is the console. 防盗链 sits right there in the bucket settings, a few rows under CORS, presented as a normal thing to turn on. On AWS you’d have to go out of your way to write a bucket policy that conditions on aws:Referer. Here it’s a checkbox. That difference is most of the reason this bug exists.

The issue is caused by two independent authentication control failures on the same storage workflow:

  1. Anonymous read bypass: the bucket allows unauthenticated listing of object keys, and object reads are protected only by a forgeable Referer header pointing at the app’s own domain. Any attacker can spoof this header and retrieve the full object content.
  2. Anonymous write / overwrite: the upload signer at /api/tos/presigned-post is unauthenticated and signs attacker-supplied bucket/key values using the company’s Volcengine TOS credential. This allows anonymous uploads and overwrite-in-place of existing object keys.

The first bug — Forgeable Referer Header Bypass

Same object, same second, same URL. Only one header changes.

GET /chat/message/[REDACTED]/20251211/images/[REDACTED].png
Host: [REDACTED]-prod.[REDACTED].volces.com

(no Referer) → 403 AccessDenied, 213 bytes
Referer: https://evil.example.com.cn/ → 403 AccessDenied, 213 bytes
Referer: https://[REDACTED].[Target].com.cn/ → 200 image/png, 7828 bytes

That’s the whole bypass.

Press enter or click to view image in full size

403 Forbidden

Press enter or click to view image in full size

Auth Bypassed

The bucket had 防盗链 switched on; anti-leech, hotlink protection. You give it a list of allowed referring domains and it blocks requests that come from anywhere else. Every Chinese cloud ships it. It exists to stop someone embedding your images on their forum and running up your bandwidth bill.

It’s a billing control. Somebody left it holding the door as an access control.

And Referer is a request header. The client writes it. There is no signature on it, no secret in it, nothing to verify. I typed the allowed domain and the bucket believed me.

How I got to the bucket in the first place??

Well, it was pretty straightforward, although getting there took a fair amount of time. I spent hours collecting, deobfuscating, and going through JavaScript bundles from the host target, following one small clue after another. One URL led to a new function, that function pointed to another endpoint, and little by little, the trail eventually brought me to the bucket URL itself.

The second bug — Unauthenticated Signer

Take a look at this part of the code from the bundle.

  cc = "[REDACTED]-prod"  

fetch("https://[app-host]/volces/api/tos/presigned-post",
{method:"POST", headers:{"Content-Type":"application/json"},
body:JSON.stringify({bucket:cc, key:h})})

m = `${cc}.[REDACTED].volces.com`
f = `https://${m}/${encodeURI(h)}`

The headers object has exactly one key. No Authorization, no token, nothing to strip, because nothing was ever there. It calls the app’s own backend rather than a cloud SDK, so somebody hand-wrote a signer. And both bucket and key come from the browser.

So I asked it for a signature.

  POST /volces/api/tos/presigned-post
Content-Type: application/json

{"bucket":"[REDACTED]-prod","key":"bugbounty-poc.txt"}

→ 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *

{"data":{
"key": "bugbounty-poc.txt",
"policy": "eyJleHBpcmF0aW9uIjoi…",
"x-tos-algorithm": "TOS4-HMAC-SHA256",
"x-tos-credential": "[AK]/20260623/[REGION]/tos/request",
"x-tos-date": "20260623T013954Z",
"x-tos-signature": "[REDACTED]"}}

A complete signed upload credential, handed to an anonymous caller, over Access-Control-Allow-Origin: *. Any website on the internet could mint one from a visitor’s browser.

Get Alvin Ferdiansyah’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

Base64-decode that policy field and the real problem shows:

{"expiration":"2026–06–23T02:39:54Z","conditions":[
{"bucket":"[REDACTED]-prod"},
{"key":"bugbounty-poc.txt"},
{"x-tos-algorithm":"TOS4-HMAC-SHA256"},
{"x-tos-date":"20260623T013954Z"},
{"x-tos-credential":"[AK]/20260623/[REGION]/tos/request"}]}

No size cap. No content-type restriction. No ACL. And no key prefix, just the exact key I asked for. A prefix tied to your session is what stops you writing into someone else’s folder. An exact key you chose yourself is a round trip that ends where it started.

Proving overwrite, not just upload

The write is a multipart POST straight to the bucket, every field lifted from that JSON:

POST /bugbounty-poc.txt
Host: [REDACTED]-prod.[REDACTED].volces.com
Content-Type: multipart/form-data
key, policy, x-tos-algorithm, x-tos-credential,
x-tos-date, x-tos-signature, file

→ 204 No Content
ETag: "bbea7bc181dd55ae2a31e308802e5f5b"

But 204 only proves I created something. Storage returns the same 204 whether the key was empty or occupied, so it can’t distinguish me from someone replacing a customer’s file. The ETag can, it’s a content hash. Same request, different content:

→ 204 No Content
ETag: "736b48e1fd44a7edf641bf367851c032"

Same key, different hash. The second write destroyed the first. Overwrite-in-place, proven without deleting anything.

Whose bug is this?

It is a fair question, especially because the storage platform and the anti-leech feature both belong to ByteDance. But the failures still sit with the insurer’s implementation. 防盗链 behaved exactly as documented: it checked the Referer, rejected domains outside the allowlist, and applied that rule to object reads. The feature itself did not malfunction.

The issue is that it was designed for a different threat model. Hotlink protection assumes a third-party website is embedding your image and that the visitor’s browser will generate the Referer from the page serving it. In that situation, the site owner controls the page, but not the request header sent by the visitor’s browser. Once the same control is placed in front of someone using curl, that assumption disappears. There is no browser left to provide an honest header; the requester simply writes it themselves. The feature did what it was built to do. It was just being asked to provide a security guarantee it was never designed to make.

The remaining issues are more direct. Bucket listing is governed separately from object reads, and an unauthenticated max-keys request returning 200 means the listing permission had been opened through configuration. The signer was also not a Volcengine service, but an Express route running on the insurer’s own host with access to the insurer’s credentials. Volcengine correctly verified the signed policy each time. What was missing was the authorization check before that policy was created, along with basic restrictions such as a session-bound key prefix, upload size limits, and content-type constraints.

The same implementation on AWS would have produced the same result, while a properly configured Volcengine deployment would not. The cloud provider supplied the controls; the application decided how to use them. That said, placing a bandwidth-protection feature beside settings such as CORS and ACLs without clearly separating its purpose can make the wrong assumption easier to make. It may help explain the mistake, but it does not change where the missing authorization ultimately lived.

Remediation

For the first issue:

  • Disable anonymous ListBucket, and audit it separately from GetObject. They are separate permissions. One unauthenticated ?max-keys=1 across your estate finds every bucket that got this wrong.
  • Serve objects through short-lived signed URLs rather than direct anonymous access. 防盗链 can stay on. It just can’t be the only thing standing there.

For the second one:

  • Authenticate the signer, and derive identity from the session, never from the request body.
  • Never take bucket from the client. It belongs in server config.
  • Constrain the policy: a starts-with key prefix built from the session, a content-length-range, a content-type restriction, a short expiry. Every condition you leave out is a permission you granted.

Closing

In the end, the vulnerability came down to a very small assumption: that a client-controlled header could be trusted to protect sensitive data. One line in a request was enough to cross a boundary that should have required real authorization.

What stayed with me most was how ordinary the path looked from the outside. A chatbot, a JavaScript bundle, a hidden route, a storage bucket. Nothing about it appeared dramatic at first. But small clues tend to matter when you keep following them carefully, and this one eventually led to thousands of real customer documents.

That is probably the part I value most about findings like this. Not the size of the bucket, the bounty, or even the simplicity of the bypass, but the reminder that meaningful vulnerabilities often sit behind controls that look reasonable until someone asks what they actually guarantee.

The issue has since been solved, and all sensitive details have been redacted in accordance with the program’s disclosure policy . Reported through HackerOne, $6,000 bounty. Remediated.


文章来源: https://infosecwriteups.com/one-header-away-from-10-gb-of-customer-documents-pii-6k-bounty-0c0ac8c335f2?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh