
Local Docker lab demonstrating CVE-2026-34234 unauthenticated RCE in CtrlPanel web installer. Includes vulnerable and patched containers, PoC scripts, and root cause analysis for security research and defensive validation.
Local Docker lab for demonstrating CVE-2026-34234 in CtrlPanel.
This repository compares:
vuln: CtrlPanel 1.1.1 pinned by digestpatched: CtrlPanel 1.2.0 pinned by digestThe lab is local-only and binds services to 127.0.0.1.
CVE-2026-34234 is an unauthenticated RCE in CtrlPanel's web installer.
The issue is caused by two bugs chained together:
install.lock gate.In this lab, the vulnerable container executes a harmless proof command and writes its output inside the container. The patched container receives the same request but does not create the proof file.
Expected result:
vulnerable => proof file created
patched => no proof file
1.1.1Original vulnerable file:
public/installer/src/functions/shell.php
Relevant upstream code in 1.1.1:
function run_console(string $command, ...) {
$path = dirname(__DIR__, 4);
$handle = proc_open("cd '$path' && bash -c 'exec -a ServerCPP $command'", ...);
}
Problem:
run_console() accepts one shell command string.bash -c.Original vulnerable file:
public/installer/src/forms/pterodactyl.php
Relevant upstream behavior in 1.1.1:
run_console("php artisan settings:set 'PterodactylSettings' 'panel_url' '$url'", ...);
run_console("php artisan settings:set 'PterodactylSettings' 'admin_token' '$key'", ...);
run_console("php artisan settings:set 'PterodactylSettings' 'user_token' '$clientkey'", ...);
Problem:
url, key, and clientkey originate from installer POST data.The advisory states that public/installer/index.php checked install.lock only after loading/executing installer form logic. That made installer handlers reachable even on already-installed instances.
The fix moves the install.lock check before form handlers are loaded.
Patched behavior:
if (file_exists('../../install.lock')) {
exit("The installation has been completed already. Please delete the File 'install.lock' to re-run");
}
Original patched file:
public/installer/src/functions/shell.php
Relevant upstream code in 1.2.0:
function run_console(array $command, ...): string {
$cwd = $cwd ?? $path;
$handle = proc_open($command, $descriptors, $pipes, $cwd, null, $options);
}
Why this fixes the issue:
run_console() now accepts an argv-style array.$() remains literal input instead of shell syntax.Patched form behavior in 1.2.0 uses array-style command execution:
run_console(['php', 'artisan', 'settings:set', 'PterodactylSettings', 'panel_url', $url], ...);
run_console(['php', 'artisan', 'settings:set', 'PterodactylSettings', 'admin_token', $key], ...);
run_console(['php', 'artisan', 'settings:set', 'PterodactylSettings', 'user_token', $clientkey], ...);
127.0.0.1:8081 -> vulnerable CtrlPanel 1.1.1
127.0.0.1:8082 -> patched CtrlPanel 1.2.0
127.0.0.1:9100 -> fake Pterodactyl API
Services:
vuln: real CtrlPanel 1.1.1patched: real CtrlPanel 1.2.0fake-api: local fake Pterodactyl API used only to satisfy installer checksmysql_vuln / mysql_patched: separate MariaDB instancesredis_vuln / redis_patched: separate Redis instancesThe lab does not modify CtrlPanel application source code.
The Dockerfiles only wrap the original container entrypoint to normalize Docker Desktop runtime permissions for:
/var/www/html/storage
/var/www/html/bootstrap/cache
After fixing permissions, the wrapper executes the original product entrypoint.
Primary PoC:
poc/poc_http_only.py
Properties:
docker execid, whoami, hostnameHelper script:
poc/poc_lab.py
Purpose:
docker compose execProof file inside the app container:
/var/www/html/storage/logs/cve_2026_34234_proof.txt
Start from a clean lab state:
docker compose down -v --remove-orphans
docker compose up -d --build
Wait until the app containers are up, then run:
python3 poc/poc_lab.py
Expected output:
== Testing vulnerable ==
proof_exists: True
result: PASS expected_proof=True
== Testing patched ==
proof_exists: False
result: PASS expected_proof=False
[+] Expected result reached:
vulnerable => proof file created
patched => no proof file
Send the HTTP-only PoC to the vulnerable target:
python3 poc/poc_http_only.py --target http://127.0.0.1:8081
Verify proof manually:
docker compose exec vuln sh -lc 'cat /var/www/html/storage/logs/cve_2026_34234_proof.txt'
Expected proof:
uid=1000(laravel) gid=1000(laravel) groups=1000(laravel)
laravel
<container-hostname>
Run the same request against patched:
python3 poc/poc_http_only.py --target http://127.0.0.1:8082
Verify patched behavior:
docker compose exec patched sh -lc 'test -f /var/www/html/storage/logs/cve_2026_34234_proof.txt && cat /var/www/html/storage/logs/cve_2026_34234_proof.txt || echo "no proof file"'
Expected:
no proof file
Remove containers, networks, and lab volumes:
docker compose down -v
This repository is provided for educational security research and defensive validation only.
All demonstrations are intended to run inside the provided local Docker lab environment. The proof-of-concept avoids destructive actions, persistence, credential theft, data exfiltration, and real-world targeting.
Do not use this project against any system without explicit authorization. The author is not responsible for misuse or damage resulting from this material.