From 5295f386d77ad7d3c08f7e3fe22ac6a9ac348ebc Mon Sep 17 00:00:00 2001 From: haeter525 Date: Sat, 24 Jan 2026 17:09:54 +0000 Subject: [PATCH 1/9] Detect and patch invalid compression method in APK --- quark/core/apkinfo.py | 5 +- quark/core/apkpatcher.py | 188 ++++++++++++++++++++++++++++ quark/core/interface/baseapkinfo.py | 55 +++++--- quark/core/r2apkinfo.py | 16 ++- quark/core/rzapkinfo.py | 17 ++- 5 files changed, 248 insertions(+), 33 deletions(-) create mode 100644 quark/core/apkpatcher.py diff --git a/quark/core/apkinfo.py b/quark/core/apkinfo.py index b18660a24..bf1e29fdc 100644 --- a/quark/core/apkinfo.py +++ b/quark/core/apkinfo.py @@ -28,11 +28,10 @@ def __init__(self, apk_filepath: Union[str, PathLike]): if self.ret_type == "APK": # return the APK, list of DalvikVMFormat, and Analysis objects - self.apk, self.dalvikvmformat, self.analysis = AnalyzeAPK(apk_filepath) + self.apk, self.dalvikvmformat, self.analysis = AnalyzeAPK(self.data, raw=True) elif self.ret_type == "DEX": # return the sha256hash, DalvikVMFormat, and Analysis objects - _, _, self.analysis = AnalyzeDex(apk_filepath) - self._manifest = None + _, _, self.analysis = get_default_session().addDEX(self.apk_filename, self.data) else: raise ValueError("Unsupported File type.") diff --git a/quark/core/apkpatcher.py b/quark/core/apkpatcher.py new file mode 100644 index 000000000..8aafc74ab --- /dev/null +++ b/quark/core/apkpatcher.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from contextlib import suppress +import mmap +import struct +import zlib +from typing import Iterator, Tuple + +EOCD_SIGNATURE = b"PK\x05\x06" +CDH_SIGNATURE = b"PK\x01\x02" +LFH_SIGNATURE = b"PK\x03\x04" + +# A set of all compression methods defined in the ZIP file format spec. +# See https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT for details. +VALID_COMPRESSION_METHODS = set(range(0, 21)) | set(range(93, 100)) + + +class SeekableMMap(mmap.mmap): + """ + A mmap.mmap subclass that adds the seekable method required by + zipfile.ZipFile in Python 3.12 or earlier. + """ + + def seekable(self) -> bool: + """ + Return whether the file supports seeking. Always return True. + See https://docs.python.org/3/library/mmap.html#mmap.mmap.seekable. + """ + return True + + +class ApkPatcher: + """ + A utility class to handle anti-analysis techniques in Android APK files. + """ + + @staticmethod + def patch(raw_data: mmap.mmap) -> bool: + """ + Finds and patches known anti-analysis techniques in an APK. + + This function perform patches in place and suppresses any errors to + prevent crashes that would interrupt the analysis. + + :param raw_data: A memory-mapped file object of the APK. + :return: True if any part of the APK was patched; False otherwise. + """ + with suppress(BaseException): + eocd_offset = ApkPatcher._find_eocd(raw_data) + cdh_count, cdh_start_offset = ApkPatcher._parse_eocd( + raw_data, eocd_offset + ) + compression_patched = ApkPatcher._patch_invalid_compression_method( + raw_data, cdh_count, cdh_start_offset + ) + return compression_patched + return False + + @staticmethod + def _find_eocd(raw_data: mmap.mmap) -> int: + """ + Finds the End of Central Directory (EOCD) record in the APK data. + + :param raw_data: A memory-mapped file object of the APK. + :raises ValueError: If the EOCD signature cannot be found. + """ + eocd_offset = raw_data.rfind(EOCD_SIGNATURE) + if eocd_offset == -1: + raise ValueError("EOCD signature not found in the file.") + return eocd_offset + + @staticmethod + def _parse_eocd(raw_data: mmap.mmap, eocd_offset: int) -> Tuple[int, int]: + """ + Parses the EOCD to find the Central Directory offset and entry count. + + :param raw_data: A memory-mapped file object of the APK. + :param eocd_offset: The offset of the EOCD record. + :return: A tuple containing the total number of CDH entries and the + starting offset of the first CDH entry. + """ + cdh_count_offset = eocd_offset + 10 + cdh_start_offset_offset = eocd_offset + 16 + + (cdh_count,) = struct.unpack_from(" Iterator[int]: + """ + Iterates over the Central Directory Headers (CDH) and yields offsets. + + :param raw_data: A memory-mapped file object of the APK. + :param cdh_count: The total number of CDH entries. + :param cdh_start_offset: The starting offset of the first CDH entry. + :return: An iterator that yields the offset of each CDH. + """ + current_offset = cdh_start_offset + for _ in range(cdh_count): + if not raw_data[current_offset:].startswith(CDH_SIGNATURE): + # No a valid CDH signature, skip it + continue + + yield current_offset + + filename_len_offset = current_offset + 28 + extra_field_len_offset = current_offset + 30 + comment_len_offset = current_offset + 32 + + (filename_len,) = struct.unpack_from( + " bool: + """ + Finds and patches entries with invalid compression methods. + + + This function checks the compression method in all Central Directory + Headers (CDHs). If an invalid compression method is found, it patches + the method to 0 in both the CDH and the corresponding Local File Header + (LFH). It also updates the compressed size to match the uncompressed + size. + + :param raw_data: A memory-mapped file object of the APK. + :param cdh_count: The total number of CDH entries. + :param cdh_start_offset: The starting offset of the first CDH entry. + :return: True if any compression method was patched, False otherwise. + """ + isPatched = False + + for current_offset in ApkPatcher._iter_cdh( + raw_data, cdh_count, cdh_start_offset + ): + compression_method_offset = current_offset + 10 + lfh_offset_offset = current_offset + 42 + + compression_method, *_ = struct.unpack_from( + " str: return f"" - @staticmethod + def __del__(self): + self.data.close() + self.file.close() + def __extractAndroidManifest( - apk_filepath: str | PathLike, - tmp_dir: str | PathLike = None - ) -> str: + self, data: SeekableMMap, tmp_dir: str | PathLike | None + ) -> str | None: tmp_dir = tempfile.mkdtemp() if tmp_dir is None else tmp_dir - with zipfile.ZipFile(apk_filepath) as apk: + + with zipfile.ZipFile(data, "r") as apk: # type: ignore apk.extract("AndroidManifest.xml", path=tmp_dir) - return os.path.join( - tmp_dir, "AndroidManifest.xml" - ) + return os.path.join(tmp_dir, "AndroidManifest.xml") @property def filename(self) -> str: @@ -284,7 +301,9 @@ def get_subclasses(self, class_name) -> Set[str]: pass @staticmethod - def _check_file_signature(raw: bytes) -> Optional[str]: + def _check_file_signature( + raw: mmap.mmap, + ) -> Literal["DEX", "APK", "AXML", None]: if raw[0:3] == b"dex": return "DEX" elif raw[0:2] == b"PK": diff --git a/quark/core/r2apkinfo.py b/quark/core/r2apkinfo.py index 1dd2e4821..fec2b4aad 100644 --- a/quark/core/r2apkinfo.py +++ b/quark/core/r2apkinfo.py @@ -54,12 +54,16 @@ def __init__( elif self.ret_type == "APK": self._tmp_dir = tempfile.mkdtemp() if tmp_dir is None else tmp_dir - # Extract AndroidManifest.xml - with zipfile.ZipFile(self.apk_filepath) as apk: - apk.extract("AndroidManifest.xml", path=self._tmp_dir) - - self._manifest = os.path.join( - self._tmp_dir, "AndroidManifest.xml") + if self.isPatched: + # The APK has been patched to mitigate anti-analysis + # techniques. Therefore, Radare2 must parse the patched data + # instead of the original APK. + self.apk_filepath = os.path.join(self._tmp_dir, "patched.apk") + with open(self.apk_filepath, "wb") as patchedApk: + patchedApk.write(self.data) + + self.data.close() + self.file.close() else: raise ValueError("Unsupported File type.") diff --git a/quark/core/rzapkinfo.py b/quark/core/rzapkinfo.py index 7749c3929..441560b78 100644 --- a/quark/core/rzapkinfo.py +++ b/quark/core/rzapkinfo.py @@ -54,12 +54,17 @@ def __init__( elif self.ret_type == "APK": self._tmp_dir = tempfile.mkdtemp() if tmp_dir is None else tmp_dir - - with zipfile.ZipFile(self.apk_filepath) as apk: - apk.extract("AndroidManifest.xml", path=self._tmp_dir) - - self._manifest = os.path.join( - self._tmp_dir, "AndroidManifest.xml") + + if self.isPatched: + # The APK has been patched to mitigate anti-analysis + # techniques. Therefore, Rizin must parse the patched data + # instead of the original APK. + self.apk_filepath = os.path.join(self._tmp_dir, "patched.apk") + with open(self.apk_filepath, "wb") as patchedApk: + patchedApk.write(self.data) + + self.data.close() + self.file.close() else: raise ValueError("Unsupported File type.") From f8f7405aa7b321531c7468b1130aa15c70c9fb2e Mon Sep 17 00:00:00 2001 From: haeter525 Date: Sat, 24 Jan 2026 17:10:18 +0000 Subject: [PATCH 2/9] Detect and patch invalid AndroidManifest signature --- quark/core/apkinfo.py | 7 ++- quark/core/apkpatcher.py | 100 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/quark/core/apkinfo.py b/quark/core/apkinfo.py index bf1e29fdc..5a6196833 100644 --- a/quark/core/apkinfo.py +++ b/quark/core/apkinfo.py @@ -3,6 +3,7 @@ # See the file 'LICENSE' for copying permission. import functools +import logging import re from collections import defaultdict from os import PathLike @@ -10,7 +11,7 @@ from androguard.core.analysis.analysis import MethodAnalysis from androguard.core.bytecodes.dvm_types import Operand -from androguard.misc import AnalyzeAPK, AnalyzeDex +from androguard.misc import AnalyzeAPK, get_default_session from quark.core.interface.baseapkinfo import BaseApkinfo from quark.core.struct.bytecodeobject import BytecodeObject @@ -27,6 +28,10 @@ def __init__(self, apk_filepath: Union[str, PathLike]): super().__init__(apk_filepath, "androguard") if self.ret_type == "APK": + # Suppress Androguard warnings about AndroidManifest, + # as we don't use Androguard’s AndroidManifest parsing results. + logging.getLogger("androguard.axml").disabled = True + logging.getLogger("androguard.apk").disabled = True # return the APK, list of DalvikVMFormat, and Analysis objects self.apk, self.dalvikvmformat, self.analysis = AnalyzeAPK(self.data, raw=True) elif self.ret_type == "DEX": diff --git a/quark/core/apkpatcher.py b/quark/core/apkpatcher.py index 8aafc74ab..041e9ae92 100644 --- a/quark/core/apkpatcher.py +++ b/quark/core/apkpatcher.py @@ -53,7 +53,10 @@ def patch(raw_data: mmap.mmap) -> bool: compression_patched = ApkPatcher._patch_invalid_compression_method( raw_data, cdh_count, cdh_start_offset ) - return compression_patched + manifest_patched = ApkPatcher._patch_manifest_signature( + raw_data, cdh_count, cdh_start_offset + ) + return compression_patched or manifest_patched return False @staticmethod @@ -186,3 +189,98 @@ def _patch_invalid_compression_method( ) return isPatched + + @staticmethod + def _patch_manifest_signature( + raw_data: mmap.mmap, cdh_count: int, cdh_start_offset: int + ) -> bool: + """ + Finds and patches the signature of an uncompressed AndroidManifest.xml. + + If the manifest file is found and its compression method is STORED (0), + this method checks if the first byte of its data is 0x03. If not, it + patches the byte and updates the CRC-32 checksum in the LFH and CDH. + + :param raw_data: A memory-mapped file object of the APK. + :param cdh_count: The total number of CDH entries. + :param cdh_start_offset: The starting offset of the first CDH entry. + :return: True if the manifest signature was patched, False otherwise. + """ + is_patched = False + for current_offset in ApkPatcher._iter_cdh( + raw_data, cdh_count, cdh_start_offset + ): + # Get filename + filename_len_offset = current_offset + 28 + (filename_len,) = struct.unpack_from( + " Date: Sat, 24 Jan 2026 17:10:33 +0000 Subject: [PATCH 3/9] Add unit tests for ApkPatcher --- tests/conftest.py | 24 +++++++++++-- tests/core/test_apkpatcher.py | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 tests/core/test_apkpatcher.py diff --git a/tests/conftest.py b/tests/conftest.py index 5857935cb..ff8c31107 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,8 +42,20 @@ "https://github.com/quark-engine/apk-samples" "/raw/master/vulnerable-samples/Vuldroid.apk" ), - "fileName": "Vuldroid.apk" - } + "fileName": "Vuldroid.apk", + }, + { + "sourceUrl": ( + "https://github.com/quark-engine/apk-samples" + "/raw/master/malware-samples/" + "3d52b5728af55c37d5bd74c3f9b7e9ea6b007a9ec202a648ce3dc7e37ff49b29" + ".apk" + ), + "fileName": ( + "3d52b5728af55c37d5bd74c3f9b7e9ea6b007a9ec202a648ce3dc7e37ff49b29" + ".apk" + ), + }, ] @@ -82,6 +94,12 @@ def SAMPLE_PATH_Ahmyth(tmp_path_factory: pytest.TempPathFactory) -> str: def SAMPLE_PATH_pivaa(tmp_path_factory: pytest.TempPathFactory) -> str: return downloadSample(tmp_path_factory, SAMPLES[3]) + @pytest.fixture(scope="session") def SAMPLE_PATH_Vuldroid(tmp_path_factory: pytest.TempPathFactory) -> str: - return downloadSample(tmp_path_factory, SAMPLES[4]) \ No newline at end of file + return downloadSample(tmp_path_factory, SAMPLES[4]) + + +@pytest.fixture(scope="session") +def SAMPLE_PATH_3d52b(tmp_path_factory: pytest.TempPathFactory) -> str: + return downloadSample(tmp_path_factory, SAMPLES[5]) diff --git a/tests/core/test_apkpatcher.py b/tests/core/test_apkpatcher.py new file mode 100644 index 000000000..04242abf3 --- /dev/null +++ b/tests/core/test_apkpatcher.py @@ -0,0 +1,67 @@ +import mmap +import zipfile +from pathlib import Path + +import pytest + +from quark.core.apkpatcher import ( + VALID_COMPRESSION_METHODS, + ApkPatcher, + SeekableMMap, +) + + +@pytest.fixture(scope="session") +def apkContent(SAMPLE_PATH_3d52b): + with open(SAMPLE_PATH_3d52b, "rb") as fp, SeekableMMap( + fp.fileno(), 0, access=mmap.ACCESS_COPY + ) as mm: + yield mm + + +class TestApkPatcher: + def test_patch(self, apkContent: SeekableMMap): + """ + Tests that ApkPatcher.patch correctly fixes invalid compression methods, + updates sizes, and patches AndroidManifest.xml signature. + """ + # The return values of patch indicates if any modification was made. + # Assuming SAMPLE_PATH_3d52b requires patching for both. + # If the sample APK doesn't have issues, this assertion might need adjustment + # (e.g., to be more specific about *which* part was patched). + # For now, we assert that *some* patching occurred. + assert ApkPatcher.patch(apkContent) is True + + # Verify all compression methods and sizes are valid. + manifest_found = False + with zipfile.ZipFile(apkContent, "r") as patched_zf: # type: ignore + for info in patched_zf.infolist(): + # Compression method and size checks + assert info.compress_type in VALID_COMPRESSION_METHODS, ( + f"File '{info.filename}' has invalid compression " + f"type {info.compress_type} after patching." + ) + + if info.compress_type == 0: # Only check STORED for size match + assert info.compress_size == info.file_size, ( + f"File '{info.filename}' has type STORED but " + f"mismatched sizes (compress:{info.compress_size}, " + f"file:{info.file_size})." + ) + + # Manifest signature check + if info.filename == "AndroidManifest.xml": + manifest_found = True + # Read AndroidManifest.xml content from the patched ZIP + manifest_content = patched_zf.read(info.filename) + assert ( + len(manifest_content) > 0 + ), "AndroidManifest.xml content is empty." + assert manifest_content[0] == 0x03, ( + "First byte of AndroidManifest.xml" + " is not 0x03 after patching." + ) + + assert ( + manifest_found + ), "AndroidManifest.xml not found in the patched APK." From fa03b4c2b5e55937b79b23e1480050ecdf21cfe3 Mon Sep 17 00:00:00 2001 From: haeter525 Date: Sun, 25 Jan 2026 06:39:38 +0000 Subject: [PATCH 4/9] Avoid accessing the data and file attributes if they do not exist --- quark/core/interface/baseapkinfo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quark/core/interface/baseapkinfo.py b/quark/core/interface/baseapkinfo.py index 9214356de..478ae5f72 100644 --- a/quark/core/interface/baseapkinfo.py +++ b/quark/core/interface/baseapkinfo.py @@ -58,8 +58,10 @@ def __repr__(self) -> str: return f"" def __del__(self): - self.data.close() - self.file.close() + if hasattr(self, "data"): + self.data.close() + if hasattr(self, "file"): + self.file.close() def __extractAndroidManifest( self, data: SeekableMMap, tmp_dir: str | PathLike | None From 7d30241bd5985f40f1490b743ae020093eef2ad0 Mon Sep 17 00:00:00 2001 From: haeter525 Date: Sun, 25 Jan 2026 08:34:14 +0000 Subject: [PATCH 5/9] Fix codacy issues --- quark/core/interface/baseapkinfo.py | 1 - quark/core/r2apkinfo.py | 4 ++-- quark/core/rzapkinfo.py | 4 ++-- tests/core/test_apkpatcher.py | 1 - 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/quark/core/interface/baseapkinfo.py b/quark/core/interface/baseapkinfo.py index 478ae5f72..1afd0444e 100644 --- a/quark/core/interface/baseapkinfo.py +++ b/quark/core/interface/baseapkinfo.py @@ -3,7 +3,6 @@ # See the file 'LICENSE' for copying permission. import hashlib -import io import mmap import os.path from abc import abstractmethod diff --git a/quark/core/r2apkinfo.py b/quark/core/r2apkinfo.py index fec2b4aad..a999a1580 100644 --- a/quark/core/r2apkinfo.py +++ b/quark/core/r2apkinfo.py @@ -55,13 +55,13 @@ def __init__( self._tmp_dir = tempfile.mkdtemp() if tmp_dir is None else tmp_dir if self.isPatched: - # The APK has been patched to mitigate anti-analysis + # The APK has been patched to mitigate anti-analysis # techniques. Therefore, Radare2 must parse the patched data # instead of the original APK. self.apk_filepath = os.path.join(self._tmp_dir, "patched.apk") with open(self.apk_filepath, "wb") as patchedApk: patchedApk.write(self.data) - + self.data.close() self.file.close() diff --git a/quark/core/rzapkinfo.py b/quark/core/rzapkinfo.py index 441560b78..d69223e7a 100644 --- a/quark/core/rzapkinfo.py +++ b/quark/core/rzapkinfo.py @@ -54,9 +54,9 @@ def __init__( elif self.ret_type == "APK": self._tmp_dir = tempfile.mkdtemp() if tmp_dir is None else tmp_dir - + if self.isPatched: - # The APK has been patched to mitigate anti-analysis + # The APK has been patched to mitigate anti-analysis # techniques. Therefore, Rizin must parse the patched data # instead of the original APK. self.apk_filepath = os.path.join(self._tmp_dir, "patched.apk") diff --git a/tests/core/test_apkpatcher.py b/tests/core/test_apkpatcher.py index 04242abf3..476d23ada 100644 --- a/tests/core/test_apkpatcher.py +++ b/tests/core/test_apkpatcher.py @@ -1,6 +1,5 @@ import mmap import zipfile -from pathlib import Path import pytest From 3faf0e4a66d06770d10dc6c196373d839919976d Mon Sep 17 00:00:00 2001 From: haeter525 Date: Wed, 28 Jan 2026 14:48:00 +0000 Subject: [PATCH 6/9] Add general log handler --- quark/evaluator/pyeval.py | 14 +++----------- quark/utils/logger.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 quark/utils/logger.py diff --git a/quark/evaluator/pyeval.py b/quark/evaluator/pyeval.py index b4638d6f4..6c6bc3202 100644 --- a/quark/evaluator/pyeval.py +++ b/quark/evaluator/pyeval.py @@ -7,24 +7,16 @@ # http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html import logging -from datetime import datetime - from quark import config from quark.core.struct.registerobject import RegisterObject from quark.core.struct.tableobject import TableObject +from quark.utils.logger import defaultHandler MAX_REG_COUNT = 40 log = logging.getLogger(__name__) log.setLevel(logging.DEBUG) -if config.DEBUG: - TIMESTAMPS = datetime.now().strftime("%Y-%m-%d") - LOG_FILENAME = f"{TIMESTAMPS}.quark.log" - handler = logging.FileHandler(LOG_FILENAME, mode="w") - format_str = "%(asctime)s %(levelname)s [%(lineno)d]: %(message)s" - handler.setFormatter(logging.Formatter(format_str)) - log.addHandler(handler) -else: - log.disabled = True +log.addHandler(defaultHandler) +log.disabled = not config.DEBUG def logger(func): diff --git a/quark/utils/logger.py b/quark/utils/logger.py new file mode 100644 index 000000000..dbc867ce8 --- /dev/null +++ b/quark/utils/logger.py @@ -0,0 +1,10 @@ +from datetime import datetime +from logging import FileHandler, Formatter + +TIMESTAMPS = datetime.now().strftime("%Y-%m-%d") +LOG_FILE_NAME = f"{TIMESTAMPS}.quark.log" +LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s [%(lineno)d]: %(message)s" + +defaultFormatter = Formatter(LOG_FORMAT) +defaultHandler = FileHandler(LOG_FILE_NAME, mode="w") +defaultHandler.setFormatter(defaultFormatter) From 71c357bd2184135cce3b969aab7fb5e443086134 Mon Sep 17 00:00:00 2001 From: haeter525 Date: Wed, 28 Jan 2026 14:53:35 +0000 Subject: [PATCH 7/9] Handle malformed filenames --- quark/core/apkpatcher.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/quark/core/apkpatcher.py b/quark/core/apkpatcher.py index 041e9ae92..6311ddbf9 100644 --- a/quark/core/apkpatcher.py +++ b/quark/core/apkpatcher.py @@ -210,17 +210,13 @@ def _patch_manifest_signature( for current_offset in ApkPatcher._iter_cdh( raw_data, cdh_count, cdh_start_offset ): - # Get filename - filename_len_offset = current_offset + 28 - (filename_len,) = struct.unpack_from( - " Date: Wed, 28 Jan 2026 14:55:14 +0000 Subject: [PATCH 8/9] Prevent OOM crashes --- quark/core/apkpatcher.py | 127 +++++++++++++++++++++++++++------------ 1 file changed, 89 insertions(+), 38 deletions(-) diff --git a/quark/core/apkpatcher.py b/quark/core/apkpatcher.py index 6311ddbf9..2a0b8c0e0 100644 --- a/quark/core/apkpatcher.py +++ b/quark/core/apkpatcher.py @@ -5,6 +5,14 @@ import struct import zlib from typing import Iterator, Tuple +import logging +from quark import config +from quark.utils.logger import defaultHandler + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) +log.addHandler(defaultHandler) +log.disabled = not config.DEBUG EOCD_SIGNATURE = b"PK\x05\x06" CDH_SIGNATURE = b"PK\x01\x02" @@ -39,13 +47,13 @@ def patch(raw_data: mmap.mmap) -> bool: """ Finds and patches known anti-analysis techniques in an APK. - This function perform patches in place and suppresses any errors to + This function perform patches in place and suppresses any errors to prevent crashes that would interrupt the analysis. :param raw_data: A memory-mapped file object of the APK. :return: True if any part of the APK was patched; False otherwise. """ - with suppress(BaseException): + try: eocd_offset = ApkPatcher._find_eocd(raw_data) cdh_count, cdh_start_offset = ApkPatcher._parse_eocd( raw_data, eocd_offset @@ -57,7 +65,10 @@ def patch(raw_data: mmap.mmap) -> bool: raw_data, cdh_count, cdh_start_offset ) return compression_patched or manifest_patched - return False + + except BaseException as e: + log.exception(e) + return False @staticmethod def _find_eocd(raw_data: mmap.mmap) -> int: @@ -94,7 +105,7 @@ def _parse_eocd(raw_data: mmap.mmap, eocd_offset: int) -> Tuple[int, int]: @staticmethod def _iter_cdh( raw_data: mmap.mmap, cdh_count: int, cdh_start_offset: int - ) -> Iterator[int]: + ) -> Iterator[tuple[int, bool]]: """ Iterates over the Central Directory Headers (CDH) and yields offsets. @@ -105,11 +116,11 @@ def _iter_cdh( """ current_offset = cdh_start_offset for _ in range(cdh_count): - if not raw_data[current_offset:].startswith(CDH_SIGNATURE): - # No a valid CDH signature, skip it - continue - - yield current_offset + actual_signature = raw_data[ + current_offset : current_offset + len(CDH_SIGNATURE) + ] + is_valid_signature = actual_signature == CDH_SIGNATURE + yield current_offset, is_valid_signature filename_len_offset = current_offset + 28 extra_field_len_offset = current_offset + 30 @@ -137,7 +148,7 @@ def _patch_invalid_compression_method( This function checks the compression method in all Central Directory Headers (CDHs). If an invalid compression method is found, it patches the method to 0 in both the CDH and the corresponding Local File Header - (LFH). It also updates the compressed size to match the uncompressed + (LFH). It also updates the compressed size to match the uncompressed size. :param raw_data: A memory-mapped file object of the APK. @@ -147,9 +158,15 @@ def _patch_invalid_compression_method( """ isPatched = False - for current_offset in ApkPatcher._iter_cdh( + for current_offset, is_valid_signature in ApkPatcher._iter_cdh( raw_data, cdh_count, cdh_start_offset ): + if not is_valid_signature: + log.warning( + f"Found invalid CDH signature at offset {current_offset}." + " Try parsing it anyway." + ) + compression_method_offset = current_offset + 10 lfh_offset_offset = current_offset + 42 @@ -176,9 +193,14 @@ def _patch_invalid_compression_method( " Date: Wed, 28 Jan 2026 15:44:54 +0000 Subject: [PATCH 9/9] Fix error due to AndroidManifest not in APK --- quark/core/interface/baseapkinfo.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/quark/core/interface/baseapkinfo.py b/quark/core/interface/baseapkinfo.py index 1afd0444e..a2ec80c5e 100644 --- a/quark/core/interface/baseapkinfo.py +++ b/quark/core/interface/baseapkinfo.py @@ -16,7 +16,9 @@ from quark.core.struct.bytecodeobject import BytecodeObject from quark.core.struct.methodobject import MethodObject from quark.core.axmlreader.python import PythonImp as AxmlReader +from quark.utils.pprint import print_warning +ANDROID_MANIFEST_FILE_NAME = "AndroidManifest.xml" class BaseApkinfo: @@ -68,8 +70,12 @@ def __extractAndroidManifest( tmp_dir = tempfile.mkdtemp() if tmp_dir is None else tmp_dir with zipfile.ZipFile(data, "r") as apk: # type: ignore - apk.extract("AndroidManifest.xml", path=tmp_dir) - return os.path.join(tmp_dir, "AndroidManifest.xml") + if ANDROID_MANIFEST_FILE_NAME not in apk.namelist(): + print_warning("APK does not contain AndroidManifest.xml.") + return None + + apk.extract(ANDROID_MANIFEST_FILE_NAME, path=tmp_dir) + return os.path.join(tmp_dir, ANDROID_MANIFEST_FILE_NAME) @property def filename(self) -> str: @@ -109,7 +115,7 @@ def permissions(self) -> List[str]: :return: a list of all permissions """ - if self.ret_type != "APK": + if self.ret_type != "APK" or self._manifest is None: return [] with AxmlReader(self._manifest) as axml: @@ -132,7 +138,7 @@ def application(self) -> XMLElement | None: :return: an application element """ - if self.ret_type != "APK": + if self.ret_type != "APK" or self._manifest is None: return None with AxmlReader(self._manifest) as axml: @@ -147,7 +153,7 @@ def activities(self) -> List[XMLElement] | None: :return: a list of all activities """ - if self.ret_type != "APK": + if self.ret_type != "APK" or self._manifest is None: return None with AxmlReader(self._manifest) as axml: @@ -162,7 +168,7 @@ def receivers(self) -> List[XMLElement] | None: :return: a list of all receivers """ - if self.ret_type != "APK": + if self.ret_type != "APK" or self._manifest is None: return None with AxmlReader(self._manifest) as axml: @@ -176,7 +182,7 @@ def providers(self) -> List[XMLElement] | None: :return: python list containing provider elements """ - if self.ret_type != "APK": + if self.ret_type != "APK" or self._manifest is None: return None with AxmlReader(self._manifest) as axml: