diff --git a/analyzer/ldap.spicy b/analyzer/ldap.spicy
index a7be8af..2bf45a9 100644
--- a/analyzer/ldap.spicy
+++ b/analyzer/ldap.spicy
@@ -601,8 +601,14 @@ public function string_representation(search_filter: SearchFilter): string {
                              search_filter.FILTER_LE.assertionValueDecoded);
       }
       case FilterType::FILTER_SUBSTR: {
-        repr  = "(%s=*%s*)" % (search_filter.FILTER_SUBSTR.attributeDesc.decode(),
-                               search_filter.FILTER_SUBSTR.assertionValueDecoded);
+        local anys: string = "";
+        if ( |search_filter.FILTER_SUBSTR.anys| > 0 )
+          anys = b"*".join(search_filter.FILTER_SUBSTR.anys).decode() + "*";
+
+        repr  = "(%s=%s*%s%s)" % (search_filter.FILTER_SUBSTR.attributeDesc.decode(),
+                                  search_filter.FILTER_SUBSTR.initial,
+                                  anys,
+                                  search_filter.FILTER_SUBSTR.final);
       }
       case FilterType::FILTER_PRESENT: {
         repr = "(%s=*)" % search_filter.FILTER_PRESENT;
@@ -620,10 +626,6 @@ type DecodedAttributeValue = unit(fType: FilterType) {
   attributeDesc_len: uint8;
   attributeDesc: bytes &size=self.attributeDesc_len;
 
-  # For some reason, two intermediate uint8 values are present in the FILTER_SUBSTR type.
-  : uint8 if ( fType == FilterType::FILTER_SUBSTR );
-  : uint8 if ( fType == FilterType::FILTER_SUBSTR );
-
   : uint8;
   assertionValue_len: uint8;
   assertionValue: bytes &size=self.assertionValue_len;
@@ -662,6 +664,33 @@ type DecodedAttributeValue = unit(fType: FilterType) {
   }
 };
 
+type SubstringFilter = unit {
+  var initial: string;
+  var final: string;
+  var anys: vector<string>;
+
+  : uint8;  # filter tag
+  attributeDesc_len: uint8;
+  attributeDesc: bytes &size=self.attributeDesc_len;
+
+  # Crunch through the sequence/choice of substrings.
+  #
+  # https://datatracker.ietf.org/doc/html/rfc4511#section-4.5.1
+  header: ASN1::ASN1Header;
+  : ASN1::ASN1Message(False)[] &size=self.header.len.len foreach {
+    local data = $$.application_data.decode();
+    if ( $$.application_id == 0 ) {
+      self.initial = data;
+    } else if ( $$.application_id == 1 ) {
+      self.anys.push_back(data);
+    } else if ( $$.application_id == 2 ) {
+      self.final = data;
+    } else {
+      throw "invalid substring choice %s" % $$.application_id;
+    }
+  }
+};
+
 type SearchFilter = unit {
   var filterType: FilterType = FilterType::Undef;
   var filterBytes: bytes = b"";
@@ -693,7 +722,7 @@ type SearchFilter = unit {
 
     FilterType::FILTER_EQ      -> FILTER_EQ:      DecodedAttributeValue(FilterType::FILTER_EQ)
                                                   &parse-from=self.filterBytes;
-    FilterType::FILTER_SUBSTR  -> FILTER_SUBSTR:  DecodedAttributeValue(FilterType::FILTER_SUBSTR)
+    FilterType::FILTER_SUBSTR  -> FILTER_SUBSTR:  SubstringFilter
                                                   &parse-from=self.filterBytes;
     FilterType::FILTER_GE      -> FILTER_GE:      DecodedAttributeValue(FilterType::FILTER_GE)
                                                   &parse-from=self.filterBytes;
diff --git a/tests/analyzer/ldap_substring_search.zeek b/tests/analyzer/ldap_substring_search.zeek
new file mode 100644
index 0000000..a61fdfa
--- /dev/null
+++ b/tests/analyzer/ldap_substring_search.zeek
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 by the Zeek Project. See LICENSE for details.
+
+# @TEST-EXEC: zeek -C -r ${TRACES}/ldap_star_single.pcap %INPUT >output 2>&1
+# @TEST-EXEC: btest-diff output
+# @TEST-EXEC: cat conn.log | zeek-cut -m ts uid history service > conn.log2 && mv conn.log2 conn.log
+# @TEST-EXEC: btest-diff conn.log
+# @TEST-EXEC: btest-diff ldap_search.log
+#
+# @TEST-DOC: Test substring filter parsed and rendered properly when initial and final are present, but no anys.
+
+@load base/protocols/conn
+@load analyzer
diff --git a/tests/baseline/analyzer.ldap_substring_search/conn.log b/tests/baseline/analyzer.ldap_substring_search/conn.log
new file mode 100644
index 0000000..3041150
--- /dev/null
+++ b/tests/baseline/analyzer.ldap_substring_search/conn.log
@@ -0,0 +1,4 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+### NOTE: This file has been sorted with diff-sort.
+XXXXXXXXXX.XXXXXX	CHhAvVGS1DHFjwGM9	D	spicy_ldap_tcp
+ts	uid	history	service
diff --git a/tests/baseline/analyzer.ldap_substring_search/ldap_search.log b/tests/baseline/analyzer.ldap_substring_search/ldap_search.log
new file mode 100644
index 0000000..3ada189
--- /dev/null
+++ b/tests/baseline/analyzer.ldap_substring_search/ldap_search.log
@@ -0,0 +1,12 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+### NOTE: This file has been sorted with diff-sort.
+#separator \x09
+#set_separator	,
+#empty_field	(empty)
+#unset_field	-
+#path	ldap_search
+#open XXXX-XX-XX-XX-XX-XX
+#fields	ts	uid	id.orig_h	id.orig_p	id.resp_h	id.resp_p	proto	message_id	scope	deref	base_object	result_count	result	diagnostic_message	filter	attributes
+#types	time	string	addr	port	addr	port	string	int	set[string]	set[string]	vector[string]	count	set[string]	vector[string]	string	vector[string]
+#close XXXX-XX-XX-XX-XX-XX
+XXXXXXXXXX.XXXXXX	CHhAvVGS1DHFjwGM9	192.168.10.152	34581	192.168.10.186	389	tcp	6	tree	always	DC=matrix\x2cDC=local	0	-	-	(gPCUserExtensionNames=[*])	-
diff --git a/tests/baseline/analyzer.ldap_substring_search/output b/tests/baseline/analyzer.ldap_substring_search/output
new file mode 100644
index 0000000..b1bb951
--- /dev/null
+++ b/tests/baseline/analyzer.ldap_substring_search/output
@@ -0,0 +1,2 @@
+### BTest baseline data generated by btest-diff. Do not edit. Use "btest -U/-u" to update. Requires BTest >= 0.63.
+### NOTE: This file has been sorted with diff-sort.
diff --git a/tests/traces/ldap_star_single.pcap b/tests/traces/ldap_star_single.pcap
new file mode 100644
index 0000000..e7821a8
Binary files /dev/null and b/tests/traces/ldap_star_single.pcap differ