← Back to Security Research Portal (ccsimplyspolit.github.io)

Anatomy of a Parser Differential: Breaking MobSF's Pre-Extraction Symlink Neutralizer via PK\x05\x06 EOCD Shadowing

GHSA-j7g2-gm7j-mfvm CVSS v3.1: 3.1 (Low) CVSS v4.0: 2.3 (Low) Author: Serhii Shunko (@sshunko / @ccsimplyspolit) • Published: 2026-11-01
Executive Summary: An incomplete mitigation for GHSA-mc92-j6mp-x9cp in Mobile Security Framework (MobSF ≤ 4.6.4) permitted bypassing pre-extraction symbolic link mode stripping. By constructing a ZIP archive whose End of Central Directory (EOCD) comment embedded the 4-byte signature PK\x05\x06 within the trailing 21 bytes, MobSF's custom parser threw an unhandled ValueError. This triggered a fail-open fallback in os_unzip() that handed the original, unmodified archive directly to system /usr/bin/unzip. Due to Info-ZIP 6.0's deferred symlink extraction architecture and MobSF's synchronous post-extraction tree sanitization, the vulnerability remained contained to a narrow process race, resulting in a Low severity classification.

0x00 // The Context: Archive Extraction in Automated Analysis Engines

Mobile Security Framework (MobSF) is an automated, all-in-one mobile application pen-testing, malware analysis, and security assessment framework. When a security engineer or CI/CD pipeline uploads an Android APK, iOS IPA, or generic ZIP archive, the engine must unpack hundreds or thousands of compressed members before decompilers (JADX, Baksmali, Ghidra) can inspect code and assets.

Archive extraction in automated pipelines is notoriously prone to path traversal vulnerabilities (Zip Slip, CWE-22) and symlink exploitation (CWE-59). In previous versions of MobSF, a report (GHSA-mc92-j6mp-x9cp) demonstrated that if an archive contained a Unix symbolic link pointing to a target outside the extraction directory, subsequent archive entries could overwrite arbitrary files on the host filesystem when unpacked with Info-ZIP.

To neutralize this risk, MobSF introduced a proactive defense layer: neutralize_zip_special_modes(src, dest_parent) in mobsf/StaticAnalyzer/views/common/shared_func.py. This function inspects the ZIP Central Directory and sanitizes Unix file permission bits (stripping S_IFLNK 0o120000 and S_IFIFO) before invoking system utilities.

0x01 // The Architecture & The Fail-Open Flaw

In mobsf/StaticAnalyzer/views/common/shared_func.py, MobSF employs a tiered extraction strategy. It first attempts to unpack files using Python's native zipfile module. However, because malformed, heavily obfuscated, or custom-packed APKs frequently fail Python's strict header validation, MobSF includes a robust fallback: os_unzip(), which delegates decompression to the host's /usr/bin/unzip.

The vulnerability lay in how os_unzip() wrapped the pre-extraction sanitizer:

# mobsf/StaticAnalyzer/views/common/shared_func.py:400-415 (MobSF <= 4.6.4)
extract_from = app_path
try:
    patched = neutralize_zip_special_modes(app_path, ext_path)
    extract_from = patched
except Exception:
    logger.warning('Could not strip symlink modes before OS unzip')

# OS unzip strips absolute paths and '..' from member names.
# Symlink modes are rewritten above so it cannot create a link.
subprocess.call(
    [unzip_b, '-o', '-q', extract_from, '-d', ext_path])

# chmod first. A directory mode such as 0500 has no write bit, so
# unlinking a symlink inside it fails and the link would survive.
# set_permissions skips links, so this does not chmod the target.
set_permissions(ext_path)
remove_external_symlinks(ext_path)

Notice the critical assumption:

Furthermore, inside neutralize_zip_special_modes():

def neutralize_zip_special_modes(src, dest_parent):
    fd, name = tempfile.mkstemp(
        prefix='mobsf-unzip-',
        suffix='.zip',
        dir=_patched_zip_dir(dest_parent))
    os.close(fd)
    try:
        shutil.copyfile(src, name)
        with open(name, 'r+b') as handle:
            _rewrite_special_central_directory(handle)
    except Exception:
        os.unlink(name)
        raise
    return name

When an exception is thrown during Central Directory rewriting, neutralize_zip_special_modes unlinks the temporary file and re-raises. os_unzip catches the exception and immediately invokes unzip -o -q on the raw app_path with all Unix symlink modes (0o120777) intact.

0x02 // Root Cause: Parser Differential & EOCD Shadowing

To trigger the fail-open fallback, an attacker simply needed to cause _rewrite_special_central_directory(handle) to throw an unhandled exception on an archive that system /usr/bin/unzip would still extract without error.

Under the ZIP specification (PKWARE APPNOTE.TXT), an archive terminates with the End of Central Directory (EOCD) record:

[Central Directory Headers...]
+-------------------------------------------------------------+
| End of Central Directory Record (EOCD) - 22 bytes minimum   |
| 0x00: 4 bytes - Signature: 0x06054b50 ("PK\x05\x06")       |
| 0x04: 2 bytes - Disk Number                                 |
| 0x06: 2 bytes - Disk with CD Start                          |
| 0x08: 2 bytes - Number of CD entries on this disk           |
| 0x0A: 2 bytes - Total Number of CD entries                  |
| 0x0C: 4 bytes - Size of Central Directory                   |
| 0x10: 4 bytes - Offset of Central Directory                 |
| 0x14: 2 bytes - ZIP Comment Length (.n bytes)               |
| 0x16: .n bytes - Variable Archive Comment                   |
+-------------------------------------------------------------+

Because the archive comment can be up to 65,535 bytes in length, locating the EOCD requires scanning backward from the end of the file. MobSF implemented the following helper:

def _eocd_offset(handle, size):
    """Return the end-of-central-directory offset."""
    window = min(size, 65535 + 22)
    data = _read_at(handle, size - window, window)
    rel = data.rfind(_EOCD_SIG)
    if rel < 0 or window - rel < 22:
        raise ValueError('EOCD not found')
    return size - window + rel

The Flaw in data.rfind(_EOCD_SIG)

Python's bytes.rfind() searches from the right (the very end of the byte string) toward the left. If a ZIP archive includes an archive comment that embeds the 4-byte sequence PK\x05\x06 within the trailing 21 bytes of the file:

  1. data.rfind(_EOCD_SIG) matches the false signature inside the comment rather than the genuine EOCD record located earlier.
  2. Because this false signature is within the trailing 21 bytes, window - rel < 22 evaluates to True.
  3. _eocd_offset immediately raises ValueError('EOCD not found').

A second trigger exists in Python's internal zipfile._EndRecData64 parser. When malformed comments or ZIP64 locator differentials cause a mismatch, Python's _EndRecData64 raises ValueError: substring not found.

A third trigger exists in Central Directory traversal rigidity:

marker = _read_at(handle, pos, 4) if pos + 4 <= size else b''
if marker not in (_EOCD_SIG, _ZIP64_EOCD_SIG):
    raise ValueError('central directory walk did not finish')

If non-zero alignment padding or extraneous bytes exist between the final Central Directory entry and the EOCD header, MobSF raises ValueError('central directory walk did not finish'). Even if all entries up to that point had their symlink modes neutralized in the temporary file, the exception handler deletes the temporary file and falls open to app_path!

0x03 // Why the Vulnerability is Low Severity: The Info-ZIP 6.0 Containment Window

In many security contexts, bypassing symlink sanitization in an automated engine would constitute an immediate Critical vulnerability (Arbitrary File Overwrite / RCE). Why, then, was GHSA-j7g2-gm7j-mfvm rated Low (CVSS v3.1: 3.1)?

The Dual Containment Barrier:
  1. Info-ZIP 6.0 Deferred Extraction & checkdir(): Modern Info-ZIP (version 6.0+) does not immediately follow unverified directory symlinks created during the same extraction pass. Info-ZIP defers symlink creation to the end of the extraction process in standard configurations and invokes internal security checks (checkdir.c) to prevent path escape.
  2. Synchronous Post-Extraction Purge: Look closely at the cleanup sequence in MobSF:
    subprocess.call([unzip_b, '-o', '-q', extract_from, '-d', ext_path])
    set_permissions(ext_path)
    remove_external_symlinks(ext_path)
    MobSF's remove_external_symlinks(ext_path) runs synchronously in the same Python thread immediately after subprocess.call returns. It performs a full depth-first traversal of ext_path, verifies os.path.realpath(entry) against the canonical root, and immediately unlinks any external symlink before MobSF begins analyzing the contents.

Consequently, an attacker cannot achieve persistent external symlinks on the host. The only theoretical vector is a race condition where a secondary archive member overwrites a target file through a transient symlink during the single unzip execution cycle. Because Info-ZIP 6.0 contains defensive boundary validation, the practical exploitability is constrained, fully justifying the maintainer's Low CVSS evaluation (AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:N).

0x04 // Deterministic Verification Code

The parser differential between MobSF's _eocd_offset and genuine ZIP structures can be proven deterministically with the following Python reproduction:

import struct

_EOCD_SIG = b"PK\x05\x06"

def buggy_mobsf_eocd_offset(data: bytes) -> int:
    """Simulates MobSF <= 4.6.4 EOCD parser."""
    window = min(len(data), 65535 + 22)
    tail = data[len(data) - window:]
    rel = tail.rfind(_EOCD_SIG)
    if rel < 0 or window - rel < 22:
        raise ValueError("EOCD not found")
    return len(data) - window + rel

def fixed_mobsf_eocd_offset(data: bytes) -> int:
    """Simulates MobSF 4.6.5 validated backward scanner."""
    window = min(len(data), 65535 + 22)
    tail = data[len(data) - window:]
    pos = window - 22
    while pos >= 0:
        if tail[pos:pos + 4] == _EOCD_SIG:
            comment_len = struct.unpack_from("

0x05 // Upstream Remediation in MobSF 4.6.5

In release 4.6.5, MobSF maintainer Ajin Abraham (@ajinabraham) applied two key architectural fixes:

  1. Fail-Closed Exception Handling: If neutralize_zip_special_modes fails, os_unzip() immediately aborts extraction, records the error, and returns an empty file list rather than executing unzip on the raw archive.
  2. Backward Scan with Length Validation: The EOCD locator was rewritten to inspect every candidate signature and verify that the 16-bit comment_length field matches the remaining byte count in the file buffer.
--- a/mobsf/StaticAnalyzer/views/common/shared_func.py
+++ b/mobsf/StaticAnalyzer/views/common/shared_func.py
@@ -240,10 +240,14 @@ def _eocd_offset(handle, size):
     """Return the end-of-central-directory offset."""
     window = min(size, 65535 + 22)
     data = _read_at(handle, size - window, window)
-    rel = data.rfind(_EOCD_SIG)
-    if rel < 0 or window - rel < 22:
-        raise ValueError('EOCD not found')
-    return size - window + rel
+    pos = window - 22
+    while pos >= 0:
+        if data[pos:pos + 4] == _EOCD_SIG:
+            comment_len = struct.unpack_from('

0x06 // Takeaways for Defensive Software Engineering

  • Never Fall Open on Sanitization Failures: If a preprocessing pass designed to strip dangerous capabilities fails, the only secure response is to abort the operation completely.
  • Beware of Naive Signatures in Variable-Length Formats: Binary formats with trailing user comments (ZIP, TAR, PDF) cannot be safely indexed using simple right-to-left searches (rfind) without length verification.
  • Defense in Depth Matters: MobSF's secondary defense layers (Info-ZIP 6.0 modern behaviors and synchronous post-extraction tree cleaning) prevented what could have been a Critical RCE from having catastrophic impact.