The Firewall API Call That Factory-Resets Your Router

It was supposed to be a five-line automation. Read a value, tweak it, write it back. The kind of thing you knock out between coffees.

I shelled into a client’s pfSense box — a clean, current 24.11 install — and ran my little script through pfSsh.php. It exited without a peep. No errors. No warnings. Just a fresh prompt blinking back at me.

Then the SSH session died.

Then the VPN dropped.

Then my phone lit up.

The investigation

I got back in through the console. The dashboard loaded, and my stomach went cold. No WAN config. No LAN rules. No NAT, no VLANs, no DHCP scopes. The box was sitting there with the bright-eyed innocence of a router that had never met a network.

The config wasn’t corrupted. It was gone. Replaced with defaults, like someone had hit the reset button — except nobody had touched the hardware.

I pulled up my script. Five lines. The offending logic looked like ancient, trustworthy pfSense scripting lore:

global $config;
$config['system']['something'] = 'newvalue';
write_config("scripted tweak");

Every old forum thread, every crusty wiki page from a decade ago, does exactly this. Grab the $config global, poke a key, call write_config(). It’s the canonical move.

So why did it nuke the box?

I added one debug line before the write:

global $config;
var_dump($config);

The output told the whole story in one ugly word: empty.

The “aha”

On modern pfSense, that legacy $config global is no longer the living, fully-populated config tree the old guides assume. In the pfSsh.php scripting context it came back essentially empty — a hollow shell.

And write_config() doesn’t merge. It doesn’t patch. It takes whatever is in memory right now and serializes that as the new, authoritative config. The whole thing.

So my script read an empty global, set one key on top of nothing, and then confidently wrote that “nothing plus one key” back as the complete configuration. write_config() did exactly what it was told. It overwrote a fully working firewall with a near-empty husk.

┌──────────────────────────────────────────────────────────┐
│  WHAT I THOUGHT HAPPENED          WHAT ACTUALLY HAPPENED  │
├──────────────────────────────────────────────────────────┤
│  $config = { full tree }     ►    $config = { } (EMPTY)   │
│        │                                  │               │
│        ▼ set one key                      ▼ set one key   │
│  { full tree + key }              { almost nothing }      │
│        │                                  │               │
│        ▼ write_config()                   ▼ write_config()│
│   ✓ saved correctly               ✗ overwrote everything  │
└──────────────────────────────────────────────────────────┘

The modern config backend wants you to read and write specific pathsconfig_get_path() and config_set_path() — never the whole global at once.

read & write ONE key — not the whole treeSAFE — path APIconfig_get_path(k)config_set_path(k,v)► touches only k ✓FOOTGUN — globalglobal $config; // emptywrite_config()✗ overwrites ALL of itone call, two very different blast radii
Scoped path writes touch one key. A global write_config() rewrites the entire config from whatever's in memory.

The fix

First, recover. Restore the last known-good config from a backup and reload:

# Restore from a saved backup and reload
config_restore /cf/conf/backup/config-<latest-good>.xml
/etc/rc.reload_all

pfSense keeps automatic backups under /cf/conf/backup/ — list them with ls -lt /cf/conf/backup/ and pick the newest one from before your script ran. That brought the firewall back to life.

Then, fix the script. Use the path API and stop touching the global entirely:

// Read a specific key (with a default), modify, write that key back.
$value = config_get_path('system/something', 'default');
config_set_path('system/something', 'newvalue');
write_config("scripted tweak via path API");

And — non-negotiable — snapshot the config before any scripted write, so recovery is one command instead of a panic:

cp /cf/config.xml "/cf/conf/backup/config-$(date +%Y%m%d-%H%M%S)-prewrite.xml"

Why it happened

Platforms migrate their internals quietly. pfSense moved to a path-based config backend, and the old global $config got demoted from “the config” to “a vestigial variable that may or may not be populated, depending on context.”

The docs got updated. The thousand forum posts from 2014 did not.

So the trap is perfect: the legacy pattern still compiles, still runs, still exits clean. It just operates on a ghost of the real config — and write_config() is happy to make that ghost permanent.

A function that overwrites everything from in-memory state is a loaded gun. When the thing loading that memory changes underneath you, the gun is now pointed at your whole config and you don’t even know it.

Takeaways

  • A global write_config() rewrites the entire config from in-memory state. If that state is stale, partial, or empty, you’ve just factory-reset your box. Treat it as a full-config replace, never a patch.
  • When a platform migrates its config backend, the old global is a footgun. It still runs clean — that’s what makes it dangerous. Verify what it actually contains before trusting it.
  • Use the scoped API. config_get_path() / config_set_path() touch exactly one key. That’s the blast radius you want.
  • Snapshot before any scripted write. cp /cf/config.xml /cf/conf/backup/...-prewrite.xml turns a disaster into a 30-second restore.
  • var_dump() your assumptions. One debug line showing an empty global would’ve saved the whole incident. When in doubt, print what you’ve actually got — not what the old guides promise you have.