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.
Neste write-up
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 lê 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:
stripslashes()remove as barras que o magic quotes do WordPress adiciona a toda entrada. Ou seja: o WordPress tinha escapado a entrada, e o plugin a desescapa antes de usá-la. A única proteção presente é ativamente desfeita.str_replace("'", '"', ...)troca aspas simples por aspas duplas (para o JSON), mas só mexe em aspa simples. Um payload que não usa aspa nenhuma passa intocado.
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 esperada | Status | Observação |
|---|---|---|
Autenticação / nopriv | Ausente | Handler registrado em wp_ajax_nopriv_wdpAjax |
Nonce (check_ajax_referer) | Lido, nunca verificado | Valor lido em :2306, sem wp_verify_nonce() em lugar algum |
| Capability check | Ausente | Sem current_user_can() no handler |
| Sanitização da entrada | Contraproducente | stripslashes() desfaz o magic-quotes; str_replace só toca aspa simples |
| Encode na saída | Ausente | echo $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 doecho. 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
- 29/06/2026 — Descoberta e confirmação em laboratório (plugin 4.5.11, PHP 8.2). Payload sem aspas refletido cru, não autenticado, sob
Content-Type: text/html. - Julho/2026 — Submetido ao WPScan pela Equipe TreeSec, com pedido de CVE.
- Versão 5.0.0 — correção publicada pela Acowebs.
- 20/07/2026 — Publicado como CVE-2026-13725, CVSS 7.1.
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.