CVE-2019-17095, CVE-2019-17096
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.
Bitdefender BOX 2, versions 2.1.47.42 and 2.1.53.45
https://www.bitdefender.com/box/
9.0 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
CWE-78: Improper Neutralization of Special Elements used in an OS Command (‘OS Command Injection’)
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:
/api/update_setup
.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
.The setup process continues using the new scripts, the next steps involve, in broad terms:
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
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.
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
2019-10-31 - Vendor Disclosure 2019-01-21 - Public Release
Discovered by Claudio Bozzato, Lilith Wyatt and Dave McDaniel of Cisco Talos.