Research/opnsense-tunables-loader
0-dayNo CVEHighPublic

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.conf is the unmatched sink.
  • CVE-2023-39005 configd.socket insecure perms. Public. Not this leftover.
  • GHSA-p9pr comma-separated user privileges. ACL.php now explodes priv on comma.
  • GHSA-vw8q Manual Config::save() vs user-config-readonly. Tunables save() 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

PHP
$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:

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:

Plain text
SUCCESS opnsense-tunables-loader newline-extra-line=yes clean-value-single-line=yes OPNSENSE-TUNABLES-LOADER-WITNESS

Stack 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:

Plain text
cd lab
./run.sh

Compose project opnsense-tunables-loader. Witness OPNSENSE-TUNABLES-LOADER-WITNESS.

References

Abraxas Labs https://abraxaslabs.tech https://github.com/abraxas https://x.com/abraxas_null (@abraxas_null) abraxas.null@proton.me