Skip to main content

Command Palette

Search for a command to run...

How a "Fixed" IDOR and an Empty String Led to 5 Million+ File Leaks

Published
6 min read
How a "Fixed" IDOR and an Empty String Led to 5 Million+ File Leaks
H
Hey! I'm a 24-year-old security researcher.

When I start looking at a target in finance, medical, etc, I always go for the most valuable data. In this case, on a major application we'll call "Redacted Corp," that meant file uploads. Invoices, personal documents, signatures... all the PII.

Part 1: The "Fixed" IDOR

My first step is always to check for Insecure Direct Object References (IDORs). I created two accounts, Account A and Account B.

With Account A, I uploaded a file and grabbed its file_id from the request.

With Account B, I replayed the GET /api/v1/files/view/{file_id} request using Account A's file_id.

GET /api/v1/files/view/UserA_MyFile_1678886400_aBcDeF123.pdf HTTP/2
Host: api.redacted-service.com
Authorization: Bearer <ACCOUNT_B_JWT>

The file downloaded.

HTTP/2 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="UserA_MyFile_1678886400_aBcDeF123.pdf"

[...binary content of the PDF file...]

This confirmed a classic IDOR vulnerability. Account B could access Account A's data.

However, there was a catch. The file_id itself was a beast: a complex, long string like [UserID]_[Filename]_[Timestamp]_[UniqueID].

This told me something crucial: The team at Redacted Corp knew about the IDOR. They had clearly seen this risk. But instead of fixing the root cause (i.e., adding a check: if (file.owner != current_user.id) { return 403; }), they just made the file ID so complex that it was "impossible" to guess.

This isn't a fix; it's just obscurity. The vulnerability was real, but it was unexploitable without a way to leak that complex value. The hunt was now on: How do I get the application to give me those IDs?

Part 2: Finding the Real Attack Vector

Since I couldn't guess the ID, I had to find an endpoint that would give me the IDs.

I started probing every API call that touched files. I found a very interesting one while reading the js files:

POST /api/v2/user/documents

This endpoint seemed to be used to register a file with a user's account. When I looked at the JSON body, I saw that it also expected one of these super-complex [UserID]_[Filename]_[Timestamp]_[UniqueID] strings in its payload. The field was something like document_key.

{
  "document_key": "[UserID]_[MyFilename]_[Timestamp]_[MyUniqueID].pdf",
  "storage_id": "redacted-prod-uploads",
  "doc_type": "user_upload",
  "context_id": "a-b-c-d-1-2-3-4",
  ...
}

The application was clearly set up to parse and handle these complex IDs. My next thought was simple: what if I don't give it one? What if I just... send it empty?

Part 3: The Empty String Attack

I went to my proxy, modified the request, and sent it with empty values for the key fields.

POST /api/v2/user/documents HTTP/2
Host: api.redacted-service.com
Authorization: Bearer <JWT>
Content-Type: application/json; charset=UTF-8

{
  "document_key": "",
  "storage_id": "",
  "doc_type": "user_upload",
  ...
}

I was half-expecting a 400 Bad Request or some JSON validation error. Instead, I got a 200 OK. The response body contained a pre-signed S3 URL.

HTTP/2 200 OK
Content-Type: application/json

{
  "upload_url": null,
  "download_url": "https://s3.amazonaws.com/redacted-prod-uploads?AWSAccessKeyId=ASIA...&Expires=1678886400&Signature=..."
}

I copied that URL and pasted it into my browser, not sure what to expect. My screen filled with XML.

<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>redacted-prod-uploads</Name>
  <Prefix></Prefix>
  <Marker></Marker>
  <MaxKeys>1000</MaxKeys>
  <IsTruncated>true</IsTruncated>
  <Contents>
    <Key>[OtherUserID_1]_[Someones_Invoice]_[1678886400]_[AbCdEf].pdf</Key>
    <LastModified>2023-03-15T12:00:00.000Z</LastModified>
    <ETag>"..."</ETag>
    <Size>12345</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  <Contents>
    <Key>[OtherUserID_2]_[Personal_Doc]_[1678886401]_[GhIjKl].jpg</Key>
    <LastModified>2023-03-15T12:00:01.000Z</LastModified>
    <ETag>"..."</ETag>
    <Size>67890</Size>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  </ListBucketResult>

By sending an empty document_key, the backend code must have passed that empty string right to the S3 API. And the S3 API interprets a request for an empty key as a ListBucket command.

Their "unguessable" file IDs? The application had just handed me a list of 1,000 of them. And that one little tag was the funniest part: <IsTruncated>true</IsTruncated>. This was just the first page.

Part 4: Chaining the Flaws to Steal Files

This was already a critical leak, but the full attack required two steps. Now I had the list, but could I read the files? As I already confirmed there is an IDOR in the first part, the same logic would likely work here. This was the final test.

  1. I went back to the XML and copied a file key that was clearly not mine.

    Let's say: [OtherUserID_2]_[Personal_Doc]_[1678886401]_[GhIjKl].jpg.

  2. I went back to that same POST /api/v2/user/documents endpoint.

  3. This time, I put the stolen file key into the document_key field.

POST /api/v2/user/documents HTTP/2
Host: api.redacted-service.com
Authorization: Bearer <JWT>
...

{
  "document_key": "[OtherUserID_2]_[Personal_Doc]_[1678886401]_[GhIjKl].jpg",
  "storage_id": "redacted-prod-uploads",
  ...
}

Again, I got a 200 OK. The response was another pre-signed S3 URL. But this one was different. It was a GET URL, pointed directly at that specific file.

HTTP/2 200 OK
Content-Type: application/json

{
  "upload_url": null,
  "download_url": "https://s3.amazonaws.com/redacted-prod-uploads/[OtherUserID_2]_[Personal_Doc]_[1678886401]_[GhIjKl].jpg?AWSAccessKeyId=ASIA...&Expires=...&Signature=...&x-amz-security-token=..."
}

I pasted this new URL in my browser.

HTTP/2 200 OK
Content-Type: image/jpeg
Content-Length: 67890
Last-Modified: Wed, 15 Mar 2023 12:00:01 GMT

[...binary content of the JPEG file...]

Someone else's personal file loaded on my screen.

This was the complete failure. The endpoint never checked if my authenticated user was authorized to read the file. It just blindly trusted any key the client sent, and if it existed, it happily signed a URL for it. Their big, complex ID "fix" on the other endpoint was completely useless.

Part 5: The Full Impact (From 1,000 to 5 Million+)

The list was truncated at 1,000 files, but if you google how S3 pagination works, you will see that in order to get the next page, you just have to add the ?marker=last_file_name_in_the_list parameter to the pre-signed S3 URL.

I took my first URL (from the empty string attack) and added the marker. It worked. A new list of 1,000 files appeared.

For testing, I sent 2-3 requests, which got me about 2,000-3,000 files. I was checking the timestamps... and I was still looking at files uploaded in 2019.

I did some quick math. This app has been around for over 6 years and has more than 1 million downloads on the Play Store alone. If a few thousand files barely got me out of 2019, this was a leak of approximately more than 5 million files. This included personal documents, invoices, licenses, and signatures.

I stopped all testing immediately. I wrote up the report, explaining the two-step attack and the pagination.

I knew it was a major holiday in the US that day, but this was a five-alarm fire. I sent a follow-up comment: "I know this is a holiday in the US today but I hope you would be able to see this report asap."

And here's the nice part. Despite the holiday, the team was on it. They had the bug triaged, a fix deployed, and the bounty paid in almost one hour. That's one of the fastest and most professional responses I've ever seen, and it shows how seriously they took the report.

It's a classic lesson: Obscurity is never a substitute for proper, server-side access control.

Aside from that, always try to put an empty string, space, %20 , etc whenever you’re testing for IDORS or PII leakage, can’t mention how many times this simple attack actually worked leaking tons of PII.

Happy hacking and stay secure!

0
0xMountain6mo ago

Fantastic article...

I have a specific question regarding the chaining of the flaws in Part 4.

In Part 1, you successfully confirmed the IDOR using GET /api/v1/files/view/{file_id}, which directly downloaded the file content. However, in Part 4, after successfully leaking the file keys, you returned to the POST /api/v2/user/documents endpoint to obtain the pre-signed S3 download URL instead of using the original GET endpoint.

I'm curious if you attempted the direct GET download with the leaked keys and if there was a specific reason to focus entirely on the POST endpoint for the final download step.

More from this blog

Hacktus

14 posts

Hey! I'm a 23-year-old security researcher. Top 140 on HackerOne.