Skip to content

CVE-2026-11851: ASUS Router bwdpi SQL Injection Patch Bypass

TL;DR

  • I began reviewing CVE-2025-59370 (command injection in bwdpi) via patch diffing of October 2025 firmware.
  • Although system() logging sinks were removed, tracing the data flow revealed SQL query results previously fed those sinks.
  • Investigating CVE-2025-59369 showed sqlite_Stat_hook() still concatenated attacker-controlled input into SQL predicates after the supposed fix.
  • I reported the patch bypass to ASUS, and it is now tracked as CVE-2026-11851.
  • ASUS introduced strict allowlisting (is_safe_app_name() / MAC-only), finally preventing attacker-controlled SQL syntax from reaching query assembly.

Summary

CVE-2026-11851 is an authenticated SQL injection in the web management interface of certain ASUS routers. A crafted request can bypass existing input validation and disclose confidential information.

  • CVE: CVE-2026-11851
  • Product: ASUS Router Firmware (web management interface / bwdpi)
  • Vulnerability: SQL Injection via the bwdpi appStat client Parameter
  • Affected Versions: ASUSWRT 3.0.0.4_386, 3.0.0.4_388, and 3.0.0.6_102 series
  • Fixed In: Model-specific; update to the latest available firmware
  • CVSS Severity: 5.9 (medium)
  • CVSS Vector: CVSS:4.0/AV:N/AC:H/AT:N/PR:H/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
  • Required Privilege: Remote, Authenticated
  • Reported: December 7, 2025
  • NVD Published: July 15, 2026

Introduction

This was originally intended to be another recent CVE analysis. However, during the investigation, I found one of the vulnerabilities had not been patched sufficiently. I did my best to verify the issue (without access to a real device) and relayed my concerns to the vendor. At first, they believed the patch was sufficient, but after some discussion they agreed the vulnerability was still present. ASUS made further changes, then asked me to review the patched firmware and ensure it was adequately fixed for the next release. So, this is the story about how I earned myself a spot in the ASUS hall of fame 😎

ASUS vulnerability disclosure hall of fame entry for the report

Note: Consider this documentation of my patch diffing practice, rather than a typical CVE analysis. I'll structure it in chronological order and explain my thought process throughout, including my mistakes and failed assumptions.

CVE-2025-59370: Command Injection in bwdpi

At the end of November, I was scrolling through the NVD and one caught my eye: CVE-2025-59370. It is a command injection vulnerability in bwdpi, a proprietary daemon found in ASUS router firmware. The advisory states:

A command injection vulnerability has been identified in bwdpi. A remote, authenticated attacker could leverage this vulnerability to potentially execute arbitrary commands, leading to the device executing unintended instructions.

Since NVD had published the CVE record the day before (November 25, 2025), it was a nice target for patch diffing. I wanted to know how the vulnerability worked and how it was fixed. Following the link in the advisory, I could see that it had been patched alongside several other CVEs.

ASUS security advisory listing multiple CVEs including bwdpi issues

I figured this might complicate matters because I would need to sift through other security fixes, probably alongside non-security-related changes. I decided to map out the other CVEs at the beginning to avoid any rabbit holes. It would also give me an easy entry point if I chose to review more of these vulnerabilities in the future.

Background

I Googled "ASUS router DPI" and found an article answering the question "How does AiProtection protect my home network?" 👇

AiProtection uses Trend Micro's Deep Packet Inspection (DPI) engine to provide several security functions:

  • Router security check: scans the router's configuration for unsafe settings such as weak passwords, unencrypted Wi-Fi, or exposed remote-access services.
  • Malicious site blocking: uses Trend Micro's Web Reputation Services (WRS) to prevent clients from accessing URLs classified as malicious.
  • Vulnerability protection: inspects inbound traffic for known exploit patterns targeting IoT and LAN devices, helping block attacks even when devices are unpatched.
  • Infected device detection and blocking: identifies compromised hosts on the LAN by detecting command-and-control (C&C) traffic and blocks those connections.

ASUS describes AiProtection as constantly monitoring network activity using Trend Micro's DPI engine and reputation database. It evaluates website reputation, detects suspicious behaviour, and logs security-related events for display in features such as Web History, Traffic Analyzer, and security dashboards.

Firmware Download

According to the official advisory, the following firmware series were vulnerable:

ASUS advisory table showing affected firmware series and recommended upgrades

Users were advised to upgrade to the latest firmware. I went with the RT-AC86U model in the download centre and downloaded two builds from the 3.0.0.4_386 series.

ASUS download centre showing RT-AC86U firmware selection

Visiting the firmware download page confirmed I had found what I was looking for. Build 51967 was released on March 25, 2025, and build 52294 on October 28, 2025. I highlighted the security-related changes, which, as you can see, make up most of the changelog!

RT-AC86U firmware changelog highlighting security fixes

Next, I extracted the archives and moved the firmware into separate folders (vuln + patched).

unzip FW_RT_AC86U_300438651967.zip && unzip FW_RT-AC86U_300438652294.zip
mkdir vuln_fw patched_fw
mv RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w vuln_fw/
mv RT-AC86U_3.0.0.4_386_52294-ga575528_ubi.w patched_fw/

Patch Diffing

ASUS packages the firmware as UBI/UBIFS images. Binwalk can extract them directly with the -Me option.

cd vuln_fw
binwalk -Me RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w

cd ../patched_fw
binwalk -Me RT-AC86U_3.0.0.4_386_52294-ga575528_ubi.w

Look for files of interest 🔎

find vuln_fw -maxdepth 7 -type f -name "*bwdpi*"

vuln_fw/_RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/lib/libbwdpi_sql.so
vuln_fw/_RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/lib/libbwdpi.so
vuln_fw/_RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/sbin/bwdpi_sqlite
vuln_fw/_RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/networkmap/nmp_bwdpi_type.js

We can ignore the JavaScript file. libbwdpi.so contains the DPI engine, so it is less likely to contain shell-command paths involving user input. That leaves two SQL-related files to review.

mkdir -p bwdpi_diff

cp vuln_fw/_RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/sbin/bwdpi_sqlite bwdpi_diff/bwdpi_sqlite_vuln
cp patched_fw/_RT-AC86U_3.0.0.4_386_52294-ga575528_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/sbin/bwdpi_sqlite bwdpi_diff/bwdpi_sqlite_patched

cp vuln_fw/_RT-AC86U_3.0.0.4_386_51967-g1c8687c_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/lib/libbwdpi_sql.so bwdpi_diff/libbwdpi_sql_vuln.so
cp patched_fw/_RT-AC86U_3.0.0.4_386_52294-ga575528_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/lib/libbwdpi_sql.so bwdpi_diff/libbwdpi_sql_patched.so

The ARM binaries are stripped, which makes the comparison more difficult 😤

file *

bwdpi_sqlite_patched:    ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.3, for GNU/Linux 2.6.32, stripped
bwdpi_sqlite_vuln:       ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.3, for GNU/Linux 2.6.32, stripped
libbwdpi_sql_patched.so: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, stripped
libbwdpi_sql_vuln.so:    ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, stripped

Static Analysis: libbwdpi_sql.so

Open both versions of libbwdpi_sql.so in Ghidra. When using the latest version of Ghidra, you'll probably struggle to use common extensions that assist with the binary diffing process. One fast/easy option is to use the version tracking feature.

Ghidra Version Tracking

Load both binaries into the same project and launch the "version tracking" wizard. You'll want to run the following correlators.

Ghidra version tracking correlators used for patch diffing

We can look through the functions, focusing on those which are clearly related to bwdpi functionality. The advisory was extremely vague, so let's check a random entry: bwdpi_monitor_ips()

Ghidra diff of bwdpi_monitor_ips before and after patch

As you can see, the diff highlighting (mostly non-matching variable names) is horrendous. We could probably improve it by changing the theme or UI configuration, but it has already helped us locate an interesting function. We can stay in Ghidra, or save the decompiled code and get a normal unified diff (or use Meld, like in a previous post). If you choose the latter, rename the variables first or the diff will be full of meaningless changes.

Logging Functionality

Here are the most relevant changes in the patch diff. cmd_buf is removed from the variable declarations and replaced by log_fp. Remember that the binary is stripped, so these are variable names chosen by me.

-  char cmd_buf [1024];
+  FILE *log_fp;

Three system() calls were present throughout the function. Each executed echo to write to the log (/tmp/BWMON.log), taking input via cmd_buf (result=%s). The calls were removed in the patched version and the logging replaced with standard file writes:

 rc = f_exists("/tmp/BWMON_LOG");
-          if (0 < rc) {
-            snprintf(cmd_buf,0x400,"echo \"[BWMON]cols=%d, rows=%d, result=%s\n\" >> /tmp/BWMON.log"
-                     ,sql_result_cols,sql_result_rows,*(undefined4 *)(sql_result_table + 4));
-            system(cmd_buf);
-          }
+          if ((0 < rc) && (log_fp = fopen("/tmp/BWMON.log","a"), log_fp != (FILE *)0x0)) {
+            fprintf(log_fp,"[BWMON]cols=%d, rows=%d, result=%s\n\n",
+                    sql_result_cols, sql_result_rows, *(undefined4 *)(sql_result_table + 4));
+            fclose(log_fp);
+          }

Firstly, notice there is a precondition for the vulnerable code segment to be reached: /tmp/BWMON_LOG must exist. This flag is not present by default, and enabling the logging path requires local access to create it.

Secondly, the result string written to the log is not constant. It comes from the SQLite result table returned by sqlite3_get_table() and corresponds to one of the string columns selected by the query. At this stage we only know it comes from the DPI statistics database; we'll look at how those columns are populated when we analyse bwdpi_sqlite.

With a command-injection sink identified, did we get lucky and find the vulnerable function on the first try, or is the same terrible logging pattern everywhere? Checking references to system() shows it is used in plenty of places, although some are obviously not useful to an attacker. For example, the first result uses a decimal format specifier:

Non-exploitable system call using numeric formatting

I counted 22 calls across seven functions in the vulnerable libbwdpi_sql.so: AiProtectionMonitor_result, bwdpi_maclist_db, bwdpi_appStat, bwdpi_HistoryStat, bwdpi_monitor_info, bwdpi_monitor_ips, and bwdpi_monitor_noips. The log filename varies by function.

Repeating the search on the patched libbwdpi_sql.so confirms there are no longer any system() references in the binary.

Static Analysis: bwdpi_sqlite

We've identified a sink (dangerous function), now let's look for the source (user-controlled input) and see what constraints exist. Repeat the Ghidra version-tracking process on the bwdpi_sqlite binaries. We already know the result string is read from an SQL table before being passed to system(), so the next question is how that table gets populated. A string search for INSERT INTO is a good place to start.

Ghidra string search for INSERT INTO statements in bwdpi_sqlite

Table Schemas

Before reviewing the remaining logging code, it helps to understand how the tables are structured. The schema for the monitor table looks like this:

CREATE TABLE monitor(
    timestamp UNSIGNED BIG INT,
    type      VARCHAR(2),
    mac       VARCHAR(18),
    src       VARCHAR(64),
    dst       VARCHAR(64),
    id        VARCHAR(10),
    dir       VARCHAR(2),
    cat_id    VARCHAR(4),
    severity  VARCHAR(2)
);

The corresponding INSERT format string was:

INSERT INTO monitor VALUES (
    '%llu',                             -- timestamp
    '%d',                               -- type
    '%02X:%02X:%02X:%02X:%02X:%02X',    -- mac (strict MAC format)
    '%02X:%02X:%02X:%02X:%02X:%02X',    -- src (also MAC-like, fixed '%02X' pairs)
    '%s',                               -- dst (raw string)
    '%d',                               -- id
    '', '', ''                          -- dir, cat_id, severity
);
  • mac and src are expanded from fixed-width %02X bytes, so they are not useful for injecting arbitrary strings.
  • dst is inserted through a raw %s, meaning its value comes from upstream DPI logic without an obvious format constraint here.

This makes dst the first field in the monitor pipeline that could contain attacker-influenced data, if the value can be spoofed upstream. I had not found a realistic path to do that at this point.

The traffic table has app_name and cat_name fields. If we are looking for user-controllable input, app_name looks like a plausible candidate, but we have not yet confirmed whether any validation or filtering applies.

CREATE TABLE traffic(
    mac       TEXT,
    app_name  VARCHAR(50),
    cat_name  VARCHAR(50),
    timestamp UNSIGNED BIG INT,
    tx        UNSIGNED BIG INT,
    rx        UNSIGNED BIG INT
);
INSERT INTO traffic VALUES (
    '%s',      -- mac (string, but typically fixed format)
    '%s',      -- app_name (raw string)
    '%s',      -- cat_name (raw string)
    '%llu',    -- timestamp
    '%llu',    -- tx bytes
    '%llu'     -- rx bytes
);

Finally, the history table contains a URL field, which also looks potentially attacker-controlled.

CREATE TABLE history(
    mac       TEXT,
    timestamp UNSIGNED BIG INT,
    url       TEXT
);
INSERT INTO history VALUES (
    '%s',     -- mac (string, but typically fixed format)
    '%llu',   -- timestamp
    '%s'      -- url (raw string, may have some validation)
);
Replacing system()

Diffing one of the functions containing the INSERT statements shows that system() was replaced by fopen(), just as it was in libbwdpi_sql.so.

Diff showing system echo logging replaced with fopen and fprintf

I counted another 21 calls across seven functions in the vulnerable bwdpi_sqlite:

Ghidra xref list of system call sites in bwdpi_sqlite

Remaining system() Calls

Three calls remain in the patched bwdpi_sqlite. One uses a %d format specifier, so the value cannot carry shell metacharacters.

unlink(__name);
snprintf(acStack_64,0x40,"kill %d",iVar1);
system(acStack_64);
logmessage_normal("BWDPI","[%s] Revert process (pid=%d)\n",param_1,iVar1);

The other two calls, both in FUN_00011718 (identical to FUN_0001169c in the vulnerable binary), deserved a closer look. As the last post showed, it is easy for one call site to survive a broader cleanup.

Ghidra view showing remaining system calls in the patched function

We can check where the function above is called:

snprintf(acStack_3e4,0x3bf,
         "SELECT timestamp, type, src, dst FROM monitor WHERE type=1 AND (timestamp > %ld AND timestamp < %ld) ORDER BY timestamp DESC",
         lVar12 - 0x82, lVar12);
FUN_00011718(local_4c0, acStack_3e4,"/jffs/.sys/AiProtectionMonitor/AiProtectionMonitorCCevent.txt");

The relevant part of FUN_00011718 looks like this:

snprintf(acStack_150,300,"echo \"Event Date ... Source Destination \" >> %s", param_3);
system(acStack_150);

snprintf(acStack_150,300,
         "echo \"%-20s %-40s %-20s %-s\" >> %s",
         date_buf,               // from timestamp
         event_type,             // from a static lookup
         src, dst,               // columns 2 and 3 of the query
         param_3);               // constant logfile path
system(acStack_150);

param_3 is always a fixed path under /jffs/.sys/AiProtectionMonitor/. The other string arguments are a formatted date, a constant event-type string, and the src and dst columns from the monitor table. I found no HTTP path that supplied free-form data to those columns, so neither call appeared to provide a way to inject shell metacharacters into the command string.

HTTP Functionality

It still wasn't clear how unfiltered user input could reach the sinks, so I moved to the web interface. The httpd binary handles the relevant HTTP requests.

file usr/sbin/httpd

usr/sbin/httpd: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.3, for GNU/Linux 2.6.32, stripped

We can search www/ for relevant web files.

grep -RiE 'TrafficAnalyzer|AiProtection|bwdpi_sqlite|BWMON' ./www* 2>/dev/null

<SNIP>
./www/require/modules/menuTree.js:retArray.push("AiProtection_HomeSecurity.asp");
TrafficAnalyzer_Statistic.asp
AiProtection_HomeSecurity.asp
AiProtection_HomeProtection.asp
AiProtection_WebProtector.asp
AiProtection_AdBlock.asp
AiProtection_Key_Guard.asp
AiProtection_MaliciousSitesBlocking.asp
AiProtection_InfectedDevicePreventBlock.asp
AiProtection_IntrusionPreventionSystem.asp
if(menuTree.list[i].tab[j].url == 'AiProtection_WebProtector.asp')
if(!bwdpi_bwMonitor_support)
if(item.index == "menu_TrafficAnalyzer")
<SNIP>

If we open TrafficAnalyzer_Statistic.asp in VS Code (or the entire www directory for easier searching), we'll see roughly 1,500 lines of code with seven references to bwdpi_. Most are just feature flags or NVRAM values, but the calls that actually talk to the DPI engine are more interesting:

grep -Rin "bwdpi_history" ./www

./www/AdaptiveQoS_WebHistory.asp:311:$.get("/appGet.cgi?hook=bwdpi_history()&client=" + _mac + "&page=" + _page + "", function(data){
./www/AdaptiveQoS_WebHistory.asp:313:var len = result.bwdpi_history.length;
./www/AdaptiveQoS_WebHistory.asp:315:all_array = $.merge(all_array, result.bwdpi_history);
./www/getWebHistory.asp:2:array_temp = <% bwdpi_history(""); %>;

AdaptiveQoS_WebHistory.asp is the "Web History" UI. The relevant JavaScript looks like this:

function exportWebHistoryLog() {
    var all_array = [];
    var mac = document.form.clientList.value;
    var page = 1;

    var get_history = function (_mac, _page) {
        $.get("/appGet.cgi?hook=bwdpi_history()&client=" + _mac + "&page=" + _page, function (data) {
            var result = jQuery.parseJSON(data);
            var len = result.bwdpi_history.length;

            if (len > 0) {
                all_array = $.merge(all_array, result.bwdpi_history);
                if (len == 50) {
                    page++;
                    get_history(mac, page);
                } else {
                    export_CSV(all_array);
                }
            } else {
                export_CSV(all_array);
            }
        });
    };

    get_history(mac, page);
}
  • The browser issues authenticated GET requests to GET /appGet.cgi?hook=bwdpi_history()&client=<MAC>&page=<N>.
  • The response is JSON with a bwdpi_history array. Each element is later treated as [macAddr, timeStamp, hostName] when the CSV is generated.

It's apparent that the web interface is pulling data from the DPI SQLite database, not writing to it. The adjacent hooks appeared to work the same way.

Re-Evaluating the Source

My train of thought at the time:

  • It was clear that bwdpi is part of Trend Micro's DPI / AiProtection pipeline, and that the history, traffic, and monitor tables are populated by that engine through bwdpi_sqlite, not by the web UI.
  • The web interface seems to only read from those tables and return JSON/CSV to the browser; I couldn't see any HTTP path that wrote free-form strings into them.
  • That makes sense. Traffic monitoring and threat logs are supposed to be derived from observed network flows, not whatever the browser sends.
  • Even if some DPI fields (dst, url, app_name, etc.) end up containing attacker-influenced hostnames or URLs, they originate from observed LAN traffic, not from the remote HTTP request we're supposed to be finding.
  • Since the CVE explicitly said remote and authenticated, making an attacker craft local traffic didn't make much sense as the intended path.
  • A web-based data backup/import option might be a plausible method to pollute the tables, but I didn't see one.

I figured I might have missed some HTTP functionality that could enable an authenticated user to write to one of the DB fields later used in a shell command. In hindsight, I should have diffed httpd at this stage, but my brain had other ideas 😅

CVE-2025-59369: SQL Injection in bwdpi

Remember when I thought I was still analysing a single CVE?

Well, I came up with a new theory. Perhaps the command injection relied on another vulnerability from the same bulletin.

A reminder of what we had learnt about bwdpi so far:

  • It reads strings such as history.url, traffic.app_name, and monitor.dst using sqlite3_get_table().
  • It interpolates them into "echo ... %s" >> logfile.
  • It sends the resulting command into system().

We'd been searching for a way to store a command-injection payload in one of the database tables. Could the SQL injection provide it?

That would form an exploit chain. Use the SQL injection to store a payload, then trigger a second request that passes it to system() 🤔

AyySSHush ASUS Router Campaign

As I began investigating CVE-2025-59369, I stumbled upon some reporting from May 2025 about an "ongoing wave of exploitation targeting ASUS routers". Several statements caught my attention:

  • Attackers target Trend Micro AiProtection components.
  • After gaining privileged access, the attackers exploit a command injection bug to create /tmp/BWSQL_LOG.
  • That file enables BWDPI logging, which contains dangerous system("echo ... %s") paths.
  • One of the older vulnerabilities involved was CVE-2023-39780.

The article links to a detailed technical analysis by GreyNoise Labs. It is worth reading in full, but the relevant points are:

  • Initial access through credential brute force or authentication bypass, including a NULL byte in asus_token.
  • A POST request to /start_apply.htm exploiting the OAuth-token path through CVE-2023-39780.
  • Execution of touch /tmp/BWSQL_LOG, activating verbose BWDPI logging and its unsafe system()-based sinks.
  • Use of legitimate ASUS settings to expose SSH on TCP port 53282 and install an attacker-controlled public key.
  • Persistence across firmware upgrades because the SSH configuration is stored through legitimate settings in NVRAM.

While the researchers note that the attackers used older vulnerabilities, they also mention a "mystery CVE" linked to the BWDPI logging design. Another researcher, leeya, had investigated the same clue and published a thorough writeup. The relevant findings were:

  • A long-standing SQL injection existed in libbwdpi_sql.so (sqlite_Stat_hook), affecting many ASUS models.
  • The vulnerable path was reachable through /appGet.cgi?hook=bwdpi_appStat() using parameters such as client, mode, dura, and date.
  • The client parameter was concatenated directly into a WHERE clause without filtering.
  • The resulting query was executed through sqlite3_get_table() without parameterisation.
  • If /tmp/BWSQL_LOG existed, the constructed SQL query was echoed through system(), enabling RCE.
  • Older ASUS source code in a public repository showed that the vulnerable construction had existed for at least six years.

Mystery CVE

At this point I wanted to confirm whether the "mystery CVE" described by GreyNoise was actually CVE-2025-59369. At first, the timeline did not add up. Leeya recorded ASUS preparing a fix for the GT-AC5300 on February 12, 2025. My research concerned a different model, but the RT-AC86U build I was treating as vulnerable was released more than a month later, on March 25. The supposedly fixed build I was comparing it against did not arrive until October 28. This left me wondering whether the February fix was model-specific, incomplete, or unrelated to the changes introduced in October.

I searched NVD for an earlier record naming sqlite_Stat_hook, libbwdpi_sql.so, or /appGet.cgi?hook=bwdpi_appStat(), but found nothing. CVE-2025-59369 was the first public CVE to explicitly describe SQL injection in bwdpi.

The GT-AC5300 firmware page had no October release. Build 51569, analysed by leeya, was released on November 12, 2024. Its successor, build 51582, was released on March 12, 2025.

GT-AC5300 firmware downloads showing 2024 and March 2025 releases

I figured I would download both (AC5300_3.0.0.4_386_51569 and AC5300_3.0.0.4_386_51582) and see how they patched the vulnerability. To my surprise, libbwdpi_sql.so was identical across both versions (no changes in sqlite_Stat_hook or bwdpi_appStat 🧐), but httpd changed. Diffing the two, I noticed FUN_00031aa8 in the patched binary was the new version of FUN_000318a4: the HTTP handler that imports sqlite_Stat_hook. So, what changed?

1. do_sqlite_Stat_hook: Entrypoint

Both firmware versions route /appGet.cgi?hook=bwdpi_appStat() through httpd, extracting client, mode, dura, and date directly into the arguments for sqlite_Stat_hook. Here's that function (FUN_000318a4 -> do_sqlite_Stat_hook) in the 2024 firmware:

Ghidra view of do_sqlite_Stat_hook in older firmware

For some reason, Ghidra wouldn't decompile this function properly and just showed undefined (most of the neighbouring functions were fine). I had to manually correct the sqlite_Stat_hook() definition to match the seven parameters passed at the call site.

Here's the March 2025 firmware (FUN_00031aa8 -> do_sqlite_Stat_hook), with variables renamed:

total_len = 0;
client = (char *)get_cgi_param("client");
if (client == (char *)0x0) {
    client = "";
}
mode = (char *)get_cgi_param("mode");
if (mode == (char *)0x0) {
    mode = "";
}
dura = (char *)get_cgi_param(&DAT_0009179e);
if (dura == (char *)0x0) {
    dura = "";
}
date_str = (undefined1 *)get_cgi_param("date");
if (date_str == (undefined1 *)0x0) {
    date_str = &DAT_0008d8d1;
}
cmp = strcmp(client,"all");
if (
    (
        ( // if client == "all", a valid MAC, or pass validation check
            (cmp == 0) ||
            (cmp = isValidMacAddress(client), cmp != 0) ||
            (cmp = check_for_shell_metachars(client), cmp == 0)
        ) &&
        ( // if mode valid
            (cmp = strcmp(mode, "day"), cmp == 0) ||
            (cmp = strcmp(mode, "hour"), cmp == 0) ||
            (cmp = strcmp(mode, "detail"), cmp == 0)
        )
    ) &&
    (
        ( // if duration valid
            (cmp = strcmp(dura, "7"), cmp == 0) ||
            (cmp = strcmp(dura, "24"), cmp == 0) ||
            (cmp = strcmp(dura, "31"), cmp == 0)
        ) &&
        // if date valid
        (cmp = validate_date(date_str), cmp != 0)
    )
) {
    sqlite_Stat_hook(2, client, mode, dura, date_str, &total_len, fp);
    uVar1 = total_len;
}
else {
    uVar1 = 0;
}

The new if statement checks the client CGI parameter and calls sqlite_Stat_hook() only if one of the following conditions is true:

  • client equals "all"
  • client is a valid MAC address
  • client doesn't contain certain special characters

The request must also contain an allowed mode and dura, while date must be valid or blank. Those fields are not injection points in this path.

The check_for_shell_metachars() (FUN_00081870) call is clearly meant as a guard against command injection:

char *p = s;

if (s && *s && (p = strpbrk(s, "&|;`"), p != 0)) {
    p = (char *)1;    // found one of & | ; `
}

How does this apply to our analysis? Well, the validation was already present in httpd in both RT-AC86U builds: 3.0.0.4_386_51967 and 3.0.0.4_386_52294. It therefore cannot be the complete SQL injection fix described by the November advisory.

check_for_shell_metachars() is called six times throughout httpd, making older versions a good place to look for command injection. This also messes with my earlier theory about writing a payload into monitor, traffic, or history and forcing its retrieval through the admin interface. Here we can see that client is validated, although the list of excluded characters doesn't look particularly extensive.

Anyway, the filter doesn't prevent SQLi because it allows quotes, comparison operators, spaces, commas, and digits. It only helps against command-injection payloads that rely on one of those four blacklisted shell characters. In other words, a payload like "OR"1"="1" makes it straight through the HTTP handler and into sqlite_Stat_hook(), even in the supposedly "patched" path.

2. sqlite_Stat_hook: Predicate Construction

With the HTTP validation understood, we can switch back to libbwdpi_sql.so from RT-AC86U build 3.0.0.4_386_51967. sqlite_Stat_hook() is implemented in that library and imported by httpd.

The parameter order and roles match leeya's writeup: (type, client, mode, dura, date, total_len, fp). To reach the branch analysed below, mode must be hour and dura must be 24. The function then formats client into the where string as either mac or app_name, without SQL escaping or parameterisation:

void sqlite_Stat_hook(int type, char *client, char *mode, char *dura, uint32_t now, int *total_len, FILE *fp)
{
    // make sure client != "all", we don't want to hit this!
    if (strcmp(client, "all") == 0) {
        if (strcmp(mode, "detail") == 0) {
            if (type == 0) {
                strcpy(group, "mac");
            } else if (type == 1) {
                strcpy(group, "app_name");
            } else {
                return;
            }
        } else {
            strcpy(group, "timestamp");
        }
        where[0] = '\0';
    } else {
        // make sure mode != "detail"
        int detail = (strcmp(mode, "detail") == 0);

        if (detail && type == 0) {
            strcpy(group, "mac");
            snprintf(where, sizeof(where), "app_name=\"%s\"", client);
        } else if (detail && type == 1) {
            strcpy(group, "app_name");
            snprintf(where, sizeof(where), "mac=\"%s\"", client);
        // we want to hit this!
        } else if (!detail && type == 0) {
            strcpy(group, "timestamp");
            snprintf(where, sizeof(where), "app_name=\"%s\"", client); // user-controlled "client" is inserted into "where"
        } else if (!detail && type == 1) {
            strcpy(group, "timestamp");
            snprintf(where, sizeof(where), "mac=\"%s\"", client);
        // or this. type is not user-controlled; leeya notes it as "0", but do_sqlite_Stat_hook above passes "2"
        } else if (!detail && type == 2) {
            strcpy(group, "app_name");
            snprintf(where, sizeof(where), "mac=\"%s\"", client); // user-controlled client is inserted into where
        } else {
            return;
        }
    }

    // we need to hit this, make sure mode == "hour" and dura == "24"
    if (strcmp(mode, "hour") == 0 && strcmp(dura, "24") == 0) {
        int start = (int)now - 0x15162;
        int first = 1;

        fprintf(fp, "[");
        for (; start < (int)now; start += 0xe10) {
            int end = start + 0xe10;

            snprintf(having, sizeof(having),
                     "(SELECT * FROM traffic WHERE timestamp BETWEEN '%d' AND '%d')",
                     start, end);

            bwdpi_appStat(buf, group, where, having, sizeof(buf)); // forward unfiltered "where" to bwdpi_appStat()
            fprintf(fp, "%s%s", first ? "" : ", ", buf);
            first = 0;
        }
        fprintf(fp, "]");
    }
    /* other mode/dura branches omitted */
}

3. bwdpi_appStat: Final SQL Assembly

sqlite_Stat_hook() forwards its predicate and timestamp window into bwdpi_appStat(), which builds the final query:

char sql_query[0x3c0];
int rows = 0;
int cols = 0;
char **result = NULL;

/* group = GROUP BY column, where = predicate, having = subquery */
snprintf(sql_query, sizeof(sql_query),
         "SELECT %s FROM %s WHERE %s GROUP BY %s",
         "mac, app_name, timestamp, SUM(tx), SUM(rx)", // fields
         having,      // subquery from sqlite_Stat_hook
         where,       // predicate: mac="%s" / app_name="%s"
         group);      // GROUP BY column

// if BWDPI logging is enabled
rc = f_exists("/tmp/BWSQL_LOG");
if (0 < rc){
    snprintf(cmd, 0x400, "echo \"[BWDPI_SQLITE]sql_query = %s\" >> /tmp/BWSQL.log", sql_query);
    system(cmd);
}

rc = sql_get_table(db, sql_query, &rows, &cols, &result); // call sqlite3_get_table

The raw where predicate containing client is inserted directly into the query passed to sql_get_table(). The underlying function is sqlite3_get_table() in /usr/lib/libsqlite3.so.0.8.6, which passes the query to sqlite3_exec() without adding any application-level escaping or parameterisation:

Disassembly showing sqlite3_get_table calling sqlite3_exec

The mode and dura constraints matter. Without mode=hour and dura=24, execution does not reach this branch with the attacker-influenced where predicate. The having and group strings are constructed internally and are not injection points here.

Control flow gating the injectable WHERE construction path

Call Chain

With all three stages aligned, the behaviour matched the SQL injection described by leeya and later assigned CVE-2025-59369. ASUS's November bulletin treated the October 2025 firmware as the fix, but the same primitive remained reachable.

To summarise, the call chain in the vulnerable firmware is:

appGet.cgi?hook=bwdpi_appStat()
   ↓
httpd extracts: client, mode, dura, date
   ↓
sqlite_Stat_hook(type, client, mode, dura, date)
   ↓
bwdpi_appStat(buf, group, where, having)
   ↓
sqlite3_get_table()

leeya demonstrated local exploitation by calling sqlite_Stat_hook() directly via dlopen/dlsym. They also showed that the same call chain is reachable remotely through /appGet.cgi?hook=bwdpi_appStat(). For example:

/appGet.cgi?hook=bwdpi_appStat()&mode=hour&dura=24&client=SQLI_PAYLOAD_GOES_HERE

Conditional Command Execution

Leeya also noted that RCE was possible in certain circumstances. Specifically, if /tmp/BWSQL_LOG exists, the injected SQL query is formatted into an echo command and executed through system().

BWSQL_LOG branch writing SQL into a system echo command

They didn't explain further because of the difficulty in ensuring /tmp/BWSQL_LOG exists, but in hindsight it sheds light on the AyySSHush campaign, where an old command injection CVE was used to run touch /tmp/BWSQL_LOG (creating an empty file and enabling BWDPI logging).

Is it the same command injection as CVE-2025-59370? ASUS's public description is too vague to prove the mapping, but the October diff certainly looks relevant. Looking at the October firmware again, check_for_shell_metachars() (FUN_0007da6c) has changed slightly:

char *p = s;

if (s && *s && (p = strpbrk(s, "&|;`$()"), p != 0)) {
    p = (char *)1;    // found one of & | ; ` $ ( )
}

ASUS blocked more characters ($())! I had already noticed the character list wasn't particularly exhaustive and thought of exactly that bypass: $(whoami). If only I had looked at the firmware before the October patch 😂

Although the filter seems to have been updated to address a bypass, most of the structurally insecure shell-based logging was also removed in the same update.

I also checked the adjacent hooks. Only bwdpi_appStat is injectable. The bwdpi_HistoryStat and bwdpi_monitor_stat handlers build SQL queries, but only for SELECT operations, and their results are formatted into JSON. Although they still contain system("echo ... %s") logging, the logged values originate from existing DPI database rows, not directly from HTTP parameters. Inputs such as mac and page only influence filtering and pagination and never reach bwdpi_sqlite, so they do not provide a remote write primitive.

History

Although the SQL injection was assigned a CVE in 2025, the underlying bug is older. Leeya's analysis traced the same sqlite_Stat_hook() logic to ASUS source code committed at least six years earlier. A public GitHub mirror contains the same unescaped sprintf() and snprintf() construction in sqlite_stat.c.

Interestingly, the earliest versions did not include the echo-based debug logging that later enabled RCE when /tmp/BWSQL_LOG is present. That logging was added in newer firmware without addressing the existing SQL injection flaw, effectively turning a long-standing SQLi into a conditional RCE primitive.

The historical code comes from a DSL-AC88U development tree. Leeya also found the same library and call pattern in GT-AC and GT-AX firmware, although the exact authentication requirements varied by model. The 2026 CVE record takes the safer approach of listing affected ASUSWRT series rather than claiming every model that contains the library is exploitable.

The Patch Bypass

I expected to conclude by explaining how ASUS patched the SQLi bug, but I couldn't find the fix. Let's trace each stage in the path and see whether the October update actually mitigated CVE-2025-59369:

  1. An authenticated user can still request appGet.cgi?hook=bwdpi_appStat().
  2. httpd still extracts client, mode, dura, and date, then forwards them to sqlite_Stat_hook().
    • The updated denylist blocks $() in client.
    • That helps against command substitution but does not block SQL syntax such as "OR"1"="1.
  3. sqlite_Stat_hook() still inserts client into the where predicate and passes it to bwdpi_appStat().
  4. bwdpi_appStat() still passes the resulting sql_query to sql_get_table():
-      result = &local_2798;
-      rc = sql_get_table(db,sql_query,&rows,&cols,result);
-      if (rc == 0) {
-        rc = f_exists("/tmp/BWSQL_LOG");
-        if (0 < rc) {
-          result = (undefined4 ****)local_2798;
-          snprintf(cmd,0x400,"echo \"[BWDPI_SQLITE]rows=%d, cols=%d\" >> /tmp/BWSQL.log",cols,
-                   local_2798);
-          system(cmd);
+      iVar4 = sql_get_table(db,sql_query,&rows,&cols,&results);
+      if (iVar4 == 0) {
+        iVar4 = f_exists("/tmp/BWSQL_LOG");
+        if ((0 < iVar4) && (pFVar6 = fopen("/tmp/BWSQL.log","a"), pFVar6 != (FILE *)0x0)) {
+          fprintf(pFVar6,"[BWDPI_SQLITE]rows=%d, cols=%d\n",cols,results);
+          fclose(pFVar6);

The October build preserved the call chain outlined earlier: the client parameter still reached sqlite3_get_table() without SQL escaping or parameterisation.

Testing

Static analysis showed the same unsafe app_name=\"%s\" and mac=\"%s\" predicates reaching sql_get_table(). The root cause was unchanged. I still wanted dynamic confirmation, but I did not have a physical router. I followed leeya's approach and an ASUS HTTP emulation guide, starting with a QEMU environment:

cp `whereis qemu-arm-static | awk '{print $2}'` ./rootfs_ubifs
umount ./rootfs_ubifs/proc
umount ./rootfs_ubifs/dev
mount -o bind /dev ./rootfs_ubifs/dev && mount -t proc /proc ./rootfs_ubifs/proc
cd rootfs_ubifs
chroot . ./bin/bash

Then launch the patched version of httpd in one terminal:

QEMU_LD_PREFIX="$PWD" ./qemu-arm-static -g 12345 ./usr/sbin/httpd

Connect pwndbg and set breakpoints in the main function of httpd (0x174c4) and at the sqlite_Stat_hook call (0x308d0):

gdb-multiarch usr/sbin/httpd -ex "b *0x174c4" -ex "b *0x308d0" -ex "target remote 127.0.0.1:12345"

Set the correct architecture, optionally add some more breakpoints for debugging, then hit "continue".

set architecture arm
break sqlite_Stat_hook
c

With the program paused, get the address of the libbwdpi_sql.so library.

cat /proc/`ps -e | grep "qemu-arm-static" | awk '{print $1}'`/maps | grep "libbwdpi_sql.so" | head -n 1

3faaf000-3fab5000 r--p 00000000 00:1b 16099109                           /home/crystal/Desktop/bug/patched_fw/_RT-AC86U_3.0.0.4_386_52294-ga575528_ubi.w.extracted/ubifs-root/0/rootfs_ubifs/usr/lib/libbwdpi_sql.so

In my case, the address is 0x3faaf000. It's now possible to add breakpoints inside the library, such as at the call to bwdpi_appStat() or the function itself.

break bwdpi_appStat
break *0x3faaf000 + 0x2604

It should look something like this:

info breakpoints

Num     Type           Disp Enb Address    What
1       breakpoint     keep y   0x000174c4
2       breakpoint     keep y   0x000308d0
3       breakpoint     keep y   0x3faa132c <sqlite_Stat_hook+44>
4       breakpoint     keep y   0x3faa093c <bwdpi_appStat+28>
5       breakpoint     keep y   0x3fab1604

Now we hit "continue" again, and stop at the first breakpoint in the main function of httpd (0x174c4). Set the arguments to those expected, and modify the PC to sqlite_Stat_hook.

# arg 1 - leeya has it as "0", but it shows "2" for me; this shouldn't affect exploitability
set $r0=2
# arg 2 - "client", aka "mac" / "app_name", aka SQLi payload
set {char[16]} 0xbb010 = "\"OR\"1\"=\"1"
set $r1=0xbb010
# arg 3 - mode (must be "hour")
set {char[16]} 0xbb020 = "hour"
set $r2=0xbb020
# arg 4 - duration (must be "24")
set {char[16]} 0xbb030 = "24"
set $r3=0xbb030
# arg 7 - fp
set $sp=$sp - 4
set {unsigned int} $sp = 0xbb280
# arg 6 - total_len
set $sp=$sp - 4
set {unsigned int} $sp = 0xbb080
# arg 5 - date_str
set $sp=$sp - 4
set {char[16]} 0xbb040 = "1000"
set {unsigned int} $sp = 0xbb040
# modify pc to sqlite_Stat_hook
set $pc=0x00308d0

When we continue, we'll arrive at the second breakpoint (the call to sqlite_Stat_hook()):

GDB breakpoint at sqlite_Stat_hook with SQL injection parameters set

If we continue, fprintf() segfaults because the file pointer is fake. I tried repeating the setup with a valid file pointer, but encountered further emulation issues and stopped at the same stage as leeya's writeup.

This was not end-to-end dynamic proof on the October build. However, the request arguments reached sqlite_Stat_hook(), while the static diff showed no change in bwdpi_appStat() that would prevent the injection.

Report to ASUS

I contacted ASUS with my concerns. They were very responsive and friendly, but believed the bug was fixed. Over the following week, ASUS explained why it considered the patch sufficient, and I replied with the code paths showing why I thought the SQL injection remained. Since I had no device to confirm, I wasn't totally sure if I was just being a n00b. Thankfully, on December 15, 2025, I received an email confirming I was not (this time).

Dear Jonah,

Thank you for your feedback.

We’ve reviewed our firmware again and have now have an modified version for this case.

Kindly help to review the beta firmware and let us know if the CVE is fully patched.

[LINK]

Thank you.
ASUS PSIRT.

First Beta Fix

The changes were applied to httpd:

// Unchanged: GET CGI parameters (user-controlled)

cmp = check_for_shell_metachars(client); // [1] Metachar filter, now unconditional.
if ( // [2] Same filter on mode, dura, date.
    (cmp == 0) &&
    (cmp = check_for_shell_metachars(mode), cmp == 0) &&
    (cmp = check_for_shell_metachars(dura), cmp == 0) &&
    (cmp = check_for_shell_metachars(date), cmp == 0) &&
    ( // [3] "all" or a valid MAC only.
        (cmp = strcmp(client, "all"), cmp == 0) ||
        (cmp = isValidMacAddress(client), cmp != 0)
    ) &&
    // allowlist: mode (unchanged)
    (
        (cmp = strcmp(mode, "day"), cmp == 0) ||
        (cmp = strcmp(mode, "hour"), cmp == 0) ||
        (cmp = strcmp(mode, "detail"), cmp == 0)
    ) &&
    // allowlist: duration (unchanged)
    (
        (cmp = strcmp(dura, "7"), cmp == 0) ||
        (cmp = strcmp(dura, "24"), cmp == 0) ||
        (cmp = strcmp(dura, "31"), cmp == 0)
    ) &&
    // allowlist: date (unchanged)
    (cmp = validate_date(date), cmp != 0)
) {
    sqlite_Stat_hook(2, client, mode, dura, date, &total_len, fp);
    uVar1 = total_len;
} else {
    uVar1 = 0;
}

The metacharacter filter at [1] now runs on client on its own instead of as one of three alternatives, and [2] applies the same filter to mode, dura, and date. I had not found a path from any of those three to a shell sink, but they cost nothing to cover. The bypass closes at [3], where client must be all or a valid MAC address. There is no longer a fallback branch accepting any string free of shell metacharacters, which is what let "OR"1"="1 through.

Revised Beta Fix

The first fix closed the injection path, but ASUS later found it broke existing functionality. They sent over revised beta firmware and an updated code snippet, which meant I could now see the real function and variable names.

static int do_sqlite_Stat_hook(int type, webs_t wp)
{
    int retval = 0;
    char *client = NULL, *mode = NULL, *dura = NULL, *date = NULL;

    client = websGetVar(wp, "client", "");
    mode   = websGetVar(wp, "mode", "");
    dura   = websGetVar(wp, "dura", "");
    date   = websGetVar(wp, "date", "");

    if (type < 0 || type > 2)
        return 0;

    // Check all params for command injection
    if (check_cmd_injection_blacklist(client) ||
        check_cmd_injection_blacklist(mode)   ||
        check_cmd_injection_blacklist(dura)   ||
        check_cmd_injection_blacklist(date))
        return 0;

    // New check specifically for "client" string
    if (type == 0 && !is_safe_app_name(client))
        return 0;

    // Allow "all" or MAC address for type 1
    if (type == 1 && strcmp(client, "all") && !isValidMacAddress(client))
        return 0;

    // Allow MAC address for type 2
    if (type == 2 && !isValidMacAddress(client))
        return 0;
    // ... remaining handler logic omitted.
}

The revised build introduced a new function: is_safe_app_name(). I opened httpd in Ghidra to see how it worked. At first, I thought they had sent me an old version because I couldn't see it. Then I realised Ghidra had omitted the function call from the decompiled code even though it was present in the assembly.

Disassembly showing the is_safe_app_name validation being invoked

The function is imported, so its implementation is not available in httpd. A symbol search locates it in libshared.so:

find . -type f -name "*.so*" -print | while read f; do
  readelf -Ws "$f" 2>/dev/null | grep -F " is_safe_app_name" | grep -vq " UND " && echo "$f"
done

./usr/lib/libshared.so

Decompiling libshared.so reveals the allowlist:

len = strlen((char *)s);                 // input length
if (len - 1 < 0x40) {                    // allow up to 64 bytes (len <= 64)
  p = s;                                 // cursor
  do {
    next = p + 1;                        // next char ptr
    ch = (uint)*p;                       // current byte
    is_alnum = isalnum(ch);              // [A-Za-z0-9]?

    // allowed:
    // - alnum
    // - '_' (0x5f)
    // - space (0x20)
    // - '-' (0x2d) and '.' (0x2e)
    if ((is_alnum == 0) && (ch != 0x5f && ch != 0x20) && (1 < ch - 0x2d)) {
      ok = 0;
      goto out;
    }

    p = next;
  } while (s + len != next);

  ok = 1;
}

The helper accepts between 1 and 64 characters from [A-Za-z0-9_.\- ]. Quotes and SQL operators cannot pass this check. For the other type values, client must be all or a valid MAC address as appropriate. This finally prevents attacker-controlled SQL syntax from reaching predicate construction.

Impact

An authenticated user can place arbitrary SQL into the WHERE predicate of the query bwdpi_appStat() assembles, and that query goes to sqlite3_get_table() with no escaping or parameterisation. sqlite_Stat_hook() writes the rows straight back into the JSON array returned by /appGet.cgi, so the predicate is a read primitive over whatever the DPI engine has recorded about the network: per-client application usage, transmit and receive volumes, and the MAC addresses behind them. ASUS scores it for confidentiality only (VC:H/VI:N/VA:N).

Command execution is not part of this CVE. That path needs /tmp/BWSQL_LOG to already exist on the device, and it belongs to the older CVE-2025-59369 and CVE-2025-59370 analysis rather than to the CVE-2026-11851 advisory. The evidence here is static analysis of the October build plus an emulated httpd reaching sqlite_Stat_hook() with attacker-supplied arguments, not a query run end to end against a physical router.

Remediation

Install the latest firmware available for the specific router model. ASUS does not name one fixed build across every product in the affected ASUSWRT series.

If an update cannot be applied immediately, ASUS recommends disabling Traffic Analyzer or Adaptive QoS and limiting the web management interface to trusted local networks. It also recommends disabling internet-accessible services such as remote WAN administration, port forwarding, DDNS, VPN server, DMZ, port triggering, and FTP. End-of-life devices that cannot run firmware released after March 2026 should be replaced.

Unexpected /tmp/BWSQL_LOG files or SSH keys also deserve investigation because both appeared in the observed AyySSHush tradecraft. If compromise is suspected, preserve any evidence needed for investigation, then factory-reset and manually reconfigure the router rather than relying on a firmware update to remove persistent settings.

Disclosure Timeline

  • December 7, 2025: Reported the patch bypass to ASUS PSIRT.
  • December 8, 2025: ASUS acknowledged the report and began investigating.
  • December 11, 2025: ASUS said it believed the existing patch was sufficient; I sent further analysis explaining why the SQL injection remained reachable.
  • December 12, 2025: ASUS suggested that the MAC-address validation would block the SQL injection even without the command-injection filter; I responded with a step-by-step breakdown showing why those checks could be bypassed.
  • December 15, 2025: ASUS supplied a modified beta build for review; it closed the SQL injection path.
  • December 18, 2025: ASUS reported a functionality regression and supplied a revised patch using is_safe_app_name(); I reviewed it and confirmed that the revised validation closed the bypass.
  • February 5, 2026: ASUS reviewed the draft and raised no concerns about publication once the fixed firmware had been released and a CVE assigned.
  • May 9, 2026: Requested a status update from ASUS.
  • May 14, 2026: ASUS confirmed that the fixed firmware had been released and suggested publishing in late May; CVE assignment was still unresolved.
  • July 15, 2026: ASUS published its security bulletin, and NVD published CVE-2026-11851.
  • August 26, 2026: Added to ASUS Hall of Fame.

Conclusion

The SQL injection itself was already documented by leeya, so the useful result here was the gap between what the November bulletin implied and what the October firmware actually changed. That build removed almost every shell-based logging sink, which addressed the command-injection half of the bulletin and made the SQLi far quieter. The unsafe query construction underneath it was untouched, and an authenticated request could still reach sqlite3_get_table() carrying its own SQL syntax. It took two rounds of beta firmware before client was validated against an allowlist rather than a denylist.

References