Talos Vulnerability Report

TALOS-2023-1781

peplink Surf SOHO HW1 upload_brand.cgi cross-site scripting (XSS) vulnerability

October 11, 2023
CVE Number

CVE-2023-34354

SUMMARY

A stored cross-site scripting (XSS) vulnerability exists in the upload_brand.cgi functionality of peplink Surf SOHO HW1 v6.3.5 (in QEMU). A specially crafted HTTP request can lead to execution of arbitrary javascript in another user’s browser. An attacker can make an authenticated HTTP request to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

Peplink Surf SOHO HW1 v6.3.5 (in QEMU)

PRODUCT URLS

Surf SOHO HW1 - https://www.peplink.com/products/soho-series-surf/

CVSSv3 SCORE

3.4 - CVSS:3.1/AV:N/AC:L/PR:H/UI:R/S:C/C:L/I:N/A:N

CWE

CWE-80 - Improper Neutralization of Script-Related HTML Tags in a Web Page (Basic XSS)

DETAILS

The Surf series of SOHO routers is marketed as an entry-level router for use at home. It provides networking via USB cellular modems, ethernet and Wi-Fi. The device can host a VPN and supports Wi-Fi meshing.

The device hosts a web interface for administrative configuration. A stored XSS vulnerability exists in the handling of requests destined for the /cgi-bin/MANGA/upload_brand.cgi endpoint which are intended to interact with the logo and branding management feature. This endpoint is accessible only after successfully authenticating as a user with write privileges on the device. The HTTP POST request must have a parameter, mode, whose value is set to api in order to reach the vulnerable code. The OEM of the device must be VISLINK, or the vulnerable brand upload feature will be disabled and inaccessible.

The vulnerable function is located in the file upload_brand.cgi at offset 0x40d390 in firmware version 6.3.5, and we refer to it as handle_brand_upload. An annotated decompilation of the function is included for reference.

#define NUM_BLOCKLIST 7
#define LEN_BLOCKLIST 100
char global_blocklist[NUM_BLOCKLIST][LEN_BLOCKLIST] = {
    "javascript",
    "<script",
    "<object",
    "<applet",
    "<embed",
    "<form",
    "\0"
};

int handle_brand_upload()
{
    // [1] Extract the attacker-controlled parameters
    char* brand_web_name = cgi_safe_param("brand_web_name");
    char* brand_product_name = cgi_safe_param("brand_product_name");
    char* brand_web_name = cgi_safe_param("brand_web_title");
    char* brand_product_name = cgi_safe_param("brand_web_link_text");
    char* brand_web_name = cgi_safe_param("brand_web_link_url");

    char brand_tmp_filepath[] = "/tmp/__upload_brand_conf.XXXXXX";
    int fd;

    char cmd[0x400] = {0};

    // [2] Check only the supplied `brand_web_name` against a short list of 'dangerous' inputs
    for (int i = 0; i < NUM_BLOCKLIST; i++)
    {
        if strcasestr(brand_web_name, global_blocklist[i])
        {
            xml_error("Request rejected due to prohibited value in brand_web_name.");
            return 0;
        }
    }

    // [3] Store the attacker-controlled parameters into a new taglist
    taglist_t* brand_tags = create_taglist();
    update_taglist_ex(brand_tags, scrub_newlines(brand_product_name), "PRODUCT_NAME");
    update_taglist_ex(brand_tags, scrub_newlines(brand_web_title), "WEB_TITLE");
    update_taglist_ex(brand_tags, scrub_newlines(brand_web_name), "WEB_NAME");
    update_taglist_ex(brand_tags, scrub_newlines(brand_web_link_text), "WEB_LINK_STRING");
    update_taglist_ex(brand_tags, scrub_newlines(brand_web_link_url), "WEB_LINK_URL");

    fd = mkstemp(&brand_tmp_filepath);
    if (fd == -1) {
        xml_error("Internal error, please try again later.");
        return 0;
    }
    close(fd);

    // [4] Store the tags to a temporary file
    save_taglist_ex(brand_tags, &brand_tmp_filepath, 1);

    // [5] Persist the data into /etc/brand.conf
    snprintf(&cmd, 0x400, "/usr/local/ilink/bin/persistent_data_utils brand -a save -f %s >/dev/null 2>&1", &brand_tmp_filepath);
    int result = system(&cmd);
    unlink(&brand_tmp_filepath);

    if (result != 0) {
        xml_error("Unable to save, please try again later.");
        return 0;
    }

    return 1;
}

Upon receiving a properly formed HTTP request, and validating that the OEM of the device is VISLINK, handle_brand_upload is called. Within this function, the attacker-controlled POST parameters of the request are extracted ([1]), and brand_web_name specifically is checked against a blocklist to ensure that none of six prohibited values are contained within the submitted value at [2]. If brand_web_name passes these checks, then the values are persisted to the device’s configuration files.

It is not necessary to know the full implementation details for the “taglist” structure except to say that values put into a taglist via update_taglist_ex ([3]), saved via save_taglist_ex ([4]) and persisted into the brand configuration file through persistent_data_utils ([5]), will be made available to any software on the system.

These particular values are used to populate the branding for the web interface, and they’re placed into the HTML within the index.cgi binary in a function named load_leftmenu. This function is located at offset 0x40a6a4 and the relevant portion of a decompilation of the function is included below.

void load_leftmenu(int menu_type)
{
    char* html_data;

    switch(menu_type)
    {
        case MAIN:
            // [6] Recover the attacker-controlled branding from /etc/brand.
            taglist_t brand_tags = load_taglist_ex("/etc/brand.conf", 1, 0);
            char* web_name = get_taglist_default_ex(brand_tags, "", "WEB_NAME");
            char* web_link_string = get_taglist_default_ex(brand_tags, "", "WEB_LINK_STRING");
            char* web_link_url = get_taglist_default_ex(brand_tags, "", "WEB_LINK_URL");

            // [7] Read the template file and replace the templated data with the brand data
            html_data = readfile("html/leftmenu_main.html");
            html_data = replace(html_data, "{PROGRAM_NAME}", __javascript_escape(web_name));
            html_data = replace(html_data, "{OEL_NAME}" , __javascript_escape(web_link_string));
            html_data = replace(html_data, "{OEL_URL}", __javascript_escape(web_link_url));
            free_taglist(brand_tags);
            break;

        ...
    }

    puts(html_data);
    free(html_data);
    return;
}

At [6], the branding taglist is loaded from /etc/brand.conf and three of the branding tags are loaded. Then, at [7], the HTML template for the menu is loaded from disk, and the branding data is used to populate the templated values. While the branding data is passed through __javascript_escape prior to being injected into the template, the escaping implemented in this function simply prefixes dangerous characters with a backslash with no HTML-specific escaping. However, this escaping does limit this vulnerability to just brand_web_name and web_link_url, due to the way in which the template is written. The template file, leftmenu_main.html, almost entirely consists of jQuery-heavy javascript, and this script is responsible for updating the DOM with the provided branding. Some of these parameters are injected into the template within locations in jQuery code that will not treat the input as an htmlString, and the __javascript_escape prevents an attacker from escaping into the <script> itself. In this instance, only brand_web_name and web_link_url will be transformed into HTML elements that can be manipulated into executing javascript.

TIMELINE

2023-06-26 - Initial Vendor Contact
2023-06-27 - Vendor Disclosure
2023-10-11 - Public Release

Credit

Discovered by Matt Wiseman of Cisco Talos.