From 68a39843e2b3b80eb2cdc35508017e00049cf058 Mon Sep 17 00:00:00 2001 From: kichikuou Date: Sun, 5 Jul 2026 10:04:27 +0900 Subject: [PATCH] Android: Prevent ZIP path traversal in launcher --- .../io/github/kichikuou/xsystem35/Launcher.kt | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt b/android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt index adecc2c..de48f2a 100644 --- a/android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt +++ b/android/app/src/main/java/io/github/kichikuou/xsystem35/Launcher.kt @@ -164,8 +164,9 @@ class Launcher private constructor(private val rootDir: File) { if (zipEntry.isDirectory || !zipEntry.name.startsWith("save/") || zipEntry.name.count{it == '/'} != 1) return@forEachZipEntry + val path = resolveOutputPath(rootDir, zipEntry.name).file Log.i("importSaveData", zipEntry.name) - FileOutputStream(File(rootDir, zipEntry.name)).buffered().use { + FileOutputStream(path).buffered().use { zip.copyTo(it) } imported = true @@ -184,15 +185,16 @@ class Launcher private constructor(private val rootDir: File) { val configWriter = GameConfigWriter() val hadDecodeError = forEachZipEntry(input) { zipEntry, zip -> Log.i("extractFiles", zipEntry.name) - val path = File(outDir, zipEntry.name) if (zipEntry.isDirectory) return@forEachZipEntry + val resolvedPath = resolveOutputPath(outDir, zipEntry.name) + val path = resolvedPath.file path.parentFile?.mkdirs() - progressCallback(zipEntry.name) + progressCallback(resolvedPath.relativePath) FileOutputStream(path).buffered().use { zip.copyTo(it) } - configWriter.maybeAdd(zipEntry.name) + configWriter.maybeAdd(resolvedPath.relativePath) } if (!configWriter.ready) { if (hadDecodeError) @@ -263,6 +265,27 @@ class Launcher private constructor(private val rootDir: File) { } } +/** + * @property file Safe canonical output path. + * @property relativePath Path relative to the install root after canonicalization. + */ +private data class ResolvedOutputPath(val file: File, val relativePath: String) + +private fun resolveOutputPath(baseDir: File, relativePath: String): ResolvedOutputPath { + val canonicalBase = baseDir.canonicalFile + val file = File(canonicalBase, relativePath).canonicalFile + if (!isFileInsideDirectory(file, canonicalBase)) { + throw IOException("Output path is outside target directory: $relativePath") + } + val basePath = canonicalBase.path + File.separator + val canonicalRelativePath = file.path.removePrefix(basePath) + return ResolvedOutputPath(file, canonicalRelativePath) +} + +private fun isFileInsideDirectory(file: File, directory: File): Boolean { + return file.path.startsWith(directory.path + File.separator) +} + private fun forEachZipEntry(input: InputStream, action: (ZipEntry, ZipInputStream) -> Unit): Boolean { val zip = if (Build.VERSION.SDK_INT >= 24) { ZipInputStream(input.buffered(), Charset.forName("Shift_JIS"))