diff --git a/src/rat_king_parser/config_parser/rat_config_parser.py b/src/rat_king_parser/config_parser/rat_config_parser.py index c4a6c5d..2ce0d39 100644 --- a/src/rat_king_parser/config_parser/rat_config_parser.py +++ b/src/rat_king_parser/config_parser/rat_config_parser.py @@ -27,7 +27,6 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from collections import OrderedDict from logging import getLogger from os.path import isfile from re import DOTALL, compile, search @@ -77,11 +76,13 @@ def __init__( } self.remap_config = remap_config self.preserve_obfuscated_keys = preserve_obfuscated_keys + self._dnpp: DotNetPEPayload | None = None + self._decryptor: ConfigDecryptor | None = None try: if data is None and not isfile(file_path): raise ConfigParserException("File not found") # Filled in _decrypt_and_decode_config() - self._incompatible_decryptors: list[int] = [] + self._incompatible_decryptors: list[Any] = [] try: self._dnpp = DotNetPEPayload(file_path, yara_rule, data) except Exception as e: @@ -90,7 +91,6 @@ def __init__( self.report["yara_possible_family"] = self._dnpp.yara_match # Assigned in _decrypt_and_decode_config() - self._decryptor: ConfigDecryptor = None self.report["config"] = self._get_config() key_hex = "None" if self._decryptor is not None and self._decryptor.key is not None: @@ -128,38 +128,7 @@ def _decrypt_and_decode_config( # Translate config value RVAs to string values for k in item_data: item_data[k] = self._dnpp.user_string_from_rva(item_data[k]) - - # Attempt to decrypt encrypted values - for decryptor in SUPPORTED_DECRYPTORS: - if decryptor in self._incompatible_decryptors: - continue - - if self._decryptor is None: - # Try to instantiate the selected decryptor - # Add to incompatible list and move on upon failure - try: - self._decryptor = decryptor(self._dnpp) - except IncompatibleDecryptorException as ide: - logger.debug( - f"Decryptor incompatible {decryptor} : {ide}" - ) - self._incompatible_decryptors.append(decryptor) - continue - try: - # Try to decrypt the encrypted strings - # Continue to next compatible decryptor on failure - item_data = self._decryptor.decrypt_encrypted_strings( - item_data - ) - break - except Exception as e: - logger.debug( - f"Decryption failed with decryptor {decryptor} : {e}" - ) - self._decryptor = None - - if self._decryptor is None: - raise ConfigParserException("All decryptors failed") + item_data = self._attempt_decryption(item_data) elif isinstance(item, config_item.ByteArrayConfigItem): for k in item_data: @@ -169,32 +138,88 @@ def _decrypt_and_decode_config( ).hex() decoded_config.update(item_data) + # UrlHost is a marker of a special case until this can be standardized if len(decoded_config) < min_config_len and "UrlHost" not in decoded_config: raise ConfigParserException( f"Minimum threshold of config items not met: {len(decoded_config)}/{min_config_len}" ) if self.remap_config: - sorted_decoded_config = OrderedDict() - normalized_fields = [] - for k in sorted(config_fields_map.keys()): - key_name = config_fields_map[k] - value = decoded_config[key_name] - key_normalized, value = check_key_n_value(key_name, value) - if key_normalized != key_name: - normalized_fields.append(key_name) - sorted_decoded_config[key_normalized] = value - # Ensure config items added by decryptors dynamically are preserved - sorted_decoded_config.update( - { - key: decoded_config[key] - for key in decoded_config - if key not in sorted_decoded_config and key not in normalized_fields - } - ) - return sorted_decoded_config + return self._remap_config(decoded_config, config_fields_map) return decoded_config + def _attempt_decryption(self, item_data: dict[str, Any]) -> dict[str, Any]: + # Attempt to decrypt encrypted values + for decryptor in SUPPORTED_DECRYPTORS: + if decryptor in self._incompatible_decryptors: + continue + + if self._decryptor is None: + # Try to instantiate the selected decryptor + # Add to incompatible list and move on upon failure + try: + self._decryptor = decryptor(self._dnpp) + except IncompatibleDecryptorException as ide: + logger.debug(f"Decryptor incompatible {decryptor} : {ide}") + self._incompatible_decryptors.append(decryptor) + continue + try: + # Try to decrypt the encrypted strings + # Continue to next compatible decryptor on failure + return self._decryptor.decrypt_encrypted_strings(item_data) + except Exception as e: + logger.debug(f"Decryption failed with decryptor {decryptor} : {e}") + self._decryptor = None + + if self._decryptor is None: + raise ConfigParserException("All decryptors failed") + return item_data + + def _remap_config( + self, decoded_config: dict[str, Any], config_fields_map: dict[int, str] + ) -> dict[str, Any]: + remapped_config = {} + normalized_fields = [] + for k in sorted(config_fields_map.keys()): + key_name = config_fields_map[k] + value = decoded_config[key_name] + + # Run your normalization (e.g. converting HostsFE -> Hosts) + key_normalized, value = check_key_n_value(key_name, value) + + if key_normalized != key_name: + normalized_fields.append(key_name) + + # --- LOGIC TO APPEND INSTEAD OF OVERWRITE --- + if key_normalized in remapped_config: + existing_val = remapped_config[key_normalized] + + # Case 1: Values are Strings (e.g. "1.2.3.4:80") + if isinstance(existing_val, str) and value: + # Append with a comma separator + new_val = ",".join(map(str, value)) if isinstance(value, list) else value + remapped_config[key_normalized] = f"{existing_val},{new_val}" + + # Case 2: Values are Lists (e.g. ["1.2.3.4:80"]) + elif isinstance(existing_val, list): + # If the new value is also a list, extend; otherwise append + if isinstance(value, list): + remapped_config[key_normalized] = existing_val + value + else: + remapped_config[key_normalized].append(value) + else: + # Key does not exist yet, create it + remapped_config[key_normalized] = value + # Ensure config items added by decryptors dynamically are preserved + remapped_config.update( + { + key: decoded_config[key] + for key in decoded_config + if key not in remapped_config and key not in normalized_fields + } + ) + return remapped_config + # Searches for the RAT configuration section, using the VerifyHash() marker # or brute-force, returning the decrypted config on success def _get_config(self) -> dict[str, Any]: diff --git a/src/rat_king_parser/config_parser/utils/config_normalization.py b/src/rat_king_parser/config_parser/utils/config_normalization.py index 9d8db7e..2783c0c 100644 --- a/src/rat_king_parser/config_parser/utils/config_normalization.py +++ b/src/rat_king_parser/config_parser/utils/config_normalization.py @@ -31,7 +31,7 @@ from typing import Any normalized_keys = { - "Hosts": ("HOSTS", "Hosts", "ServerIp", "hardcodedhosts", "PasteUrl"), + "Hosts": ("HOSTS", "Hosts", "HostsFE", "ServerIp", "hardcodedhosts", "PasteUrl"), "Ports": ("Port", "Ports", "ServerPort"), "Mutex": ("MTX", "MUTEX", "Mutex", "mutex_string"), "Version": ("VERSION", "Version"), @@ -39,14 +39,16 @@ "Group": ("Group", "Groub", "GroupTag", "TAG"), } +_normalized_keys_map = { + alias: k for k, aliases in normalized_keys.items() for alias in aliases +} + # Normalizes config keys/values for easier mapping def check_key_n_value(key: str, value: Any) -> tuple[str, Any]: - key = key.replace("_", "") - for k, v in normalized_keys.items(): - if key in v: - key = k - break + key_clean = key.replace("_", "") + if key_clean in _normalized_keys_map: + key = _normalized_keys_map[key_clean] if key in ("Hosts", "Ports") and isinstance(value, str): if value not in ("null", "false"): diff --git a/src/rat_king_parser/config_parser/utils/decryptors/config_decryptor_aes_with_iv.py b/src/rat_king_parser/config_parser/utils/decryptors/config_decryptor_aes_with_iv.py index c64b654..16646a6 100644 --- a/src/rat_king_parser/config_parser/utils/decryptors/config_decryptor_aes_with_iv.py +++ b/src/rat_king_parser/config_parser/utils/decryptors/config_decryptor_aes_with_iv.py @@ -36,8 +36,6 @@ from typing import Tuple from Cryptodome.Cipher import AES -from Cryptodome.Cipher.AES import MODE_CBC as CBC -from Cryptodome.Cipher.AES import MODE_CFB as CFB from Cryptodome.Protocol.KDF import PBKDF2 from Cryptodome.Util.Padding import unpad @@ -54,8 +52,8 @@ class ConfigDecryptorAESWithIV(ConfigDecryptor): # Minimum length of valid ciphertext _MIN_CIPHERTEXT_LEN = 48 _ALGO_MAP = { - b"\x17": CBC, - b"\x1a": CFB, + b"\x17": AES.MODE_CBC, + b"\x1a": AES.MODE_CFB, } # Patterns for identifying AES metadata @@ -66,9 +64,7 @@ class ConfigDecryptorAESWithIV(ConfigDecryptor): # Do not re.compile in-line replacement patterns _PATTERN_AES_KEY_BASE = b"(.{3}\x04).%b" _PATTERN_AES_SALT_INIT = b"\x80%b\x2a" - _PATTERN_AES_SALT_ITER = re.compile( - b"[\x02-\x05]\x7e(.{4})\x20(.{4})\x73", re.DOTALL - ) + _PATTERN_AES_SALT_ITER = re.compile(b"[\x02-\x05]\x7e(.{4})\x20(.{4})\x73", re.DOTALL) def __init__(self, payload: DotNetPEPayload) -> None: super().__init__(payload) @@ -77,7 +73,7 @@ def __init__(self, payload: DotNetPEPayload) -> None: self._key_candidates: list[bytes] = None self._key_size: int = None self._key_rva: int = None - self._aes_algo = CBC + self._aes_algo = AES.MODE_CBC try: self._get_aes_metadata() except Exception as e: @@ -89,19 +85,16 @@ def _decrypt(self, iv: bytes, ciphertext: bytes) -> bytes: logger.debug( f"Decrypting {ciphertext} with key {self.key.hex()} and IV {iv.hex()}..." ) - cipher = AES.new(self.key, mode=self._aes_algo, iv=iv) - unpadded_text = "" padded_text = cipher.decrypt(ciphertext) try: - # Attempt to unpad first + # Attempt to unpad unpadded_text = unpad(padded_text, AES.block_size) except Exception as e: raise ConfigParserException( f"Error decrypting ciphertext {ciphertext} with IV {iv.hex()} and key {self.key.hex()} : {e}" ) - logger.debug(f"Decryption result: {unpadded_text}") return unpadded_text @@ -118,13 +111,15 @@ def _derive_aes_passphrase_candidates(self, key_val: str) -> list[bytes]: # Decrypts encrypted config values with the provided cipher data def decrypt_encrypted_strings( - self, encrypted_strings: dict[str, str] - ) -> dict[str, str]: + self, encrypted_strings: dict[str, str]) -> dict[str, str]: logger.debug("Decrypting encrypted strings...") if self._key_candidates is None: self._key_candidates = self._get_aes_key_candidates(encrypted_strings) decrypted_config_strings = {} + successfully_decrypted_count = 0 + successful_key = None + for k, v in encrypted_strings.items(): # Leave empty strings as they are if len(v) == 0: @@ -150,59 +145,88 @@ def decrypt_encrypted_strings( # after the IV, and run the decryption iv, ciphertext = decoded_val[32:48], decoded_val[48:] result, last_exc = None, None - key_idx = 0 - # Run through key candidates until suitable one found or failure - while result is None and key_idx < len(self._key_candidates): + + # Try the successful key first if we found one + if successful_key: try: - self.key = self._key_candidates[key_idx] - key_idx += 1 + self.key = successful_key result = decode_bytes(self._decrypt(iv, ciphertext)) - except ConfigParserException as e: + except (ValueError, ConfigParserException) as e: last_exc = e + result = None + + # Run through key candidates until suitable one found or failure + if result is None: + for candidate_key in self._key_candidates: + if candidate_key == successful_key: + continue + try: + self.key = candidate_key + result = decode_bytes(self._decrypt(iv, ciphertext)) + successful_key = candidate_key + break + except (ValueError, ConfigParserException) as e: + last_exc = e if result is None: logger.debug( f"Decryption failed for item {v}: {last_exc}; Leaving as original value..." ) result = v + else: + successfully_decrypted_count += 1 logger.debug(f"Key: {k}, Value: {result}") decrypted_config_strings[k] = result + if successfully_decrypted_count == 0: + raise ConfigParserException( + "No strings could be decrypted with the available keys" + ) + + # Set the key to the successful one for reporting + if successful_key: + self.key = successful_key + logger.debug("Successfully decrypted strings") return decrypted_config_strings # Extracts AES key candidates from the payload - def _get_aes_key_candidates(self, encrypted_strings: dict[str, str]) -> list[bytes]: + def _get_aes_key_candidates( + self, encrypted_strings: dict[str, str]) -> list[bytes]: logger.debug("Extracting AES key candidates...") keys = [] - # Use the key Field name to index into our existing config - key_raw_value = encrypted_strings[ - self._payload.field_name_from_rva(self._key_rva) - ] - passphrase_candidates = self._derive_aes_passphrase_candidates(key_raw_value) - - for candidate in passphrase_candidates: - try: - key = PBKDF2(candidate, self.salt, self._key_size, self._iterations) - keys.append(key) - logger.debug(f"AES key derived: {keys[-1]}") - except Exception as e: - logger.debug(f"Error in key generation: {e}") + # We need to try all combinations of metadata candidates and their passphrase candidates + for meta in self._metadata_candidates: + field_name = self._payload.field_name_from_rva(meta["key_rva"]) + if field_name not in encrypted_strings: continue + + key_raw_value = encrypted_strings[field_name] + passphrase_candidates = self._derive_aes_passphrase_candidates(key_raw_value) + + for candidate in passphrase_candidates: + try: + key = PBKDF2( + candidate, meta["salt"], self._key_size, meta["iterations"] + ) + if key not in keys: + keys.append(key) + logger.debug(f"AES key derived: {key.hex()}") + except Exception as e: + logger.debug(f"Error in key generation: {e}") + continue if len(keys) == 0: raise ConfigParserException( - f"Could not derive key from passphrase candidates: {passphrase_candidates}" + "Could not derive key from any metadata candidate" ) return keys # Extracts the AES key and block size from the payload def _get_aes_key_and_block_size_and_algo(self) -> Tuple[int, int, int]: logger.debug("Extracting AES key and block size...") - hit = re.search( - self._PATTERN_AES_KEY_AND_BLOCK_SIZE_AND_ALGO, self._payload.data - ) + hit = re.search(self._PATTERN_AES_KEY_AND_BLOCK_SIZE_AND_ALGO, self._payload.data) if hit is None: raise ConfigParserException("Could not extract AES key or block size") @@ -222,9 +246,7 @@ def _get_aes_key_rva(self, metadata_ins_offset: int) -> int: logger.debug("Extracting AES key RVA...") # Get the RVA of the method that sets up AES256 metadata - metadata_method_token = self._payload.method_from_instruction_offset( - metadata_ins_offset, by_token=True - ).token + metadata_method_token = self._payload.method_from_instruction_offset(metadata_ins_offset, by_token=True).token # Insert this RVA into the KEY_BASE pattern to find where the AES key # is initialized key_hit = re.search( @@ -243,29 +265,34 @@ def _get_aes_key_rva(self, metadata_ins_offset: int) -> int: # sets the necessary values needed for decryption def _get_aes_metadata(self) -> None: logger.debug("Extracting AES metadata...") - metadata = None + self._metadata_candidates = [] # Some payloads have multiple embedded salt values: - # Find the one that is actually used for initialization - for candidate in re.finditer(self._PATTERN_AES_SALT_ITER, self._payload.data): + # Find the ones that are actually used for initialization + for hit in re.finditer(self._PATTERN_AES_SALT_ITER, self._payload.data): try: - self.salt = self._get_aes_salt(candidate.groups()[0]) - metadata = candidate - self._key_rva = self._get_aes_key_rva(metadata.start()) + salt = self._get_aes_salt(hit.groups()[0]) + key_rva = self._get_aes_key_rva(hit.start()) + iterations = bytes_to_int(hit.groups()[1]) + self._metadata_candidates.append( + {"salt": salt, "key_rva": key_rva, "iterations": iterations} + ) except ConfigParserException as cfe: logger.info( - f"Initialization using salt candidate {hex(bytes_to_int(candidate.groups()[0]))} failed: {cfe}" + f"Initialization using salt candidate {hex(bytes_to_int(hit.groups()[0]))} failed: {cfe}" ) continue - if metadata is None: + if not self._metadata_candidates: raise ConfigParserException("Could not identify AES metadata") - logger.debug(f"AES metadata found at offset {hex(metadata.start())}") + + # Extraction of common metadata self._key_size, self._block_size, self._aes_algo = ( self._get_aes_key_and_block_size_and_algo() ) - logger.debug("Extracting AES iterations...") - self._iterations = bytes_to_int(metadata.groups()[1]) - logger.debug(f"Found AES iteration number of {self._iterations}") + # Legacy fields for backward compatibility, use first valid candidate + self.salt = self._metadata_candidates[0]["salt"] + self._key_rva = self._metadata_candidates[0]["key_rva"] + self._iterations = self._metadata_candidates[0]["iterations"] # Extracts the AES salt from the payload, accounting for both hardcoded # salt byte arrays, and salts derived from hardcoded strings @@ -278,9 +305,7 @@ def _get_aes_salt(self, salt_rva: int) -> bytes: # # stsfld uint8[] Client.Algorithm.Aes256::Salt # ret - aes_salt_initialization = self._payload.data.find( - self._PATTERN_AES_SALT_INIT % salt_rva - ) + aes_salt_initialization = self._payload.data.find(self._PATTERN_AES_SALT_INIT % salt_rva) if aes_salt_initialization == -1: raise ConfigParserException("Could not identify AES salt initialization") @@ -292,9 +317,7 @@ def _get_aes_salt(self, salt_rva: int) -> bytes: salt_op = bytes([self._payload.data[salt_op_offset]]) # Get the salt RVA from the 4 bytes following the initialization op - salt_strings_rva_packed = self._payload.data[ - salt_op_offset + 1 : salt_op_offset + 5 - ] + salt_strings_rva_packed = self._payload.data[salt_op_offset + 1 : salt_op_offset + 5] salt_strings_rva = bytes_to_int(salt_strings_rva_packed) # If the op is a ldstr op, just get the bytes value of the string being @@ -309,9 +332,7 @@ def _get_aes_salt(self, salt_rva: int) -> bytes: # byte array value from the FieldRVA table elif salt_op == OPCODE_LDTOKEN: salt_size = self._payload.data[salt_op_offset - 7] - salt = self._payload.byte_array_from_size_and_rva( - salt_size, salt_strings_rva - ) + salt = self._payload.byte_array_from_size_and_rva(salt_size, salt_strings_rva) else: raise ConfigParserException(f"Unknown salt opcode found: {salt_op.hex()}") diff --git a/src/rat_king_parser/config_parser/utils/dotnetpe_payload.py b/src/rat_king_parser/config_parser/utils/dotnetpe_payload.py index f720f64..97da06a 100644 --- a/src/rat_king_parser/config_parser/utils/dotnetpe_payload.py +++ b/src/rat_king_parser/config_parser/utils/dotnetpe_payload.py @@ -28,6 +28,7 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from bisect import bisect_right from dataclasses import dataclass from hashlib import sha256 from logging import getLogger @@ -72,6 +73,8 @@ def __init__( self.dotnetpe = dnPE(data=self.data, clr_lazy_load=True) else: self.dotnetpe = dnPE(self.file_path, clr_lazy_load=True) + if not self.dotnetpe.net or not self.dotnetpe.net.mdtables: + raise ConfigParserException("Failed to load project as dotnet executable (missing Metadata)") except Exception: raise ConfigParserException("Failed to load project as dotnet executable") @@ -83,13 +86,23 @@ def __init__( self._methods = self._generate_method_list() self._methods_by_offset = sorted(self._methods, key=lambda m: m.offset) self._methods_by_token = sorted(self._methods, key=lambda m: m.token) + self._offsets = [m.offset for m in self._methods_by_offset] + self._tokens = [m.token for m in self._methods_by_token] + + # Pre-compute FieldRva mapping for O(1) lookups + self._field_rva_map = {} + if getattr(self.dotnetpe.net.mdtables, "FieldRva", None): + self._field_rva_map = { + row.struct.Field_Index: row.struct.Rva + for row in self.dotnetpe.net.mdtables.FieldRva + } # Given a byte array's size and RVA, translates the RVA to the offset of # the byte array and returns the bytes of the array as a byte string def byte_array_from_size_and_rva(self, arr_size: int, arr_rva: int) -> bytes: arr_field_rva = self.fieldrva_from_rva(arr_rva) arr_offset = self.offset_from_rva(arr_field_rva) - return self.data[arr_offset : arr_offset + arr_size] + return self.data[arr_offset: arr_offset + arr_size] # Given an offset, and either a terminating offset or delimiter, extracts # the byte string @@ -113,7 +126,10 @@ def byte_string_from_offset( # Given an RVA, derives the corresponding Field name def field_name_from_rva(self, rva: int) -> str: try: - return self.dotnetpe.net.mdtables.Field.rows[ + field_table = getattr(self.dotnetpe.net.mdtables, "Field", None) + if not field_table: + raise ConfigParserException(f"Could not find Field table for RVA {rva}") + return field_table.rows[ (rva ^ MDT_FIELD_DEF) - 1 ].Name.value except Exception: @@ -122,9 +138,8 @@ def field_name_from_rva(self, rva: int) -> str: # Given an RVA, derives the corresponding FieldRVA value def fieldrva_from_rva(self, rva: int) -> int: field_id = rva ^ MDT_FIELD_DEF - for row in self.dotnetpe.net.mdtables.FieldRva: - if row.struct.Field_Index == field_id: - return row.struct.Rva + if field_id in self._field_rva_map: + return self._field_rva_map[field_id] raise ConfigParserException(f"Could not find FieldRVA for RVA {rva}") # Generates a list of DotNetPEMethod objects for efficient lookups of method @@ -133,8 +148,11 @@ def _generate_method_list( self, ) -> list[DotNetPEMethod]: method_objs = [] + method_def_table = getattr(self.dotnetpe.net.mdtables, "MethodDef", None) + if not method_def_table: + return method_objs - for idx, method in enumerate(self.dotnetpe.net.mdtables.MethodDef.rows): + for idx, method in enumerate(method_def_table.rows): method_offset = self.offset_from_rva(method.Rva) # Parse size from flags @@ -144,7 +162,7 @@ def _generate_method_list( method_size = flags >> 2 elif flags & 3 == 3: # Fat format (add 12-byte header) method_size = 12 + bytes_to_int( - self.data[method_offset + 4 : method_offset + 8] + self.data[method_offset + 4: method_offset + 8] ) method_objs.append( @@ -194,10 +212,14 @@ def methods_from_name(self, name: str) -> list[DotNetPEMethod]: def method_from_instruction_offset( self, ins_offset: int, step: int = 0, by_token: bool = False ) -> DotNetPEMethod: - for idx, method in enumerate(self._methods_by_offset): + idx = bisect_right(self._offsets, ins_offset) - 1 + if idx >= 0: + method = self._methods_by_offset[idx] if method.offset <= ins_offset < method.offset + method.size: + if step == 0: + return method return ( - self._methods_by_token[self._methods_by_token.index(method) + step] + self._methods_by_token[bisect_right(self._tokens, method.token) - 1 + step] if by_token else self._methods_by_offset[idx + step] ) @@ -240,21 +262,28 @@ def custom_attribute_from_type(self, typespacename: str, typename: str) -> dict: config = {} try: ca_map = {} - for ca in self.dotnetpe.net.mdtables.CustomAttribute.rows: - idx = ca.Parent.row_index - if idx not in ca_map: - ca_map[idx] = [] - ca_map[idx].append(ca) + ca_table = getattr(self.dotnetpe.net.mdtables, "CustomAttribute", None) + if ca_table: + for ca in ca_table.rows: + idx = ca.Parent.row_index + if idx not in ca_map: + ca_map[idx] = [] + ca_map[idx].append(ca) + + td_table = getattr(self.dotnetpe.net.mdtables, "TypeDef", None) + if not td_table: + return config - for td in self.dotnetpe.net.mdtables.TypeDef.rows: + for td in td_table.rows: if ( td.TypeNamespace.value != typespacename and td.TypeName.value != typename ): continue - for pd_row_index, pd in enumerate( - self.dotnetpe.net.mdtables.Property.rows - ): + prop_table = getattr(self.dotnetpe.net.mdtables, "Property", None) + if not prop_table: + continue + for pd_row_index, pd in enumerate(prop_table.rows): if pd.Name.value.startswith(( "Boolean_", "BorderStyle_",