← Voltar para Insights Write-up · CVE-2026-12582

A anatomia da nossa primeira CVE: SQL injection não autenticado em um plugin WordPress

De uma varredura automatizada em mais de 13 mil plugins até um SQL injection que um visitante anônimo dispara em uma única requisição — e que lê o banco inteiro, hashes de senha incluídos. Como achamos, por que funciona, por que o WordPress não protege sozinho, e como se corrige de verdade.

Identificação

CVE-2026-12582 CVSS 8.6 · Alto

Software: Library Management System (plugin WordPress) · slug library-management-system

Versões afetadas: 3.5 a 3.5.7 · corrigido na 3.5.8

Classe: SQL Injection (CWE-89) · OWASP A03:2021 — Injection

Privilégio necessário: nenhum (não autenticado) · Crédito: Equipe TreeSec

Referências: WPScan · CVE-2026-12582 · WordPress.org

Aviso legal: toda a validação foi feita exclusivamente em laboratório próprio (WordPress + MariaDB em Docker), de forma não destrutiva — apenas leitura. Nenhum sistema de terceiros foi testado. Este artigo respeita o embargo de divulgação coordenada: a falha já está corrigida e pública, mas o passo a passo do exploit permanece redigido até 29/07/2026 (ver seção 7).

1. Por que uma CVE importa

Toda empresa de segurança fala que "faz pesquisa". Poucas mostram o resultado com um número público atrelado ao próprio nome. Uma CVE (Common Vulnerabilities and Exposures) é exatamente isso: um identificador global, emitido por uma autoridade reconhecida, que registra permanentemente que uma vulnerabilidade específica existiu, quem a encontrou, e o quanto ela era grave.

A CVE-2026-12582 é a primeira CVE da TreeSec. Ela foi atribuída pelo WPScan (a CNA — CVE Numbering Authority — de referência para o ecossistema WordPress) e credita a Equipe TreeSec. Não é um "achado de scanner": é uma falha inédita, confirmada em laboratório, com causa raiz mapeada no código-fonte e correção proposta ao autor do plugin.

Este artigo é o making-of dela. Não pela vaidade do troféu, mas porque o caminho — da estratégia de caça ao detalhe do porquê a falha existe — é uma aula condensada de como um SQL injection moderno nasce, sobrevive à revisão de código e é, ainda assim, trivial de explorar.

2. A caça: 13 mil plugins, uma máquina

Não saímos "procurando bug em plugin X". A abordagem foi industrial: tratar o repositório do WordPress como uma superfície de ataque em escala e deixar a máquina fazer a peneira grossa. O pipeline tem quatro estágios.

Estágio 1

Colheita mecânica (overnight)

Download de ~13.310 plugins do repositório oficial wp.org (lista "popular", em lotes), apagando o código-fonte após processar cada lote para manter o disco mínimo. O objetivo não é ler tudo — é ter tudo disponível para a análise estática.

Estágio 2

Análise estática com taint tracking (Semgrep)

Regras AST customizadas rastreiam fluxo de dados (taint): da source (entrada do usuário: $_GET, $_POST, $_REQUEST) até o sink perigoso (uma query SQL, um system(), um include). Se o dado chega ao sink sem passar por sanitização, é um candidato.

Estágio 3

Filtro de alcançabilidade

O que separa ruído de ouro: cada candidato é classificado como UNAUTH (callback de wp_ajax_nopriv_* ou rota REST aberta — qualquer anônimo alcança) ou GATED (exige capability, nonce real ou admin). Restaram 737 candidatos não autenticados — a fila que realmente vale o tempo de um humano.

Estágio 4

Triagem humana e prior-art

Leitura manual da função, confirmação da cadeia source → sink, e — mandamento número um — busca por CVE anterior antes de investir. Reportar uma falha já conhecida queima tempo e credibilidade.

A lição metodológica que este caso ensinou: a maioria dos candidatos a SQLi da estática eram falsos positivos, mortos pelo slashing automático do WordPress. O filtro decisivo foi o contexto do sink — e é aí que essa falha se destacou.

3. A superfície: um AJAX aberto a anônimos

O plugin Library Management System (do autor Online Web Tutor) gerencia acervos de biblioteca dentro do WordPress: catálogo de livros, empréstimo, devolução e um portal de autoatendimento (self-checkout). Toda a comunicação do front-end passa por um único handler AJAX. E ele é registrado em duas variantes:

// includes/class-libmns.php
$this->loader->add_action('wp_ajax_owt7_front_handler',        $public, 'owt7_library_front_request_handler');
$this->loader->add_action('wp_ajax_nopriv_owt7_front_handler', $public, 'owt7_library_front_request_handler'); // ← ANÔNIMO

No WordPress, um hook wp_ajax_nopriv_* é, por definição, chamável por qualquer visitante não logado em /wp-admin/admin-ajax.php. O sufixo nopriv ("no privilege") é literal. Toda a lógica do handler — incluindo o ramo de self-checkout — está, portanto, ao alcance de um atacante sem conta.

4. O nonce que não protege nada

"Mas o AJAX exige um nonce", diria a defesa. Exige. E o nonce não vale nada como fronteira de autorização — porque o próprio plugin o entrega de graça.

Um nonce no WordPress é um token curto pensado para evitar CSRF. O problema é onde este é impresso:

// public/class-libmns-public.php — enqueue_scripts()
if ( ! $this->is_library_public_page() ) { return; } // qualquer página com [owt7_library_books]

wp_localize_script( $this->plugin_name, "owt7_library", array(
    "ajax_nonce" => wp_create_nonce( 'owt7_library_actions' ),   // ← impresso no HTML público
));

Qualquer visitante anônimo que abra a página do portal (a que contém o shortcode [owt7_library_books] — ou seja, a função principal do plugin, que todo site que o usa expõe) recebe um nonce válido no código-fonte da página. Confirmamos em laboratório: o nonce impresso no HTML é idêntico ao que o WordPress gera para o usuário de uid 0 (anônimo).

Regra que as CNAs aplicam e que vale memorizar: nonce exposto em página pública não é barreira de autorização. Se o token que "protege" a ação está impresso para quem não fez login, a ação é, para todos os efeitos, não autenticada.

5. O sink: base64 e contexto numérico

Chegamos ao coração da falha. Dentro do handler, no ramo owt7_lms_do_user_checkout, o parâmetro book_id vindo do atacante é processado assim:

// public/class-libmns-public.php — ramo owt7_lms_do_user_checkout
$book_id = sanitize_text_field( wp_unslash( trim( (string) $_REQUEST['book_id'] ) ) );
$book_id = base64_decode( $book_id );                       // ← decodifica em bytes crus

$book_data = $wpdb->get_row(
    "SELECT name, category_id from " . $tabela_books . " WHERE id = {$book_id}"   // ← sem prepare, sem cast
);

Três detalhes, juntos, formam a falha:

  1. O valor é decodificado de Base64 (base64_decode), o que reconstrói bytes arbitrários a partir da entrada.
  2. Ele é concatenado diretamente na string SQL — sem $wpdb->prepare(), sem binding de parâmetro.
  3. O contexto é numérico: WHERE id = {$book_id}, sem aspas ao redor do valor.

O mesmo $book_id ainda flui, sem prepare, para uma segunda query (na tabela de empréstimos) no mesmo ramo. Dois sinks pelo preço de um input.

6. Por que o WordPress não salva

O WordPress aplica automaticamente wp_magic_quotes() (essencialmente um addslashes) em $_GET, $_POST, $_REQUEST e $_COOKIE. É por isso que a maioria dos "SQLi" que a análise estática aponta são falsos positivos: se o valor cai dentro de aspas (WHERE nome = '...'), a aspa do atacante vira \' e a injeção morre.

Aqui, essa proteção é neutralizada por dois motivos independentes — e basta um:

Defesa do WordPressPor que falha neste caso
Slashing de aspas (addslashes)O valor passa por base64_decode depois do slashing — os bytes crus são reconstruídos, sem as barras de escape.
Escape só protege contexto-stringO sink é numérico (id = {$x}, sem aspas). Não é preciso quebrar aspa nenhuma para injetar.

Em outras palavras: mesmo que o addslashes ainda estivesse valendo, ele não faria diferença — não há aspa para escapar. E como não há, a entrada Base64 de algo como 0 UNION SELECT ... entra crua na query. O base64_decode, que parecia só um detalhe de "codificação de ID", é justamente o que contorna a única proteção que o framework oferecia.

7. Provando a exploração

Com um sink numérico sem prepare, o atacante controla parte da query. Três técnicas clássicas transformam isso em leitura de dados — e todas foram confirmadas no laboratório:

Time-based blind (universal)

Mesmo quando a aplicação não devolve nada útil na resposta, o atacante injeta uma condição que faz o banco dormir por N segundos. Se a resposta HTTP demora N segundos, a query executou. É o oráculo mais robusto: funciona mesmo com exibição de erro desligada. No lab, uma requisição de baseline respondia em ~0,08 s; a requisição com um atraso de 3 s injetado respondia em ~3,06 s, e a de 5 s em ~5,12 s. Atraso controlado pelo atacante = execução de SQL confirmada.

Boolean blind

Variando a condição (“o primeiro caractere da versão do banco é 1?”) e observando se o atraso acontece ou não, dá para extrair o banco bit a bit — sem depender de nenhuma mensagem de erro.

Error-based

Quando a instalação reflete erros de SQL (por exemplo, com WP_DEBUG ligado), a técnica de extractvalue despeja o dado direto no corpo da resposta. No laboratório, uma única requisição anônima devolveu o login e o hash de senha do administrador. A resposta ecoava a query executada — confirmando o sink exato — junto do dado exfiltrado:

XPATH syntax error: '~admin:$wp$2y$10$ZNDr8kY7bTJL...'
# login + prefixo do hash do admin, extraídos anonimamente (lab próprio)

Detalhe que fecha o caso: a query vulnerável executa antes de qualquer checagem de "self-checkout está habilitado?". Testamos numa instalação limpa, com a feature desligada (estado padrão), e o atraso injetado aconteceu mesmo assim. Não há pré-condição de configuração: basta o plugin estar instalado e ter uma página de portal publicada.

PoC completo sob embargo até 29/07/2026

Os payloads exatos, o script de exploração automatizado e o vídeo da extração ao vivo foram deliberadamente mantidos privados. Quando submetemos a falha ao WPScan, agendamos a liberação da prova de conceito para 29 de julho de 2026 — dando tempo para que administradores atualizem para a 3.5.8 antes de o exploit circular.

Isso é divulgação responsável na prática: a comunidade sabe que a falha existe e como se proteger; quem quer atacar não recebe a arma pronta. Esta seção será atualizada com o walkthrough completo (e o GIF do PoC) em 29/07.

8. Causa raiz: uma regressão de feature

O detalhe mais interessante não é a falha — é que o desenvolvedor já sabia como fazer certo. Este mesmo plugin já teve um SQL injection no passado (a CVE-2025-12707, no parâmetro bid), e o autor o corrigiu com o padrão correto:

// caminho JÁ corrigido (parâmetro bid) — o jeito certo
$decoded = base64_decode( $raw_bid, true );
if ( ctype_digit( $decoded ) ) { $book_id = intval( $decoded ); } else { $book_id = 0; }  // guard inteiro
$prepared = $wpdb->prepare( $sql, $book_id );   // %d → seguro

Ou seja: o guard existe, o dev conhece o padrão base64 → valida inteiro → prepare. O problema é que, ao adicionar a feature de self-checkout (nova a partir da versão 3.5, numa classe reescrita), ele reaproveitou o base64_decode mas esqueceu o guard e o prepare. Uma vulnerabilidade nova reintroduzida ao lado de código seguro.

VersãoParâmetro bid (CVE antiga)Ramo self-checkout (book_id)Sink cru WHERE id={$x}
3.2.1 / 3.3guardado (ctype_digit + prepare)ausenteausente
3.5 – 3.5.7guardadopresentepresente (2×), sem guard
3.5.8guardadopresentecorrigido

Por isso não é "correção incompleta" da CVE antiga, e sim uma vulnerabilidade nova e independente: parâmetro diferente (book_id vs bid), feature diferente (self-checkout), arquivo diferente. Foi exatamente esse argumento de novidade — provado por diff de versões — que sustentou a atribuição de um CVE próprio.

9. CVSS 8.6 — e por que não 9.8

O WPScan classificou a falha em 8.6 (Alto). Um SQLi não autenticado costuma tentar puxar para o teto de 9.8 (Crítico), então vale explicar por que a análise conservadora fica abaixo disso — e por que isso é uma virtude, não uma fraqueza.

O conector do plugin usa mysqli em modo single-statement: queries empilhadas não executam. Confirmamos no lab — um payload de stacking (0; SELECT SLEEP(4)) respondeu instantaneamente, enquanto a leitura via UNION funcionou. Tradução: o atacante consegue ler dados arbitrários (confidencialidade alta), mas não escrever por esse vetor. Na nossa análise interna, o vetor é AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N — leitura total, sem escrita nem indisponibilidade. O escopo entra como alterado (S:C) porque a injeção atravessa do plugin para o banco e lê dados sob outra autoridade de segurança (os hashes em wp_users) — é isso que leva a nota aos 8.6.

Pontuar honestamente é parte da reputação técnica. Inflar tudo para 9.8 é o que faz analista de CNA desconfiar. “Leitura arbitrária do banco, incluindo hashes de senha” já é grave o suficiente — não precisa de exagero.

10. A correção certa

A correção é a mesma que o próprio autor já aplicava no parâmetro bid: validar como inteiro e usar prepared statement. Foi o que entrou na 3.5.8.

// correção: valida inteiro + prepared statement
$book_id = base64_decode( $book_id, true );
$book_id = is_numeric( $book_id ) ? absint( $book_id ) : 0;   // guard inteiro
if ( $book_id <= 0 ) { wp_send_json( ... ); return; }

$book_data = $wpdb->get_row( $wpdb->prepare(
    "SELECT name, category_id FROM " . $tabela_books . " WHERE id = %d",
    $book_id
) );

Duas linhas de defesa, e qualquer uma sozinha já mataria a injeção: o absint() garante que $book_id é um inteiro não negativo (adeus payload SQL), e o %d do prepare() força o tipo numérico no binding. O princípio geral: nunca concatene entrada em SQL — parametrize sempre, e valide o tipo esperado o mais cedo possível.

11. Disclosure e timeline

Seguimos divulgação coordenada: o autor foi avisado no mesmo dia da descoberta, em paralelo à submissão para a CNA que historicamente atende este plugin.

Se você usa o Library Management System: atualize para a 3.5.8 ou superior imediatamente. As versões 3.5 a 3.5.7 permitem que um visitante anônimo leia o seu banco de dados — incluindo os hashes de senha dos usuários.

12. O que isso ensina

Para quem desenvolve

  1. Prepared statement não é opcional. Qualquer entrada — mesmo “só um ID”, mesmo já “codificada” — precisa ser parametrizada. Concatenar em SQL é a falha, ponto.
  2. Codificação não é validação. base64_decode não limpa nada; ele apenas reconstrói bytes — e, de quebra, contorna o slashing automático do framework.
  3. Contexto numérico é traiçoeiro. A intuição “o WordPress escapa aspas para mim” falha quando não há aspas. Valide o tipo (inteiro) explicitamente.
  4. Feature nova herda os riscos antigos. Já corrigiu esse padrão num lugar? Garanta que todo caminho novo que reusa o mesmo input carregue o mesmo guard. Um grep pelo padrão perigoso em cada release evita a regressão.
  5. Nonce não é autorização. Se o token está impresso para o público, ele protege contra CSRF, não contra acesso não autenticado.

Para empresas e gestores

  1. Plugin é código de terceiro rodando dentro de casa. Um único plugin com um SQLi coloca todo o banco do site — e as senhas dos seus usuários — a um request de distância. Inventarie e mantenha tudo atualizado.
  2. Atualizar é controle de segurança. Esta falha tem correção. A diferença entre estar exposto e estar seguro é aplicar a 3.5.8. Um processo de patch é defesa de primeira linha, não burocracia.
  3. Scanner acha o comum; pesquisa acha o inédito. Esta CVE não estava em nenhuma base — ela foi criada por análise manual em cima de um filtro automatizado. É esse tipo de profundidade que separa “passei um scanner” de “entendi meu risco real”.

Encontrar a falha é só o começo: o valor está em provar o impacto, mapear a causa raiz e conduzir a divulgação sem expor ninguém no caminho. É exatamente isso que a TreeSec faz nos testes dos nossos clientes — a mesma capacidade ofensiva que gerou esta CVE, aplicada a favor da segurança da sua empresa.

Sua aplicação aguentaria essa análise?

Nossos pentests Web & API vão além do scanner: caçamos lógica de negócio, injeções e falhas de autorização que ferramentas automatizadas não pegam.