← Voltar para Insights Write-up · File Upload Não Autenticado → RCE

ImproveSEO ≤ 2.0.11: o upload que aceita PHP porque só confere os bytes

O plugin lê a extensão do arquivo, mas decide se aceita olhando só os primeiros bytes. Um arquivo que começa com GIF89a; e se chama shell.php passa como “imagem” e é gravado como PHP no diretório público — sem login, sem nonce, sem cookie. Execução remota de código com um único curl.

Identificação

CVE-2026-16618 CVSS 10.0 · Crítico

Software: ImproveSEO (plugin WordPress) · slug improveseo

Versões afetadas: ≤ 2.0.11 (a 2.0.11 é a última versão publicada) · sem correção disponível — o plugin foi fechado no repositório do WordPress.org em 23/07/2026.

Classe: Upload de arquivo arbitrário não autenticado (CWE-434) → Execução Remota de Código

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

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

Validado em laboratório: 19 de julho de 2026 · Publicado no WPScan em 24/07/2026

Referências: WPScan · WordPress.org

Aviso legal: toda a validação foi feita exclusivamente em laboratório próprio (WordPress + PHP 8.2 em Docker), de forma não destrutiva. Nenhum sistema de terceiros foi testado ou afetado. Como não há correção disponível, o payload final e o script de automação estão deliberadamente omitidos deste artigo.

1. Por que file upload é o pior caso

Entre as classes de vulnerabilidade web, upload de arquivo arbitrário sem autenticação é a que menos exige do atacante e a que mais entrega. Não há sessão para roubar, nonce para forjar ou vítima para enganar: o atacante fala direto com o servidor, grava um arquivo .php dentro do webroot e passa a executar código arbitrário no contexto do processo PHP. A partir daí é servidor comprometido — leitura do wp-config.php, credenciais do banco, pivô para a rede interna.

É por isso que o WPScan classificou este achado em CVSS 10.0, a nota máxima. O vetor é AV:N/AC:L/PR:N/UI:N (rede, baixa complexidade, sem privilégio, sem interação) com escopo alterado e impacto total em confidencialidade, integridade e disponibilidade. Não existe combinação mais grave.

O detalhe que torna este caso didático é por que a proteção falha. O plugin não esqueceu de validar o upload — ele valida. Só que valida a coisa errada.

2. A superfície: handler nopriv + nonce comentado

O ImproveSEO registra um handler AJAX de upload e o expõe também para usuários não autenticados, no topo do arquivo — fora de qualquer bloco !is_admin():

// improveseo.php:1071
add_action('wp_ajax_my_plugin_upload',        'my_plugin_handle_upload');
// improveseo.php:1073
add_action('wp_ajax_nopriv_my_plugin_upload', 'my_plugin_handle_upload'); // ← anônimo chega aqui

Com o gancho nopriv, qualquer visitante alcança o handler por /wp-admin/admin-ajax.php. Restaria o nonce como barreira — mas ele está comentado:

// improveseo.php:1001 — dentro de my_plugin_handle_upload()
// check_ajax_referer('my-plugin-nonce', '_wpnonce');   ← desativado

Não há check_ajax_referer() ativo e não há current_user_can(). O handler é integralmente não autenticado: sem token, sem capability, sem sessão. A única coisa entre o atacante e o disco é a validação do arquivo — e é aí que mora o bug.

3. A validação que não valida

O handler faz exatamente uma checagem de conteúdo antes de gravar:

// improveseo.php:1021 — identifica o tipo pelo CONTEÚDO do arquivo
$fileType = mime_content_type($tmpName);
// :1025 — só aceita imagem
// whitelist: ['image/jpeg', 'image/png', 'image/gif']

// :1027 — monta o nome final. sanitize_file_name NÃO remove ".php"
$filename   = uniqid() . '_' . sanitize_file_name($name);
$uploadPath = wp_upload_dir()['path'];   // webroot, executa PHP

O erro é escolher mime_content_type() como autoridade. Essa função (libmagic) decide o tipo lendo os bytes iniciais do arquivo — os “números mágicos” —, não a extensão. E o nome final é montado com sanitize_file_name(), que higieniza caracteres perigosos do nome mas preserva a extensão: shell.php continua shell.php.

Ou seja: a decisão de “isto é seguro?” é tomada pelos primeiros bytes, mas o que determina se o arquivo executa é a extensão. São dois critérios independentes — e o atacante controla os dois.

4. O polyglot GIF/PHP

A ponte entre os dois critérios é um polyglot: um arquivo que é, ao mesmo tempo, um GIF válido para o libmagic e um script PHP válido para o interpretador. Basta que ele comece com a assinatura de um GIF e, em seguida, contenha código PHP:

# Estrutura conceitual do arquivo (payload real omitido)
GIF89a;<?php /* código do atacante */ ?>

Os bytes GIF89a; fazem o mime_content_type() devolver image/gif — passa na whitelist. Mas o arquivo se chama shell.php, e o PHP não liga para os bytes iniciais: ao receber uma requisição para shell.php, ele ignora o cabeçalho GIF (que é só texto solto para o parser) e executa tudo que estiver entre <?php ... ?>.

É o mesmo princípio de qualquer bypass de upload por “magic bytes”: validar o conteúdo não substitui validar a extensão. Um arquivo pode satisfazer o formato de imagem e ainda ser um programa. Quem só olha os bytes está checando se parece uma imagem, não se é seguro executar.

5. move_uploaded_file: por que fura o WordPress

O WordPress tem defesa nativa contra exatamente isto. A função wp_handle_upload() passa o arquivo por wp_check_filetype_and_ext() e por uma blocklist de extensões executáveis — .php é barrado ali, independentemente do MIME. O problema é que o plugin não usa nada disso. Ele grava o arquivo na mão:

// improveseo.php:1033 — grava direto, sem passar pelo core
move_uploaded_file($tmpName, $filePath);
// :1035 — e devolve a URL pública do arquivo salvo
wp_send_json_success([$publicUrl]);

move_uploaded_file() é uma primitiva pura do PHP: move o arquivo temporário para o destino, ponto. Não conhece a blocklist do WordPress, não valida extensão, não sanitiza nada além do que já veio. Ao trocar wp_handle_upload() por move_uploaded_file(), o plugin abriu mão de toda a proteção do core — e ainda entrega ao atacante, na resposta JSON, a URL exata onde o arquivo foi parar. Não é preciso adivinhar o caminho: o servidor informa.

Barreira esperadaStatusObservação
Autenticação / noprivAusenteHandler registrado também em wp_ajax_nopriv_ — anônimo alcança
Nonce (check_ajax_referer)ComentadoLinha 1001 desativada — nenhum token exigido
Capability checkAusenteSem current_user_can() em nenhum ponto do handler
Validação de extensãoContornávelDecide por mime_content_type() (bytes), extensão .php preservada
Blocklist do coreIgnoradamove_uploaded_file() em vez de wp_handle_upload()

São cinco barreiras. Nenhuma delas está de pé. Basta uma para quebrar a cadeia — e é justamente por não haver nenhuma que a nota é 10.0.

6. Confirmado em laboratório

Validamos o achado em ambiente isolado (lab local, WordPress com ImproveSEO 2.0.11 sobre PHP 8.2.31), de forma não autenticada — sem cookie, sem nonce. O fluxo confirmado, em três passos:

  1. Enviar o polyglot para action=my_plugin_upload via admin-ajax.php, no campo images[], com Content-Type: image/gif.
  2. O servidor responde {"success":true,...} com a URL do arquivo gravado em /wp-content/uploads/AAAA/MM/<uniqid>_shell.php.
  3. Um GET nessa URL retorna o resultado da execução do PHP no servidor.

Payload e automação retidos — sem correção disponível

Diferente dos nossos outros write-ups, aqui não há versão corrigida: o plugin foi removido do repositório, mas as instalações existentes seguem vulneráveis. Por isso o polyglot final e o script de exploração estão omitidos deste artigo.

Confirmado em laboratório: o GET ao arquivo gravado retornou a saída de um cálculo feito pelo servidor (7*7=49) concatenada à versão do PHP — prova de que o PHP enviado executou. Sem cookie. Sem login. Sem nonce.

7. Sem patch: o que fazer agora

Este é o ponto que mais importa para quem administra sites. O plugin foi fechado no WordPress.org em 23/07/2026, o que significa duas coisas: não há atualização para instalar, e sites que já o têm instalado continuam com o código vulnerável ativo — fechar um plugin no repositório não o desinstala de ninguém.

Se você usa o ImproveSEO, a única mitigação real é:

  1. Desative e remova o plugin imediatamente. Não há como configurar em volta da falha — o handler vulnerável é registrado sempre que o plugin está ativo.
  2. Cace artefatos. Procure arquivos .php recentes em wp-content/uploads/ (o diretório de uploads não deveria conter PHP algum). Nomes no padrão <hex>_*.php são suspeitos.
  3. Bloqueie execução de PHP em uploads no servidor web (regra de .htaccess/nginx negando .php sob /uploads/) — defesa em profundidade que teria neutralizado esta falha por completo.
  4. Se houve exposição, trate como comprometimento: rotacione as credenciais do wp-config.php, revise usuários administrativos e investigue a partir dos logs de acesso ao admin-ajax.php.

8. A correção certa

Do lado do desenvolvedor, a correção não é ajustar a whitelist de MIME — é parar de decidir por MIME. Quatro mudanças, e qualquer uma quebra a cadeia:

1. Usar o pipeline de upload do WordPress

// Deixa o core validar extensão e aplicar a blocklist
$upload = wp_handle_upload($_FILES['images'], ['test_form' => false]);
if (isset($upload['error'])) { wp_send_json_error($upload['error']); }

2. Validar extensão E tipo, não só o conteúdo

$check = wp_check_filetype_and_ext($tmpName, $name);
if (! in_array($check['ext'], ['jpg','jpeg','png','gif'], true)) {
    wp_send_json_error('Extensão não permitida', 400);
}

3. Reativar nonce e exigir capability

check_ajax_referer('my-plugin-nonce', '_wpnonce');
if (! current_user_can('upload_files')) {
    wp_send_json_error('Forbidden', 403);
}

E, no servidor, a regra de negar execução de PHP no diretório de uploads — que transforma qualquer upload malicioso, presente ou futuro, em um arquivo inerte.

O princípio: a extensão é o que decide se um arquivo executa; portanto é a extensão que precisa ser validada. Checar o conteúdo (magic bytes) responde “isto se parece com uma imagem?”, não “é seguro gravar isto com este nome?”. E wp_handle_upload() existe precisamente para você não ter que acertar essa distinção na mão.

9. Disclosure e timeline

Se você usa o ImproveSEO: remova o plugin hoje. Como não há versão corrigida e o repositório já não o distribui, não existe “atualizar e seguir” — a mitigação é a desinstalação, seguida de varredura do diretório de uploads.


Este caso resume um padrão que encontramos repetidamente: a defesa existia no framework, e o código a contornou “na mão” — um move_uploaded_file() no lugar de um wp_handle_upload(), um nonce comentado “só para testar”. Achar isso não é rodar um scanner; é ler o fluxo do handler, entender o que cada função realmente valida e onde a cadeia se rompe. É esse nível de profundidade que a TreeSec aplica nos testes dos nossos clientes.

Sua aplicação aceita uploads de usuário?

Nossos pentests Web & API testam cada ponto de upload contra bypass de MIME, polyglots, path traversal e execução no webroot — exatamente a cadeia que virou esta CVE 10.0.