Introduction

Earlier this year, NetSPI discovered a series of flaws in the pfBlockerNG package. pfBlockerNG is used by pfSense firewalls to expand its preventative controls and provides DNS-based blocking and IP-based firewall rules fed by threat intelligence lists. It is one of the most widely installed pfSense packages.

The package supports an Unbound Python mode, which is the recommended setup for regex blocking, TLD allow lists, and appliances configured with large blocklists and limited RAM. In this mode, a Python module hooks into the Unbound resolver and intercepts queries directly.

One feature of this mode is DNS Reply Logging, which means that pfBlockerNG records the resolved addresses that come back from upstream servers and displays them in the Reports page of the pfSense web interface (webConfigurator).

TL;DR

  • NetSPI recently reported a high-risk vulnerability to Netgate that affects the pfBlockerNG package on pfSense. This chain allows for an attacker who is able to make a DNS request from inside a network where pfBlockerNG is processing DNS requests, and has DNS reply logging configured, to store JavaScript on a number of pages on the pfSense web management portal in a stored Cross-Site Scripting (XSS) attack.
  • If an administrative user views these pages, this can result in the admin’s browser retrieving additional JavaScript from an attacker controlled server which in turn results in Remote Code Execution (RCE) on the underlying appliance – providing a remote attacker with root access on a privileged edge device within the target environment.
  • Certain conditions are required in order for the package and pfSense to be vulnerable, but analysis by NetSPI indicated that the conditions are more likely to be configured by users when memory availability is more limited, such as in some of the smaller commercial appliances where RAM is at a premium and a more performant configuration is required to use more of the appliance features.
  • Once Netgate was informed, they updated the package and provided a fix to customers within hours of receiving details, despite the package being largely maintained by an external 3rd party.

The Vulnerability

The first flaw existed in pfb_unbound.py. When pfBlockerNG processes a DNS reply, the function convert_other() in pfb_unbound.py converts raw RDATA bytes into a string. Bytes in the printable ASCII range (34-126) are passed through as literal characters.

That includes < , > , " , ' , and = :

# Lines 607-636
def convert_other(x):
    global pfb
    final = ''
    if x:
        for i in x[3:]:
            if pfb['py_v3']:
                val = i
            else:
                val = ord(i)
            if val == 0:
                i = '|'
            elif 1 <= val <= 12:
                i = '.'
            elif val == 13:
                break
            elif val == 32:
                i = ' '
            elif val == 58:
                i = ':'
            elif val <= 33 or val > 126:
                continue
            else:
                if pfb['py_v3']:
                    i = chr(i)
            final += i
        final = final.strip('.|')
    return is_unknown(final)

The resulting r_addr string is written to CSV with no escaping:

# Line 990
csv_line = ','.join('{}'.format(v) for v in ('DNS-reply', timestamp, m_type, o_type, q_type, ttl, q_name, q_ip, r_addr, iso_code))
log_entry(csv_line, '/var/log/pfblockerng/dns_reply.log')
log_entry(csv_line, '/var/log/pfblockerng/unified.log')

The attacker payload can be rendered in two places of interest due to flaws that were present in pfblockerng_alerts.php, first under the Reports the DNS Reply tab. convert_dns_reply_log() reads this CSV with fgetcsv() and renders the resolved address field ($fields[8]) straight into HTML without calling htmlspecialchars() . The value ends up in two places: the visible cell content and a title attribute:

# Lines 2640-2643
if (strlen($fields[8]) >= 17) {
  $pfb_title8 = $fields[8];
  $fields[8] = substr($fields[8], 0, 16) . "<small>...</small>";
}
# Line 2655: both contexts are injectable
  <td title=\"{$pfb_title8}\">{$fields[8]}</td>

A " character in the payload breaks out of the title="..." attribute, and from there an attacker can inject arbitrary HTML elements.

The DNS Reply tab isn’t an ideal sink for an attacker as it only shows by default the previous 200 results, meaning unless an admin user clicks through pages of the table until they hit a record with the payload stored, it will never execute.

However, the aggregated Stats page is far more promising and much more likely to be viewed by an admin user.

This is a separate code path that never calls convert_dns_reply_log(), so the escaping applied there does not protect it. For each statistic the page shells out over the full reply log ( $alert_log = $pfb['dnsreplylog'], i.e. the same dns_reply.log ) and de-duplicates:

# Line 1889
exec("{$cut_cmd} {$alert_log} | {$agent_cmd} {$su_cmd} | sort -nr 2>&1", $stats);
	break;

Each aggregated value is stored as an array key:

# Line 1903
$alert_stats[$alert_view][$stat_type][$data[1] ?: $unknown_msg] = $data[0] ?: 0;

$data[1] is the raw resolved value / domain (r_addr / q_name) taken straight from the log.

It’s then rendered with no escaping in two consumers:

  • The Stats Table (~L4653–4728):
    the visible cell {$data}, the filter-button value="{$filter_value}" / title="...[ {$data} ]", and a threat-lookup href
  • The Pie Chart labels in pie_block() (~L4862):
    print('"label": "' . $k[$i] . '"...')

Because the aggregation reads the whole file and collapses duplicates with uniq -c, one poisoned reply persists here for the lifetime of the log entry, independent of the 200-row cap on the log table.

The Attack

To exploit these flaws and gain access, an attacker needs to:

  1. Set up a DNS server that returns a crafted TXT record with the XSS payload in its RDATA.
  2. Force a LAN client to perform a DNS lookup to an attacker-controlled domain so that the query is forwarded through the pfSense resolver.
  3. pfBlockerNG logs the reply into dns_reply.log.
  4. An administrator opens the Reports page and views the Stats tab. The stored XSS fires and loads an external JavaScript file.
  5. That JavaScript fetches /diag_command.php (pfSense’s built-in command execution page), scrapes a CSRF token from the response, then POSTs a shell command back using the stolen token. The command downloads and runs a reverse shell script.
  6. A root shell connects back to the attacker’s listener.

Whilst a full Proof-of-Concept for the DNS server and payloads exists, these are left as an exercise for the reader.

The attack demonstration can be seen in the GIF below:

Exploitation of this vulnerability would grant an attacker a privileged location in the network for which to launch further attacks or perform further actions such as:

  • Read the full firewall configuration, stored credentials, and any network traffic passing through the device.
  • Modify firewall rules, DNS resolution, and routing – opening the door to man-in-the-middle attacks across the entire network.
  • Use the firewall as a pivot point into the internal network, since it typically sits between trusted and untrusted segments.

Closing

Netgate fixed this issue and pushed an updated package for customers within 24 hours of the initial report.

Primarily, this was achieved by adding a new function to pfb_unbound.py:

+ def safe(v):
  # pfBlockerNG XSS/CSV hardening: drop commas (CSV field split),
  # control chars (CR truncation / log injection) and non-ASCII bytes.
+    v = str(v)[:253]
+    return ''.join(c for c in v if 0x20 <= ord(c) < 0x7f and c != ',')

Which in turn prevents q_name and r_addr from storing unsafe content in the associated log files:

@@ -987,7 +994,7 @@ def get_details_reply(m_type, qinfo, qstate, rep, kwargs):
             continue
         break
-    csv_line = ','.join('{}'.format(v) for v in ('DNS-reply', timestamp, m_type, o_type, q_type, ttl, q_name, q_ip, r_addr, iso_code))
+    csv_line = ','.join('{}'.format(v) for v in ('DNS-reply', timestamp, m_type, o_type, q_type, ttl, safe(q_name), q_ip, safe(r_addr), iso_code))
     log_entry(csv_line, '/var/log/pfblockerng/dns_reply.log')
     log_entry(csv_line, '/var/log/pfblockerng/unified.log')

This fix was tested by NetSPI at time of release and confirmed valid.

Disclosure Timeline

  • 2026-07-01 – Bug report submitted
  • 2026-07-02 – Bug confirmed and fix (v. 3.2.16_1) pushed to prod by Netgate
  • 2026-09-09 – Mitre Issued CVE
  • 2026-09-22 – Blog post published