Talos Vulnerability Report

TALOS-2019-0919

Bitdefender BOX 2 bootstrap download_image command injection vulnerability

January 21, 2019
CVE Number

CVE-2019-17095, CVE-2019-17096

Summary

An exploitable command injection vulnerability exists in the bootstrap stage of Bitdefender BOX 2, versions 2.1.47.42 and 2.1.53.45. The API method /api/download_image unsafely handles the production firmware URL supplied by remote servers, leading to arbitrary execution of system commands. An unauthenticated attacker should impersonate a remote nimbus server to trigger this vulnerability.

Tested Versions

Bitdefender BOX 2, versions 2.1.47.42 and 2.1.53.45

Product URLs

https://www.bitdefender.com/box/

CVSSv3 Score

9.0 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H

CWE

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

Details

Bitdefender produces Bitdefender BOX 2, a device aimed at protecting home networks from a variety of threats, such as malware, phishing websites and hacking attempts. It also provides a way to monitor specific devices in the network and limit their internet access.

To achieve this, Bitdefender works as a gateway and splits the home network in two: a monitored network and an unmonitored network (where the main home router is). This way, it can inspect (and block) malicious traffic on the internet. They also provide an app called "Bitdefender Central app," available for Android and iOS, that can be used to manage the Bitdefender BOX from any location.

Bitdefender BOX 2 is based on "OpenWrt Chaos Calmer 15.05," running on a Mindspeed Comcerto 2000 processor, with its firmware stored in a 4GB NAND flash.

When the device is reset to factory settings, the bootstrap partition is used for booting up until the device is completely configured. In our test device, this partition currently contains an old version of the Bitdefender BOX 2 firmware: 2.0.1.91 (built on Oct. 24, 2017).

At this stage, the device exposes an HTTP server (via OpenWRT's uhttpd) that uses Lua scripts (stored in /opt/bitdefender/www/lua/basic_ws) to handle the incoming requests from the smartphone app, needed to configure the device and perform firmware updates.

To start the setup process, the smartphone connects to the "Bitdefender BOX" SSID, created by the device itself, allowing the "Bitdefender Central app" to communicate to the box via HTTP. Then, in summary:

  • The smartphone app sends an encrypted and signed file called "full_ws.tar.gz" via the HTTP method /api/update_setup.
  • The device verifies the archive and triggers the script install_full_ws which will extract new files in /opt/bitdefender/www/ and /opt/bitdefender/share/scripts. In particular this creates a new full_ws directory, together with additional setup scripts, effectively replacing the current Lua logic used by uhttpd.
  • Any subsequent request will now be handled by a more up-to-date version of the Lua scripts.

The setup process continues using the new scripts, the next steps involve, in broad terms:

  • Fetching an updated firmware from remote servers (nimbus.bitdefender.net and its subdomains)
  • Verifying the signed firmware
  • Creating new partitions for storing the new "production" firmware
  • Rebooting into the new environment (production partition)

Note that the bootstrap partition remains untouched during this process, although it is technically possible to update it.

In particular, in this advisory we're interested into the way the device fetches a new firmware. This happens via the api request /api/download_image, which calls the function download_image in full_ws/handler.lua. We identified two separate but identical issues in this code path.

    function download_image(env)
        local payload = ""
        payload = "{ \"started\" : true }"
        send_200(payload)
        write_state(build_progress_state("full", true, "download_image", "ok", tostring(0), tostring(1)))

        async(function()
            local extra_json = {}
            os.execute("rm " .. IMAGE_FILENAME)
            local result = ""
            local sz = 0
[1]         local image_info, err = get_image_url()
            if image_info == nil then
                result = err;
                write_state(build_state("full", false, "download_image", err))
            else
[5]             local img_size = get_image_size(image_info["url"])
                if not img_size then
                    result = "error_invalid_url"
                    write_state(build_state("full", false, "download_image", "error_invalid_url"))
                    -- uhttpd.cmd_unlock()
                    -- return
                else
                    write_state(build_progress_state("full", true, "download_image", "ok", tostring(sz), tostring(img_size)))
[4]                 code, total_time = execute_command(CURL .. image_info["url"] .. " -o "  .. IMAGE_FILENAME .. " -w%{time_total}", "download_image", true)

                    if code == 0 then
                        result = "ok"
                        sz = img_size
                    else
                        result = "error_no_internet_connection"
                        sz = 0
                    end

                    write_state(build_progress_state("full", false, "download_image", result, tostring(sz), tostring(sz)))
                end
            end
            ...
        end)
    end

[1] function get_image_url()
        local query_fd = nil
        local query = {}
        local code, output;
        local user_token, device_id;

        query, err = build_rpc_json("/tmp/list_apps.json")
        if err then
            return nil, err
        end

        local retries = 5
        while retries > 0 do
[2]         code, output = execute_command("/opt/bitdefender/bin/charon"
                .. " -c \"generic_query\""
                .. " -i \"/tmp/list_apps.json\""
                .. " -m \"list_apps\""
                .. " -s \"connect/app_mgmt\""
                .. " -p \"*\""
                .. " -t \"array\"", "list_apps")
            if code == 0 then
                local result = JSON:decode(output)
                if result == nil then
                    break
                end
                -- print(result[0]["appid"])
                for i, v in pairs(result) do
                    if v["app_id"] == "com.bitdefender.boxse" and v["url"] then
                        return v, nil
                    end
                end
            end

            retries = retries - 1
            os.execute("sleep 2")
        end

        return nil, "error_cloud_communication"
    end

CVE-2019-17095 - Bitdefender BOX 2 bootstrap download_image command injection vulnerability

The function download_image gets the image URL via get_image_url() [1], which is calling an external binary called charon [2], that talks to nimbus servers ("nimbus.bitdefender.net" and its subdomains) via an HTTPS connection.

POST /connect/app_mgmt HTTP/1.1
Host: elb-fra-amz.nimbus.bitdefender.net
User-Agent: BDNC v2.4.19.10904 openwrt_armeleabi (31950cd)
...

{
    "id": 1,
    "jsonrpc": "2.0",
    "method": "list_apps",
    "params": {
        "app_id": "com.bitdefender.boxse",
        "connect_destination": {
            "device_id": "..."
        },
        "connect_source": {
            "app_id": "com.bitdefender.boxse",
            "device_id": "...",
            "user_token": "..."
        }
    }
}

----

HTTP/1.1 200 OK
content-type: application/json
server: istio-envoy
x-envoy-upstream-service-time: 86
x-nimbus-zone: fra-amz-kube
x-processing-time: 87
Content-Length: 372
Connection: keep-alive

{
    "id": 1,
    "jsonrpc": "2.0",
    "result": [
        {
            "app_id": "com.bitdefender.boxse",
            "download_type": 1,
            "enable": 1,
            "install_type": 1,
            "latest_version": "2.1.53.45",
            "md5": "25d4a7db16ceac3bee68d5470dd01a5a",
            "notification": 1,
            "product_name": "",
            "system_requirements": {
                "cpu": 0,
                "hdd": 0,
                "ram": 0
            },
[3]         "url": "https://download.bitdefender.com/box/update/release/boxv2/release_v2.1.53-45_production.tar.gz"
        }
    ]
}

As we can see at [3], the firmware URL is returned by nimbus servers, as a JSON-encoded message.
The image's URL is then extracted from the JSON message and retrieved using curl [4]:

[4]
code, total_time = execute_command(CURL .. image_info["url"] .. " -o "  .. IMAGE_FILENAME .. " -w%{time_total}", "download_image", true)

The execute_command function internally uses os.execute. Since there are no checks on the URL returned by nimbus servers, it is possible to inject a system command at [4] (e.g. simply by adding ";" after the URL), which is executed with the same privileges of the uhttpd server (root).

Note that while nimbus servers should be able to dispatch signed firmware updates, they wouldn't necessarily be able to create a signed firmware, which should be ideally handled by a different entity, disconnected from the production network.
If an attacker were to compromise nimbus servers, they wouldn't be able to update the firmware on remote devices without owning the signing keys. However, they could exploit this vulnerability to execute commands on the devices and execute any firmware update without verification.

This issue can thus be exploited by any nimbus server, or by any attacker able to impersonate them.

CVE-2019-17096 - Bitdefender BOX 2 bootstrap getimagesize command injection vulnerability

Similarly to the issue already presented, an additional command injection [6] is present in the get_image_size function [5], invoked by download_image:

function download_image(env)
    local payload = ""
    payload = "{ \"started\" : true }"
    send_200(payload)
    write_state(build_progress_state("full", true, "download_image", "ok", tostring(0), tostring(1)))

    async(function()
        local extra_json = {}
        os.execute("rm " .. IMAGE_FILENAME)
        local result = ""
        local sz = 0
        local image_info, err = get_image_url()
        if image_info == nil then
            result = err;
            write_state(build_state("full", false, "download_image", err))
        else
[5]         local img_size = get_image_size(image_info["url"])


function get_image_size(url)
[6] local proc = io.popen(CURL .. " -I " .. url)
    local headers=proc:read("*all")
    proc:close()

    if not string.find(headers,"200 OK") then
        return nil
    end

    local _, _ ,m =  string.find(headers, "Content%-Length: (%x+)")

    return m
end

Timeline

2019-10-31 - Vendor Disclosure 2019-01-21 - Public Release

Credit

Discovered by Claudio Bozzato, Lilith Wyatt and Dave McDaniel of Cisco Talos.