CVE-2026-3612: Wavlink NU516U1 OTA Command Injection
TL;DR
- CVE-2026-3612 is a command injection in the Wavlink NU516U1 OTA upgrade handler.
- User supplied parameters are inserted into a shell command executed via system().
- Attackers can inject arbitrary commands through the
firmware_urlparameter. - The vendor patch adds filtering and escaping, but still relies on shell execution.
- A later firmware quietly replaces sprintf with snprintf to fix a separate overflow.
Summary
A command injection vulnerability in the Wavlink NU516U1 Print Server OTA upgrade mechanism
- CVE: CVE-2026-3612
- Product: Wavlink NU516U1 (V240425)
- Vulnerability: Command injection
- Affected Versions: M16U1_V240425
- Fixed In: M16U1_V251208
- CVSS Severity: 7.3 (high)
- CVSS Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P
- Required Privilege: Remote, Authenticated
- NVD Published: March 05, 2026
The OTA firmware upgrade handler constructs a shell command using user supplied HTTP parameters. Because the command is executed via system(), attackers can inject arbitrary shell commands through the firmware_url parameter. The vendor patch adds input validation and escaping, but the design still relies on shell execution.
Introduction
In this analysis we investigate CVE-2026-3612, a command injection vulnerability affecting the Wavlink NU516U1 USB printer server. Using firmware extraction and patch diffing we identify the vulnerable code path, examine how the vendor attempted to fix the issue, and analyse the root cause of the vulnerability.
CVE-2026-3612: OTA Command Injection via firmware_url Parameter
The CVE was published to NVD 5th March 2026 but the PoC was public on GitHub since at least 9th February 2026.
Advisory
A vulnerability was determined in Wavlink WL-NU516U1 V240425. This affects the function
sub_405AF4of the file/cgi-bin/adm.cgiof the component OTA Online Upgrade. This manipulation of the argumentfirmware_urlcauses command injection. It is possible to initiate the attack remotely. The exploit has been publicly disclosed and may be utilized. The vendor was contacted early about this disclosure.
When visiting the firmware download site, we can see the vulnerable version (M16U1_V240425) and the patched version (M16U1_V251208). We'll also see another version that was released only 2 weeks ago (M16U1_V260227) which also mentions fixing "vulnerabilities in the web interface" - more on this later! 👀

Patch Diffing
After downloading the .bin files and extracting with binwalk, we can open both rootfs folders in a diffing tool like BeyondCompare.

However, in this case the advisory specifically highlighted the vulnerable function (sub_405AF4) in the vulnerable file (/cgi-bin/adm.cgi). Unfortunately, this is not a format we can diff directly with BeyondCompare.
file adm.cgi
adm.cgi: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld-uClibc.so.0, no section header
I initially attempted binary diffing in IDA Pro using diaphora, however without the MIPS decompiler the analysis would require manual assembly diffing. Since the advisory already identified the vulnerable function, analysing the binary in Ghidra was faster.
Since the vendor was so explicit where the vulnerability is, let's just go straight to sub_405AF4 - I renamed variables/functions accordingly:
void FUN_00405af4(undefined4 param_1)
{
char *brand;
char *model;
char *version;
char *firmware_url;
char *firmware_md5;
int log_exists;
FILE *__stream;
char cmd[1024];
memset(cmd,0,0x400);
brand = (char *)http_get_param("brand",param_1,0);
brand = strdup(brand);
model = (char *)http_get_param("model",param_1,0);
model = strdup(model);
version = (char *)http_get_param("version",param_1,0);
version = strdup(version);
firmware_url = (char *)http_get_param("firmware_url",param_1,0);
firmware_url = strdup(firmware_url);
firmware_md5 = (char *)http_get_param(&dat_md5,param_1,0);
firmware_md5 = strdup(firmware_md5);
sprintf(cmd,
"/bin/winstar_ota_upgrade.sh \"%s\" \"%s\" \"%s\" \"%s\"&",
model, brand, firmware_md5, firmware_url);
log_exists = access("/tmp/web_log",0);
if ((log_exists == 0) && (__stream = fopen("/dev/console","w+"), __stream != (FILE *)0x0)) {
fprintf(__stream,"%s:%s:%d:ota upgrade cmd %s\n\n",
"adm.c","ota_new_upgrade",0x3c6,cmd);
fclose(__stream);
}
system(cmd);
return;
}
The function:
- Reads some HTTP parameters (brand, model, version, URL, MD5)
- Formats these parameters into arguments for a
winstar_ota_upgrade.shscript (cmd) - Calls
system(cmd)with user-supplied input
The vulnerability is clear; unsanitised user-input is passed directly into a dangerous sink (system call). So, what changed in the patch? Here's a diff:
-void FUN_00405af4(undefined4 param_1)
+void FUN_00406194(undefined4 param_1)
{
char *brand;
char *model;
char *version;
char *firmware_url;
char *firmware_md5;
int log_exists;
FILE *__stream;
char cmd[1024];
+ unsigned char escaped_string[260];
brand = http_get_param("brand", param_1, 0);
model = http_get_param("model", param_1, 0);
version = http_get_param("version", param_1, 0);
firmware_url = http_get_param("firmware_url", param_1, 0);
firmware_md5 = http_get_param("md5", param_1, 0);
- sprintf(cmd,"/bin/winstar_ota_upgrade.sh \"%s\" \"%s\" \"%s\" \"%s\"&",
- model, brand, firmware_md5, firmware_url);
- system(cmd);
+ if (contains_bad_shell_char(brand) ||
+ contains_bad_shell_char(model) ||
+ contains_bad_shell_char(firmware_md5)) {
+ log("invalid char in ota upgrade");
+ } else {
+ memset(escaped_string, 0, sizeof(escaped_string));
+ shell_escape_all_chars(firmware_url, escaped_string);
+
+ sprintf(cmd,"/bin/winstar_ota_upgrade.sh \"%s\" \"%s\" \"%s\" %s &",
+ model, brand, firmware_md5, escaped_string);
+ system(cmd);
+ }
The vulnerable system call is still there, but three of the HTTP parameters (brand, model, firmware_md5) go through a sanitisation check:
undefined4 contains_bad_shell_char(char *param_1)
{
size_t len;
char *p;
char *end;
len = strlen(param_1);
end = param_1 + len;
do {
if (param_1 == end) {
return 0;
}
p = strchr("|`&<>$()\"\'[]{}*?!^~\\#%",(int)*param_1);
param_1 = param_1 + 1;
} while (p == (char *)0x0);
return 1;
}
The other parameter (firmware_url) goes through a more restrictive function. Strangely, the function escapes every character in the string by prefixing it with a backslash. This effectively neutralises shell metacharacters but results in an unusual fully escaped string.
undefined4 shell_escape_all_chars(char *input,char *escaped_out)
{
size_t input_len;
int log_exists;
FILE *__stream;
uint i;
char escape_char [12];
escape_char[0] = '\0';
escape_char[1] = '\0';
escape_char[2] = '\0';
escape_char[3] = '\0';
i = 0;
while( true ) {
input_len = strlen(input);
if (input_len <= i) break;
sprintf(escape_char,"\\%c",(int)input[i]);
i = i + 1;
strcat(escaped_out,escape_char);
}
log_exists = access("/tmp/web_log",0);
if ((log_exists == 0) && (__stream = fopen("/dev/console","w+"), __stream != (FILE *)0x0)) {
fprintf(__stream,"%s:%s:%d:3---%s\n\n","utils.c","check_Escape_char",0x53a,escaped_out);
fclose(__stream);
}
return 0;
}
Recall that when downloading the firmware, we saw a brand new version that is not referred to by this CVE. Let's diff the function again
n-day?
There's only one small change here, can you spot it?
-void FUN_00406194(undefined4 param_1)
+void FUN_00406234(undefined4 param_1)
- shell_escape_all_chars(firmware_url,escaped_string);
- sprintf(cmd,"/bin/winstar_ota_upgrade.sh \"%s\" \"%s\" \"%s\" %s &",model,brand,firmware_md5,
- escaped_string);
+ shell_escape_all_chars(firmware_url,escaped_string,0x100);
+ snprintf(cmd,0x400,"/bin/winstar_ota_upgrade.sh \"%s\" \"%s\" \"%s\" %s &",
+ model, brand, firmware_md5, escaped_string);
- FUN_00409cc4(5,brand,model,version,firmware_url,firmware_md5);
+ FUN_00409e34(5,brand,model,version,firmware_url,firmware_md5);
sprintf was replaced by snprintf, which now takes a maximum of 0x400 (1024) bytes. In other words, they patched a buffer overflow vulnerability! Checking NVD again, we'll find CVE-2026-2566 matches the description - and it was indeed fixed in the M16U1_V260227 release.
Root Cause Analysis
The vulnerability is unsafe shell command construction. OTA handler inserts HTTP parameters (brand, model, md5, firmware_url) directly into the following command:
/bin/winstar_ota_upgrade.sh "<model>" "<brand>" "<md5>" "<firmware_url>"
Executed unsanitised with system(), the firmware_url parameter allows shell injection:
HTTP parameter
↓
sprintf command construction
↓
system(cmd)
↓
shell execution
Patch adds:
- Blacklist filter for
brand,model,firmware_md5 - Escapes all characters in
firmware_url
It still relies on shell execution.
Exploitability
POST to /cgi-bin/adm.cgi with page=ota_new_upgrade and parameters brand, model, version, md5, firmware_url. Command runs as root. Authentication required but weak/reused credentials lower barrier.
PoC
The NVD advisory links to a Github PoC:
POST /cgi-bin/adm.cgi HTTP/1.1
Host: meow.cat
Content-Type: application/x-www-form-urlencoded
Cookie: session=420426969
page=ota_new_upgrade&brand=wavlink&model=420&version=4.2&md5=1337&firmware_url=$(touch /tmp/pwned)
Remediation
Patch in M16U1_V251208: validates inputs, escapes firmware_url. Later release M16U1_V260227 uses snprintf to prevent buffer overflow.
Conclusion
The patch reduces the immediate risk but leaves the core design unchanged: the web interface still constructs shell commands from HTTP parameters and executes them with system().
Embedded firmware frequently relies on shell scripts for update logic, but this pattern makes command injection bugs extremely common.