CVE-2026-16250 CVSS 10.0 · Crítico
Software: Personal QR Message (plugin WordPress) · slug personal-qr-message
Versões afetadas: ≤ 1.0 (a 1.0 é a última versão publicada) · sem correção disponível — o plugin foi fechado no repositório do WordPress.org em 21/07/2026 e está abandonado desde 2022.
Classe: Upload de arquivo arbitrário não autenticado (CWE-434) → Execução Remota de Código
Vetor: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Privilégio necessário: nenhum (não autenticado) · Pré-condição: WooCommerce ativo (dependência do plugin)
Crédito: Equipe TreeSec · Validado em laboratório: 18/07/2026 · Publicado no WPScan em 24/07/2026
Referências: WPScan · WordPress.org
Aviso legal: toda a validação foi feita exclusivamente em laboratório próprio (WordPress + WooCommerce + PHP em Docker), de forma não destrutiva. Nenhum sistema de terceiros foi testado ou afetado. Como não há correção disponível, o payload final e o script de automação estão deliberadamente omitidos deste artigo.
Neste write-up
1. Outro upload não autenticado, outra nota 10.0
Assim como no ImproveSEO (CVE-2026-16618), aqui um visitante anônimo grava um arquivo .php no servidor e passa a executar código arbitrário — a classe de falha mais grave que existe, pontuada em CVSS 10.0 (vetor AV:N/AC:L/PR:N/UI:N, escopo alterado, impacto total). Mas os dois casos falham por motivos diferentes, e é aí que este é instrutivo.
O ImproveSEO ao menos tentava validar — conferia o MIME pelos bytes e foi contornado por um polyglot. O Personal QR Message não chega a isso: ele declara uma lista de tipos permitidos e depois nunca a usa. Não há polyglot a construir, porque não há checagem alguma para enganar. E a porta de entrada é ainda menos óbvia: não é o admin-ajax.php de sempre — é o mecanismo wc-ajax do WooCommerce.
2. A porta anônima: wc-ajax do WooCommerce
O plugin depende do WooCommerce e registra seu upload como um endpoint wc-ajax. Esse mecanismo é próprio do WooCommerce: o WC_AJAX::do_wc_ajax() roda cedo, no hook init, e atende requisições de qualquer visitante por /?wc-ajax=<acao> — incluindo não autenticados. É um caminho paralelo ao admin-ajax.php, com a mesma exposição a anônimos, mas que passa despercebido por quem só audita o admin-ajax.
# A requisição não vai ao admin-ajax.php — vai ao wc-ajax do WooCommerce
GET/POST /?wc-ajax=upload_media
# WC_AJAX::do_wc_ajax() despacha isso no 'init', para anônimos
Do lado do plugin, o wrapper pqrm_upload_media() apenas inclui o arquivo que faz o trabalho:
// pqrm_upload_media() — colado ao endpoint wc-ajax
require 'upload.php'; // toda a lógica de gravação está aqui
Nenhuma checagem de nonce, de current_user_can() ou de is_user_logged_in() em nenhum ponto desse caminho. A porta está aberta antes mesmo de o arquivo de upload começar a decidir o que fazer.
3. Do handler até o disco
Dentro de upload.php, o fluxo é direto: um switch no parâmetro action, o caso add, e a gravação em disco:
// upload.php — switch($_POST['action']) → case 'add' → pqrm_save_file()
move_uploaded_file(
$_FILES['file']['tmp_name'],
__DIR__ . '/media/' . basename($_FILES['file']['name'])
);
É a mesma primitiva crua do ImproveSEO: move_uploaded_file(), que move o arquivo temporário para o destino sem consultar a blocklist de extensões do WordPress, sem validar nada. O nome do destino é montado com o nome enviado pelo atacante, passado por basename(). E o destino é __DIR__ . '/media/' — um subdiretório dentro da própria pasta do plugin.
4. A allowlist que é código morto
O detalhe que torna este caso singular: o desenvolvedor escreveu uma defesa. No topo do upload.php existe uma allowlist de tipos permitidos:
// upload.php:5 — declarada...
$allowedFileTypes = ['image/jpeg', 'image/png', 'image/gif', ...];
// ...e nunca comparada com o arquivo enviado. Código morto.
A variável é definida e simplesmente abandonada. Em nenhum ponto o código verifica o tipo do upload contra ela — não há in_array(), não há if, nada. É a ilusão perfeita de segurança: quem passa o olho no arquivo vê $allowedFileTypes e assume que há um filtro. Não há. A allowlist existe só no papel.
A lição: declarar uma regra não é aplicá-la. Uma allowlist que nunca é comparada com a entrada oferece exatamente zero de proteção — e ainda mascara a ausência do controle para quem revisa o código rápido. Segurança se mede no ponto de decisão (
if), não na declaração da variável.
5. basename() e o webroot do plugin
Sem checagem de tipo e sem checagem de extensão, resta o nome do arquivo. E basename() não ajuda: ele remove o caminho (../, diretórios), mas preserva a extensão. Um arquivo chamado shell.php continua shell.php depois do basename().
O golpe final é o destino. O arquivo é gravado em wp-content/plugins/personal-qr-message/media/ — dentro do webroot, servido diretamente pelo servidor. Como não há um index.php guardião nem regra de servidor bloqueando execução ali, o .php recém-gravado é requisitável pela URL e executa. O atacante sabe exatamente onde ele foi parar: o caminho é fixo e público.
| Barreira esperada | Status | Observação |
|---|---|---|
| Autenticação | Ausente | Despacho via wc-ajax do WooCommerce, atende anônimos no init |
Nonce (check_ajax_referer) | Ausente | Nenhum token exigido em todo o caminho |
| Capability check | Ausente | Sem current_user_can() nem is_user_logged_in() |
| Allowlist de MIME | Código morto | $allowedFileTypes (upload.php:5) declarada, nunca comparada |
| Validação de extensão | Ausente | basename() tira o path, preserva .php |
| Destino não executável | Falho | Grava em plugins/personal-qr-message/media/ — webroot, executa |
6. Confirmado em laboratório
Validamos o achado em ambiente isolado (WordPress + WooCommerce + Personal QR Message 1.0), não autenticado — sem cookie, sem nonce. O fluxo confirmado, em dois passos:
- Enviar o arquivo para
/?wc-ajax=upload_mediacomaction=adde o campofile. O servidor responde{"status":"success"}. - Um
GETemwp-content/plugins/personal-qr-message/media/<arquivo>.phpretorna o resultado da execução do PHP no servidor.
7. Sem patch: o que fazer agora
O plugin foi fechado no WordPress.org em 21/07/2026 e não recebe atualização desde 2022. Não há correção para instalar, e fechar o plugin no repositório não desinstala nada de quem já o tem. Se você usa o Personal QR Message:
- Desative e remova o plugin imediatamente. O endpoint vulnerável existe sempre que o plugin está ativo.
- Cace artefatos em
wp-content/plugins/personal-qr-message/media/e em toda a árvore deuploads: qualquer.phpali é suspeito. O diretóriomedia/não deveria conter scripts executáveis. - Bloqueie execução de PHP nos diretórios de conteúdo do usuário (regra de servidor negando
.phpemmedia/euploads/) — defesa em profundidade que teria neutralizado a falha. - Se houve exposição, trate como comprometimento: rotacione credenciais do
wp-config.php, revise usuários administrativos e investigue os logs de acesso a/?wc-ajax=upload_media.
8. A correção certa
Do lado do desenvolvedor, a correção começa por usar a allowlist que já existe — mas não para por aí, porque validar MIME não basta (foi o que o ImproveSEO provou). Quatro mudanças, e qualquer uma quebra a cadeia:
1. Autenticar e autorizar o endpoint
check_ajax_referer('pqrm-upload', '_wpnonce');
if (! is_user_logged_in() || ! current_user_can('upload_files')) {
wp_send_json_error('Forbidden', 403);
}
2. Deixar o WordPress gravar o arquivo
// wp_handle_upload valida extensão e aplica a blocklist do core
$upload = wp_handle_upload($_FILES['file'], ['test_form' => false]);
if (isset($upload['error'])) { wp_send_json_error($upload['error']); }
3. Validar extensão E tipo — e realmente comparar
$check = wp_check_filetype_and_ext($tmp, $name);
if (! in_array($check['ext'], ['jpg','jpeg','png','gif'], true)) {
wp_send_json_error('Extensão não permitida', 400); // ← a allowlist USADA
}
E, no servidor, negar execução de PHP no diretório do plugin — que transforma qualquer upload malicioso em arquivo inerte.
O princípio: uma defesa só conta quando roda. Entre uma allowlist declarada e uma allowlist comparada há a diferença entre 0 e 10.0 de CVSS. E gravar arquivo de usuário sempre deve passar por
wp_handle_upload(), nunca por ummove_uploaded_file()direto no webroot.
9. Disclosure e timeline
- 18/07/2026 — Descoberta e confirmação em laboratório (WordPress + WooCommerce + Personal QR Message 1.0). RCE não autenticado executado. Prior-art verificado: o WPScan listava 0 vulnerabilidades para o plugin — achado inédito.
- Julho/2026 — Submetido ao WPScan pela Equipe TreeSec, com pedido de CVE.
- 21/07/2026 — Plugin fechado no repositório do WordPress.org, pendente de revisão.
- 24/07/2026 — Publicado como CVE-2026-16250, CVSS 10.0. Sem correção disponível até a data.
Se você usa o Personal QR Message: remova o plugin hoje. Não há versão corrigida e o repositório já não o distribui — a mitigação é a desinstalação, seguida de varredura por
.phpno diretóriomedia/do plugin.
Dois plugins, duas CVEs 10.0 no mesmo dia, a mesma classe — e falhas de causa raiz distintas: um foi enganado por um polyglot, o outro sequer aplicava a defesa que escreveu, atrás de uma porta anônima que muita auditoria nem olha. Encontrar isso é seguir cada entrada até o move_uploaded_file(), entender qual gancho a expõe e verificar se cada defesa aparente realmente executa. É esse nível de profundidade que a TreeSec aplica nos testes dos nossos clientes.
Suas integrações expõem endpoints que ninguém audita?
Nossos pentests Web & API mapeiam toda a superfície AJAX — inclusive caminhos alternativos como o wc-ajax — e verificam se cada defesa declarada de fato roda. Exatamente a cadeia que virou esta CVE 10.0.