Diffing Zyxel GS1900 Firmware to Confirm Patch 3 Actually Closes the Root RCE

Diffing Zyxel GS1900 Firmware to Confirm Patch 3 Actually Closes the Root RCE

pr0h0••23 min read•hacking
AI Usage (79%)
On this page

What this Zyxel GS1900 firmware diff needs to settle

This walkthrough shows how to diff two Zyxel GS1900 firmware images to confirm whether the follow-up patch actually closes the root command execution path, and how to verify the result on a lab switch instead of trusting the advisory. The narrow question is not “is the Zyxel GS1900 vulnerable.” Three outlets reported attacks, and the flaw behind those attacks was described as separate from the one everyone thought had been fixed. The question I actually care about is smaller and harder: after the follow-up patch, is the path from an HTTP request to a root shell gone, or did the vendor filter the exact string from the incident write-up while the sink stayed reachable through a sibling handler?

You cannot answer that from an advisory. Advisories describe symptoms and affected versions. They almost never say “the system() call in cfg_handler is now unreachable from any authenticated request.” A firmware diff says that, or it says the opposite, and it says it in a form you can re-run.

So this is a walkthrough of the diff I would run against two GS1900 images — the pre-patch build and the one the vendor shipped afterward — and what each of the two plausible outcomes actually means. Where I have not run something on real GS1900 hardware, I say so. The commands are real and runnable; transcripts are labelled as illustrative where I could not produce them from the actual images.

What the Zyxel GS1900 reporting actually establishes — and what it does not

The root RCE claim under test

The reports, all dated 2026-11-05, line up on the same story: attacks against Zyxel GS1900 switches that had been attributed to an already-patched flaw were in fact enabled by a separate defect, and that defect yields root command execution. The vendor then shipped a further patch for affected models. The Hacker News headline frames it as the patch failing to block a zero-day exploit that allows root command execution; Infosecurity frames it as the patch missing the flaw behind the attacks; SecurityWeek frames it as the attacks being blamed on a patched flaw when a zero-day was the real cause.

That is a coherent and specific story. It is also, in the material I was given, three headlines and a summary. I have not read the full articles, and I am not going to reconstruct their details from memory.

What the story implies technically is this: there are at least two defects, not one. Defect A got patched. Attacks continued. Defect B is what actually let an attacker run commands as root, and it survived the first fix. That is the claim a diff can settle — because if defect B reached the same sink through a different caller, the first fix would have changed a caller and left the sink intact.

What the public summaries leave unresolved

The gap between “a patch was issued” and “you can verify the patch” is where defenders get hurt. Missing from the coverage I have:

  • A CVE identifier mapped to the second defect. None appeared in the material I was given. It may exist; I did not see it, and I am not going to guess one.
  • The fixed build string per model. Without that, “did Patch 3 land” is unanswerable, because the filename you downloaded is not the build you are running.
  • Root cause detail: which binary, which CGI endpoint, which parameter, which sink.
  • Any statement of whether the second patch removes the shell invocation or filters input before it.

The first three come from the vendor’s own advisory, which is the only source that can tell you which model maps to which fixed build. The fourth comes from a diff, and only from a diff.

Confirmed claims versus open questions

ClaimWhere it comes fromHow I am treating it
GS1900 switches were attackedSecurityWeek, The Hacker News, Infosecurity (2026-11-05)Reported by three outlets; not independently verified by me
The attacks had been attributed to an already-patched flawSame threeReported
The real defect is separate and yields root command executionSame threeReported; consistent with the class of bug
A further patch was issued for affected modelsSame threeReported
“10 models” are affectedtech-insider.org onlyUnconfirmed — single lower-tier source
The fix is called “Patch 3”tech-insider.org onlyUnconfirmed — an ordinal, not a build string
CVE identifier for the second defectNot in the material I was givenUnknown
Fixed build string per modelNot in the material I was givenUnknown — vendor advisory only
Which binary/handler/sink is involvedNot in the material I was givenUnknown — this is what the diff answers

The “10 models” and “Patch 3” numbers are the kind of detail that gets repeated into permanence because it reads precise. Precision is not accuracy. Treat the model count as an upper bound until the vendor advisory confirms it, and treat “Patch 3” as a journalist’s label rather than a version you can compare against show version.

Why “the patch didn’t patch” keeps happening on embedded switches

I have taken apart enough consumer and SMB network gear to have a default hypothesis, and it is not “clever attacker bypass.” The default hypothesis is that the patch fixed the thing that was reported, and the thing that was reported was one route into a shared sink.

Symptom-level fixes leave the sink open

The report an incident responder files says something like: request X with parameter Y reaches a shell. The fastest fix that makes the test case pass is to reject that parameter, or that endpoint, or that specific metacharacter. It closes the reported instance. It does not close the sink, because the sink was never the parameter — the sink is the place where a string built from request data gets handed to /bin/sh.

Filtering at the entry point feels like defense in depth. On these devices it is usually just the first of several entry points, and the developer who wrote the fix knew about exactly one of them.

Shared admin plane creates sibling paths

Embedded switch firmware is rarely a clean layered application. The web UI is typically a small C or Go HTTP daemon with a CGI directory of handlers, plus shell scripts in /etc that do the actual config work, plus a CLI daemon that writes to the same config store. Several handlers independently reach the same helper that shells out. Fix set_dns.cgi and set_ntp.cgi still calls the same do_system() wrapper with a string it built from the POST body.

This is why a “bypass” so often looks trivial in retrospect: it is not a bypass at all. It is the same bug, at a different door, that was never closed.

No sink regression test in the release

Here is the part I hold vendors responsible for. Almost no embedded firmware release ships a regression test that asserts a sink is unreachable. “Patched” in practice means “recompiled with a changed string and QA clicked through the web UI once.”

A test that would have caught the second defect is not exotic: take every handler that can write into the config, feed each one a canary payload, and assert that no child process is spawned with that payload in its argv. That test is maybe eighty lines. It exists for web applications. It does not exist for switch firmware, and the result is a patch cycle you have to verify yourself.

Building a lab where this diff is safe to run

Scope rules

⚠️

Only diff firmware you obtained legitimately from the vendor, and only run dynamic tests against hardware you own or are explicitly authorised to test. Downloading a firmware image from a third-party mirror is not authorisation, and probing a switch on someone else’s network is a criminal act in most jurisdictions regardless of intent.

Static analysis of an image on your own laptop is low risk. Anything that sends a request is not, so keep dynamic checks on a bench unit that is not connected to production, or on an isolated VLAN with no route to the management plane of anything you care about.

Tooling

What I actually use:

  • binwalk for container triage
  • unsquashfs (or sasquatch for vendor-modified squashfs) for the rootfs
  • diffoscope for the file-level diff, because it normalises a lot of noise for free
  • radare2 for quick function-level comparisons, Ghidra when I need decompilation to reason about control flow
  • a USB-TTL serial console or an isolated VLAN plus a process listing for anything dynamic

Install on Debian-ish hosts:

sudo apt install binwalk squashfs-tools diffoscope radare2
## Ghidra separately; keep it out of the analysis VM's network namespace if you like

Handling both firmware images without losing the baseline

Never overwrite the baseline. Structure it like this:

setup.sh
mkdir -p lab/gs1900/{baseline,patched,extract}
cd lab/gs1900
cp ~/firmware/GS1900-<old-build>.bin baseline/
cp ~/firmware/GS1900-<new-build>.bin  patched/
chmod a-w baseline/*.bin
sha256sum baseline/*.bin patched/*.bin | tee SHA256SUMS

Name the directories after the build string you read off the device or the release notes, not after the download date. Six weeks later, “patched” and “new” and “final” will mean nothing to you.

Extracting the GS1900 firmware

binwalk triage of the firmware container

binwalk-triage.sh
cd lab/gs1900
binwalk baseline/*.bin

The output shape you are looking for is a bootloader header, a compressed kernel, and a filesystem:

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             uImage header, ... name: "Linux Kernel"
64            0x40            LZMA compressed data
1048576       0x100000        Squashfs filesystem, little endian, version 4.0,
                               size: 12058624 bytes, compression: xz

Those offsets are from my notes on a different MIPS switch image, not from a GS1900. Treat them as a shape, not a result — your numbers will differ, and a GS1900 image may use a different container layout or a vendor-modified squashfs that stock unsquashfs refuses. The line that matters is the filesystem offset; everything above it is boot plumbing.

If unsquashfs fails on the extracted blob, that is a signal, not a dead end: try sasquatch, or check whether the filesystem is cramfs, or look at whether there is a second-stage container inside. Zyxel has used more than one packaging scheme across product lines, so do not assume the GS1900 matches whatever blog post you found.

Unpacking the rootfs and mapping the tree

unpack.sh
cd lab/gs1900
unsquashfs -d extract/baseline-rootfs -o 1048576 baseline/*.bin
unsquashfs -d extract/patched-rootfs  -o 1048576 patched/*.bin
du -sh extract/*-rootfs

Then map the tree instead of guessing at paths:

map-tree.sh
cd lab/gs1900/extract/baseline-rootfs
ls -la bin sbin usr/bin usr/sbin 2>/dev/null | head -60
ls -la etc/init.d | head -40
## find the web surface rather than assuming /www
find . -maxdepth 4 -type d ( -name 'www' -o -name 'web' -o -name 'htdocs' ) 2>/dev/null
find . -type f ( -name 'httpd' -o -name 'lighttpd' -o -name '*web*' ) -perm -u+x 2>/dev/null

Two habits pay off here. First, file everything that looks like a daemon, because on these images the “HTTP server” is often a vendor binary with an innocuous name. Second, read /etc/init.d/* and rcS before you read any binary: the init scripts tell you which daemon serves the UI, which user it runs as, and whether there is a CGI helper process.

Locating the management surface and CGI handlers

find-cgi.sh
cd lab/gs1900/extract/baseline-rootfs
## CGI handlers and anything that looks like a config setter
find . -type f -path '*cgi*' -o -type f -name '*.cgi' | head -50
## Which handlers shell out? Strings are the fastest first cut.
grep -rlE 'system(|popen(' --include='*' . 2>/dev/null | head
for f in $(find . -type f -perm -u+x -size +20k | head -40); do
if strings -a "$f" | grep -qE '^(/bin/(sh|ash)|system|popen)'; then echo "$f"; fi
done

grep over binaries mostly finds the string system in help text and error messages, so the strings pass is the discriminating one: a binary that contains /bin/sh as a literal is a binary that probably invokes a shell. That shortlist plus the CGI directory is where the rest of this walkthrough lives.

Diffing the pre-patch GS1900 firmware against Patch 3

File-level triage with diffoscope

diffoscope.sh
cd lab/gs1900
diffoscope --no-progress --text diff-filelevel.txt extract/baseline-rootfs extract/patched-rootfs
grep -E '^[+-]{3} ' diff-filelevel.txt | head -80

The first real signal is the changed-file list. On a firmware update, the list is normally wide and shallow: a version banner, a build timestamp, a couple of rebuilt shared objects, and one or two binaries with genuine logic changes. The genuinely changed binaries are what you read first.

Filtering build noise out of the firmware diff

Every firmware diff looks enormous because of embedded build metadata. Cut it down:

diff-narrow.sh
cd lab/gs1900
## compare file inventories first
( cd extract/baseline-rootfs && find . -type f -printf '%p	%s
' | sort ) > base.list
( cd extract/patched-rootfs  && find . -type f -printf '%p	%s
' | sort ) > new.list
diff base.list new.list | head -60

## then hash-match the identical ones and only diff the rest
comm -12 <(cut -f1 base.list) <(cut -f1 new.list) > common.paths
while read -r p; do
a="extract/baseline-rootfs/$p"; b="extract/patched-rootfs/$p"
cmp -s "$a" "$b" || echo "CHANGED $p"
done < common.paths

cmp is blunt and fast and it does not care about timestamps. What survives is the actual list of changed bytes. Expect that list to still include rebuilt-but-identical binaries, which you can eliminate by comparing stripped sections or by checking whether the diff is confined to .comment, .note.gnu.build-id, or a version string.

Symbol and string deltas

string-delta.sh
cd lab/gs1900
for p in usr/sbin/httpd usr/bin/cfgmgr; do   # use your actual paths
a="extract/baseline-rootfs/$p"; b="extract/patched-rootfs/$p"
strings -a -n 6 "$a" | sort -u > "/tmp/$(basename $p).old"
strings -a -n 6 "$b" | sort -u > "/tmp/$(basename $p).new"
echo "== $p"; diff "/tmp/$(basename $p).old" "/tmp/$(basename $p).new"
done

What I look for in the delta, in priority order:

  • New error strings. A new “invalid parameter” or “not permitted” message next to a config setter is the sound of a filter being added.
  • Removed format strings. A vanished "%s"-shaped format that used to build a shell command is the sound of a concatenation being removed.
  • New helper names in the symbol table (if the binary is not stripped) like validate_, sanitize_, is_safe_.
  • Changed paths: a handler that used to call do_system() and now calls something else.

New strings and new symbols are suggestive. They are not proof. Proof is the call graph.

The firmware diff outcomes that matter

Everything in that delta collapses into one of three findings:

Finding in the diffWhat it probably meansConfidence
The sink itself changed — system()/popen() call sites removed, replaced with execve + argv, or the shell string construction deletedA real fix at the dangerous layerHigh, if all call sites changed
Only a caller changed — a new validation branch added in one handler ahead of the same unchanged sinkA probable bypass; siblings likely still reach the sinkHigh, if the sink is byte-identical
Sink unchanged, no new validation anywhereThe patch addressed something else entirelyMedium — verify against the advisory

In my experience with this class of device, the second row is the common outcome and the first row is rare. That is an opinion, not a finding about Zyxel specifically — I have not diffed these two GS1900 builds.

Tracing how a request reaches root command execution

The HTTP request-to-root data flow

The path is boringly consistent across vendors:

  1. An HTTP request hits the UI daemon, authenticated as admin.
  2. The daemon dispatches to a CGI handler or a route in the same process.
  3. The handler reads a parameter out of the POST body or query string.
  4. The handler writes it into the config store — often by invoking a helper with -k key -v value.
  5. The helper, or the handler, builds a string and hands it to a shell.

Step 5 is the only step that matters. Steps 1–4 are how you get there.

The sinks that matter

SinkTypical useWhy it is dangerous
system(const char *)run a config script, ping, traceroutepasses the whole string to /bin/sh -c
popen(const char *, "r")read command output for the UIsame, plus the output may be reflected
execl("/bin/sh", "sh", "-c", buf, NULL)convenience wrapperidentical exposure
execve("/bin/sh", argv, env) with a request-derived argv elementshell script with argumentsafe only if argv is not re-parsed by the script
vendor do_system() wrapperabstraction over system()hides the shell from grep, not from the attacker

The wrapper in the last row is why string-grepping system( is not sufficient on these images. Find the wrapper once, then find every caller of it.

The dangerous shell-command pattern

Here is the shape I expect to see in a pre-patch handler, written as the pattern rather than as decompiled Zyxel code:

/* handler reads "hostname" from the POST body */
char cmd[256];
snprintf(cmd, sizeof(cmd), "cfg set system.hostname '%s'", user_value);
do_system(cmd);   /* -> /bin/sh -c */

And the “fix” that keeps showing up:

if (strchr(user_value, ';') || strchr(user_value, '|') ||
    strchr(user_value, '&') || strchr(user_value, '$')) {
    return -1;   /* rejected */
}

A metacharacter blacklist. It misses backticks in some shells, it misses newlines (which terminate a command just as well as ; in sh -c), it misses the quote character that breaks out of the single-quoted context above, and it does nothing about a value that later gets echoed into a config file that a boot script sources. Filtering is a guess about where the parser boundary is; using an argument vector removes the question.

The same failure exists one layer down in shell scripts:

## /etc/scripts/set_hostname.sh
hostname "$1"
echo "HOSTNAME='$1'" >> /etc/sysconfig/network

If any caller invokes that script as set_hostname.sh <user input> via system(), the quoting is already lost before the script sees it.

Confirming the sink is actually closed with patch verification

Static check: trace every call site to the sink

Three conditions, all required:

  1. In the changed binary, no call site reaches system()/popen() with a buffer that has request data in it. Verify in a decompiler, not by symbol name — these binaries are usually stripped, so main is an address, and you trace backward from the sink.
  2. Every sibling handler that writes the same config key reaches the same validation, or does not reach the shell at all. Enumerate the siblings from the CGI directory, not from the incident report.
  3. The shell scripts invoked with arguments do not re-parse them. If a script uses eval or builds a command from $1, argument-level handling upstream is false comfort.

If condition 1 fails, stop. There is nothing to test dynamically; the fix is not in the image.

Dynamic check: watch for shell children on a lab unit

On a bench unit, on an isolated VLAN, with a serial console attached:

verify-sink.sh
# baseline process count for the web daemon
PID=$(pidof httpd || pgrep -f httpd | head -1)
echo "daemon pid: $PID"
pgrep -P "$PID" | wc -l     # children before

## canary request: authorised lab unit only, non-destructive payload
curl -sk -b cookies.txt -X POST --data "hostname=lab-canary-2718" "https://192.0.2.10/cgi-bin/set_hostname.cgi" -o /dev/null -w '%{http_code}
'

## what to look for: a short-lived shell child, or the canary in a child's argv
pgrep -P "$PID" | wc -l
ps -ef | grep -F 'lab-canary-2718' | grep -v grep

The evidence you want is negative: no extra child of the web daemon, no sh -c in ps, and no canary string in any process argv. The evidence that the bug is live is the opposite — a sh that lives for a few milliseconds and is gone before you can look, which is why you capture it from the serial console with ps running in a loop rather than trusting a single snapshot:

while :; do ps -ef | grep -E 'sh -c|canary' | grep -v grep; sleep 0.2; done

Note that “the request returned 200 and the hostname changed” tells you almost nothing. A handler can accept the value, store it verbatim, and render it back into the UI without ever reaching a shell — and equally, it can reject the ; payload while still being exploitable through a different handler you did not test.

Verification results table

Filled in for the method; the GS1900-specific cells are open because I do not have the hardware or the images in front of me.

EndpointPre-patch (expected)After follow-up patchEvidence sourceStatus
Config-write handler named in the reportshell child spawned with request dataunknownvendor advisory + diffUntested by me
Sibling handler writing the same keylikely same sink, same exposureunknowncall-graph diffUntested by me — this is the one I would check first
CLI daemon equivalent pathunknownunknownbinary diffUntested by me
Boot script re-parsing a stored valueunknownunknowngrep -n 'eval|\\$1' /etc/scripts/*Untested by me

The honest reading of that table is that the public reporting does not let you fill it in, and neither does this post. The diff does, and it takes an afternoon.

What a durable fix looks like versus a filter

Remove the shell from the path

/* no shell, no parsing, no metacharacters to blacklist */
char *argv[] = { "/usr/sbin/cfg", "set", "system.hostname", user_value, NULL };
pid_t p = fork();
if (p == 0) { execv(argv[0], argv); _exit(127); }
waitpid(p, NULL, 0);

Overwrite the buffer between fork and exec if it held anything sensitive; the argument vector is still visible in ps to a local attacker, which on a switch with a single admin role mostly does not matter, but it is a real change in exposure compared with a config-file write.

Centralize authorization and input validation at the config-write layer

The per-handler check is the structural problem. If seven CGI handlers write to the config store, there should be one function that writes to the config store, and that function should be the only path to the shell — or better, there should be no shell in it at all, just a validated key/value assignment plus a service reload triggered by a fixed, non-parameterised command.

Moving the check down also fixes the sibling problem without needing to find every sibling. That is the argument for it: you cannot forget a handler you never enumerated.

Add a regression test that fails if any handler reaches the shell with request data

On the lab unit, in CI, from a Node harness that runs against a freshly flashed build:

sink-regression.test.js
// Authorised lab unit only. Fails if a request-derived string reaches a shell.
const { execSync } = require("node:child_process");

const BASE = process.env.LAB_SWITCH;          // e.g. https://192.0.2.10
const CANARY = "hjcanary" + Date.now();

async function assertNoShellChild(path, body) {
const before = childCount();
await fetch(BASE + path, { method: "POST", body });
const during = childCount();
const inPs = execSync("ps -ef").toString().includes(CANARY);
if (during > before || inPs) {
  throw new Error("sink reachable via " + path);
}
}

function childCount() {
const pid = execSync("pidof httpd || pgrep -f httpd | head -1").toString().trim();
return Number(execSync("pgrep -P " + pid + " | wc -l").toString().trim());
}

It is not a perfect test — a shell child can spawn and exit between samples. It is enormously better than no test, and it would have caught the class of defect that the reporting describes: a second handler reaching a sink nobody re-checked.

Defensive playbook for anyone running GS1900 hardware

Check management-plane exposure first

Before patch levels, check reachability. An internet-facing switch management UI is the finding, at any patch level.

exposure-check.sh
# from an authorised vantage point only
nmap -Pn -p 80,443,8080,8443 --open <mgmt-ip-range>
## and confirm from outside your perimeter, not from inside it

Then go through the inventory question that actually decides your risk: how many GS1900 units do you have, who can route to their management VLAN, and is that VLAN reachable from the guest network or the VPN pool. On most networks I have looked at, the answer to the last one is “yes, because it was convenient in 2019.”

Verifying that Patch 3 actually landed

Do not trust the filename. Read the running build:

  • In the web UI, the firmware/version page — capture the build string, not just the model.
  • On the console, whatever the CLI exposes as version or firmware info.
  • Compare against the fixed build in the vendor advisory, per model.

Two switches in the same rack can be on different builds and the same model number. The only thing that proves the patch is on the device is the string the device reports.

Post-compromise hunting

If the second defect really did give root command execution, and the first patch did not close it, then the window between the first patch and the second is a window in which an attacker with UI access could have had root. Patching does not evict anyone. Look for:

  • Local accounts you did not create, especially in any config export
  • Config drift you did not authorise — VLANs, mirror ports, management ACLs, SNMP communities, syslog targets
  • New listeners or services enabled on the management plane
  • Logging disabled, or the log target changed
  • Unexplained reboots or config resets
  • The surrounding network: a switch is a great place to sit for SPAN/mirror traffic, and a compromised one is a pivot, not just an endpoint

A config export compared against a known-good baseline from before the incident is the highest-value artifact here. If you do not have a pre-incident baseline, you have learned something about what to keep.

Patch, verify, then hunt: order of operations

  1. Patch. Reduce the window even if you cannot verify yet.
  2. Verify the build string from the device, not the download.
  3. Verify the sink behaviour on a sample unit if you have a lab image and a bench device.
  4. Hunt. Because step 1 does not remove anything that is already resident.

Doing 4 before 1 is a choice; doing 1 and stopping is the common one, and it is the wrong one when root command execution was on the table.

What I confirmed versus what I did not test

I did not confirm, by testing, anything about Zyxel GS1900 firmware. I do not have the images or the hardware, and I did not run the diff above against them.

What I am going on:

  • The event itself — active attacks, a prior patch, a separate defect yielding root command execution, a further patch — as reported by SecurityWeek, The Hacker News, and Infosecurity on 2026-11-05. I read the headlines and summary, not the full articles.
  • The “10 models” figure and the “Patch 3” label from a single lower-tier source. Unconfirmed, and the label especially should not be used to verify an install.
  • CVE mapping, per-model fixed builds, and root cause: unknown to me. Vendor advisory for the first two, a diff for the third.

What the method above gives you that this post cannot: your own evidence. If you run the file-level diff and the sink is byte-identical while only a caller gained a validation branch, you have found a probable bypass in the build you are about to deploy, and you have found it before an attacker does. That is worth an afternoon and a bench switch.

Further Reading

Share this post

More posts

Loading comments…