//nefariousplan

CVE-2026-6644: ASUSTOR Escaped the Credentials. The Server Reached /bin/sh.

pattern

cve

proof of concept

ASUSTOR's PPTP VPN client config is generated by a single function in vpn.cgi. The function takes three fields from the admin's request and writes them into a pppd peer config: username, password, server address. It quote-escapes the first two. It does not escape the third.

The third is the one pppd hands to /bin/sh -c.

The function escapes the credentials. Not the server.

The vulnerable function is FUN_00403f21 in /portal/apis/settings/vpn.cgi. It runs when an administrator clicks Connect on a PPTP VPN client profile in ADM's management UI. The function reads the form fields the admin filled in, builds a pppd peer configuration file, and asks pppd to load it.

The peer config has three interpolated fields: server, username, password. The relevant format calls live in the same function, written by the same developer in the same change. The format string at line 208, the one the PoC reproduces, is:

fprintf(pFVar11, "pty '%s %s --nolaunchpppd'\n", pptp_path, server);

pptp_path is the hardcoded /usr/builtin/bin/pptp. server is the address the admin typed into the PPTP client form. The format wraps both into a single-quoted string literal. That string is the value pppd will later pass to its pty handler.

The username and password fields go through fprintf calls of their own, into adjacent lines of the same config file. Those fields are first run through a single-quote escape routine: any literal single quote inside the value is rewritten as '\'' before it is interpolated. A password containing a ' keeps its meaning as data and does not break the quoting. The server field gets no such treatment. It is concatenated into the format string verbatim.

Looking at the resulting config, the asymmetry is visible at a glance. The credential lines carry escaped quoting that holds even when the value has metacharacters. The pty line carries a value that the format string's literal single quotes claim to delimit, but that the format string never actually defended.

The researcher who reported the bug names the asymmetry in their own writeup: "The same function does apply single-quote escaping to the username (acc) and password (pass) parameters, but omits this escaping for the server address." They call this an "implementation oversight."

It is not an oversight. An oversight is forgetting the concept. This function ran the escape routine twice in the same flow, once for each credential field. The author knew what single-quote escaping was for. They applied it where they thought it belonged. The server address was not on that list.

The pty value is /bin/sh -c

A pppd peer config line that begins with pty tells pppd: instead of reading PPP frames from a serial device, fork a process and read frames from its stdout/stdin. The argument is the command line. pppd implements this by forking and execing /bin/sh with -c and the pty value.

The bytes vpn.cgi writes between the single quotes after pty are bytes /bin/sh will parse.

Upstream pppd's pty option is a documented feature, not a bug. It exists so administrators can pipe PPP frames through arbitrary local commands: pty 'ssh user@host pppd', pty 'plink ...', anything that produces a PPP frame stream on its stdout. The shell invocation is the feature. The trust model upstream is that pty lines come from a pppd peer config file the administrator wrote with a text editor; the administrator is therefore authorized to run shell. ASUSTOR's vpn.cgi violated that trust model on the caller side by routing user-sourced HTTP form input into the pty line without changing the handler's contract. The handler did exactly what its documentation promised. The handler's caller wrote bytes the documentation did not contemplate.

The PoC ships a local Python reproducer that models the chain. It does not talk to an ASUSTOR device. It builds the pty directive the way vpn.cgi builds it, then runs the directive through /bin/sh -c the way pppd runs it.

def build_pty_directive(server: str) -> str:
    """Reproduce vpn.cgi's fprintf (line 208):
    fprintf(pFVar11, "pty '%s %s --nolaunchpppd'\n", pptp_path, server)
    """
    return f"'{PPTP_PATH} {server} --nolaunchpppd'"


def run_as_pppd(pty_value: str) -> int:
    """Reproduce pppd's pty execution: execl("/bin/sh", "sh", "-c", pty_value, NULL)"""
    result = subprocess.run(
        ["/bin/sh", "-c", pty_value],
        capture_output=True, text=True,
    )

The payload is the standard quote-break shape:

payload = f"x' $(id>{EVIDENCE_FILE}) #'"

Concatenated with the format, the directive becomes:

'/usr/builtin/bin/pptp x' $(id>/tmp/poc_evidence) #' --nolaunchpppd'

Read left to right by /bin/sh: open quote, literal pptp x, close quote, command substitution $(id > /tmp/poc_evidence) runs, # comments out the trailing --nolaunchpppd and the dangling closing quote.

The reproducer confirms by reading /tmp/poc_evidence afterwards. On a real ASUSTOR device, pppd is invoked by the same admin web action that wrote the config and runs as root. Whatever shell the attacker's server address opens, opens as root.

PR:H is the metric. admin/admin is the deployment.

NVD's CVSS 3.1 vector for this CVE is AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H. PR:H is "privileges required: high"; in this context, an authenticated administrator. S:C is "scope: changed", because the action begins inside the CGI's process boundary and lands at root in the OS.

PR:H is technically correct and operationally a fiction.

ADM ships with admin for both the default username and the default administrator password. The researcher's own writeup acknowledges the gap: "ADM's default administrator account uses admin for both username and password. Environments where default credentials remain unchanged are at elevated risk."

A device that ships with admin/admin and exposes its management interface to the internet does not have PR:H. It has PR:N with a paragraph in the readme.

The researcher's Censys query for ASUSTOR-related hosts returns roughly 19,000 results. The figure is an upper-bound estimate; not every host runs ADM, not every ADM host is on a vulnerable version, not every vulnerable host exposes the management interface to the WAN. The number that matters for this CVE is the subset where someone reaches the management interface, types admin, types admin, and clicks Connect on a PPTP profile. We do not know that number. ASUSTOR does not know that number. Anyone who runs the credential pair against the 19,000 knows that number.

The bug is coverage, not knowledge.

This is the part nobody is explaining.

The function in question handles three user-controlled fields and applies sanitization to two of them. The function as shipped is evidence that its author understood quote injection. A developer who did not understand quote injection writes zero escapes. This developer wrote two.

The two fields that got escaped are the ones a security-aware developer flags in a heuristic pass: password, credential, secret. Those are the fields you protect. The mental model is "user input with security weight gets cleaned."

The field that did not get escaped is the server address. In the developer's mental model, that field is configuration. It is a target host, an FQDN or an IP, the value the admin pastes from a VPN provider's setup page. It is not a secret. It was not on the list.

The mental model was sorting on the wrong axis.

There is a specific developer move that produces this outcome. The function had a sanitization helper available; the developer reached for it when their attention landed on a field they expected to contain "dangerous" content. Passwords contain anything; treat them carefully. Usernames are user-controlled; treat them carefully. Server addresses are URLs or hostnames; the developer's working image of that field is a string of letters, digits, and dots. The metacharacter set is small and the developer's experience with it is low. So the helper does not get reached for.

The actual security-relevant property of server was not its perceived metacharacter density. It was its position in a format string whose downstream consumer is /bin/sh -c. That property is identical to the one that made escaping the credentials necessary. The developer's heuristic could not see it because it sorted by what the field is, not by where the field goes.

The escape routine in this function does not exist because the credential fields are sensitive. It exists because those fields flow into a shell-interpreted string. The trigger for sanitization is the destination, not the field's perceived sensitivity. The two credential escapes were correct by accident of overlap: the destination check would have flagged those fields, and so would the sensitivity check. The third field was correct only by the destination check. The developer was running the second.

Every field in the fprintf at line 208 reaches /bin/sh -c. The format puts all three of them inside the same single-quoted argument pppd's pty handler will execute. The escape function was already loaded into the function's address space. It ran on the two fields the developer mentally flagged as risky. It did not run on the field that came first in the format string.

ADM 5.1.3.RGO1 ships the patch. The function presumably now escapes the server too, which makes the patch correct for this CVE. The pattern that produced the bug stays open in any codebase that sanitizes by perceived field sensitivity rather than by data flow. We name this shape Sanitized The Secrets, Not The Sink: an escape pass that fires on the fields a developer flags as sensitive, while skipping the field that actually flows into the dangerous interpreter. When you decide which fields to escape based on which fields look dangerous, the field you forget is the one you did not categorize as dangerous. The sink does not care how you categorized.

PoC: uky007/CVE-2026-6644

The fields the developer escaped are not the fields that reached the shell.