# GDPR data export vulnerability: how one export leaked another user's orders

Published: 2026-11-12 · Updated: 2026-11-03 · By HackerGPT Team
Canonical: https://hackergpt.org/blog/gdpr-data-export-vulnerability/

> A "download my data" feature that matched orders on a masked email, so a new account got another customer's order history. How the CyberGym challenge works, how to test your own export, and how to fix it.

**TL;DR:** A data export leaks other users' data when it finds "your" records by something other than your user id. In CyberGym's GDPR Data Theft challenge, orders are matched on the email with its vowels masked, so a new account whose address differs from a real customer's by one vowel exports that customer's orders. The fix is to select records by the authenticated user's id and test it with two accounts.

A "download my data" button looks like the safest feature in an app: it only shows you your own data. This article is for learners working through OWASP Juice Shop and for developers who ship an export. You will see how the challenge works, why the bug needs no injection and no request tampering, and how to run the same two-account test on an app you own. Test only applications you own or have written permission to test; [CyberGym](https://cybergym.hackergpt.app), our hosted Juice Shop instance, is built for practice.

[Watch on YouTube: CyberGym Challenge: GDPR Data Theft](https://www.youtube.com/watch?v=Hl_Jt6lyrYg)

## What is the GDPR Data Theft challenge?

It is a four-star challenge in the *Sensitive Data Exposure* category of [OWASP Juice Shop](https://owasp.org/www-project-juice-shop/), described in the [Pwning OWASP Juice Shop companion guide](https://pwning.owasp-juice.shop/companion-guide/latest/part2/sensitive-data-exposure.html) as "Steal someone else's personal data without using Injection." CyberGym runs the same application, so the challenge is there too.

The guide's hints shape the whole approach:

- "Trick the regular Data Export to give you more than actually belongs to you."
- The target must be a user who has actually ordered something.
- The export runs on the server, so "it won't be possible to just tamper with some HTTP requests".
- Server responses that contain user-specific data point to the developers' mistake.

The last two hints matter most. There is no parameter to change. The flaw lives in how the server decides which records belong to you.

## Why does a data export leak someone else's data?

It leaks when the query that gathers "your" records uses a key that is not unique to you. An export joins data from several tables: profile, orders, reviews, addresses. If any of those joins uses a derived value, such as a display name, a lower-cased email or a masked email, then two users can produce the same value and share each other's records.

MITRE files the general weakness as [CWE-639, Authorization Bypass Through User-Controlled Key](https://cwe.mitre.org/data/definitions/639.html). Here the attacker controls the key indirectly: they choose their own email at registration, and that choice decides which orders the server considers theirs. The data that comes out is covered by [CWE-359, Exposure of Private Personal Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/359.html). Both sit under [A01:2025 Broken Access Control](https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/) in the OWASP Top 10.

![Two emails differing by one vowel mask to the same key, so the export returns the other customer's orders](https://hackergpt.org/blog/gdpr-data-export-vulnerability/masked-email-collision.webp) *The export never asks who owns an order. It asks which orders carry a string that two different users share.*

Masking an email is a reasonable thing to do when you *display* it. The mistake is storing the masked value and then using it as the lookup key. Replacing every vowel with `*` maps many different addresses onto one string, and the server treats that string as an identity.

## How was the bug found on CyberGym?

The run in the video uses ordinary pages and a proxy, in five steps. It reads like a CTF, but it is the same method you would use on your own app.

1. **Collect public user data.** Product reviews on the shop show the reviewer's email. One address on the shop's own domain, for an "accountant" user, stood out as an account likely to have order history.
2. **Make a clean test account with one order.** A fresh account starts with an empty order history. The tester placed one order with test payment data so the account had a known record.
3. **Read every response that carries user data.** The order-tracking response, viewed in Burp Suite's Repeater, held the order's details and an `email` field with every vowel replaced by `*`. That is the clue the fourth hint points at: the server stores a masked copy of the email with each order.
4. **Export the test account's data.** Privacy and Security → Request Data Export (JSON) returned exactly that one order. So far, correct.
5. **Register a colliding address.** A second fresh account used the accountant's address with its first vowel swapped (`a` to `o`, so `occountant`). Its order history immediately showed an order it never placed, seven products from the accountant's purchases, and its JSON export carried its own email next to that order. That is the challenge's objective: another user's personal data, no injection.

What made this quick was the clean account. A new account should have nothing. When it shows something, you have a finding without having to understand the code.

To see the collision without touching any server, here is the same logic as a few lines of Node.js, with placeholder addresses:

```js
const mask = (email) => email.replace(/[aeiou]/gi, "*");

const orders = [
  { owner: "accountant@shop.example", item: "Hoodie" },
  { owner: "accountant@shop.example", item: "Sticker pack" },
].map((o) => ({ ...o, email: mask(o.owner) }));

for (const me of ["accountant@shop.example", "occountant@shop.example", "new.user@shop.example"]) {
  const mine = orders.filter((o) => o.email === mask(me)).map((o) => o.item);
  console.log(me.padEnd(26), mask(me).padEnd(26), mine);
}
```

Running it with `node mask.mjs` printed:

```text
accountant@shop.example    *cc**nt*nt@sh*p.*x*mpl*    [ 'Hoodie', 'Sticker pack' ]
occountant@shop.example    *cc**nt*nt@sh*p.*x*mpl*    [ 'Hoodie', 'Sticker pack' ]
new.user@shop.example      n*w.*s*r@sh*p.*x*mpl*      []
```

Two people, one key. The second line is the bug.

## What does GDPR say about a data export?

The [General Data Protection Regulation (EU) 2016/679](https://eur-lex.europa.eu/eli/reg/2016/679/oj) gives people the right to a copy of the personal data a service holds about them (Article 15, right of access) and to receive data they provided in a structured, machine-readable format (Article 20, right to data portability). Those rights are why so many apps have an export button.

Both articles are about data concerning *the person asking*. Article 15(4) states that the right to obtain a copy "shall not adversely affect the rights and freedoms of others". An export that hands one customer's order history to another does the opposite of what the feature exists for. This article is not legal advice. Treat a leaking export as a personal data incident and take it to whoever handles data protection in your organisation.

## How do I check my own app's data export?

Use two accounts you control and look for anything in one account's export that the other created. This is the test from the run, made repeatable:

1. Create **account A** and give it a distinctive record: an order, a review, an address with a marker string like `MARKER-A-7731`.
2. Create **account B** with no activity. If your sign-up allows it, make B's username or email as close to A's as your rules permit: same letters with different case, one vowel changed, a `+tag`, a trailing dot.
3. Export B's data. Search it for `MARKER-A-7731` and for any id that belongs to A.
4. Repeat with every page that lists "your" records: order history, saved addresses, invoices, notifications.
5. Read the export code. Every query should filter on the user's primary key from the session, not on an email, name or any value derived from them.

```bash
# Run against your own app only. $TOKEN_B is account B's session token.
curl -s -H "Authorization: Bearer $TOKEN_B" https://your-app.example/api/me/export \
  | grep -c 'MARKER-A-7731'
```

Anything other than `0` is a finding. The endpoint path and auth header are placeholders; use whatever your export calls. If your app was put together quickly, our [security checklist for vibe-coded apps](https://hackergpt.org/blog/vibe-coded-app-security-checklist/) covers the same object-ownership check across the rest of the API.

HackerGPT's [CTF Mode](https://hackergpt.org/features/ctf-mode/) works a target in a headless browser, watches the traffic and explains each step. That makes it useful for exactly this kind of comparison on your own site or on CyberGym. The video ends with HackerGPT in CTF Mode taking on the same challenge on CyberGym, where the run ends with the solved banner and a list of remediation steps.

## How do I fix it?

Select every exported record by the authenticated user's id, and keep display formatting out of the data model:

- **Join on the owner's primary key.** `WHERE orders.user_id = :sessionUserId`, with the id taken from the server-side session, never from the request or from a derived field.
- **Store the real reference, mask on output.** If an order needs the customer's email for receipts, link it to the user record and mask only in the response that shows it.
- **Make identity fields unique after normalisation.** If you compare emails case-insensitively anywhere, enforce uniqueness on the normalised value at sign-up.
- **Write the two-account test as an automated test.** It is short and it catches regressions in every endpoint that returns "my" data. The [IDOR walkthrough](https://hackergpt.org/blog/idor-vulnerability-example/) shows a two-user test you can adapt.
- **Log exports.** An export is a bulk read of personal data. Recording who exported what, and when, lets you tell afterwards whether the flaw was used.

## Key takeaways

- Build every "my data" query on the session's user id. Emails, names and masked values are not owner checks.
- Masking is a display concern. Storing a masked value and querying by it creates collisions between users.
- A brand-new test account is your best detector: anything it can see that it did not create is a finding.
- Read the responses that carry user data. In this challenge, one `email` field showed the developers' mistake.
- Add a two-account export test to your test suite so the bug cannot come back silently.

## FAQ

### Is the GDPR Data Theft challenge an SQL injection?

No. The challenge text rules out injection, and the hints say request tampering will not work either. The flaw is in how the server matches orders to users: it compares a masked email, so registering a colliding address is enough.

### Why does masking the email cause the leak?

Replacing vowels with `*` maps many addresses to the same string. When that string is stored with each order and used to find "your" orders, any account whose email masks to the same value gets the same orders. Masking is fine for display, but it is not an identifier.

### How is this different from an IDOR?

In a classic IDOR you change an id in the request to reach another record. Here nothing in the request changes: the server builds the wrong key itself from your profile. Both are broken access control, and both are fixed by checking ownership against the session's user id.

### Does GDPR require a data export feature?

Articles 15 and 20 of the GDPR give people the right to a copy of their personal data and, for data they provided, to receive it in a machine-readable format. Many services meet that with a self-service export. The right covers the requester's own data and must not harm the rights of others, so an export that leaks other users' data defeats its purpose.

### Where can I practise this challenge legally?

On CyberGym at cybergym.hackergpt.app, a hosted OWASP Juice Shop instance meant for practice, or on a copy of Juice Shop you run yourself. Our guide to [practising hacking legally](https://hackergpt.org/blog/practice-hacking-legally/) lists other safe targets.

## Sources

- [Pwning OWASP Juice Shop: Sensitive Data Exposure (GDPR Data Theft)](https://pwning.owasp-juice.shop/companion-guide/latest/part2/sensitive-data-exposure.html)
- [OWASP Juice Shop project](https://owasp.org/www-project-juice-shop/)
- [OWASP Top 10:2025, A01 Broken Access Control](https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/)
- [CWE-639: Authorization Bypass Through User-Controlled Key](https://cwe.mitre.org/data/definitions/639.html)
- [CWE-359: Exposure of Private Personal Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/359.html)
- [Regulation (EU) 2016/679 (GDPR), Articles 15 and 20, on EUR-Lex](https://eur-lex.europa.eu/eli/reg/2016/679/oj)

Practise the two-account test on [CyberGym](https://cybergym.hackergpt.app), or follow the [CTF Mode field guide](https://hackergpt.org/docs/ctf/) to work it with HackerGPT.
