OPNsense tunables inject /boot/loader.conf lines
OPNsense core 26.7.6 System: Advanced: Tunables still interpolates name/value into /boot/loader.conf. Leftover sibling of GHSA-fq94. Live sysctl is shell_safe. The leftover is extra loader directives on next boot. Labbed.
- Name
- OPNsense tunables inject /boot/loader.conf lines
- 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) System: Advanced: Tunables. page-system-advanced-sysctl without page-all. Tunables.xml name/value are TextField. system_login_configure writes {$param}="{$value}".
- Published
- 13 Oct 2026
- Updated
- 13 Oct 2026
- Tags
- 0-day, opnsense, tunables, loader.conf, cwe-93
GHSA-fq94 named the other fields. loader.conf still concatenates.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/opnsense-tunables-loader (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, gadget.php. Authorized lab only. It talks to loopback.
This is OPNsense core 26.7.6 "Xenial Xenops" (a842fe48c21288d11a422cb944678e8eb308becf). No CVE yet. CWE-93. 8.1 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H). Remote. Config line injection. Privilege page-system-advanced-sysctl.
Tunables.xml name and value are bare TextField. Newlines stay on. system_login_configure() writes {$param}="{$value}" into /boot/loader.conf. Tunables reconfigure also restarts login. Live sysctl(8) goes through shell_safe plus an OID filter, so that hop is not shell injection. The leftover is extra loader directives on the next boot.
GHSA-fq94 closed configuration line injection via GUI text fields. It did not name this field.
Why tunables, on this pin
I pinned 26.7.6, not master, not 27.1.a, not EOL 26.1. Twenty-eight hunts. After the 2026 wave, concatenated mwexec is gone. NTP timeserver hosts were stripped of CR/LF. Wireless SSID got AllowNewlines=N. The pattern is familiar: a public High lands on the obvious tokens, and a sibling field keeps the old interpolation.
System: Advanced: Tunables looks like "set a sysctl." A limited GUI user who was granted that page is not supposed to mint vfs.root.mountfrom or init_path for the next boot. Default sample config only grants page-all to admins. The leftover is a delegated tunables editor without All pages.
The same hunt also looked at snapshots and configd.socket. Snapshot activate without page-all is a next-boot / swap on an ACL named System: Snapshots. I treated that as the page. configd.socket 0666 is leftover of CVE-2023-39005 and is public. Tunables into loader.conf is the unmatched sibling of GHSA-fq94.
The skip list
- GHSA-fq94-cxvc-9r7w configuration line injection via GUI text fields (High, 2026-07-01). Do not re-file fields that were patched. Tunables to
/boot/loader.confis the unmatched sink. - CVE-2023-39005 configd.socket insecure perms. Public. Not this leftover.
- GHSA-p9pr comma-separated user privileges. ACL.php now explodes
privon comma. - GHSA-vw8q Manual
Config::save()vsuser-config-readonly. Tunablessave()throws. - CVE-2026-44193 XMLRPC restore. CVE-2026-44194 email-as-shell. CVE-2026-45158 WAN DHCP. CVE-2026-53581 NTP path. CVE-2026-57155 closed on 26.7.
- NTP GPS/PPS fudge and wireless WPA identity are sibling leftovers of the same GHSA class. Separate reports. This one is loader.conf.
Surfaces that returned nothing
Live /sbin/sysctl looked like command injection. set_sysctl() with $filter = true requires the OID in sysctl -WaN, then /sbin/sysctl %s=%s via Shell::exec_safe / escapeshellarg. I dropped shell injection on that hop.
set_single_sysctl uses filter=false but still escapeshellarg. TunablesController does not reach it with a user-controlled name.
Sudoers %{{system.sudo_allow_group}} uses a group-name Mask. Admin-only picker. Drop.
Unauth is 401. User without page-system-advanced-sysctl is 403. user-config-readonly throws on save().
I almost stopped at "runtime sysctl is filtered." Then I read reconfigureAction. It restarts both login and sysctl. core_services() maps login to system_login_configure. That function rewrites /boot/loader.conf.
How login configure writes the file
$new_loader_conf .= "# dynamically generated tunables settings follow\n";
foreach (system_sysctl_get() as $param => $value) {
$new_loader_conf .= "{$param}=\"{$value}\"\n";
}
@file_put_contents('/boot/loader.conf', $new_loader_conf);system_sysctl_get() copies sysctl/item/tunable and value from config with no charset filter. A quote plus newline in value (or a newline in tunable) closes the loader assignment and starts a new loader directive. Reconfigure is POST /api/core/tunables/reconfigure on the same ACL. rc.reload_all and boot also call system_login_configure, so a saved injection survives until the next login/sysctl rewrite even if the attacker never hits reconfigure themselves.
set_sysctl will not apply a fake OID at runtime. Loader.conf will still persist it. That is the split: runtime sysctl is filtered; boot file is not.
Tunables.xml:
<tunable type="TextField"/>
<value type="TextField"/>TextField defaults allow spaces, newlines, and specials. Validators only fire if those flags are turned off. TunablesController does not set $internalSaveRequiresAdmin. ACL page-system-advanced-sysctl is ui/core/tunables plus api/core/tunables/*, separable from page-all.
Wrong turns, in order
I dropped tunables because live sysctl is shell_safe. Correct for shell injection. Wrong for loader.conf.
I treated GHSA-fq94 as covering every GUI text field that lands in a conf file. The advisory named the fields it patched. This one was not on the list.
I spent time on snapshot bectl activate without page-all. The page is called System: Snapshots. Activate is the page. Confidence on keeping it was already "ACL may be intended." Parent dropped it. Tunables extra loader lines are not the page's job.
I used hw.usb.no_boot_wait as the tunable name so the assignment is a real OID shape. The witness is an extra comment line after a closed quote, not a live sysctl -w of garbage.
I did not reboot the gadget. Next-boot honor of vfs.root.mountfrom / init_path / module load stays inferred. The lab proves the extra line.
The lab
PHP 8.3 gadget of pinned system.inc system_login_configure(). Positive value is 1"\n# OPNSENSE-TUNABLES-LOADER-WITNESS. Negative value is 1. Oracle files are /oracle/loader-positive.conf and /oracle/loader-negative.conf. Positive must close the assignment and put the witness on its own line. Negative must stay a single assignment with no witness.
Compose project opnsense-tunables-loader. Hunt slot 18850 in the 18800-18860 gadget family. This compose file uses docker compose exec php, not a published HTTP bind. Full OPNsense FreeBSD VM is out of this lab pass. No exploit payloads.
Last run:
SUCCESS opnsense-tunables-loader newline-extra-line=yes clean-value-single-line=yes OPNSENSE-TUNABLES-LOADER-WITNESSStack tears down on EXIT.
What an attacker can do
| Method | Sink | Privilege |
|---|---|---|
Tunables value CR/LF (hw.usb.no_boot_wait plus "\n# witness) |
pin system_login_configure() extra line in /boot/loader.conf |
Integrity (lab-proven): extra # OPNSENSE-TUNABLES-LOADER-WITNESS on its own line after a closed assignment. Lab: newline-extra-line=yes. |
Clean value 1 |
same writer | Negative (lab-proven): single assignment line, no witness. Lab: clean-value-single-line=yes. |
| Same extra loader lines on next boot | loader init_path / module_path / *_load / vfs.root.mountfrom / console |
Integrity (inferred): next-boot loader honors attacker keys. Not rebooted in this gadget. |
Garbage loader lines / wrong vfs.root.mountfrom |
/boot/loader.conf |
Availability (inferred): boot fail or wrong root. |
POST /api/core/tunables/searchItem |
tunables API | Recon (inferred): list tunables, including current/default/type. Intended for this ACL. |
Live sysctl -w of a valid OID |
set_sysctl + OID filter |
Intended page job. Extra loader lines are the leftover. |
Lab-proven vs inferred: newline extra loader line and clean-value single-line are lab-proven. Next-boot loader apply is inferred. Gadget, not a full OPNsense VM.
Requires: signed-in user or that user's Local API key with page-system-advanced-sysctl and without page-all. POST /api/core/tunables/*. CSRF on session POST. Reconfigure restarts login; rc.reload_all and boot also call system_login_configure.
Adjacent surface that stays closed
| Surface | Lab |
|---|---|
| Live sysctl shell injection | shell_safe plus OID filter |
| Clean value | single assignment line |
| Unauth | 401 |
User without page-system-advanced-sysctl |
403 |
user-config-readonly |
save() throws |
page-all tunables admin setting a real OID |
intended |
| GHSA-fq94 patched GUI text fields | different sinks; this field was not named |
No unauth. No live sysctl shell. No Diagnostics: Command. Snapshot activate stays on its own ACL. configd.socket 0666 is public history.
The fix
Strip CR/LF and " from tunables name and value, same class as GHSA-fq94. Type the name as an OID mask, not a bare TextField. Quote with a helper that cannot close the assignment. Do not write a tunable into /boot/loader.conf unless it is a known loader key.
Replay:
cd lab
./run.shCompose project opnsense-tunables-loader. Witness OPNSENSE-TUNABLES-LOADER-WITNESS.
References
- Pack: github.com/abraxas/opnsense-tunables-loader
- Product: github.com/opnsense/core
a842fe48c21288d11a422cb944678e8eb308becf system.incsystem_login_configureTunables.xml- Sibling (other GUI text fields, not this sink): GHSA-fq94
- CWE-93
Abraxas Labs
https://abraxaslabs.tech
https://github.com/abraxas
https://x.com/abraxas_null (@abraxas_null)
abraxas.null@proton.me