CVE-2026-13596 CVSS 8.6 · Alto
Software: Participants Database (plugin WordPress) · slug participants-database
Versões afetadas: < 2.7.8.4 · corrigido na 2.7.8.4 · Active installs: 7.000+
Classe: SQL Injection não autenticado (CWE-89) · OWASP A03:2021 — Injection
Vetor: CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N
Privilégio necessário: nenhum (não autenticado) · Crédito: Equipe TreeSec
Data de descoberta: 27 de junho de 2026 · Publicado no WPScan em 20/07/2026
Referências: WPScan · WordPress.org
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 e sem exfiltrar dados. Nenhum sistema de terceiros foi testado ou afetado. A falha já está corrigida na versão 2.7.8.4.
Neste write-up
1. SQLi não autenticado, na prática
SQL injection é a falha clássica, mas o que muda tudo é o adjetivo: não autenticado. Não é preciso conta, não é preciso enganar ninguém — o atacante conversa direto com o banco a partir de uma requisição anônima ao site. Num plugin como o Participants Database, cuja função é armazenar cadastros de pessoas (nome, contato, campos personalizados), a mesma tabela que alimenta a busca pode ser usada como alavanca para ler o que o atacante quiser: usuários do WordPress, hashes de senha, qualquer dado do banco.
O WPScan classificou o achado em CVSS 8.6, com escopo alterado e impacto alto em confidencialidade (C:H). O vetor marca integridade e disponibilidade como nulas (I:N/A:N) — é uma falha de leitura: o caminho vulnerável seleciona dados, não os altera. Mas “só” leitura, quando o alvo é o banco inteiro, é o suficiente para vazar credenciais e PII.
2. A superfície: a busca pública do [pdb_list]
O plugin oferece o shortcode [pdb_list] para exibir a lista de participantes em uma página pública, com um campo de busca. A instalação padrão já traz first_name e last_name como campos de texto pesquisáveis — ou seja, o caminho vulnerável está exposto logo de cara, sem configuração especial.
A requisição de busca (action=pdb_list_filter, ou via GET quando allow_get_searches está ligado) é processada sem nonce e sem capability check. O valor do usuário é lido por filter_input_array() com a flag FILTER_FLAG_NO_ENCODE_QUOTES:
// leitura do termo de busca — a flag preserva a aspa literal
filter_input_array(INPUT_POST, [
'value' => ['flags' => FILTER_FLAG_NO_ENCODE_QUOTES],
]);
Repare: essa flag existe justamente para não codificar aspas. O valor chega ao restante do código como texto cru, com o " intacto e sem as barras do magic quotes do WordPress. Guarde a aspa — ela é a chave.
3. A aspa que o esc_like() não vê
Na rota de busca por curinga (o ramo LIKE, em PDb_List_Query_Filter), o termo é embrulhado em aspas duplas como delimitador e escapado com $wpdb->esc_like():
// PDb_List_Query.php — delimitadores do termo no ramo LIKE
$delimiter = array('"', '"'); // termo vira "..."
// PDb_List_Query_Filter.php — escape aplicado ao termo
$term = $wpdb->esc_like($raw); // ← função ERRADA para este contexto
Aqui está o coração do bug. esc_like() não é uma função de escape de SQL. Ela existe para uma finalidade estreita: neutralizar os curingas do LIKE — escapa %, _ e \ para que sejam tratados como literais. É só isso. Ela não escapa a aspa dupla, porque aspa não é um curinga de LIKE.
O problema é que a aspa dupla é justamente o delimitador escolhido para embrulhar o termo. Então temos a combinação fatal: o delimitador é ", o único escape aplicado ignora ", e a entrada preserva ". Um " no termo fecha a string antes da hora e o que vem depois é interpretado como SQL.
A lição transferível:
esc_like()não substitui$wpdb->prepare()nemesc_sql(). Ela protege o padrão doLIKE, não a query. Usá-la como se fosse escape de SQL — ainda por cima com aspa dupla como delimitador — é deixar a porta do delimitador escancarada.
4. O * que desvia da rota segura
O plugin tem, sim, um ramo seguro. Para buscas sem curinga, ele usa esc_sql() — que escaparia a aspa corretamente. A pergunta óbvia é: por que o atacante não cai nesse ramo?
Porque quem escolhe o ramo é o próprio atacante. A presença de um * no termo sinaliza “busca com curinga” e roteia a execução para o ramo LIKE — o vulnerável, com esc_like(). Basta incluir um * no payload para contornar o ramo protegido e cair exatamente onde a aspa passa.
É um caso interessante de proteção assimétrica: duas rotas para a mesma funcionalidade, uma segura e uma vulnerável, e a entrada do usuário decide qual delas roda. A existência da rota segura dá uma falsa sensação de que “tem escape” — tem, mas não onde importa.
5. O sink: get_results sem prepare
A cláusula montada por concatenação chega, sem $wpdb->prepare(), a um get_results() cru:
// PDb_List.php — execução da query montada por concatenação
$result = $wpdb->get_results($list_query); // sem prepare()
Nenhum prepare() em nenhum ponto da cadeia. O termo, com a aspa preservada e sem escape efetivo, é interpolado direto na string SQL e executado. A cláusula resultante fica assim (payload em destaque):
-- query executada pelo servidor (o " quebra o delimitador)
SELECT p.id, p.first_name, p.last_name, p.city, p.state
FROM wp_participants_database p
WHERE (p.first_name LIKE "qq%" OR SLEEP(3) OR "x"="y")
ORDER BY p.date_updated DESC
6. O que dá para ler
A query da lista seleciona cinco colunas que são ecoadas na resposta (id, first_name, last_name, city, state). Isso é um presente para o atacante: cinco posições de saída visíveis abrem caminho para injeção baseada em UNION, extraindo dados arbitrários diretamente no HTML da lista — usuários do WordPress, hashes de senha, qualquer campo de qualquer tabela do banco.
E, mesmo onde a saída não fosse conveniente, o caminho comporta injeção cega baseada em tempo (o SLEEP() do exemplo), que extrai o banco bit a bit medindo o tempo de resposta. Em resumo: leitura arbitrária do banco, autenticado por ninguém.
7. Prova de conceito
Validado em laboratório (WordPress + Participants Database 2.7.8.3, PHP 8.2) sobre uma instalação padrão — uma página com [pdb_list search=true] e o campo first_name. Não autenticado, sem cookie. Baseline vs. injeção baseada em tempo:
# Baseline — busca normal, sem payload
curl -s -o /dev/null -w '%{time_total}s\n' 'https://LAB/?page_id=<PDB_LIST>' \
--data-urlencode 'action=pdb_list_filter' \
--data-urlencode 'search_field[]=first_name' \
--data-urlencode 'operator[]=LIKE' \
--data-urlencode 'value[]=zz*' --data-urlencode 'submit_button=Search'
# → ~0.32s
# Injeção cega por tempo — a aspa quebra o delimitador
curl -s -o /dev/null -w '%{time_total}s\n' 'https://LAB/?page_id=<PDB_LIST>' \
--data-urlencode 'action=pdb_list_filter' \
--data-urlencode 'search_field[]=first_name' \
--data-urlencode 'operator[]=LIKE' \
--data-urlencode 'value[]=qq*" OR SLEEP(3) OR "x"="y' \
--data-urlencode 'submit_button=Search'
# → ~12s (o servidor dormiu → injeção confirmada)
Três detalhes fazem o payload funcionar: o prefixo qq* não casa com nada (para o OR SLEEP() ser sempre avaliado), o * força o ramo LIKE vulnerável, e o OR "x"="y" equilibra a expressão sem virar uma tautologia constante — que o otimizador dobraria antes de executar o SLEEP.
8. A correção certa
A correção é usar a ferramenta certa para o contexto certo. O escape de curinga e o escape de SQL são coisas diferentes e devem coexistir:
// ANTES — esc_like tratando de delimitador de string (errado)
$term = $wpdb->esc_like($raw);
$sql = '... first_name LIKE "' . $term . '" ...';
// DEPOIS — prepare() com placeholder; esc_like só para os curingas
$like = '%' . $wpdb->esc_like($raw) . '%';
$sql = $wpdb->prepare(
'... first_name LIKE %s ...', $like
);
Duas mudanças, e o vetor morre: o valor entra por placeholder do prepare() (que cuida do delimitador e do escape), e esc_like() volta ao seu único papel legítimo — neutralizar % e _ dentro do padrão. Nunca montar a cláusula LIKE delimitada por aspas via concatenação. É exatamente o que o mantenedor fez na 2.7.8.4.
O princípio: dados vão para a query por parâmetro, não por concatenação. Toda vez que você precisa “escapar na mão” para colar um valor numa string SQL, a pergunta certa não é “qual função de escape?”, e sim “por que isto não é um
prepare()?”.
9. Disclosure e timeline
- 27/06/2026 — Descoberta e confirmação em laboratório (Participants Database 2.7.8.3, PHP 8.2). Injeção cega por tempo confirmada em instalação padrão, não autenticada.
- Julho/2026 — Submetido ao WPScan pela Equipe TreeSec, com pedido de CVE.
- Versão 2.7.8.4 — correção publicada pelo mantenedor.
- 20/07/2026 — Publicado como CVE-2026-13596, CVSS 8.6.
Se você usa o Participants Database: atualize para a 2.7.8.4 ou superior. Se por algum motivo não puder atualizar de imediato, desabilite a busca pública do
[pdb_list](eallow_get_searches) como mitigação temporária — mas a correção real é a atualização.
Este caso é um lembrete de que “tem escape” não é o mesmo que “está seguro”. Havia escape — só que a função escolhida protegia o curinga e não o delimitador, e uma rota alternativa entregava a entrada do atacante direto no ramo frágil. Distinguir esc_like() de esc_sql() de prepare(), e seguir a entrada por todos os ramos até o get_results(), é o tipo de leitura que nenhum scanner faz por você. É esse nível de profundidade que a TreeSec aplica nos testes dos nossos clientes.
Sua aplicação monta SQL com concatenação?
Nossos pentests Web & API rastreiam cada entrada até o sink SQL, testam bypass de escape e rotas assimétricas — exatamente a cadeia que virou esta CVE 8.6.