RandoriSec 18 min

TL; DR

During a penetration test engagement, RandoriSec came across a Siemens OZW772 [0] device exposed on the Internet and decided to study its custom HTTP component. Two critical vulnerabilities were discovered by our team [1], affecting old versions of the firmware:

  • (CVE-2025-26389 – CVSS 3.1 = 10): Pre-authentication remote code execution (RCE) with root privileges (OS command injection) – affected versions: < V8.0
  • (CVE-2025-26390 – CVSS 3.1 = 9.8): Authentication bypass (SQL injection) – affected versions: < V6.0

We responsibly disclosed these vulnerabilities to Siemens ProductCERT before sharing in-depth analysis of our findings.

RandoriSec recommends updating OZW firmwares to the latest version 12.0 (see Mitigation section).

OZWx72

These devices, OZW672 and OZW772, are web servers that aim at managing building control devices (over KNX [2] bus), probing for measurement data and reporting them on so-called diagrams:

OZW diagram

During an engagement, the auditors faced a login form.

Network scans revealed a limited attack surface with only a few services, namely an SSH service (dropbear) and an HTTP server exhibiting the banner “Siemens Switzerland Ltd”. It is not unusual to find custom web servers running on embedded devices (IoT, firewalls, etc.); these are often designed as all-in-one binaries (sometimes running with high privileges) that implement basic HTTP protocol along with vendor-specific features.

These particularities make them interesting targets to study for vulnerability research. RandoriSec decided to allocate time during the engagement to investigate this unknown HTTP component.

Firmware analysis

Recent versions of firmwares are available on Siemens support portal [3]. The oldest version still available for download (V8.0) was selected to have a first look at the global layout of the firmware.

Extraction

The update archive needs to be decompressed several times:

user@randorisec:~/Firmware/tmp$ unzip OZW772_Firmware_V08.00.zip
user@randorisec:~/Firmware/tmp$ ls -lh
total 53M
-rw-r--r-- 1 user user 4.8M Feb 26  2018 OZW772_Firmware_V08.00.Readme_OSS.txt
-rw-r--r-- 1 user user  24M Feb 26  2018 OZW772_Firmware_V08.00.update
-rwxr-xr-x 1 user user  25M Jan  3 18:03 OZW772_Firmware_V08.00.zip
user@randorisec:~/Firmware/tmp$ unzip OZW772_Firmware_V08.00.update
user@randorisec:~/Firmware/tmp$ ls -lh
total 89M
-rw-r--r-- 1 user user 4.8M Feb 23  2018 OZW772.250-ozw772-xx-smartwebv1-Readme3rdPartySoftware.txt
-rw-r--r-- 1 user user  31M Feb 23  2018 OZW772.250-ozw772-xx-smartwebv1.ubifs
-rw-r--r-- 1 user user 4.8M Feb 26  2018 OZW772_Firmware_V08.00.Readme_OSS.txt
-rw-r--r-- 1 user user  24M Feb 26  2018 OZW772_Firmware_V08.00.update
-rwxr-xr-x 1 user user  25M Jan  3 18:03 OZW772_Firmware_V08.00.zip
-rw-r--r-- 1 user user 9.5K Feb 23  2018 fmwz.xsd
-rw-r--r-- 1 user user  256 Feb 26  2018 sign.sig
-rw-r--r-- 1 user user  469 Feb 23  2018 sign.txt
-rw-r--r-- 1 user user 422K Feb 23  2018 u-boot-smartwebv1.bin
-rw-r--r-- 1 user user  828 Feb 23  2018 update.xml

OZW772.250-ozw772-xx-smartwebv1.ubifs is an UBIfs [4] image of the firmware filesystem. It can be unpacked using the ubi_reader [5] tool:

user@randorisec:~/Firmware/tmp$ ubireader_extract_files OZW772.250-ozw772-xx-smartwebv1.ubifs
user@randorisec:~/Firmware/tmp$ ls -lh ubifs-root/
total 68K
drwxr-xr-x  2 user user 4.0K Feb 23  2018 bin
drwxr-xr-x  2 user user 4.0K Feb 23  2018 boot
drwxr-xr-x  2 user user 4.0K Feb 23  2018 dev
drwxr-xr-x 23 user user 4.0K Feb 23  2018 etc
drwxr-xr-x  3 user user 4.0K Feb 23  2018 home
drwxr-xr-x  5 user user 4.0K Feb 23  2018 lib
lrwxrwxrwx  1 user user   19 Jan  3 18:45 linuxrc -> /bin/busybox.nosuid
drwxr-xr-x  2 user user 4.0K Feb 23  2018 media
drwxr-xr-x  2 user user 4.0K Feb 23  2018 mnt
drwxr-xr-x  4 user user 4.0K Feb 23  2018 opt
drwxr-xr-x  2 user user 4.0K Feb 23  2018 proc
drwxr-xr-x  3 user user 4.0K Feb 23  2018 root
drwxr-xr-x  2 user user 4.0K Feb 23  2018 run
drwxr-xr-x  2 user user 4.0K Feb 23  2018 sbin
drwxr-xr-x  2 user user 4.0K Feb 23  2018 sys
drwxr-xr-x  2 user user 4.0K Feb 23  2018 tmp
drwxr-xr-x 10 user user 4.0K Feb 23  2018 usr
drwxr-xr-x  9 user user 4.0K Feb 23  2018 var

The filesystem is now accessible and ready for investigation. Our primary goal was to find where the binary implements the HTTP service. After some research, an interesting binary called “smartweb” was identified:

user@randorisec:~/Firmware/tmp/ubifs-root$ file opt/smartweb/bin/smartweb
opt/smartweb/bin/smartweb: ELF 32-bit LSB executable, ARM, EABI5 version 1 (GNU/Linux), dynamically linked, interpreter /lib/ld-linux.so.3, for GNU/Linux 3.2.0, BuildID[sha1]=6148b88ac73453d4d76e645faa5db82dc2fee8ab, stripped

Indeed, “smartweb” is the internal name of the HTTP component implementing all the business-logic features that can be accessed through the user panel.

RandoriSec managed to get an older version of it that matches the target. This will be the subject of the following study.

The smartweb binary

file command output shows that smartweb is a stripped ARM binary. Also, a quick overview using IDA Pro reveals that it has been developed using C++. Although symbols have been removed, RTTI [6] information remains in the binary and provides hints during reverse engineering.

OZW772 leverages nginx reverse proxy to expose smartweb service. The latter listens to different Unix sockets, waiting for CGI requests sent through different endpoints as described in the nginx configuration file (/etc/nginx/platform-nginx.conf):

...
#
# api served via fastcgi
#
location /api/ {
    fastcgi_pass unix:/tmp/Api.socket;
    fastcgi_read_timeout 300;
}

# main.app served via fastcgi
location /main.app {
    fastcgi_pass unix:/tmp/WebApp.socket;
    fastcgi_read_timeout 300;
}

# web served via fastcgi
location /web {
    fastcgi_pass unix:/tmp/WebApp.socket;
    fastcgi_read_timeout 300;
}

# ajax.app served via fastcgi
location /ajax.app {
    fastcgi_pass unix:/tmp/AjaxInterface.socket;
} 
...

On the other side, smartweb process is listening to new requests arriving on these sockets, using libfcgi (FastCGI) library. For example, here is the code initializing the socket for the /ajax.app endpoint:

int __fastcall _openUnixSocket_Ajax(int a1)
{
  int v2;
  int v3;

  FCGX_Init();
  v2 = FCGX_OpenSocket("/tmp/AjaxInterface.socket", 15);  // (0)
  if ( v2 < 0 )
    LOG_ERROR(0);
  v3 = fcntl(v2, 3, 0);
  fcntl(v2, 4, v3 | 0x800);
  return FCGX_InitRequest(a1 + 136, v2, 1);
}

This method opens the appropriate Unix socket (0) and initializes a FCGX_Request [7] structure stored within the C++ “this” (a1) object (at offset 136).

Actually, the _openUnixSocket_Ajax method is part of a C++ class (CWebAjaxInterface), its address is stored within the corresponding vftable (2):

.rodata:0020D350 ; `vtable for'CWebAjaxInterface
.rodata:0020D350 _ZTV17CWebAjaxInterface DCD 0
.rodata:0020D354                 DCD _ZTI17CWebAjaxInterface
.rodata:0020D358 off_20D358      DCD sub_1D9B40
.rodata:0020D358                                    
.rodata:0020D35C                 DCD sub_D156C
.rodata:0020D360                 DCD sub_D1530
.rodata:0020D364                 DCD sub_DC394 ; (1)
.rodata:0020D368                 DCD __openUnixSocket_Ajax_0 ; (2)
.rodata:0020D36C ; `typeinfo name for'CWebAjaxInterface
.rodata:0020D36C _ZTS17CWebAjaxInterface DCB "17CWebAjaxInterface",0

Looking at the other methods whose pointers are stored within this vftable, it turns out that sub_DC394 (1) is the one dedicated to handling requests sent to the /ajax.app endpoint:

int __fastcall sub_DC394(int a1)
{ 
  struct FCGX_Request *fcgx_req;
  int is_ready;
  int sessions_table;
  ...

  fcgx_req = (struct FCGX_Request *)(a1 + 136);
  if ( !FCGX_Accept_r(a1 + 136) )               // a1 + 136 == FCGX_Request
  {
    is_ready = _check_ready();
    _reqobj::ctor(&req_obj, fcgx_req);
    _set_buffs(req_buff_obj, (int)fcgx_req);
    sessions_table = _g_SESSIONS_TABLE;
    _get_SessionID((std::string *)&session_id, &req_obj);
    _get_session_obj_from_table(&session_obj, sessions_table, (const std::string *)&session_id);
    v5 = session_id - 12;
    if ( (int *)(session_id - 12) != &std::string::_Rep::_S_empty_rep_storage
      && (int)__gnu_cxx::__exchange_and_add((volatile int *)(session_id - 4), -1) <= 0 )
    {
      std::string::_Rep::_M_destroy(v5, &v31);
    }
    std::string::string((int)&v25, (int)"service", (int)&v30);
    _get_HTTP_param((std::string *)&_service_param, (int)&req_obj, (std::string *)&v25);
    v6 = v25 - 12;
    if ( &std::string::_Rep::_S_empty_rep_storage != (int *)(v25 - 12)
      && (int)__gnu_cxx::__exchange_and_add((volatile int *)(v25 - 4), -1) <= 0 )
    {
      std::string::_Rep::_M_destroy(v6, &v32);
    }
    if ( is_ready )
    {
      if ( !std::string::compare((std::string *)&_service_param, "getDp") )
      {
        sub_D28A0(a1, (int)&session_obj, (int)&req_obj, (std::ostream *)req_buff_obj);
      }
      else if ( !std::string::compare((std::string *)&_service_param, "getAlarmstate") )
      {
        sub_D2158(a1, (int)&session_obj, (int)&req_obj, (int)req_buff_obj);
      }
      ...
    }
    ...
  }
  ...
}

Apart from C++ strings manipulation garbage, the main steps performed by this piece of code are:

  • Receive new CGI request by calling FCGX_Accept_r
  • Set buffers for input and output
  • Get client-supplied session ID (parameter or cookie named “SessionId”) – more on this later
  • Get session-specific data linked to this ID
  • Read value of service HTTP parameter and dispatch to the associated handle function

A valid URL could be: /ajax.app?SessionId=<SESSIONID>&service=getAlarmstate.

NOTE: the whole reverse engineering process (static analysis) is not detailed in this paper. Most functions, variables and structures names that appear in code excerpts have been manually defined by the auditors, as the result of a prior analysis work.

CVE-2025-26389

The time frame during a penetration test engagement is limited, so quick wins are targeted. For instance, it is not uncommon to discover command injection vulnerabilities in such targets, a powerful class of vulnerabilities due to its reliable exploitation. This is what the auditors chose to focus on.

Bottom-up approach

In this approach, “sinks” are first looked at (i.e. dangerous functions that could lead to code execution) before going up to their triggering paths. For command injection vulnerabilities class, sinks refer to calls of externals programs with dynamic parameters. Indeed, a service can leverage native OS binaries to delegate tasks (e.g. modprobe for driver loading), sometimes in an insecure manner.

In a vulnerable scenario, the target would make calls to external programs along with parameters controlled by users, where injections could happen.

Hunting for sinks

External process creation can be done using C/C++ standard library (e.g. system()) or direct system calls (e.g. execve()).

Inside smartweb binary, execve usage was identified at a single location:

int __fastcall _EXEC_BIN_SH_C(int a1, const std::string *a2, const std::string *a3)
{
  __pid_t v5;
  __pid_t v6;
  int v7;
  __pid_t v8;
  bool v9;
  int i;
  char *argv[6];
  char *v13;
  int stat_loc;
  char v15;

  v13 = (char *)&_g_EmptyString_;
  v5 = fork();
  v6 = v5;
  if ( !v5 )
  {
    std::string::assign((std::string *)&v13, a2);
    std::string::append((std::string *)&v13, _CST_SPACE, 1u);
    std::string::append((std::string *)&v13, a3);
    argv[0] = "sh";
    argv[3] = 0;
    argv[1] = "-c";
    argv[2] = v13;
    for ( i = 3; i != 101; ++i )
      close(i);
    execve("/bin/sh", argv, (char *const *)environ);
    exit(127);
  }
  ...
}

This is a helper function that the auditors named “_EXEC_BIN_SH_C” whose purpose is to fork the current process before spawning a /bin/sh instance while passing it a command line ("-c" option). This is precisely a pattern potentially prone to injection vulnerabilities.

Here are the cross-references to this helper function:

X-refs

Few functions use it (9 unique). Among these, most are using _EXEC_BIN_SH_C to call binaries with no dynamic parameters:

  ...
  std::string::string(&v46, "/sbin/modprobe", &v56);
  std::string::string(&v47, "isp1362gapi", &v57);
  sub_1D7968((int)&v30, (const std::string *)&v46, (const std::string *)&v47);
  ...
  std::string::string(&v50, "isp1362gapi", &v60);
  std::string_assign(&v32, &v50);
  v25 = v50 - 12;
  if ( &std::string::_Rep::_S_empty_rep_storage != (int *)(v50 - 12)
    && (int)__gnu_cxx::__exchange_and_add((volatile int *)(v50 - 4), -1) <= 0 )
  {
    std::string::_Rep::_M_destroy(v25, &v70);
  }
  _EXEC_BIN_SH_C_0((int)&v32);
  _EXEC_BIN_SH_C_0((int)&v30);
  ...

However, among cross-references, an interesting call is made from sub_1D6244 function (this function has been renamed to “_EXEC_TAR_CF”):

  ...
  std::string::string(&__CST_TAR_CF, "tar -cf ", &v21);
  sub_1D7968((int)&v14, (const std::string *)&__CST_TAR_CF, (const std::string *)&tar_cmd_line);
  v8 = _EXEC_BIN_SH_C_0((int)&v14);
  ...

tar process is created along with a dynamic parameter (tar_cmd_line) containing process arguments (among those the files to be compressed).

The auditors spent some time understanding precisely how this files list was generated.

A closer look was given to the parent function (let’s call it “__ExportDiagram”):

  ...
  std::string::string((std::string *)sTarOutput, (const std::string *)&v90);
  std::string::append((std::string *)sTarOutput, "diagram_export.tar", 18u);
  v32 = (unsigned __int8)(v26 & _EXEC_TAR_CF(
                                  (const std::string *)v88,
                                  (const std::string *)&_s_files_to_compress,
                                  (std::string *)sTarOutput));
  ...

tar_cmd_line variable used within _EXEC_TAR_CF function is built based on _s_files_to_compress argument.

This _s_files_to_compress variable is populated by listing all files (except ‘.’, ‘..’ and ‘*.tar’) within the specified folder /mnt/cf_memory/plant/diagram/<DIAGRAM_ID>:

  ...
  v21 = std::operator<<<std::char_traits<char>>(&v51[8], "/mnt/cf_memory/plant/diagram/");
  v22 = std::ostream::operator<<(v21, aDiagramID);
  std::operator<<<std::char_traits<char>>(v22, "/");
  ...
  std::string::string((int)__GLOB_START_DOT_STAR, (int)"*.*", (int)&v99);
  v26 = _ListDir_0((const char **)v78, (std::string *)__GLOB_START_DOT_STAR, (int)v56);
  std::string::~string((std::string *)__GLOB_START_DOT_STAR);
  while ( __CST_NULL != v57 )
  {
    std::string::string((std::string *)cur_dir_entry, v57);
    if ( !std::string::compare((std::string *)cur_dir_entry, ".")
      || !std::string::compare((std::string *)cur_dir_entry, "..") )
    {
      v27 = v57;
      if ( v57 != (std::string *)(v59 - 4) )
        goto LABEL_29;
    }
    else
    {
      ptr_ext = std::string::rfind((std::string *)cur_dir_entry, ".", 0xFFFFFFFF, 1u);
      ptr_end_filename = *(_DWORD *)(cur_dir_entry[0] - 12);
      if ( ptr_ext > ptr_end_filename )
        std::__throw_out_of_range("basic_string::substr");
      std::string::string(
        (std::string *)file_ext,
        (const std::string *)cur_dir_entry,
        ptr_ext,
        ptr_end_filename - ptr_ext);
      if ( std::string::compare((std::string *)file_ext, ".tar") )
      {
        v25 = v87;                              // Add to list if not '*.tar'
        std::operator+<char>((int)v87, (char *)_CST_SPACE);
        std::string::append((std::string *)&_s_files_to_compress, (const std::string *)v87);
        std::string::~string((std::string *)v87);
      }
      ...
    }
    std::string::~string(v27);
    operator delete(v58);
    v30 = *++v60;
    v59 = (int)*v60 + 512;
    v57 = v30;
    v58 = v30;
LABEL_30:
    std::string::~string((std::string *)cur_dir_entry);
  }
  ...

Through the OZW772 panel, users can create as many diagrams as they want, each of which is assigned an (incremental) identifier. Above code and documentation reading suggests that the user also can import (and export) diagrams from the administration panel. This code seems related to this feature and is responsible for creating a tar archive (diagram_export.tar) for a specified project, containing diagram data along with other files.

The final built and executed command line has the following shape:

/bin/sh –c tar -cf diagram_export.tar -C <diagrams_dir> <list_of_files>

With:

  • diagram_dir = /mnt/cf_memory/plant/diagram/<DIAGRAM_ID>/
  • list_of_files = _s_files_to_compress (list of files in the diagram_dir folder)

Here is a potential injection point: no control seems to be performed on filenames, an attacker having the possibility to control files being compressed could execute arbitrary code (e.g. by specifying a filename like “;cat /etc/passwd”).

Two questions now arise:

  • Can a user remotely trigger this code?
  • Can a user control the files (and their names) inside a diagram folder?

In addition, these two requirements should be ideally addressable by an unauthenticated user.

Making the whole path

Reaching the sink

Referring to the quick overview of smartweb and the sub_DC394 function that has been quickly analyzed, the auditors spotted something interesting:

      ...
      else if ( !std::string::compare((std::string *)&_service_param, "exportDiagramPage") )
      {
        _cb_exportDiagramPage(a1, (int)&session_obj, (int)&req_obj, (std::ostream *)req_buff_obj);
      }
      else if ( !std::string::compare((std::string *)&_service_param, "uploadDiagramPageBkgImage") )
      {
        sub_D8914(a1, (int)&session_obj, (int)&req_obj, (std::ostream *)req_buff_obj);
      }
      ...

The exportDiagramPage service dispatches to a function that has been named “_cb_exportDiagramPage”. The latter is responsible for parsing client request, processing it (i.e. doing export), and formatting a JSON response. The following function is then called:

int __fastcall sub_2D2EC(int **a1, struct_user_obj *a2, int plant_id, int a4, int a5, int a6)
{
  int *v6;
  int v11;
  int *v12;

  v6 = *a1;
  do
  {
    v12 = v6;
    if ( a1[1] == v6 )
      return 0;
    v11 = *v6++;
  }
  while ( plant_id != sub_1C65C(v11) );
  if ( !*v12 )
    return 0;                                   
  return (*(int (__fastcall **)(int, struct_user_obj *, int, int, int))(*(_DWORD *)*v12 + 160))(*v12, a2, a4, a5, a6); // (3)
}

In this code, the last statement (3) is a C++ method call: *v12 points to a vftable from which a function pointer located at offset 160 is used for calling function. However, static analysis doesn’t allow us to easily find out which method is concerned by an indirect call.

Previously, __ExportDiagram function was mentioned: the parent function of _EXEC_TAR_CF (responsible for tar archive creation). By unwinding cross-references to __ExportDiagram, an interesting pointer in .rodata section was found:

.rodata:002030F0 ; `vtable for'CSysSystemDefinitionMgr
.rodata:002030F0 _ZTV23CSysSystemDefinitionMgr DCD 0     ; offset to this
.rodata:002030F4                 DCD _ZTI23CSysSystemDefinitionMgr ; `typeinfo for'CSysSystemDefinitionMgr
.rodata:002030F8 off_2030F8      DCD sub_36348           ; DATA XREF: sub_3571C+8o
.rodata:002030F8                                         ; .text:off_35A50o ...
.rodata:002030FC                 DCD sub_3571C
.rodata:00203100                 DCD sub_3554C
.rodata:00203104                 DCD sub_333F8
...
.rodata:00203184                 DCD sub_32BB8
.rodata:00203188                 DCD sub_32B98
.rodata:0020318C                 DCD sub_32B64
.rodata:00203190                 DCD __ExportDiagram_0   ; +160 (4)
.rodata:00203194                 DCD sub_33E64
.rodata:00203198                 DCD sub_33D78
.rodata:0020319C                 DCD sub_33C8C
.rodata:002031A0                 DCD sub_33BA8
.rodata:002031A4                 DCD sub_33AC4
.rodata:002031A8                 DCD sub_339E0
.rodata:002031AC                 DCD sub_3356C
.rodata:002031B0                 DCD sub_32B18
.rodata:002031B4                 DCD sub_350C0
.rodata:002031B8                 DCD -12                 ; offset to this
.rodata:002031BC                 DCD _ZTI23CSysSystemDefinitionMgr ; `typeinfo for'CSysSystemDefinitionMgr

The address of _ExportDiagram_0 (the parent function of _ExportDiagram) is identified in the vftable of the CSysSystemDefinitionMgr class (4). Moreover, this address entry is located at offset 160 (0x00203190-0x002030F0), which is consistent with what was observed earlier (3).

Consequently, we can assume that __ExportDiagram will ultimately be called upon /ajax.app?service=exportDiagramPage HTTP request.

Moreover, live testing on the client device revealed that this URL was accessible prior to any authentication.

Controlling files being compressed

The sink can be triggered by an anonymous user.

The second question we raised earlier was about the ability to control files within a diagram folder. The goal is to trigger tar compression with controlled parameters (filenames to be compressed) so that one of them contains the command injection.

First, the attacker needs the knowledge of an existing location for a diagram project (or to create a new empty project). Luckily, the target device was also vulnerable to CVE-2019-13941 [8] that allowed anonymous access to the diagrams folders and their contents, at predictable paths:

Anonymous access to diagrams

The image above shows a diagram project (ID 23138) containing:

  • diagram.dip: default diagram file (vendor-specific format)
  • diagram_export.tar: generated archive upon export completion

Then, looking back at the trigger path (Reaching the sink), the auditors analyzed the several possible values for the service parameter. Among these, uploadDiagramPageBkgImage seemed promising: this service allowed an unauthenticated user to upload a background image for a diagram. Moreover, tests against the running target device revealed that no filtering was applied on uploaded files; their names and extensions were fully controllable:

uploadDiagramPageBkgImage - 1

The auditors were therefore able to send a file named “;id>myid” that consequently appeared in the diagram directory:

uploadDiagramPageBkgImage - 2

According to the previous analysis (Hunting for sinks), export feature should now trigger the execution of the following command line:

/bin/sh –c tar -cf diagram_export.tar -C <diagrams_dir> diagram.dip ;id>myid

The auditors finally asked for the export archive to be created by sending the following request:

RCE trigger

The expected myid file containing command output was created alongside the newly generated diagram_export.tar archive:

RCE proof

Pre-authentication root RCE on OZW772 device.

Fixed versions

RandoriSec did not have access to all the different firmwares versions for testing but was able to assess this vulnerability against the oldest publicly available version (V8.0 at the time of writing).

It turns out that from this version, an authentication check is implemented on the /ajax.app endpoint, preventing anonymous access and thus pre-authentication exploitation of the vulnerability.

CVE-2025-26390

Still looking for quick wins, the auditors also analyzed the authentication and session management mechanisms. While black-box SQL injection attempts proved unsuccessful, a closer examination revealed some interesting findings.

SQLite queries

Heavy usage of SQLite queries by smartweb binary was observed:

    ...
    if ( sub_32858(a1) )
    {
      v6 = *(_DWORD *)(a1 + 24);
      std::string::string((int)&v32, (int)"SELECT `ParentId` FROM `PlantItem` WHERE `PlantItemId` = ?", (int)&v39);  // (5)
      v24 = _sqlite::exec(v6, &v32);
      v7 = v32 - 12;
      if ( &std::string::_Rep::_S_empty_rep_storage != (int *)(v32 - 12)
        && (int)__gnu_cxx::__exchange_and_add((volatile int *)(v32 - 4), -1) <= 0 )
      {
        std::string::_Rep::_M_destroy(v7, &v46);
      }
      v8 = _insecure_build_sql_query(
             *(_DWORD *)(a1 + 20),
             "SELECT `PlantItemId`, `TypeId`, `MessageSetId`, `MessageRowId`, `UserdefinedText`, `Accessrights`, `DeviceI"
             "d` FROM `PlantItem` WHERE `ParentId` = ? AND `TypeId` != %i AND `TypeId` != %i AND `TypeId` != %i AND `Type"
             "Id` != %i ORDER BY `OrderNr`",  // (6)
             5,
             8,
             6,
             7);
      ...
    }
    ...

Several SQL queries (5) use safe prepared statements [9] whereas others are constructed using vnsprintf and C-style format strings (6). This last technique is unsafe: no sanitization is applied on parameters while building the statement, some dangerous special characters could be inserted (e.g. ‘'’, ‘"’, ‘--’, etc.) and thus modify the original query behavior.

The auditors decided to look at this kind of SQL statements construction at different places into the binary. Indeed, it is likely that the application builds such statements while using user-controlled parameters; this situation might lead to exploitable injections.

Session management

One critical place the auditors were interested in is session management.

As previously mentioned, a cookie named “SessionId” is responsible for session tracking. This cookie value (random GUID) is set by the server once the user is authenticated:

SessionId cookie

This value is then reused by the frontend when querying endpoints and is transmitted via a Cookie header or an HTTP parameter.

A closer look was given at sub_DC394 (once again) to understand how this value was processed:

  ...
  if ( !FCGX_Accept_r(a1 + 136) )               // a1 + 136 == FCGX_Request 
  {
    is_ready = _check_ready();
    _reqobj::ctor(&req_obj, fcgx_req);
    _set_buffs(req_buff_obj, (int)fcgx_req);
    sessions_table = _g_SESSIONS_TABLE;
    _get_SessionID((std::string *)&session_id, &req_obj);  // (7)
    _get_session_obj_from_table(&session_obj, sessions_table, (const std::string *)&session_id);  // (8)
    ...
  }
  ...

These two functions were analyzed: _get_SessionID (7) and _get_session_obj_from_table (8). The first function is responsible for retrieving SessionId value from HTTP query. Regarding the second one, a user structure (struct_user_obj) is created and initialized with the retrieved session identifier value:

    ...
    _init_user_obj(&user_obj1);
    std::string::string((std::string *)&session_id_1, session_id);
    _user_obj::set_session_id(&user_obj1, (const std::string *)&session_id_1);
    ...
int __fastcall _user_obj::set_session_id(struct_user_obj *a1, const std::string *session_id)
{
  return std::string::assign((std::string *)&a1->s_session_id, session_id);
}

This initialized structure will be used by the server throughout the processing of the user HTTP request.

Looking for injections

Several methods are possible to hunt for injections: it is possible to either look at strings in binary (e.g. using a regex like “^SELECT .*”) or analyze cross-references to SQLite3 functions (e.g. _sqlite::exec), then determine whether the query is safely prepared or not.

The auditors searched for insecure SQL statements while targeting the session management mechanism. Following this methodology, a hit occurred within a function that has been named “_authenticate_users_by_sessionid”:

      ...
      _user_obj::get_sessionid((std::string *)&v67, user_obj);
      v30 = v67 - 12;
      v31 = *(_DWORD *)(v67 - 12);              // get length of session ID string
      if ( (int *)(v67 - 12) != &std::string::_Rep::_S_empty_rep_storage
        && (int)__gnu_cxx::__exchange_and_add((volatile int *)(v67 - 4), -1) <= 0 )
      {
        std::string::_Rep::_M_destroy(v30, &v84);
      }
      if ( v31 != 36 )                          // check UID length! (9)
        goto LABEL_17;
      v32 = *(_DWORD *)(a1 + 20);
      _user_obj::get_sessionid((std::string *)&v68, user_obj);
      v33 = v68;
      _sqlite3::exec_unsafe(
        v32,
        "select User.UserId, User.GroupId, User.LanguageCode, UserGroup.DpAccessLevel, UserGroup.UserAdministration, User"
        "Group.Faults, UserGroup.History, UserGroup.Billing, User.UserName, User.UserDescription, User.UserEmail, User.Us"
        "erPassword from User,UserGroup where User.GroupId = UserGroup.GroupId and User.SessionId = '%s'",  // (10)
        v68);
      ...

This function role is to register a new session for an authenticated user in a global sessions table. If the session was already recorded, the function retrieves session-related information to populate a new structure.

In the above code snippet, the current user’s session identifier is retrieved using the method _user_obj::get_sessionid. The latter is the counterpart function to _user_obj::set_session_id (mentioned earlier) and reads the s_session_id field from struct_user_obj. Moreover, the auditors noted that the following SQL statement is built in an unsafe manner: a C-style format string is used (10) to directly incorporate the retrieved session identifier value into it. Since the session identifier is user-controlled (cookie or HTTP parameter), this situation leads to an SQL injection.

Exploitation

Before executing the SQL query, a check is performed (9) to verify the session identifier string length (36 characters). No sanitization is performed regarding special characters on this value; an attacker could access the following URL to bypass the login form and authenticate as Administrator user (UserId=1):

/main.app?SessionId='+OR+User.UserId%3d1--XXXXXXXXXXXXXXXX

Fixed Versions

Once again, RandoriSec could not determine by itself the specific vulnerable versions. However, coordinated work with Siemens allowed to determine that this vulnerability was fixed from version V6.0.

Mitigation

RandoriSec recommends updating the OZWx72 firmware to the latest version (V12.0), which addresses all of these vulnerabilities.

Also, exposing such devices to the Internet poses significant risks. Ideally, such devices should only be managed through a VPN access. It is also recommended implementing IP addresses filtering to restrict access to all device services (e.g. SSH, HTTP panel) to only authorized users.

Disclosure timeline

2024-12-30: vulnerabilities were reported to Siemens ProductCERT

2025-05-06: advisory draft sent to RandoriSec, CVE IDs assignment

2025-05-13: public advisory release

References

[0] https://hit.sbt.siemens.com/RWD/app.aspx?MODULE=Catalog&ACTION=ShowProduct&KEY=BPZ%3AOZW772..

[1] https://cert-portal.siemens.com/productcert/html/ssa-047424.html

[2] https://en.wikipedia.org/wiki/KNX

[3] https://support.industry.siemens.com/cs/document/62564534/ozw772-factory-firmware-update-and-system-definition?dti=0&lc=en-FR

[4] https://en.wikipedia.org/wiki/UBIFS

[5] https://github.com/onekey-sec/ubi_reader

[6] https://en.wikipedia.org/wiki/Run-time_type_information

[7] https://github.com/toshic/libfcgi/blob/master/include/fcgiapp.h#L91

[8] https://nvd.nist.gov/vuln/detail/CVE-2019-13941

[9] https://cheatsheetseries.owasp.org/cheatsheets/Query_Parameterization_Cheat_Sheet.html#prepared-statement-examples