← Voltar para Insights Write-up · File Upload Não Autenticado → RCE

Personal QR Message ≤ 1.0: a allowlist que nunca roda e a porta anônima do WooCommerce

O plugin até declara uma lista de tipos de arquivo permitidos — só que ela é código morto: nunca é comparada com nada. E a rota de upload não passa pelo admin-ajax, e sim pelo wc-ajax do WooCommerce, que atende anônimos. Sem nonce, sem login: um .php vai parar dentro do diretório do plugin e executa.

Identificação

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.

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 esperadaStatusObservação
AutenticaçãoAusenteDespacho via wc-ajax do WooCommerce, atende anônimos no init
Nonce (check_ajax_referer)AusenteNenhum token exigido em todo o caminho
Capability checkAusenteSem current_user_can() nem is_user_logged_in()
Allowlist de MIMECódigo morto$allowedFileTypes (upload.php:5) declarada, nunca comparada
Validação de extensãoAusentebasename() tira o path, preserva .php
Destino não executávelFalhoGrava 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:

  1. Enviar o arquivo para /?wc-ajax=upload_media com action=add e o campo file. O servidor responde {"status":"success"}.
  2. Um GET em wp-content/plugins/personal-qr-message/media/<arquivo>.php retorna o resultado da execução do PHP no servidor.

Payload e automação retidos — sem correção disponível

Como no ImproveSEO, aqui não há versão corrigida: o plugin foi fechado no repositório em 21/07/2026, mas instalações existentes seguem vulneráveis. Por isso o payload final e o script de exploração estão omitidos deste artigo.

Confirmado em laboratório: subimos um arquivo de teste (<?php echo "PQRM_RCE_".(6*7); ?>) sem autenticação; o GET ao arquivo gravado retornou PQRM_RCE_42 — o servidor calculou 6*7, prova de que o PHP executou. Sem cookie. Sem login.

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:

  1. Desative e remova o plugin imediatamente. O endpoint vulnerável existe sempre que o plugin está ativo.
  2. Cace artefatos em wp-content/plugins/personal-qr-message/media/ e em toda a árvore de uploads: qualquer .php ali é suspeito. O diretório media/ não deveria conter scripts executáveis.
  3. Bloqueie execução de PHP nos diretórios de conteúdo do usuário (regra de servidor negando .php em media/ e uploads/) — defesa em profundidade que teria neutralizado a falha.
  4. 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 um move_uploaded_file() direto no webroot.

9. Disclosure e timeline

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 .php no diretório media/ 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.