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

Document Gallery < 5.1.1: um handler AJAX sem nonce que vira drive-by XSS em um clique

O plugin carrega 8.000 sites. O handler dg_generate_gallery aceita qualquer requisição anônima, devolve a entrada do usuário em text/html sem escape — e o ataque é um simples link GET. Da caça ao sink, passando pela causa raiz e pela correção certa.

Identificação

CVE-2026-12982 CVSS 7.1 · Alto

Software: Document Gallery (plugin WordPress) · slug document-gallery

Versões afetadas: < 5.1.1 (testado no 5.1.0, last_updated 2025-12-09) · Active installs: ~8.000

Classe: Reflected XSS não autenticado (CWE-79) · OWASP A03:2021 — Injection

Vetor: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L

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

Data descoberta: 29 de junho de 2026 · publicado no WPScan

Referências: WPScan

Aviso legal: toda a validação foi feita exclusivamente em laboratório próprio (WordPress em Docker), de forma não destrutiva. Nenhum sistema de terceiros foi testado ou afetado. O PoC completo e o script de automação estão sob divulgação coordenada — os payloads finais não estão incluídos neste artigo.

1. Por que um XSS não autenticado importa

XSS é frequentemente minimizado: “precisa de interação do usuário, não é tão grave.” Essa leitura é errada quando o vetor é não autenticado e entregável por GET. Um atacante não precisa de conta, não precisa de nonce, não precisa de engenharia social elaborada — basta um link. E links chegam por e-mail, mensagem, QR code.

O impacto sobe de patamar quando a vítima é um administrador logado: sessão sequestrada, painel do WordPress comprometido, conteúdo alterado. Em 8.000 instalações ativas, a probabilidade de uma delas estar vulnerável e com um admin navegando ao mesmo tempo não é desprezível — é uma questão de escala.

Reflected XSS (OWASP A03) é o tipo mais comum de XSS e, historicamente, o mais fácil de explorar via phishing direcionado. O que diferencia este achado do ruído diário de CVEs é a combinação: handler anônimo + Content-Type text/html + ausência total de escape na renderização do erro.

2. A superfície: handler AJAX aberto sem nonce

O plugin Document Gallery gerencia galerias de documentos dentro do WordPress por meio do shortcode [dg]. Para renderizar pré-visualizações, ele expõe um handler AJAX dedicado — e o registra tanto para usuários autenticados quanto para visitantes anônimos:

// src/admin/class-ajax-handler.php
add_action('wp_ajax_dg_generate_gallery',        array('DG_AjaxHandler','generateGallery'));
add_action('wp_ajax_nopriv_dg_generate_gallery', array('DG_AjaxHandler','generateGallery')); // ← anônimo

public static function generateGallery() {
    if (isset($_REQUEST['atts'])) {
        if (!headers_sent()) {
            header('Content-Type: text/html; charset='.get_bloginfo('charset')); // text/html!
        }
        echo DocumentGallery::doShortcode($_REQUEST['atts']); // $_REQUEST['atts'] array
    }
    wp_die();
}

Três problemas imediatos neste código:

  1. Sem check_ajax_referer: não há verificação de nonce. Qualquer requisição ao endpoint /wp-admin/admin-ajax.php?action=dg_generate_gallery é processada.
  2. Sem capability check: não há current_user_can(). Visitante anônimo e administrador recebem o mesmo tratamento.
  3. Content-Type text/html: a resposta é declarada explicitamente como HTML. Se qualquer fragmento da saída contiver markup executável, o browser o interpreta.

O array $_REQUEST['atts'] é passado diretamente para DocumentGallery::doShortcode(), que o repassa ao construtor de DG_Gallery. A partir daí, os atributos são validados — ou melhor, deveriam ser.

3. O caminho até o sink: erro que não escapa

O construtor de DG_Gallery chama sanitizeDefaults(), que por sua vez invoca DG_GallerySanitization::sanitizeParameter() para cada atributo. Para os atributos booleanos do shortcode — descriptions, fancy, new_window, attachment_pg, include_children — a validação espera literalmente "true" ou "false". Qualquer outro valor cai no ramo de erro:

// class-gallery-sanitization.php — ramo de erro para booleanos
self::$binary_err = __('The %s parameter may only be "%s" or "%s." You entered "%s."', ...);
$err = sprintf(self::$binary_err, 'descriptions', 'true', 'false', $value); // $value = input cru

O método toBool() em class-util.php:72 retorna null para qualquer string que não seja reconhecida como booleana — portanto, qualquer payload sempre cai neste ramo. O erro é armazenado em $this->errs e renderizado em __toString():

// class-gallery.php:469-472
public function __toString() {
    if (!empty($this->errs)) {
        return '<p>'.implode('</p><p>', $this->errs).'</p>'; // sem esc_html!
    }
}

A cadeia completa: $_REQUEST['atts']['descriptions']sanitizeParameter()sprintf($binary_err, ..., $value)$this->errs[]__toString()echo → browser.

Ponto de defesa esperadoPresente?Motivo da falha
Nonce (check_ajax_referer)AusenteHandler registrado sem qualquer verificação de referer/nonce
Capability check (current_user_can)AusenteNenhuma restrição de papel — anônimo acessa igual a admin
Escape na saída (esc_html)Ausente__toString() concatena os erros sem escape
Content-Type seguroFalhaDeclarado explicitamente como text/html — browser executa o markup

4. Provando a exploração (PoC parcial, drive-by GET)

O PoC foi confirmado em laboratório isolado (WordPress em Docker, sem acesso externo). O handler aceita tanto POST quanto GET — admin-ajax.php processa ambos via $_REQUEST. Isso transforma o XSS em um drive-by via link: nenhuma interação além de clicar em uma URL.

Via POST (curl, sem cookie)

# Confirmação de laboratório — POST anônimo
curl -s 'http://localhost:8080/wp-admin/admin-ajax.php' \
  --data-urlencode 'action=dg_generate_gallery' \
  --data-urlencode 'atts[descriptions]=PAYLOAD_AQUI'
# Resposta: <p>The descriptions parameter may only be "true" or "false." You entered "PAYLOAD_AQUI."</p>

A resposta reflete literalmente o valor fornecido dentro de um elemento <p>, com Content-Type: text/html. Qualquer tag HTML funciona. No lab, um marcador SVG sem alert() foi usado para confirmar a execução sem simular ataque real.

Drive-by via GET

Como $_REQUEST abrange GET, POST e COOKIE, o atacante pode construir uma URL completa que, ao ser aberta pelo navegador da vítima, executa o payload imediatamente. Não há formulário, não há POST, não há segundo clique.

Payload e URL de ataque sob divulgação coordenada

O payload SVG/script confirmado no lab, a URL GET completa e o script de automação estão deliberadamente omitidos deste write-up. Eles serão publicados após a confirmação da versão corrigida (5.1.1) e período de adoção de patch pelos administradores dos 8.000 sites afetados.

Params injetáveis confirmados: descriptions, fancy, new_window, attachment_pg — todos os atributos booleanos do shortcode [dg].

Prior-art: pesquisa exaustiva no WPScan, Patchstack e NVD não retornou nenhum registro de XSS via dg_generate_gallery nas versões 4.x/5.x. Este achado é inédito.

5. A correção certa

A correção exige duas ações independentes — e qualquer uma delas, sozinha, já quebra a cadeia de ataque:

1. Escapar o valor na mensagem de erro

// class-gallery-sanitization.php — ANTES (vulnerável)
$err = sprintf(self::$binary_err, 'descriptions', 'true', 'false', $value);

// DEPOIS (corrigido)
$err = sprintf(self::$binary_err, 'descriptions', 'true', 'false', esc_html($value));

Uma chamada a esc_html() no quarto argumento do sprintf converte <, > e & em entidades HTML — o payload vira texto inerte na tela em vez de código executado.

2. Adicionar verificação de nonce no handler

// class-ajax-handler.php — DEPOIS (corrigido)
public static function generateGallery() {
    check_ajax_referer('dg_generate_gallery_nonce', 'security'); // ← barreira de CSRF
    if (isset($_REQUEST['atts'])) {
        echo DocumentGallery::doShortcode($_REQUEST['atts']);
    }
    wp_die();
}

O nonce deve ser gerado no front-end (via wp_localize_script) e transmitido em cada chamada AJAX. Isso não impede o drive-by via GET de um usuário autenticado que já tenha o nonce, mas elimina qualquer uso não autenticado — o cenário de maior risco.

A regra geral: toda saída que vai para o HTML deve ser escapada no ponto de saída. Não na entrada, não no meio do caminho — no exato momento do echo/print. E qualquer endpoint nopriv_ que devolva HTML deve ser tratado como superfície pública de ataque.

6. Disclosure e timeline

Se você usa o Document Gallery: até a versão 5.1.1 estar disponível, considere desativar o plugin em sites que tenham administradores com sessão ativa — especialmente em sites que recebam links por e-mail ou mensagens de fontes externas.


Este achado é mais um resultado direto da metodologia de caça em escala da TreeSec: varredura estática de mais de 13 mil plugins, filtro por alcançabilidade anônima, triagem humana da causa raiz. O mesmo processo que gera CVEs públicas é o que aplicamos nos pentests dos nossos clientes — porque encontrar a falha certa exige entender o código, não só rodar ferramentas.

Sua aplicação WordPress está protegida contra este tipo de falha?

Nossos pentests Web & API identificam XSS, injeções e falhas de autorização que scanners automatizados não detectam — incluindo handlers AJAX expostos como este.