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).
Neste write-up
- Por que uma CVE importa
- A caça: 13 mil plugins, uma máquina
- A superfície: um AJAX aberto a anônimos
- O nonce que não protege nada
- O sink: base64 e contexto numérico
- Por que o WordPress não salva
- Provando a exploração
- Causa raiz: uma regressão de feature
- CVSS 8.6 — e por que não 9.8
- A correção certa
- Disclosure e timeline
- O que isso ensina
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.
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.
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.
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.
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:
- O valor é decodificado de Base64 (
base64_decode), o que reconstrói bytes arbitrários a partir da entrada. - Ele é concatenado diretamente na string SQL — sem
$wpdb->prepare(), sem binding de parâmetro. - 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 WordPress | Por 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-string | O 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.
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ão | Parâmetro bid (CVE antiga) | Ramo self-checkout (book_id) | Sink cru WHERE id={$x} |
|---|---|---|---|
| 3.2.1 / 3.3 | guardado (ctype_digit + prepare) | ausente | ausente |
| 3.5 – 3.5.7 | guardado | presente | presente (2×), sem guard |
| 3.5.8 | guardado | presente | corrigido |
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.
- 17/06/2026 — Descoberta via análise estática (Semgrep taint) e confirmação em laboratório (time-based, boolean e error-based; hash do admin extraído).
- 17/06/2026 — Novidade confirmada por diff de versões (3.2.1 / 3.3 / 3.5.7). Autor notificado por e-mail e falha submetida ao WPScan (CNA), com pedido de CVE.
- 22/06/2026 — CVE-2026-12582 publicada pelo WPScan, creditada à Equipe TreeSec.
- Versão 3.5.8 — correção disponibilizada pelo autor. Atualize.
- 29/07/2026 — liberação agendada da prova de conceito completa (janela de patch para os administradores).
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
- Prepared statement não é opcional. Qualquer entrada — mesmo “só um ID”, mesmo já “codificada” — precisa ser parametrizada. Concatenar em SQL é a falha, ponto.
- Codificação não é validação.
base64_decodenão limpa nada; ele apenas reconstrói bytes — e, de quebra, contorna o slashing automático do framework. - 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.
- 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.
- 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
- 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.
- 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.
- 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.