Skip to content

Commit 310fb45

Browse files
committed
archive: harden detection on old Android
1 parent 4df0495 commit 310fb45

5 files changed

Lines changed: 86 additions & 27 deletions

File tree

app/src/main/java/com/kyhsgeekcode/FileExtensions.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import java.util.*
44

55
object FileExtensions {
66
val textFileExts: MutableSet<String> = HashSet()
7+
val archiveFileExts: MutableSet<String> = HashSet()
78

89
init {
910
textFileExts.add("xml")
@@ -16,6 +17,15 @@ object FileExtensions {
1617
textFileExts.add("properties")
1718
}
1819

20+
init {
21+
archiveFileExts.add("zip")
22+
archiveFileExts.add("apk")
23+
archiveFileExts.add("jar")
24+
archiveFileExts.add("aar")
25+
archiveFileExts.add("ar")
26+
archiveFileExts.add("tar")
27+
}
28+
1929
val peFileExts: MutableSet<String> = HashSet()
2030

2131
init {
@@ -33,3 +43,8 @@ object FileExtensions {
3343
peFileExts.add("tsp")
3444
}
3545
}
46+
47+
fun isKnownArchiveExtension(fileName: String): Boolean {
48+
val extension = fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
49+
return extension.isNotEmpty() && FileExtensions.archiveFileExts.contains(extension)
50+
}

app/src/main/java/com/kyhsgeekcode/Util.kt

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,44 +41,51 @@ import kotlin.reflect.full.memberProperties
4141
import kotlin.reflect.jvm.isAccessible
4242

4343
fun extractZip(from: File, toDir: File, publisher: (Long, Long) -> Unit = { _, _ -> }) {
44-
val zi = ZipInputStream(from.inputStream())
45-
var entry: ZipEntry
4644
val buffer = ByteArray(2048)
4745
var processed = 0L
4846
val total = from.length()
49-
while (zi.nextEntry.also { entry = it } != null) {
50-
val name = entry.name
51-
val outfile = File(toDir, name)
52-
outfile.delete()
53-
outfile.parentFile.mkdirs()
54-
val canonicalPath: String = outfile.canonicalPath
55-
if (!canonicalPath.startsWith(toDir.canonicalPath)) {
56-
throw SecurityException(
57-
"The zip/apk file may have a Zip Path Traversal Vulnerability." +
58-
"Is the zip/apk file trusted?"
59-
)
60-
}
61-
var output: FileOutputStream? = null
62-
try {
63-
output = FileOutputStream(outfile)
64-
var len: Int
65-
while (zi.read(buffer).also { len = it } > 0) {
66-
output.write(buffer, 0, len)
47+
ZipInputStream(from.inputStream()).use { zi ->
48+
var entry: ZipEntry?
49+
while (zi.nextEntry.also { entry = it } != null) {
50+
val zipEntry = entry ?: continue
51+
val outfile = File(toDir, zipEntry.name)
52+
val canonicalPath = outfile.canonicalPath
53+
if (!canonicalPath.startsWith(toDir.canonicalPath)) {
54+
throw SecurityException(
55+
"The zip/apk file may have a Zip Path Traversal Vulnerability." +
56+
"Is the zip/apk file trusted?"
57+
)
58+
}
59+
if (zipEntry.isDirectory) {
60+
if (!outfile.isDirectory && !outfile.mkdirs()) {
61+
throw IOException("failed to create directory $outfile")
62+
}
63+
continue
64+
}
65+
val parent = outfile.parentFile
66+
if (parent != null && !parent.isDirectory && !parent.mkdirs()) {
67+
throw IOException("failed to create directory $parent")
6768
}
68-
} finally { // we must always close the output file
69-
output?.close()
69+
outfile.outputStream().use { output ->
70+
var len: Int
71+
while (zi.read(buffer).also { len = it } > 0) {
72+
output.write(buffer, 0, len)
73+
}
74+
}
75+
processed += maxOf(zipEntry.compressedSize, zipEntry.size, 0L)
76+
publisher(processed, total)
7077
}
71-
processed += entry.size
72-
publisher(total, processed)
73-
zi.close()
7478
}
7579
}
7680

7781
fun File.isArchive(): Boolean {
82+
if (isKnownArchiveExtension(name)) {
83+
return true
84+
}
7885
return try {
7986
ArchiveStreamFactory().createArchiveInputStream(BufferedInputStream(inputStream()))
8087
true
81-
} catch (e: Exception) {
88+
} catch (e: Throwable) {
8289
false
8390
}
8491
}
@@ -126,6 +133,14 @@ fun extractSupportedArchive(
126133
toDir: File,
127134
publisher: (Long, Long) -> Unit = { _, _ -> }
128135
) {
136+
if (from.extension.equals("zip", true) ||
137+
from.extension.equals("apk", true) ||
138+
from.extension.equals("jar", true) ||
139+
from.extension.equals("aar", true)
140+
) {
141+
extractZip(from, toDir, publisher)
142+
return
143+
}
129144
val totalSize = from.length()
130145
try {
131146
ArchiveStreamFactory().createArchiveInputStream(BufferedInputStream(from.inputStream())).use { archi ->
@@ -162,6 +177,8 @@ fun extractSupportedArchive(
162177
publisher(archi.bytesRead, totalSize)
163178
}
164179
}
180+
} catch (e: NoClassDefFoundError) {
181+
throw IOException("archive support is unavailable on this device for ${from.extension}", e)
165182
} catch (e: ArchiveException) {
166183
throw IOException("error inflating archive", e)
167184
} catch (e: ZipException) {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.kyhsgeekcode
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertFalse
5+
import kotlin.test.assertTrue
6+
7+
class ArchiveDetectionTest {
8+
@Test
9+
fun `isKnownArchiveExtension recognizes supported archive names`() {
10+
assertTrue(isKnownArchiveExtension("sample.zip"))
11+
assertTrue(isKnownArchiveExtension("sample.apk"))
12+
assertTrue(isKnownArchiveExtension("sample.jar"))
13+
assertTrue(isKnownArchiveExtension("sample.aar"))
14+
assertTrue(isKnownArchiveExtension("sample.ar"))
15+
assertTrue(isKnownArchiveExtension("sample.tar"))
16+
}
17+
18+
@Test
19+
fun `isKnownArchiveExtension ignores non archive names`() {
20+
assertFalse(isKnownArchiveExtension("sample.dex"))
21+
assertFalse(isKnownArchiveExtension("sample.so"))
22+
assertFalse(isKnownArchiveExtension("sample"))
23+
}
24+
}

docs/maintenance/backlog-triage.ko.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
| 대용량/메모리/RecyclerView 크래시 | `#219`, `#235`, `#442`, `#523` | `planned-fast-follow` | `#728`에서 큰 파일 byte cache 제한과 문자열 검색 결과 상한/stable key를 먼저 넣었다 | `#728` 병합 후 실제 150MB 파일과 긴 문자열 리스트로 재검증하고 나머지 OOM 경로를 분리 |
3838
| 회전/상태 복원 크래시 | `#160` | `covered-by-open-pr` | `#728`에서 Activity 재생성 시 외부 import intent 재처리를 막는 1차 가드를 넣었다 | `#728` 병합 후 실제 회전 회귀를 확인하고 정리 |
3939
| `.so`/ELF/autosetup | `#514`, `#543`, `#576`, `#137` | `covered-by-open-pr` | `#728`에서 64-bit ELF machine type 매핑과 override autosetup 재적용 경로를 먼저 수정했다 | `#728` 병합 후 실제 `.so` 샘플로 재검증하고 남는 parser 문제만 분리 |
40-
| crash report 저신호 묶음 | `#716`, `#672`, `#512`, `#508`, `#507`, `#490`, `#438`, `#376`, `#280` | `needs-repro` | 제목만으로는 원인 판단이 어렵고 재현 자료가 부족하다 | 공통 템플릿으로 추가 정보 요청 후 재현 안 되면 정리 |
40+
| 구형 Android archive 감지 크래시 | `#507`, `#508` | `covered-by-open-pr` | `#728`에서 archive 확장자 fast path와 `NoClassDefFoundError` 방어를 넣어 구형 Android의 Commons Compress 감지 크래시 경로를 우회했다 | `#728` 병합 후 Android 6~7 계열에서 archive chooser와 open 경로를 재확인하고 정리 |
41+
| crash report 저신호 묶음 | `#716`, `#672`, `#512`, `#490`, `#438`, `#376`, `#280` | `needs-repro` | 제목만으로는 원인 판단이 어렵고 재현 자료가 부족하다 | 공통 템플릿으로 추가 정보 요청 후 재현 안 되면 정리 |
4142
| SWF 요청 | `#721` | `planned-fast-follow` | 모바일 SWF 확장/디컴파일 요구는 남아 있지만 추적 스레드는 하나로 줄었다 | 기준선 병합 후 포맷 확장 우선순위에서 다시 평가 |
4243
| 포맷 확장 요청 | `#120`, `#116`, `#124`, `#129` | `planned-fast-follow` | `#129``#728`에서 generic archive extraction으로 먼저 흡수했고, 나머지는 기준선 복구 후가 맞다 | `#129``#728` 병합 후 정리하고 나머지는 포맷별 난이도와 수요를 다시 평가 |
4344
| export/저장 유틸 | `#123`, `#159`, `#720` | `covered-by-open-pr` | `#728`에서 project ZIP export, detail `.txt` 저장, import 파일명 정규화/테스트를 함께 정리했다 | `#728` 병합 후 실제 기기에서 export/save 동작 확인하고 정리 |

docs/maintenance/implementation-log.ko.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
| 이슈 `#95` imported project reopen 경계 정리 | exported project ZIP을 import한 뒤 `sourceFilePath`, `generatedFolder`, `project_info.json` 경로를 새 프로젝트 디렉터리 기준으로 재작성하고, temp extract 경로 매핑을 제거하도록 바꿨다 | SAF나 로컬 파일에서 가져온 project archive를 다시 열었을 때 삭제된 temp extract 경로나 예전 경로를 계속 참조하는 문제를 막아 reopen/open 경계를 실제 프로젝트 위치로 닫았다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectManager.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ProjectManagerTest.kt` | 완료 |
4444
| 이슈 `#95` project archive / 기존 project reopen 연결 | chooser의 `Open as project` 흐름이 기존 project 디렉터리와 exported project ZIP을 실제로 열도록 연결하고, project zip은 `project_info.json` 존재 여부로 판별하게 바꿨다 | 이전에는 `openProject` 플래그가 state에만 저장되고 실질 동작이 없어서 reopen 경계가 끊겨 있었는데, 이제 기존 project 디렉터리는 바로 열고 exported project ZIP은 바로 import할 수 있다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectManager.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt`, `app/src/main/java/com/kyhsgeekcode/filechooser/model/FileItem.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/viewmodel/ProjectOpenActionTest.kt` | 완료 |
4545
| 이슈 `#129` `.ar` archive 지원 | 공용 archive extractor를 추가하고, Compose/legacy drawer의 archive 확장 경로를 ZIP 전용 구현에서 generic archive extractor로 교체했다 | archive 판정은 되는데 실제 확장은 ZIP만 되던 불일치를 제거해서 `.ar` 같은 지원 가능한 archive도 실제로 탐색 가능하게 만들었다 | `app/src/main/java/com/kyhsgeekcode/Util.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/ui/FileDrawerTree.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/FileDrawerListItem.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/FileDrawerListAdapter.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ArchiveExtractionTest.kt` | 완료 |
46+
| 이슈 `#507`, `#508` 구형 Android archive 감지 크래시 | archive 판정에 확장자 fast path를 추가하고, Commons Compress 탐지/추출 경로가 `NoClassDefFoundError`를 앱 크래시로 터뜨리지 않도록 방어했다. ZIP 추출기는 첫 엔트리 뒤 스트림을 닫던 버그도 같이 수정했다 | Android 6~7 계열에서 `ArchiveStreamFactory.detect(...)`가 내부적으로 사용할 수 없는 클래스를 건드리며 죽던 경로를 우회했고, ZIP/APK/JAR/AAR는 안전한 내장 경로로 처리하도록 바꿔 chooser와 archive open 경계를 안정화했다 | `app/src/main/java/com/kyhsgeekcode/FileExtensions.kt`, `app/src/main/java/com/kyhsgeekcode/Util.kt`, `app/src/test/java/com/kyhsgeekcode/ArchiveDetectionTest.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ArchiveExtractionTest.kt` | 완료 |
4647
| 프로젝트 경로/파일명 회귀 방지 | 프로젝트 상대경로 계산과 import 파일명 정규화를 pure helper로 분리 | 단위 테스트가 가능하도록 로직을 분리하고 경계 케이스를 줄였다 | `app/src/main/java/com/kyhsgeekcode/disassembler/project/ProjectManager.kt`, `app/src/main/java/com/kyhsgeekcode/disassembler/viewmodel/MainViewModel.kt` | 완료 |
4748
| 회귀 테스트 부재 | `ProjectManager`, 저장소 권한, Hex 레이아웃, import 파일명 테스트 추가 | 최소한의 유지보수 안전망을 확보했다 | `app/src/test/java/com/kyhsgeekcode/disassembler/ProjectManagerTest.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/PermissionUtilsTest.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/ui/components/HexViewLayoutTest.kt`, `app/src/test/java/com/kyhsgeekcode/disassembler/viewmodel/ImportedFileNameTest.kt` | 완료 |
4849

@@ -78,6 +79,7 @@
7879
| project export archive 테스트 | 통과 | ZIP 엔트리가 절대경로를 포함하지 않고 `sourceFilePath`, `baseFolder/...`로 묶이는지 확인 |
7980
| details export file name 테스트 | 통과 | detail 저장 파일명이 원본 파일명 기반으로 생성되고 금지 문자를 제거하는지 확인 |
8081
| generic archive extraction 테스트 | 통과 | ZIP과 `.ar`가 같은 extractor로 풀리고 path traversal 항목은 거부되는지 확인 |
82+
| archive detection 회귀 테스트 | 통과 | 지원 확장자는 fast path로 archive로 인식하고, ZIP/APK/JAR/AAR 경로는 구형 Android에서도 Commons Compress 탐지 크래시 없이 처리하는 기반을 고정 |
8183
| workflow YAML 파싱 | 통과 | `.github/workflows/ci.yml`, `.github/workflows/release.yml` 모두 Ruby YAML 파서 기준 확인 |
8284

8385
## 다음 웨이브 후보

0 commit comments

Comments
 (0)