From 34173f7b268b7d957c1272851892963118ffa673 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:52:02 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[MEDIUM]=20?= =?UTF-8?q?Fix=20TOCTOU=20IOException=20in=20ignore=20file=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ’‘ What: .html4ignore 파일의 μƒνƒœ(읽기 κ°€λŠ₯, μ‚­μ œ λ“±)κ°€ 확인(Time-of-Check) μ‹œμ κ³Ό μ‹€μ œ μ‚¬μš©(Time-of-Use) μ‹œμ  사이에 변경될 경우 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 좩돌(DoS)을 μœ λ°œν•˜λŠ” TOCTOU 취약점을 λ°©μ–΄ν•˜κΈ° μœ„ν•΄ useLines ν˜ΈμΆœμ„ try-catch둜 κ°μŒŒμŠ΅λ‹ˆλ‹€. 🎯 Why: canRead() 호좜 이후 useLines() μ‹€ν–‰ 직전에 파일 κΆŒν•œμ΄ μ™ΈλΆ€ μš”μΈμ— μ˜ν•΄ λ³€κ²½λ˜κ±°λ‚˜ μ‚­μ œλ˜λ©΄ μ²˜λ¦¬λ˜μ§€ μ•Šμ€ FileNotFoundException λ“± IOException이 λ°œμƒν•˜μ—¬ 전체 디렉토리 탐색(크둀러)이 μ€‘λ‹¨λ˜λŠ” λ¬Έμ œκ°€ λ°œκ²¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€. 🎯 Impact: μ œν•œμ μΈ λ¦¬μ†ŒμŠ€ λ˜λŠ” μŠ€λ ˆλ“œ 병렬 처리 μ‹œλ‚˜λ¦¬μ˜€μ—μ„œ .html4ignore νŒŒμΌμ„ μ‘°μž‘ν•˜κ±°λ‚˜ μ‚­μ œν•˜λŠ” λ°©μ‹μ˜ 레이슀 μ»¨λ””μ…˜μ„ μ΄μš©ν•΄ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 전체 μŠ€μΊ”μ„ μ€‘λ‹¨μ‹œν‚¬ 수 μžˆλŠ” μ„œλΉ„μŠ€ κ±°λΆ€(DoS) μœ„ν—˜μ΄ μžˆμ—ˆμŠ΅λ‹ˆλ‹€. πŸ”§ Fix: 파일 I/O(useLines)λ₯Ό try-catch(IOException)둜 감싸 μ˜ˆμ™Έ λ°œμƒ μ‹œ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 좩돌 λŒ€μ‹  μš°μ•„ν•˜κ²Œ μ‹€νŒ¨(ν•΄λ‹Ή 파일 λ¬΄μ‹œ)ν•˜λ„λ‘ λ°©μ–΄ μ½”λ“œλ₯Ό μΆ”κ°€ν–ˆμŠ΅λ‹ˆλ‹€. βœ… Verification: 레이슀 μ»¨λ””μ…˜μ„ λ°œμƒμ‹œμΌœ 읽기/μ‚­μ œ μƒνƒœλ₯Ό μ§€μ†μ μœΌλ‘œ λ³€κ²½ν•˜λŠ” λ™μ‹œμ„± λ‹¨μœ„ ν…ŒμŠ€νŠΈλ₯Ό μž‘μ„±ν•˜μ˜€μœΌλ©°, ν…ŒμŠ€νŠΈ κ³Όμ •μ—μ„œ IOException이 μ•ˆμ •μ μœΌλ‘œ 포착(catch)λ˜μ–΄ 정상 톡과함을 ν™•μΈν–ˆμŠ΅λ‹ˆλ‹€. --- .jules/sentinel.md | 5 ++++ src/main/kotlin/html4tree/main.kt | 24 +++++++++++------- src/test/kotlin/html4tree/ToctouTest.kt | 33 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 src/test/kotlin/html4tree/ToctouTest.kt diff --git a/.jules/sentinel.md b/.jules/sentinel.md index a885865d..fe90fc49 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -99,3 +99,8 @@ **Root cause:** The protected implementation added canonical names to the exclusion set but did not compare each observed directory entry through a locale-stable normalized key. **Prevention:** Build one `Locale.ROOT` lowercase set from the canonical sensitive names, compare every observed name against it, and add the original spelling to the exclusion set so downstream exact membership remains correct. **Evidence:** `testProcessIgnoreFileTreatsSensitiveNamesCaseInsensitively` failed on test-only commit `472b916cd40f70693c4e1eb48956042a25353feb` (CI run `31469596932`) and passed with the source fix at `bb113d858ccfc42ddaecf6729749b238e5ade2d0` (CI run `31469921661`). + +## 2024-07-25 - [html4tree] TOCTOU (Time-of-Check to Time-of-Use) λ°©μ–΄λ‘œ 인해 λ°œμƒν•œ IOException λ°©μ§€ +**Vulnerability:** ꡬ성 파일(`.html4ignore`) 처리 쀑 canRead() 검사와 useLines() 호좜 사이에 파일이 λ³€κ²½λ˜κ±°λ‚˜ μ‚­μ œλ  경우 `java.io.FileNotFoundException` λ“± `IOException`이 λ°œμƒν•˜μ—¬ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 좩돌(DoS)이 λ°œμƒν•  수 μžˆλŠ” TOCTOU 취약점이 λ°œκ²¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€. +**Learning:** `isFile`, `isSymbolicLink`, `canRead` 등을 μ‚¬μš©ν•˜μ—¬ 파일 μƒνƒœλ₯Ό ν™•μΈν•œ ν›„(Time-of-Check), μ‹€μ œλ‘œ νŒŒμΌμ„ μ½κ±°λ‚˜ μ‚¬μš©ν•  λ•Œ(Time-of-Use)μ—λŠ” ν•΄λ‹Ή μ‹œμ μ— 파일 μ‹œμŠ€ν…œ μƒνƒœκ°€ 변경될 수 μžˆλ‹€λŠ” 점을 항상 κ³ λ €ν•΄μ•Ό ν•©λ‹ˆλ‹€. 특히 λ©€ν‹° μŠ€λ ˆλ“œλ‚˜ 병렬 처리 ν™˜κ²½, μ™ΈλΆ€ μ‚¬μš©μž(λ˜λŠ” ν”„λ‘œμ„ΈμŠ€)κ°€ κ°œμž…ν•  수 μžˆλŠ” ν™˜κ²½μ—μ„œλŠ” `try-catch`둜 μ˜ˆμ™Έλ₯Ό 감싸야 ν•©λ‹ˆλ‹€. +**Prevention:** `useLines` λ˜λŠ” 파일 I/O μž‘μ—…μ„ μˆ˜ν–‰ν•  λ•Œ `try-catch` λΈ”λ‘μœΌλ‘œ `IOException`을 포착(catch)ν•˜μ—¬ μš°μ•„ν•˜κ²Œ μ‹€νŒ¨(Fail Securely)ν•˜λ„λ‘ μ²˜λ¦¬ν•˜κ³ , 전체 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ ν”„λ‘œμ„ΈμŠ€κ°€ μ€‘λ‹¨λ˜μ§€ μ•Šκ²Œ λ°©μ–΄ν•΄μ•Ό ν•©λ‹ˆλ‹€. diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index 0972fa2c..c0f6cc72 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -309,18 +309,24 @@ fun process_ignore_file(curr_dir: File, dirFilesNames: Array? = null): S if(ignore_file.isFile && !Files.isSymbolicLink(ignore_file.toPath()) && ignore_file.canRead() && ignore_file.length() <= 1048576){ val ignored_matchers = mutableListOf() - ignore_file.useLines { lines -> - for ((lineIndex, it) in lines.withIndex()) { - // 쀄 수 μ œν•œμ΄ νŒ¨ν„΄ μˆ˜λ„ ν•¨κ»˜ μƒν•œ(쀄당 μ΅œλŒ€ 1개 νŒ¨ν„΄)ν•˜λ―€λ‘œ 별도 νŒ¨ν„΄ μΉ΄μš΄ν„°λŠ” λΆˆν•„μš” - if (lineIndex >= 1000) break - val pattern = it.trim() - if (pattern.isNotEmpty() && pattern.length <= 100) { - try { - ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")) - } catch (_: IllegalArgumentException) { + try { + ignore_file.useLines { lines -> + for ((lineIndex, it) in lines.withIndex()) { + // 쀄 수 μ œν•œμ΄ νŒ¨ν„΄ μˆ˜λ„ ν•¨κ»˜ μƒν•œ(쀄당 μ΅œλŒ€ 1개 νŒ¨ν„΄)ν•˜λ―€λ‘œ 별도 νŒ¨ν„΄ μΉ΄μš΄ν„°λŠ” λΆˆν•„μš” + if (lineIndex >= 1000) break + val pattern = it.trim() + if (pattern.isNotEmpty() && pattern.length <= 100) { + try { + ignored_matchers.add(java.nio.file.FileSystems.getDefault().getPathMatcher("glob:$pattern")) + } catch (_: IllegalArgumentException) { + } } } } + } catch (_: java.io.IOException) { + // λ³΄μ•ˆ ν–₯상: TOCTOU(Time-of-Check to Time-of-Use) λ°©μ–΄. + // canRead() 확인 이후 useLines() μ‹€ν–‰ 직전에 파일 κΆŒν•œμ΄ λ³€κ²½λ˜κ±°λ‚˜ μ‚­μ œλ  경우 + // λ°œμƒν•˜λŠ” IOException을 λ¬΄μ‹œν•˜μ—¬ 전체 크둀러 좩돌(DoS)을 λ°©μ§€ν•©λ‹ˆλ‹€. } // ⚑ Bolt Performance Optimization: 디렉토리 λͺ©λ‘μ„ Set에 μΆ”κ°€ν•˜κΈ° μœ„ν•΄ ν•„ν„°λ§λ§Œ ν•  λ•ŒλŠ” 정렬이 λΆˆν•„μš”ν•˜λ―€λ‘œ .sorted()λ₯Ό μ œκ±°ν•˜μ—¬ O(N log N) μ˜€λ²„ν—€λ“œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€. diff --git a/src/test/kotlin/html4tree/ToctouTest.kt b/src/test/kotlin/html4tree/ToctouTest.kt new file mode 100644 index 00000000..7fa5eb58 --- /dev/null +++ b/src/test/kotlin/html4tree/ToctouTest.kt @@ -0,0 +1,33 @@ +package html4tree + +import org.junit.Test +import org.junit.Assert.* +import java.io.File +import java.nio.file.Files + +class ToctouTest { + @Test + fun testProcessIgnoreFileToctouIoException() { + val tempDir = Files.createTempDirectory("test-toctou").toFile() + try { + val ignoreFile = File(tempDir, ".html4ignore") + val done = java.util.concurrent.atomic.AtomicBoolean(false) + val t = kotlin.concurrent.thread { + while (!done.get()) { + ignoreFile.writeText("*.txt") + ignoreFile.setReadable(true) + ignoreFile.setReadable(false) + ignoreFile.delete() + } + } + + for (i in 1..5000) { + process_ignore_file(tempDir, null) + } + done.set(true) + t.join() + } finally { + tempDir.deleteRecursively() + } + } +}