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, and3.0.0.6_102series - 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 😎

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.

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.
- CVE-2025-59365: Stack buffer overflow in certain routers (authenticated)
- CVE-2025-59366: Authentication bypass in AiCloud
- CVE-2025-59368: Integer underflow in AiCloud
- CVE-2025-59369: SQL injection in bwdpi (remote, authenticated)
- CVE-2025-59370: Command injection in bwdpi (remote, authenticated) <-- CURRENT TARGET
- CVE-2025-59371: Authentication bypass in IFTTT integration feature (remote, authenticated)
- CVE-2025-59372: Path traversal in certain routers (remote, authenticated)
- CVE-2025-12003: Path traversal in WebDAV (remote, unauthenticated)
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:

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.

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!

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.
![]()
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()

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:

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.

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
);
macandsrcare expanded from fixed-width%02Xbytes, so they are not useful for injecting arbitrary strings.dstis 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.

I counted another 21 calls across seven functions in the vulnerable 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.

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_historyarray. 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
bwdpiis part of Trend Micro's DPI / AiProtection pipeline, and that thehistory,traffic, andmonitortables are populated by that engine throughbwdpi_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, andmonitor.dstusingsqlite3_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?
- CVE-2025-59369: SQL injection in bwdpi (remote, authenticated) <-- 🤔👀
- CVE-2025-59370: Command injection in bwdpi (remote, authenticated) <-- CURRENT TARGET
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.htmexploiting the OAuth-token path through CVE-2023-39780. - Execution of
touch /tmp/BWSQL_LOG, activating verbose BWDPI logging and its unsafesystem()-based sinks. - Use of legitimate ASUS settings to expose SSH on TCP port
53282and 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 asclient,mode,dura, anddate. - The
clientparameter was concatenated directly into aWHEREclause without filtering. - The resulting query was executed through
sqlite3_get_table()without parameterisation. - If
/tmp/BWSQL_LOGexisted, the constructed SQL query was echoed throughsystem(), 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.

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:

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:
clientequals "all"clientis a valid MAC addressclientdoesn'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:

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.

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().

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:
- An authenticated user can still request
appGet.cgi?hook=bwdpi_appStat(). httpdstill extractsclient,mode,dura, anddate, then forwards them tosqlite_Stat_hook().- The updated denylist blocks
$()inclient. - That helps against command substitution but does not block SQL syntax such as
"OR"1"="1.
- The updated denylist blocks
sqlite_Stat_hook()still insertsclientinto thewherepredicate and passes it tobwdpi_appStat().bwdpi_appStat()still passes the resultingsql_querytosql_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()):

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.

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.