PK\x05\x06 EOCD ShadowingPK\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.
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.
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:
extract_from is initialized to app_path (the raw, untrusted user-uploaded file).neutralize_zip_special_modes() succeeds, extract_from is updated to point to the sanitized temporary file patched.neutralize_zip_special_modes() raises any exception, the exception is caught, logged as a mere warning, and execution falls through with extract_from = app_path!
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.
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
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:
data.rfind(_EOCD_SIG) matches the false signature inside the comment rather than the genuine EOCD record located earlier.window - rel < 22 evaluates to True._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!
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)?
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.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).
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("
In release 4.6.5, MobSF maintainer Ajin Abraham (@ajinabraham) applied two key architectural fixes:
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.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('
rfind) without length verification.