CVE-2024-9395
Overview
Background
- `sanitizeFileName`
- a Kotlin
Stringextension inString.ktthat cleans a proposed download filename before it is shown in the Android download dialog. - `replaceEscapedCharacters`
- a helper that maps control and reserved characters (matched by
escapedCharactersRegex) to theUNDERSCOREcharacter so they cannot corrupt or disguise the name. - Extension-spoofing
- a display attack where whitespace or other filler is packed before the extension so the user sees a benign-looking name while the real type (the trailing extension) is scrolled or clipped out of view.
Root Cause Analysis
The vulnerable code path is sanitizeFileName, which normalized dots and then only called replaceEscapedCharacters before returning the name for display in the download dialog.
The invariant that was violated is that a sanitized filename should present its true extension to the user without misleading padding, but replaceEscapedCharacters’s regex escapedCharactersRegex ([\x00-\x13*"?<>:|\\]) collapsed several whitespace control characters (tab \u0009, line feed \u000A, vertical tab \u000B, form feed \u000C, carriage return \u000D) into UNDERSCORE yet did nothing about ordinary spaces, so a name with a long run of spaces such as invoice.pdf<many spaces>.apk could push the real extension out of the visible field.
The fix adds replaceContinuousSpaces, which uses the \s+ regex to collapse any run of whitespace down to a single SPACE, and chains a trailing trim(), so no amount of padding survives to obscure the extension. Because whitespace is now folded rather than preserved, the displayed name faithfully reflects the file’s true extension, defeating the display-obfuscation trick.
sanitizeFileName treated only reserved/control characters as dangerous while leaving arbitrary runs of spaces intact, letting a filename visually hide its extension; the fix adds replaceContinuousSpaces plus a trim() so continuous whitespace is normalized to one SPACE and the extension can no longer be pushed out of view.Attack Path
- Craft a padded filename
The attacker names a hosted file so a legitimate-looking extension is followed by a long run of spaces and then the real, dangerous extension (e.g.
photo.jpg+ hundreds of spaces +.apk). - Serve to Firefox for Android
A victim on Firefox for Android is induced to download the file, and the server-supplied name flows into
sanitizeFileName. - Whitespace survives sanitization
Because
replaceEscapedCharactersonly handled control characters and not spaces, the many-space run passed through unchanged into the name used for display. - Dialog obscures the extension In the download dialog the visible portion shows only the benign prefix while the true trailing extension is scrolled or clipped beyond the field.
- User approves under false pretenses Believing the file is the harmless type shown, the user accepts the download of a file whose real extension is malicious.
Impact Assessment
Files Changed
mobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.ktmobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt
Audit Directions
- Display-vs-storage divergenceLook for any place a user-controlled string is sanitized for safety characters but not normalized for whitespace or bidi/format characters before being shown in a security-relevant UI element.
- Regex allowlist gapsReview character-class regexes like
escapedCharactersRegexfor excluded but abusable characters (plain spaces, Unicode whitespace, RTL overrides) that fall outside the intended match set. - Truncated UI fieldsAudit dialogs and labels that can scroll or clip long values, where a trailing extension or suffix could be hidden by leading padding, and confirm inputs are trimmed and collapsed first.
Patch
diff --git a/mobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt b/mobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt
index ae9d4393a94..192120bd914 100644
--- a/mobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt
+++ b/mobile/android/android-components/components/support/ktx/src/main/java/mozilla/components/support/ktx/kotlin/String.kt
@@ -53,6 +53,8 @@ const val MAX_URI_LENGTH = 25000
private const val FILE_PREFIX = "file://"
private const val MAX_VALID_PORT = 65_535
+private const val SPACE = " "
+private const val UNDERSCORE = "_"
/**
* Shortens URLs to be more user friendly.
@@ -307,7 +309,9 @@ fun String.sanitizeFileName(): String {
file.name.replace("\\.\\.+".toRegex(), ".")
} else {
file.name.replace(".", "")
- }.replaceEscapedCharacters()
+ }.replaceContinuousSpaces()
+ .replaceEscapedCharacters()
+ .trim()
}
/**
@@ -316,7 +320,15 @@ fun String.sanitizeFileName(): String {
*/
private fun String.replaceEscapedCharacters(): String {
val escapedCharactersRegex = "[\\x00-\\x13*\"?<>:|\\\\]".toRegex()
- return replace(escapedCharactersRegex, "_")
+ return replace(escapedCharactersRegex, UNDERSCORE)
+}
+
+/**
+ * Replaces continuous spaces with a single space.
+ */
+private fun String.replaceContinuousSpaces(): String {
+ val escapedCharactersRegex = "\\s+".toRegex()
+ return replace(escapedCharactersRegex, SPACE)
}
/**
diff --git a/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt b/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt
index 12ee4beab6b..51ed86a70f8 100644
--- a/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt
+++ b/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt
@@ -199,11 +199,11 @@ class StringTest {
"acknowledge\u0006signal" to "acknowledge_signal",
"bell\u0007sound" to "bell_sound",
"back\u0008space" to "back_space",
- "horizontal\u0009tab" to "horizontal_tab",
- "new\u000Aline" to "new_line",
- "vertical\u000Btab" to "vertical_tab",
- "form\u000Cfeed" to "form_feed",
- "return\u000Dcarriage" to "return_carriage",
+ "horizontal\u0009tab" to "horizontal tab",
+ "new\u000Aline" to "new line",
+ "vertical\u000Btab" to "vertical tab",
+ "form\u000Cfeed" to "form feed",
+ "return\u000Dcarriage" to "return carriage",
"shift\u000Eout" to "shift_out",
"shift\u000Fin" to "shift_in",
"escape\u0010data" to "escape_data",
Regression Test / PoC
diff --git a/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt b/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt
index 12ee4beab6b..51ed86a70f8 100644
--- a/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt
+++ b/mobile/android/android-components/components/support/ktx/src/test/java/mozilla/components/support/ktx/kotlin/StringTest.kt
@@ -199,11 +199,11 @@ class StringTest {
"acknowledge\u0006signal" to "acknowledge_signal",
"bell\u0007sound" to "bell_sound",
"back\u0008space" to "back_space",
- "horizontal\u0009tab" to "horizontal_tab",
- "new\u000Aline" to "new_line",
- "vertical\u000Btab" to "vertical_tab",
- "form\u000Cfeed" to "form_feed",
- "return\u000Dcarriage" to "return_carriage",
+ "horizontal\u0009tab" to "horizontal tab",
+ "new\u000Aline" to "new line",
+ "vertical\u000Btab" to "vertical tab",
+ "form\u000Cfeed" to "form feed",
+ "return\u000Dcarriage" to "return carriage",
"shift\u000Eout" to "shift_out",
"shift\u000Fin" to "shift_in",
"escape\u0010data" to "escape_data",