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

Dynamic Pricing < 5.0.0: o nonce que é lido mas nunca verificado

O handler wdpAjax lê um nonce da requisição — e nunca o checa. Pior: a “sanitização” que ele aplica ao DisData remove a proteção nativa do WordPress e só troca aspas simples, então um payload sem aspas como <svg onload=...> chega cru ao echo. XSS refletido, não autenticado, servido como HTML.

Identificação

CVE-2026-13725 CVSS 7.1 · Alto

Software: Dynamic Pricing With Discount Rules for WooCommerce (Acowebs) · slug aco-woo-dynamic-pricing

Versões afetadas: < 5.0.0 · corrigido na 5.0.0 · Active installs: 5.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 de descoberta: 29 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 (WooCommerce + PHP 8.2 em Docker), de forma não destrutiva. Nenhum sistema de terceiros foi testado ou afetado. A falha já está corrigida na versão 5.0.0.

1. Por que XSS não autenticado importa

A leitura preguiçosa de XSS é “precisa de interação, não é grave”. Na prática, XSS refletido não autenticado é uma das classes mais úteis para um atacante em campanha: não exige conta, não deixa rastro de login e o payload viaja por qualquer canal — um link em e-mail, mensagem, anúncio. Quando a vítima é um administrador com sessão ativa, o mesmo link vira sequestro de painel: criar usuário admin, instalar plugin malicioso, exfiltrar dados.

O WPScan pontuou o achado em CVSS 7.1, com escopo alterado (S:C) e impacto baixo em confidencialidade, integridade e disponibilidade (C:L/I:L/A:L), exigindo interação da vítima (UI:R). O que eleva a nota é o handler estar aberto a anônimos e a resposta ser servida como HTML executável.

2. A superfície: wp_ajax_nopriv_wdpAjax

O plugin registra o handler wdpAjax também para usuários não autenticados e o roteia para wdpDynamicPricingTable():

// class-awdp-front-end.php:200
add_action('wp_ajax_nopriv_wdpAjax', [$this, 'wdpDynamicPricingTable']); // ← anônimo alcança

Com o gancho nopriv, qualquer visitante chega ao handler por /wp-admin/admin-ajax.php. Sobra a expectativa de que exista, dentro dele, uma verificação de nonce ou de capability. Existe um nonce — mas não uma verificação.

3. O nonce que é lido e nunca checado

Este é o detalhe mais insidioso do achado. O handler um nonce da requisição:

// class-awdp-discount.php:2306 — o nonce é lido...
$nonce = $_POST['nonce'] ?? '';
// ...e nunca passa por check_ajax_referer() ou wp_verify_nonce()

Ler o valor não é verificar o valor. Em nenhum ponto do fluxo aparece check_ajax_referer() ou wp_verify_nonce(), e não há current_user_can(). O nonce está no código como decoração — dá a impressão, para quem lê rápido, de que há uma barreira CSRF/autorização ali. Não há. O atacante pode mandar qualquer valor (ou nenhum) no campo nonce: o handler processa do mesmo jeito.

É um anti-padrão que vemos com frequência: a presença de um nonce na requisição não prova que ele é validado. Auditar autorização é seguir o valor até o check_ajax_referer() — se ele nunca é chamado, o token é enfeite. Nonce lido ≠ nonce verificado.

4. A “sanitização” que ajuda o atacante

Passada a (falsa) barreira, com type=change o handler processa o parâmetro DisData. E aqui acontece algo raro: o tratamento aplicado à entrada não só falha em proteger — ele remove a proteção que o WordPress já tinha posto:

// class-awdp-discount.php:2313
$DisData = json_decode( stripslashes( str_replace("'", '"', $_POST['DisData']) ) );

Duas operações, as duas a favor do atacante:

E payloads de XSS não precisam de aspas. <svg onload=alert(1)> não tem uma única aspa — atravessa o str_replace, atravessa o stripslashes e chega ao próximo passo exatamente como foi enviado.

5. O sink: echo sem esc_html

O valor decodificado é concatenado direto na tabela HTML de preços, sem qualquer escape, e ecoado:

// class-awdp-discount.php — monta a linha da tabela sem esc_html
$table .= '<tr><td>' . $quantity_rule->start_range . ' +</td>...';
// :2440 — e ecoa a tabela montada
echo $table;

Sem esc_html(), sem wp_kses(). E como o admin-ajax.php serve a resposta com Content-Type: text/html (padrão quando o handler não sobrescreve), o markup refletido é interpretado pelo navegador como parte do documento — o <svg onload> executa.

Barreira esperadaStatusObservação
Autenticação / noprivAusenteHandler registrado em wp_ajax_nopriv_wdpAjax
Nonce (check_ajax_referer)Lido, nunca verificadoValor lido em :2306, sem wp_verify_nonce() em lugar algum
Capability checkAusenteSem current_user_can() no handler
Sanitização da entradaContraproducentestripslashes() desfaz o magic-quotes; str_replace só toca aspa simples
Encode na saídaAusenteecho $table sem esc_html, servido como text/html

6. Prova de conceito

Validado em laboratório (WooCommerce + plugin 4.5.11, PHP 8.2). Não autenticado, sem cookie:

# O payload sem aspas atravessa o str_replace e o stripslashes
curl -s 'https://LAB/wp-admin/admin-ajax.php' \
  --data-urlencode 'action=wdpAjax' \
  --data-urlencode 'type=change' \
  --data-urlencode 'ProdPrice=10' \
  --data-urlencode 'price=10' \
  --data-urlencode 'DisData=[{"start_range":"<svg onload=alert(1)>","end_range":""}]'
# → resposta (Content-Type: text/html):
#   <tr><td><svg onload=alert(1)> +</td>...   → payload refletido cru → XSS

O start_range é lido do JSON do atacante e vai direto para a linha da tabela. Nenhuma etapa entre a entrada e o echo escapa o valor — ao contrário, a etapa que existia (o stripslashes) trabalhou a favor da injeção.

7. A correção certa

Três mudanças, cada uma quebrando a cadeia por conta própria:

1. Escapar na saída

// esc_html em cada valor antes de montar a linha
$table .= '<tr><td>' . esc_html($quantity_rule->start_range) . ' +</td>...';

2. Verificar o nonce de verdade (e a capability)

check_ajax_referer('wdp-nonce', 'nonce');   // não apenas ler — verificar
if (! current_user_can('edit_shop_orders')) {
    wp_send_json_error('Forbidden', 403);
}

3. Não desfazer a proteção da entrada

// nada de stripslashes() antes de usar; sanitizar de fato
$raw     = wp_unslash($_POST['DisData']);
$DisData = json_decode($raw);
$start   = sanitize_text_field($DisData[0]->start_range ?? '');

O princípio: escape acontece na saída, no contexto da saída. Como o valor termina em HTML, ele precisa de esc_html() no ponto do echo. E “sanitizar” nunca deveria significar remover o escape que o framework já aplicou — stripslashes() antes de ecoar é o oposto de defender.

8. Disclosure e timeline

Se você usa o Dynamic Pricing With Discount Rules for WooCommerce: atualize para a 5.0.0 ou superior. É a correção definitiva — não há configuração que neutralize o handler enquanto o plugin vulnerável estiver ativo.


O que torna este caso instrutivo é a diferença entre parecer defendido e estar defendido: um nonce que é lido dá a impressão de proteção, uma chamada a stripslashes/str_replace parece “tratamento de entrada”. Só que ler não é verificar, e desfazer o escape não é sanitizar. Distinguir uma coisa da outra exige ler o handler linha a linha, do registro do gancho até o echo — e é esse nível de profundidade que a TreeSec aplica nos testes dos nossos clientes.

Seus handlers AJAX validam o que dizem validar?

Nossos pentests Web & API mapeiam cada ação AJAX, conferem se o nonce é de fato verificado, se há capability check e se a saída é escapada — exatamente a cadeia que virou esta CVE 7.1.