This is part of a series on the 2025 NSA Codebreaker Challenge. Start from the beginning.
Challenge
The adversary downloaded a custom Android app to archive messages locally. Exploit this application running on the adversary’s device by submitting a malicious file through Mattermost that the app will process.
Objective: Submit a crafted file (...zip) that achieves code execution on the adversary’s Android device through the Mattermost Media Archiver app.
Provided files:
- The Mattermost Media Archiver APK (
com.badguy.mmarchiver) - Zippier plugin bundle (plugin system source code + interfaces)
- Zippier plugin starter (template for building format handler plugins)
- Zippys (a Go-based Zip Slip security research tool)
Two Weeks of Wrong Answers
The file timestamps in my bad tests/ folder tell the story of this task better than I can. This was by far the hardest challenge, spanning from October 1st through October 14th. The progression:
Oct 1 (Day 1) – Initial probing. Created probe_traversal.zip, exploit.zip, exploit_absolute.zip, symlink_escape.zip, and several variants. Wrote test.py to generate 38 different traversal patterns across 11 file extensions. Standard path traversal – ../, ../../, URL-encoded variants. None worked.
Oct 2-3 (Days 2-3) – Escalation. Generated traversal_pack.zip with dozens of encoded variants. Used the Zippys tool to generate 20+ categorized payloads: parent traversals, backslashes, mixed slashes, absolute paths, URL encoding, Unicode variants, symlinks. Created a full mm_path_traversal_tests/ directory with every encoding trick I could think of – %2e%2e%2f, %252e%252e%252f, %c0%af, %e0%80%af, %u2215, Unicode fullwidth dots, fraction slashes. None worked.
Oct 4-5 (Days 4-5) – Desperation. payload2.zip through payload8.zip, payloadzzzz.zip, zzz2.zip. Trying canonicalization bypasses, duplicate entries with different orderings, junk gap injection, store vs deflate compression modes. Still nothing.
Oct 6-10 (Days 6-10) – The alt-solve rabbit hole. This is where it gets interesting. I found a legitimate vulnerability in the Mattermost upload session API – the /api/v4/uploads endpoint accepts a filename parameter and doesn’t sanitize path traversal characters. I wrote alt_solve.py that creates an upload session with filename: "../zippier/formats/net.axolotl.zippier.ZipFormat_tar.zip", which causes the archiver app to write the downloaded file directly into the plugin formats directory:
FileDownloadWorker: file written to
/data/user/0/com.badguy.mmarchiver/cache/download/../zippier/formats/
net.axolotl.zippier.ZipFormat_tar.zip
The ZIP contained a classes.dex (the malicious format handler) and a zzzzzzzzzz.tar (trigger file). When the app processed the ZIP, it found the .tar entry, loaded the format handler JAR from the formats directory, and my exploit ran. Full RCE – I could see it in logcat:
ZipFormat_tar.uncompress(
/data/user/0/com.badguy.mmarchiver/cache/zippier/extract/
net.axolotl.zippier.ZipFormat_tar/zzzzzzzzzz.tar, ...)
I was ecstatic. I submitted a Get Help ticket with full logs, scripts, and proof. Then came the response from Mike:
“If you use a special API to upload it then you need to find an alternative. We’re not uploading it with a special API.”
My heart sank. I argued back – the challenge says “upload a file to the mattermost channel,” and this is uploading a file to the channel, just using the API instead of drag-and-drop. It’s a real Mattermost vulnerability! Mike’s response:
“You need to submit the file to be graded on the CBC website. The exploit needs to be completely contained in your submission. Don’t give up now. You’re probably very close.”
He was right. The exploit worked, but the challenge’s grading system needed a self-contained file. The upload API trick required running a Python script with specific session cookies – that can’t be submitted as a single file.
Oct 10-14 (Final stretch) – With Mike’s encouragement (“you’re probably very close”), I went back to the drawing board. The alt-solve had proven that the dynamic class loading chain worked perfectly. I just needed a different way to get the JAR into the formats directory – one that was self-contained in the uploaded file itself. That’s when I finally looked at the filename of the ZIP instead of the contents, and ...zip clicked.
Understanding the App
The Mattermost Media Archiver (com.badguy.mmarchiver) is an Android app that monitors a Mattermost channel and automatically downloads/archives attachments. When it receives a file, the FileDownloadWorker downloads it to /cache/download/<filename> using the original Mattermost filename, then passes it to the Zippier library for processing.
The Zippier library processes files through ArchiveAccumulator.addFile(), which detects the file extension, finds the appropriate format handler, and calls its uncompress() method. The built-in ZipFormat_Zip handler processes .zip files. Additional format handlers for extensions like xz, tar, gz, etc. are loaded dynamically as plugins from JAR files on disk.
The critical utility method is c.a() (decompiled name for getBaseName()), which does two things:
|
|
This method is used to construct the extraction subdirectory when processing a ZIP. It strips path separators (preventing standard ../ traversal in the filename) and strips the file extension. The combination of these two operations is the vulnerability.
The ...zip Exploit
The filename ...zip (three dots, no space before “zip”) exploits the extension-stripping behavior of getBaseName():
- Mattermost delivers the file as
...zip FileDownloadWorkersaves it to/cache/download/...zipArchiveAccumulator.addFile()detects the.zipextension and finds the built-inZipFormat_Ziphandler- Before calling
uncompress(), it constructs the extraction directory:1 2 3 4 5File extractDir = new File(this.workingDir, c.a(file.getAbsolutePath())); // c.a() on "/cache/download/...zip": // Step 1: strip path → "...zip" // Step 2: strip extension (last dot at index 2) → "...zip".substring(0, 2) → ".." // extractDir = new File("/cache/zippier/extract/", "..") → /cache/zippier/ - The extraction directory resolves to
/cache/zippier/– the Zippier base directory, one level up from the intended sandbox
The app also has a canonical path check inside ZipFormat_Zip.uncompress() – a standard Zip Slip guard that verifies each extracted entry’s canonical path starts with the target directory:
|
|
But ...zip bypasses this guard entirely. targetPath is /cache/zippier/extract/.., whose canonical form resolves to /cache/zippier/. Every entry we extract into /cache/zippier/ naturally has a canonical path starting with /cache/zippier/, so the check always passes. The guard protects against entries escaping the target directory, but it can’t detect that the target directory itself has already escaped its sandbox.
Chaining with Dynamic Class Loading
Getting files into /cache/zippier/ is only half the exploit. The Zippier library has a plugin system for dynamically loading format handlers, configured by zippier.json bundled in the APK’s assets:
|
|
This tells the app: when you encounter a file with one of these extensions, look for a format handler JAR in the formats/ subdirectory of /cache/zippier/. The naming convention is <classpathBase>.ZipFormat_<ext>.jar, where classpathBase defaults to net.axolotl.zippier (configurable via the "classpath" key in zippier.json). The FQCN passed to loadClass() is derived by running getBaseName() on the JAR filename – stripping the .jar extension to produce net.axolotl.zippier.ZipFormat_xz.
The loading chain works like this – after ZipFormat_Zip extracts the ZIP entries to disk, each extracted file is recursively passed back through ArchiveAccumulator.addFile(). When an extracted file has an extension in the allowed list (like .xz), the app:
- Checks
FormatManager’s in-memory handler map – no handler forxzyet - Checks the allowed formats list –
xzis allowed - Looks for
/cache/zippier/formats/net.axolotl.zippier.ZipFormat_xz.jaron disk - If found, loads it with
PathClassLoaderand instantiates the class via reflection:
|
|
There is no signature verification, no allowlist of class hashes, no integrity check. The only “protection” is a file.setWritable(false, false) call in attempting_class_load() – but it runs in the same method that immediately loads the class, so the file is already written and about to be executed. Any JAR with a valid DEX file in the right directory with the right filename gets loaded and executed.
This is why the entry ordering in the exploit ZIP matters. The JAR (formats/...jar) must appear before the trigger file (payload.xz) in the ZIP’s entry list. ZipFormat_Zip processes entries sequentially – extract to disk, then immediately call addFile() on the result. The JAR needs to be on disk before the .xz trigger causes the app to look for it.
Building the Payload
Step 1: Malicious Format Handler
I built a ZipFormat_xz class that implements the ZipFormat interface. The exploit runs in the static initializer – code that executes automatically when the class is loaded, before any method is called:
|
|
Step 2: DEX Compilation
Android can’t execute standard Java bytecode – it needs DEX format. I compiled the class with the Android SDK tools:
|
|
Step 3: Craft ...zip
The final payload ZIP is named ...zip and contains:
...zip
├── formats/net.axolotl.zippier.ZipFormat_xz.jar ← malicious plugin
└── payload.xz ← trigger file
payload.xz can even be an empty file – the format lookup is purely extension-based, so it just needs the .xz extension. Its presence triggers the app to look for an xz format handler, which it finds in the JAR we just planted via the path traversal.
The Kill Chain
1. Upload ...zip to Mattermost channel
2. FileDownloadWorker downloads to /cache/download/...zip
3. addFile() detects .zip extension → uses built-in ZipFormat_Zip
4. getBaseName() strips extension: "...zip" → ".."
5. Extraction directory: /cache/zippier/extract/.. → /cache/zippier/
6. ZipFormat_Zip extracts entries to /cache/zippier/:
a. formats/net.axolotl.zippier.ZipFormat_xz.jar
→ /cache/zippier/formats/net.axolotl.zippier.ZipFormat_xz.jar
→ addFile(): extension "jar" not in allowed list → skipped
b. payload.xz → /cache/zippier/payload.xz
→ addFile(): extension "xz" IS in allowed list
→ FormatManager looks for formats/net.axolotl.zippier.ZipFormat_xz.jar
→ Found! (planted in step 6a)
7. PathClassLoader loads the JAR, instantiates ZipFormat_xz
8. Static initializer executes → RCE achieved
Answer
The submission was the crafted ...zip file. Uploading it to the adversary’s Mattermost channel triggered the full exploit chain on their Android device.
Takeaways
- The vulnerability was in the filename, not the contents. I spent almost two weeks trying every path traversal encoding inside ZIP entry names when the actual exploit was the name of the ZIP file itself. The
getBaseName()utility strips the.zipextension from...zip, producing.., which the OS resolves as “parent directory” when used in a path. Sometimes the simplest approach is the one you overlook. - Dynamic class loading without verification is game over. The Zippier plugin system uses
PathClassLoaderwith no signature checks, no allowlist, no integrity verification. Any file that lands in the right directory with the right name gets loaded and executed. This is a common pattern in plugin architectures and it’s almost always exploitable if you can control the filesystem. - Static initializers are the perfect exploit vector. You don’t need the target to call any specific method – just loading the class triggers execution. This makes the exploit reliable regardless of how the plugin system invokes the handler.
- Zip Slip research tools exist for a reason. The challenge even provided
zippys– a purpose-built Zip Slip tool. I used it extensively to generate test payloads, but I was too focused on entry-name traversal. The tool was more useful for understanding the problem space than for generating the final exploit. - Task 6’s Mattermost work paid off. The hours I spent digging into Mattermost CVEs during Task 6 gave me familiarity with the platform’s file handling, which helped me understand how uploaded files get delivered to the archiver app.