The Firewall Fetches Its Own Exploit: OPNsense’s GeoIP Importer Takes a Filename From the Feed

NVD published five OPNsense records in one second on 15 October 2026 — CVE-2026-57155 at 16:18:17.300Z and CVE-2026-58391, CVE-2026-58392, CVE-2026-58394 and CVE-2026-58395 at 16:18:18Z — all sourced to security-advisories@github.com, all vulnStatus Received, and all saying the same thing about versions: prior to 26.1.11. That release was tagged by Franco Fichtner on 1 July 2026 at 08:57:38Z with the commit message “stable release”. The code fix is older still: 23 June. So this is not a new hole in an edge firewall; it is a hole that was closed one hundred and six days before any machine-readable record of it existed.

The headline record is worth the wait. CVE-2026-57155 carries CVSS 3.1 9.9 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H) under CWE-22, CWE-73 and CWE-78, and CISA’s SSVC decision point recorded the same day reads exploitation: poc, automatable: no, technicalImpact: total. It describes a defensive appliance that downloads a threat-intelligence feed from an operator-named URL, unpacks it as root, and lets a column inside that feed choose the filename it writes.

  • CVE-2026-57155 (9.9, CWE-22/73/78, GHSA-wjqq-rfmm-v5h3). The Firewall Alias GeoIP importer writes one file per country and names it from the unvalidated country_code column of the downloaded CSV. A country code of ../../../../../etc/newsyslog.conf.d/zzz_pwn escapes /usr/local/share/GeoIP/alias and writes attacker-chosen content as root. Reachable by a non-administrative user holding only the Firewall: Alias: Edit privilege.
  • CVE-2026-58395 (4.3). The generic MVC safe-delete routine interpolates the URL delete token straight into an XPath query, turning the shared endpoint into a read oracle over config.xml via HTTP 500 error text. The reporter counts twenty-one endpoints reached through that one routine.
  • CVE-2026-58391 (5.4), CVE-2026-58392 (5.4), CVE-2026-58394 (5.2). Three stored XSS: category names and alias descriptions rendered into a title="..." attribute escaped with ENT_NOQUOTES; the NTP GPS initcmd escaped in its base64 form and then decoded back onto the page; certificate descriptions printed into an <option> tag with no escaping at all.

The feed is the input

The GeoIP alias feature exists so an operator can write a rule meaning “all of country X”. OPNsense periodically fetches a country-IP database and expands it into alias files. The importer runs as root because it writes into a system directory, and it fetches from whatever URL is configured because the whole point is that you choose your GeoIP provider. The advisory states the consequence of that combination plainly: “The GeoIP source URL field has no host restriction, so the attacker can simply point it at a server they control.”

Two functions unpack the archive, process_zip() and process_gzip(), and the advisory notes they share the identical flaw. The write sink, quoted from the advisory’s own annotation of src/opnsense/scripts/filter/lib/alias/geoip.py:

_target_dir = '/usr/local/share/GeoIP/alias'                 # line 46
...
country_code = country_codes[parts[1]]                       # line 88
if country_code not in output_handles:
    output_handles[country_code] = open(
        '%s/%s-%s' % (cls._target_dir, country_code, proto), 'w')   # line 90-91 - WRITE SINK
...
output_handles[country_code].write("%s\n" % parts[0])        # line 94 - file content

Both halves of the written file come from the archive. The path comes from the country_iso_code column of the locations CSV; the content comes from the network column of the blocks CSV, joined on geoname_id. The importer believes it is writing network ranges into country-named files. It is in fact writing caller-supplied bytes into a caller-supplied path, as root.

The suffix that shaped the exploit

One structural constraint survives the traversal: every filename ends in -IPv4 or -IPv6, because the importer appends the protocol. Normally a forced suffix is what keeps an arbitrary-write primitive from becoming code execution — you cannot drop a .php shell or a crontab line whose name must end in something else. The reported chain is a neat answer to that constraint rather than a way around it: FreeBSD’s newsyslog reads every file matching /etc/newsyslog.conf.d/*, so the suffix does not disqualify the fragment, and the default OPNsense image runs it hourly as root:

# Rotate log files every hour, if necessary.
0       *       *       *       *       root    newsyslog

So the exploit writes two files instead of one: a newsyslog entry with an R (run-command) action, and an oversized bait log file so the size-based rotation trigger fires on the next run. On the hour, root’s cron rotates the bait and executes the command. Three authenticated API calls get there — POST /api/firewall/alias/set to store the attacker URL, POST /api/firewall/alias/reconfigure to render it into /usr/local/etc/filter_geoip.conf, and POST /api/firewall/alias/update/geoip to trigger the configd update.geoip action that runs geoip.py as root. The advisory summarises the privilege arithmetic: “This escalates an alias-only account to full root control of the firewall.” We did not reproduce any of this; the researcher states reproduction on 26.1.6_2 and 26.1.10.

That is the part worth sitting with. Firewall: Alias: Edit is the privilege you hand to the person who maintains address lists. It is the textbook safe delegation — no rules, no interfaces, no shell. The PR:L in the vector is doing almost no work, because the privilege it describes is one almost everyone gives away.

Two lines, and what they say about the original

The fix is commit c46aced9 by Ad Schellevis, 23 June 2026 at 14:53:50Z, cherry-picked to the stable branch as e7fbfaa7 the same second, titled “Firewall: Aliases - safeguard ISO country codes in alias download” with the body “PR: GHSA-wjqq-rfmm-v5h3”. It is two lines changed in one file:

-                            if len(parts[4]) >= 1:
+                            if len(parts[4]) == 2 and parts[4].isalnum():

-                    else:
+                    elif len(record[country_field]) == 2 and record[country_field].isalnum():

Read the left-hand side carefully. The zip path did have a check on the country code — it checked that the string was not empty. The gzip path had no check at all; the fixed line replaces a bare else. A length-at-least-one test is the shape of validation written to guard against a malformed feed, not a hostile one: it answers “did this row parse?” and never “should this value be allowed to pick a path?”. The replacement is the correct question asked in the only form the data supports — an ISO country code is exactly two alphanumeric characters, and nothing else is a country. This is the same lesson the researcher draws from the three XSS findings in the same batch, in their words: “validate and escape as close to the sink as possible, because every extra step in between is a chance for the protection to quietly fall off.”

We have covered this shape repeatedly in the last fortnight from the agent side — eighteen CVEs where a file name reached a shell, a container name interpolated into a root command, an image annotation that decided how the sandbox was built. OPNsense is the infrastructure version of the same error, and it predates all of them: a value inside the data chose where the privileged process wrote. The only thing that makes a firewall feed special is that ingesting untrusted third-party data is the feature, which is exactly why the parser deserved hostile-input treatment from the first commit.

The record is thinner than the advisory

Two gaps are worth flagging for anyone driving remediation from tooling rather than from this page.

The affected range in the CVE is narrower than the vendor’s. The GHSA lists affected versions as <= 26.1.10, <= 26.4.1 and patched versions as 26.1.11, 26.4.1p1 — two product lines. The NVD record’s affected data carries only opnsense core < 26.1.11, and every one of the five descriptions says “Prior to version 26.1.11”. A scanner matching on the CVE’s version data has no expression for the second line at all. Check your build against the advisory, not the CVE.

There is no OSV or global-advisory record. At time of writing, GET https://api.github.com/advisories/GHSA-wjqq-rfmm-v5h3 returns 404, and api.osv.dev returns 404 for both the GHSA and the CVE identifier. The advisory page itself states “No package listed” under Package, and that is the likely mechanism: an advisory with no package-ecosystem coordinate has nothing for the global database to index against, so it never propagates to the feeds that osv-scanner, Dependabot and most SCA products consume. This is the inverse of the problem we described for Renovate, where the advisories existed but the fix shipped as an unlabelled changelog line. Here the advisory is detailed, public since 1 July, scored Critical by the vendor — and machine-invisible. Appliance software consistently falls outside package-ecosystem tooling, and a published GHSA is not the same thing as a record your scanners will ever see.

What to do

  • Confirm the build, not the marketing version. Community edition needs 26.1.11 or later; the business line needs 26.4.1p1 or later per the vendor advisory. Current community tags are well past this (the 26.7.x series), so most actively updated firewalls are already covered — the exposed population is the appliance nobody has touched since spring, which on an edge firewall is a common state.
  • Treat Firewall: Alias: Edit as a privileged grant until you are patched. It is the only credential the chain needs. Enumerate who holds it, including service accounts and any role that inherited it from a group.
  • Audit the configured GeoIP source URL. It lives in config.xml and is rendered into /usr/local/etc/filter_geoip.conf. A URL pointing anywhere other than your intended provider is the first stage of this chain and is cheap to check. Pin it, and keep it pinned after upgrading.
  • If you were running an affected build with the privilege delegated, look for artefacts rather than alerts. The chain produces files, not crashes: unexpected entries under /etc/newsyslog.conf.d/ (particularly names ending -IPv4 or -IPv6), unexplained oversized files in /var/log, and any file outside /usr/local/share/GeoIP/alias carrying those suffixes. An import that succeeded looks like a successful import in the logs.
  • Generalise past this file. Every place your security stack ingests a third-party feed — blocklists, GeoIP, threat intel, rule bundles — is a parser running with elevated privilege over data you do not control. Ask of each one which fields influence a path, a command, or a configuration, and whether the validation on them is a hostile-input check or a malformed-input check.

Verification note. All five CVE identifiers, publication timestamps, CVSS 3.1 scores and vectors, CWE assignments, affected-version data, vulnStatus values, source identifiers, and the CISA SSVC decision points were read first-hand from the NVD 2.0 API. The advisory summary, affected and patched version lists, root-cause annotation of geoip.py, the three-request chain, the newsyslog escalation, the impact statement, severity and credit were read first-hand from GHSA-wjqq-rfmm-v5h3 on the opnsense/core repository; every sentence in quotation marks is verbatim from that advisory or from the reporter’s write-up. Commits c46aced9c47d956167e294911113bc334fea5f48 and e7fbfaa7f5e070da667e3f243da11fd3d4b731fa, their author, timestamp, message and complete two-line diff, plus the 26.1.11 tag object, tagger and date, were read first-hand from the GitHub API. The researcher’s methodology, the five-finding table and the XSS analysis were read first-hand from the linked write-up. The GitHub global-advisory and OSV 404 responses were measured at time of writing. Brave Search returned only CVE-mirror aggregators and automated scoring pages; the reporter’s write-up is the only first-hand analysis. Nothing was installed, no OPNsense instance was deployed or contacted, no archive was crafted, no proof-of-concept was downloaded or executed, and no third-party request was sent. Remediation guidance is our editorial analysis. We have no evidence of exploitation in the wild and imply none.

Sources: