fix(security): 阶段1-3 核心安全修复
阶段1:阻断 Root 注入和路径穿越 - 使用 PackageName.safe() 过滤备份目录中的包名 - canonicalFile 校验防止路径穿越 - APK 文件名拒绝 / \ . .. 空白 - pm install 路径加引号 - RestoreArchiveSafety 拒绝相对路径如 etc/passwd - 压缩方式 allowlist (zstd/tar) - chmod/tar/cp 统一 quoting 阶段2:修复备份正确性 - 删除错误增量跳过逻辑 (APK version 不应跳过 app data) - APK copy 失败计入失败统计 - gzip/tar 参数顺序修正 - 权限收紧 chmod go-rwx - 归档安全检查增强 阶段3:恢复流程安全 UX - 默认不全选应用 - 全选应用/取消全选按钮 - 恢复确认弹窗 - Wi-Fi 恢复 opt-in - partial 终态保持 error 色
This commit is contained in:
@@ -49,7 +49,8 @@ object BackupAppDataOps {
|
|||||||
val bundledTar = BinaryResolver.tarPath(context)
|
val bundledTar = BinaryResolver.tarPath(context)
|
||||||
val tarCmd = bundledTar ?: "tar"
|
val tarCmd = bundledTar ?: "tar"
|
||||||
|
|
||||||
var isZstd = compression == "zstd"
|
val compressionMethod = BackupConfig.normalizeCompressionMethod(compression)
|
||||||
|
var isZstd = compressionMethod == "zstd"
|
||||||
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
|
val bundledZstd = if (isZstd) BinaryResolver.zstdPath(context) else null
|
||||||
val zstdCmd = bundledZstd ?: "zstd"
|
val zstdCmd = bundledZstd ?: "zstd"
|
||||||
if (isZstd && bundledZstd == null) {
|
if (isZstd && bundledZstd == null) {
|
||||||
@@ -157,7 +158,7 @@ object BackupAppDataOps {
|
|||||||
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
|
) { "'${it.shellEscape()}'" }} 2>/dev/null | $zstdCmd -T0 -o '$outputFile.zst'",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
RootShell.exec("$tarCmd -czf $excludeArgs '$outputFile.gz' ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
RootShell.exec("$tarCmd -czf '$outputFile.gz' $excludeArgs ${dirs.joinToString(" ") { "'${it.shellEscape()}'" }} 2>/dev/null")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +176,9 @@ object BackupAppDataOps {
|
|||||||
val escapedPkg = packageName.shellEscape()
|
val escapedPkg = packageName.shellEscape()
|
||||||
// Exclude cache and backup temp files from OBB archive
|
// Exclude cache and backup temp files from OBB archive
|
||||||
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
val obbExcludes = "--exclude='cache' --exclude='Backup_*'"
|
||||||
|
val compressionMethod = BackupConfig.normalizeCompressionMethod(compression)
|
||||||
val result =
|
val result =
|
||||||
when (compression) {
|
when (compressionMethod) {
|
||||||
"zstd" -> {
|
"zstd" -> {
|
||||||
RootShell.exec(
|
RootShell.exec(
|
||||||
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'",
|
"set -o pipefail; tar -cf - $obbExcludes '$obbDir' 2>/dev/null | zstd -T0 -o '$escapedAppDir/${escapedPkg}_obb.tar.zst'",
|
||||||
@@ -184,24 +186,24 @@ object BackupAppDataOps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
RootShell.exec("tar -czf $obbExcludes '$escapedAppDir/${escapedPkg}_obb.tar.gz' '$obbDir' 2>/dev/null")
|
RootShell.exec("tar -czf '$escapedAppDir/${escapedPkg}_obb.tar.gz' $obbExcludes '$obbDir' 2>/dev/null")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
|
Log.e(TAG, "Failed to backup OBB for $packageName: exit=${result.exitCode} err=${result.error}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val obbArchiveExt = if (compression == "zstd") ".zst" else ".gz"
|
val obbArchiveExt = if (compressionMethod == "zstd") ".zst" else ".gz"
|
||||||
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
|
val obbFile = File(appDir, "${packageName}_obb.tar$obbArchiveExt")
|
||||||
val obbArchivePath = obbFile.absolutePath.shellEscape()
|
val obbArchivePath = obbFile.absolutePath.shellEscape()
|
||||||
val verifyCmd = if (compression == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
|
val verifyCmd = if (compressionMethod == "zstd") "zstd -t '$obbArchivePath' 2>/dev/null" else "gzip -t '$obbArchivePath' 2>/dev/null"
|
||||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||||
if (!verificationOk) {
|
if (!verificationOk) {
|
||||||
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
|
Log.e(TAG, "OBB archive integrity check FAILED for $packageName")
|
||||||
}
|
}
|
||||||
// Validate OBB tar structure
|
// Validate OBB tar structure
|
||||||
val tarListCmd =
|
val tarListCmd =
|
||||||
if (compression == "zstd") {
|
if (compressionMethod == "zstd") {
|
||||||
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
"zstd -d -c '$obbArchivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||||
} else {
|
} else {
|
||||||
"tar -tf '$obbArchivePath' > /dev/null 2>&1"
|
"tar -tf '$obbArchivePath' > /dev/null 2>&1"
|
||||||
@@ -233,18 +235,19 @@ object BackupAppDataOps {
|
|||||||
return 0L // Not an error, just no data
|
return 0L // Not an error, just no data
|
||||||
}
|
}
|
||||||
|
|
||||||
val archiveExt = if (compression == "zstd") ".zst" else ".gz"
|
val compressionMethod = BackupConfig.normalizeCompressionMethod(compression)
|
||||||
|
val archiveExt = if (compressionMethod == "zstd") ".zst" else ".gz"
|
||||||
val archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
|
val archiveFile = File(appDir, "${packageName}_external_data.tar$archiveExt")
|
||||||
val archivePath = archiveFile.absolutePath.shellEscape()
|
val archivePath = archiveFile.absolutePath.shellEscape()
|
||||||
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
|
val dataExcludes = "--exclude='cache' --exclude='Backup_*' --exclude='.ota'"
|
||||||
|
|
||||||
val result =
|
val result =
|
||||||
if (compression == "zstd") {
|
if (compressionMethod == "zstd") {
|
||||||
RootShell.exec(
|
RootShell.exec(
|
||||||
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
|
"set -o pipefail; tar -cf - $dataExcludes '$externalDataDir' 2>/dev/null | zstd -T0 -o '$archivePath'",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
RootShell.exec("tar -czf $dataExcludes '$archivePath' '$externalDataDir' 2>/dev/null")
|
RootShell.exec("tar -czf '$archivePath' $dataExcludes '$externalDataDir' 2>/dev/null")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
@@ -253,7 +256,7 @@ object BackupAppDataOps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify compression integrity
|
// Verify compression integrity
|
||||||
val verifyCmd = if (compression == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
|
val verifyCmd = if (compressionMethod == "zstd") "zstd -t '$archivePath' 2>/dev/null" else "gzip -t '$archivePath' 2>/dev/null"
|
||||||
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
val verificationOk = RootShell.exec(verifyCmd).isSuccess
|
||||||
if (!verificationOk) {
|
if (!verificationOk) {
|
||||||
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
|
Log.e(TAG, "backupExternalData: $packageName integrity check FAILED")
|
||||||
@@ -262,7 +265,7 @@ object BackupAppDataOps {
|
|||||||
|
|
||||||
// Validate tar structure
|
// Validate tar structure
|
||||||
val tarListCmd =
|
val tarListCmd =
|
||||||
if (compression == "zstd") {
|
if (compressionMethod == "zstd") {
|
||||||
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
"zstd -d -c '$archivePath' 2>/dev/null | tar -tf - > /dev/null 2>&1"
|
||||||
} else {
|
} else {
|
||||||
"tar -tf '$archivePath' > /dev/null 2>&1"
|
"tar -tf '$archivePath' > /dev/null 2>&1"
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ object BackupOperation {
|
|||||||
return@withContext BackupResult(0, 0, 0, absOut, 0)
|
return@withContext BackupResult(0, 0, 0, absOut, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val compressionMethod = BackupConfig.normalizeCompressionMethod(config.compressionMethod)
|
||||||
|
|
||||||
// Create backup structure
|
// Create backup structure
|
||||||
val backupRoot = File(outputDir, "Backup_${config.compressionMethod}_$userId")
|
val backupRoot = File(outputDir, "Backup_${compressionMethod}_$userId")
|
||||||
if (!mkdirsForBackup(backupRoot)) {
|
if (!mkdirsForBackup(backupRoot)) {
|
||||||
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
|
LogUtil.e(TAG, "backupApps: cannot create output dir ${backupRoot.absolutePath}")
|
||||||
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
return@withContext BackupResult(0, 0, 0, outputDir.absolutePath, 0)
|
||||||
@@ -170,7 +172,7 @@ object BackupOperation {
|
|||||||
app = app,
|
app = app,
|
||||||
backupRoot = backupRoot,
|
backupRoot = backupRoot,
|
||||||
oldMetaJson = oldMetaJson,
|
oldMetaJson = oldMetaJson,
|
||||||
config = config,
|
config = config.copy(compressionMethod = compressionMethod),
|
||||||
userId = userId,
|
userId = userId,
|
||||||
noDataBackup = noDataBackup,
|
noDataBackup = noDataBackup,
|
||||||
appInfoCache = appInfoCache,
|
appInfoCache = appInfoCache,
|
||||||
@@ -196,7 +198,7 @@ object BackupOperation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val elapsed = System.currentTimeMillis() - startTime
|
val elapsed = System.currentTimeMillis() - startTime
|
||||||
RootShell.exec("chmod -R 0755 '${backupRoot.absolutePath}'")
|
RootShell.exec("chmod -R go-rwx '${backupRoot.absolutePath.shellEscape()}'")
|
||||||
val successCount = successAtomic.get()
|
val successCount = successAtomic.get()
|
||||||
val failCount = failAtomic.get()
|
val failCount = failAtomic.get()
|
||||||
val skippedCount = skippedAtomic.get()
|
val skippedCount = skippedAtomic.get()
|
||||||
@@ -213,7 +215,7 @@ object BackupOperation {
|
|||||||
val integrityReport = BackupIntegrityChecker.checkBackupIntegrity(
|
val integrityReport = BackupIntegrityChecker.checkBackupIntegrity(
|
||||||
backupDir = backupRoot,
|
backupDir = backupRoot,
|
||||||
packages = apps.map { it.packageName.value },
|
packages = apps.map { it.packageName.value },
|
||||||
compression = config.compressionMethod,
|
compression = compressionMethod,
|
||||||
)
|
)
|
||||||
LogUtil.i(TAG, "backupApps: integrity check completed — ${integrityReport.passedPackages}/${integrityReport.checkedPackages} passed")
|
LogUtil.i(TAG, "backupApps: integrity check completed — ${integrityReport.passedPackages}/${integrityReport.checkedPackages} passed")
|
||||||
|
|
||||||
@@ -221,7 +223,7 @@ object BackupOperation {
|
|||||||
BackupIntegrityChecker.generateChecksumFile(
|
BackupIntegrityChecker.generateChecksumFile(
|
||||||
backupDir = backupRoot,
|
backupDir = backupRoot,
|
||||||
packages = apps.map { it.packageName.value },
|
packages = apps.map { it.packageName.value },
|
||||||
compression = config.compressionMethod,
|
compression = compressionMethod,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,16 +283,24 @@ object BackupOperation {
|
|||||||
progressTracker.updateStage("apk", "正在备份 APK…")
|
progressTracker.updateStage("apk", "正在备份 APK…")
|
||||||
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
|
emit(BackupProgress(index + 1, totalCount, pkgName, "apk", "正在备份 APK…"))
|
||||||
val paths = appInfoCache.getApkPaths(pkgName)
|
val paths = appInfoCache.getApkPaths(pkgName)
|
||||||
if (paths.isNotEmpty()) {
|
if (paths.isEmpty()) {
|
||||||
val cpOk =
|
failAtomic.incrementAndGet()
|
||||||
paths.withIndex().all { (i, apkPath) ->
|
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "APK 路径为空"))
|
||||||
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
|
return
|
||||||
RootShell
|
}
|
||||||
.exec(
|
val cpOk =
|
||||||
"cp '${apkPath.shellEscape()}' '${appDir.absolutePath.shellEscape()}/${destName.shellEscape()}'",
|
paths.withIndex().all { (i, apkPath) ->
|
||||||
).isSuccess
|
val destName = if (paths.size > 1) "${pkgName}_split_$i.apk" else "$pkgName.apk"
|
||||||
}
|
val dest = File(appDir, destName)
|
||||||
if (!cpOk) LogUtil.w(TAG, "backupApps: APK cp failed for $pkgName, continuing")
|
RootShell
|
||||||
|
.exec(
|
||||||
|
"cp '${apkPath.shellEscape()}' '${dest.absolutePath.shellEscape()}'",
|
||||||
|
).isSuccess && BackupFileIO.backupPathExists(dest) && BackupFileIO.backupFileSize(dest) > 0L
|
||||||
|
}
|
||||||
|
if (!cpOk) {
|
||||||
|
failAtomic.incrementAndGet()
|
||||||
|
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "APK 备份失败"))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
skippedAtomic.incrementAndGet()
|
skippedAtomic.incrementAndGet()
|
||||||
@@ -302,28 +312,8 @@ object BackupOperation {
|
|||||||
val hasKeystore = appInfoCache.hasKeystore(pkgName) ?: false
|
val hasKeystore = appInfoCache.hasKeystore(pkgName) ?: false
|
||||||
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
|
if (hasKeystore) emit(BackupProgress(index + 1, totalCount, pkgName, "data", "⚠ 包含密钥库条目"))
|
||||||
|
|
||||||
// ── Size-based data incremental skip ──
|
// App data changes independently of APK version; do not skip mutable
|
||||||
var skipData = false
|
// data based only on stale metadata from a previous backup.
|
||||||
if (!apkChanged) {
|
|
||||||
val oldUserSize =
|
|
||||||
try {
|
|
||||||
oldEntry?.optJSONObject("user")?.optString("Size", null)?.toLongOrNull()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val oldObbSize =
|
|
||||||
try {
|
|
||||||
oldEntry?.optJSONObject("obb")?.optString("Size", null)?.toLongOrNull()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (oldUserSize != null || oldObbSize != null) {
|
|
||||||
skipData = true
|
|
||||||
Log.d(TAG, "backupApps: $pkgName data sizes known from backup, skipping data backup (incremental)")
|
|
||||||
progressTracker.skipApp(pkgName, "数据大小已知,跳过数据备份")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var userSize: Long? = null
|
var userSize: Long? = null
|
||||||
var userDeSize: Long? = null
|
var userDeSize: Long? = null
|
||||||
var dataSize: Long? = null
|
var dataSize: Long? = null
|
||||||
@@ -331,14 +321,14 @@ object BackupOperation {
|
|||||||
|
|
||||||
// Force-stop before data backup for consistency.
|
// Force-stop before data backup for consistency.
|
||||||
// Exclude the app itself (avoid suicide) and well-known persistent apps.
|
// Exclude the app itself (avoid suicide) and well-known persistent apps.
|
||||||
if (config.backupMode == 1 && !skipData) {
|
if (config.backupMode == 1) {
|
||||||
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", context.packageName)) {
|
if (pkgName !in listOf("bin.mt.plus", "com.termux", "bin.mt.plus.canary", context.packageName)) {
|
||||||
RootShell.exec("am force-stop --user ${userId.shellEscape()} '${pkgName.shellEscape()}' 2>/dev/null")
|
RootShell.exec("am force-stop --user ${userId.shellEscape()} '${pkgName.shellEscape()}' 2>/dev/null")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Backup user data
|
// 2. Backup user data
|
||||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||||
if (pkgName in noDataBackup) {
|
if (pkgName in noDataBackup) {
|
||||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
|
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "跳过数据备份(已排除)"))
|
||||||
} else {
|
} else {
|
||||||
@@ -354,12 +344,10 @@ object BackupOperation {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (skipData) {
|
|
||||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "数据无变化,跳过"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Backup OBB
|
// 3. Backup OBB
|
||||||
if (config.backupMode == 1 && config.backupObbData == 1 && !skipData) {
|
if (config.backupMode == 1 && config.backupObbData == 1) {
|
||||||
val hasObb = AppScanner.hasObbData(pkgName)
|
val hasObb = AppScanner.hasObbData(pkgName)
|
||||||
if (hasObb) {
|
if (hasObb) {
|
||||||
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
|
emit(BackupProgress(index + 1, totalCount, pkgName, "obb", "正在备份 OBB…"))
|
||||||
@@ -373,10 +361,15 @@ object BackupOperation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3.5 Backup external data
|
// 3.5 Backup external data
|
||||||
if (config.backupMode == 1 && config.backupUserData == 1 && !skipData) {
|
if (config.backupMode == 1 && config.backupUserData == 1) {
|
||||||
if (pkgName !in noDataBackup) {
|
if (pkgName !in noDataBackup) {
|
||||||
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
|
emit(BackupProgress(index + 1, totalCount, pkgName, "data", "正在备份外部数据…"))
|
||||||
dataSize = BackupAppDataOps.backupExternalData(pkgName, appDir, userId, config.compressionMethod)
|
dataSize = BackupAppDataOps.backupExternalData(pkgName, appDir, userId, config.compressionMethod)
|
||||||
|
if (dataSize == null) {
|
||||||
|
failAtomic.incrementAndGet()
|
||||||
|
emit(BackupProgress(index + 1, totalCount, pkgName, "appdone", "外部数据备份失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ object RestoreApkInstaller {
|
|||||||
LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null")
|
LogUtil.e(TAG, "installApk: $packageName — listBackupFiles returned null")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val apkFiltered = apkNames.filter { it.endsWith(".apk") }.sorted()
|
val apkFiltered =
|
||||||
|
apkNames
|
||||||
|
.filter { it.endsWith(".apk") && !it.contains('/') && !it.contains('\\') && it != "." && it != ".." }
|
||||||
|
.sorted()
|
||||||
LogUtil.i(TAG, "installApk: $packageName apkFiltered=$apkFiltered")
|
LogUtil.i(TAG, "installApk: $packageName apkFiltered=$apkFiltered")
|
||||||
if (apkFiltered.isEmpty()) return false
|
if (apkFiltered.isEmpty()) return false
|
||||||
|
|
||||||
@@ -61,7 +64,7 @@ object RestoreApkInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun doInstall(): Boolean {
|
suspend fun doInstall(): Boolean {
|
||||||
val apkPaths = localApks.joinToString(" ") { it.absolutePath.shellEscape() }
|
val apkPaths = localApks.joinToString(" ") { "'${it.absolutePath.shellEscape()}'" }
|
||||||
if (localApks.size > 1) {
|
if (localApks.size > 1) {
|
||||||
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
val result = RootShell.exec("pm install-create -r -t 2>/dev/null")
|
||||||
val sessionId =
|
val sessionId =
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ object RestoreAppDataOps {
|
|||||||
for (archive in dataFiles) {
|
for (archive in dataFiles) {
|
||||||
val archivePath = archive.absolutePath.shellEscape()
|
val archivePath = archive.absolutePath.shellEscape()
|
||||||
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
Log.d(TAG, "restoreData: found archive ${archive.name}")
|
||||||
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd)) {
|
if (!RestoreArchiveSafety.isArchiveSafe(
|
||||||
|
archive,
|
||||||
|
zstdCmd,
|
||||||
|
additionalAllowedPrefixes = dataPaths.map { "$it/" },
|
||||||
|
)) {
|
||||||
Log.e(TAG, "restoreData: archive UNSAFE, ABORTING restore for $packageName: ${archive.name}")
|
Log.e(TAG, "restoreData: archive UNSAFE, ABORTING restore for $packageName: ${archive.name}")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -82,11 +86,11 @@ object RestoreAppDataOps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
archive.name.endsWith(".gz") -> {
|
archive.name.endsWith(".gz") -> {
|
||||||
"$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
"$tarCmd -xzf '$archivePath' $excludeArgs -C / 2>/dev/null"
|
||||||
}
|
}
|
||||||
|
|
||||||
archive.name.endsWith(".tar") -> {
|
archive.name.endsWith(".tar") -> {
|
||||||
"$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null"
|
"$tarCmd -xf '$archivePath' $excludeArgs -C / 2>/dev/null"
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -156,8 +160,8 @@ object RestoreAppDataOps {
|
|||||||
var anyExtracted = false
|
var anyExtracted = false
|
||||||
for (archive in obbFiles) {
|
for (archive in obbFiles) {
|
||||||
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
|
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
|
||||||
"/storage/emulated/0/Android/obb/",
|
"/storage/emulated/0/Android/obb/$packageName/",
|
||||||
"/data/media/$userId/Android/obb/",
|
"/data/media/$userId/Android/obb/$packageName/",
|
||||||
))) {
|
))) {
|
||||||
Log.e(TAG, "restoreObb: archive UNSAFE, ABORTING OBB restore for $packageName: ${archive.name}")
|
Log.e(TAG, "restoreObb: archive UNSAFE, ABORTING OBB restore for $packageName: ${archive.name}")
|
||||||
return false
|
return false
|
||||||
@@ -170,11 +174,11 @@ object RestoreAppDataOps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
archive.name.endsWith(".gz") -> {
|
archive.name.endsWith(".gz") -> {
|
||||||
RootShell.exec("$tarCmd -xzf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
RootShell.exec("$tarCmd -xzf '$archivePath' $excludeArgs -C / 2>/dev/null")
|
||||||
}
|
}
|
||||||
|
|
||||||
archive.name.endsWith(".tar") -> {
|
archive.name.endsWith(".tar") -> {
|
||||||
RootShell.exec("$tarCmd -xf $excludeArgs '$archivePath' -C / 2>/dev/null")
|
RootShell.exec("$tarCmd -xf '$archivePath' $excludeArgs -C / 2>/dev/null")
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -227,8 +231,8 @@ object RestoreAppDataOps {
|
|||||||
for (name in extNames) {
|
for (name in extNames) {
|
||||||
val archive = File(appDir, name)
|
val archive = File(appDir, name)
|
||||||
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
|
if (!RestoreArchiveSafety.isArchiveSafe(archive, zstdCmd, additionalAllowedPrefixes = listOf(
|
||||||
"/data/media/$userId/Android/data/",
|
"/data/media/$userId/Android/data/$packageName/",
|
||||||
"/storage/emulated/0/Android/data/",
|
"/storage/emulated/0/Android/data/$packageName/",
|
||||||
))) {
|
))) {
|
||||||
Log.e(TAG, "restoreExternalData: archive UNSAFE, ABORTING external data restore for $packageName: $name")
|
Log.e(TAG, "restoreExternalData: archive UNSAFE, ABORTING external data restore for $packageName: $name")
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -53,17 +53,17 @@ object RestoreArchiveSafety {
|
|||||||
result = RootShell.exec(fallbackCmd)
|
result = RootShell.exec(fallbackCmd)
|
||||||
}
|
}
|
||||||
if (!result.isSuccess) return false
|
if (!result.isSuccess) return false
|
||||||
|
val allowedPrefixes = additionalAllowedPrefixes.ifEmpty { BUILTIN_ALLOWED_PREFIXES }
|
||||||
return !result.output.lines().any { line ->
|
return !result.output.lines().any { line ->
|
||||||
val parts = line.split(" -> ", limit = 2)
|
val parts = line.split(" -> ", limit = 2)
|
||||||
val rawPath = parts[0]
|
val rawPath = parts[0]
|
||||||
val path = rawPath.trimStart('/')
|
val path = rawPath.trimStart('/')
|
||||||
|
val normalizedPath = "/$path"
|
||||||
val linkTarget = parts.getOrNull(1)
|
val linkTarget = parts.getOrNull(1)
|
||||||
|
|
||||||
// 1. 拒绝绝对路径(以 / 开头)——防止 tar -C / 写入系统文件
|
// 1. 恢复使用 tar -C /,所以相对路径 etc/passwd 也会写入
|
||||||
// 但允许内置的 app data 前缀和调用方指定的额外前缀。
|
// /etc/passwd。所有条目必须落在调用方允许的目标前缀内。
|
||||||
if (rawPath.startsWith("/") && !isPathAllowed(rawPath, additionalAllowedPrefixes)) {
|
if (!matchesAllowedPrefix(normalizedPath, allowedPrefixes)) return@any true
|
||||||
return@any true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 拒绝路径遍历
|
// 2. 拒绝路径遍历
|
||||||
if (path.split("/").any { it == ".." }) return@any true
|
if (path.split("/").any { it == ".." }) return@any true
|
||||||
@@ -88,7 +88,14 @@ object RestoreArchiveSafety {
|
|||||||
rawPath: String,
|
rawPath: String,
|
||||||
additionalAllowedPrefixes: List<String>,
|
additionalAllowedPrefixes: List<String>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return (BUILTIN_ALLOWED_PREFIXES + additionalAllowedPrefixes).any { prefix ->
|
return matchesAllowedPrefix(rawPath, BUILTIN_ALLOWED_PREFIXES + additionalAllowedPrefixes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesAllowedPrefix(
|
||||||
|
rawPath: String,
|
||||||
|
allowedPrefixes: List<String>,
|
||||||
|
): Boolean {
|
||||||
|
return allowedPrefixes.any { prefix ->
|
||||||
rawPath == prefix.dropLast(1) || rawPath.startsWith(prefix)
|
rawPath == prefix.dropLast(1) || rawPath.startsWith(prefix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,12 +69,15 @@ object RestoreOperation {
|
|||||||
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
|
LogUtil.i(TAG, "restoreApps: appListContent=${appListContent?.substringBefore("\n")?.take(100)}")
|
||||||
val allPackages =
|
val allPackages =
|
||||||
appListContent?.let { content ->
|
appListContent?.let { content ->
|
||||||
content.lines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
content.lines()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||||
|
.mapNotNull { PackageName.safe(it)?.value }
|
||||||
} ?: run {
|
} ?: run {
|
||||||
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
|
LogUtil.i(TAG, "restoreApps: readTextFile returned null, trying listBackupFiles")
|
||||||
val children = BackupOperation.listBackupFiles(backupDir)
|
val children = BackupOperation.listBackupFiles(backupDir)
|
||||||
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
|
LogUtil.i(TAG, "restoreApps: listBackupFiles returned ${children?.size} children")
|
||||||
children?.filter { name ->
|
children?.mapNotNull { name -> PackageName.safe(name)?.value }?.filter { name ->
|
||||||
val apkFile = File(File(backupDir, name), "$name.apk")
|
val apkFile = File(File(backupDir, name), "$name.apk")
|
||||||
val exists = BackupOperation.backupPathExists(apkFile)
|
val exists = BackupOperation.backupPathExists(apkFile)
|
||||||
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
|
LogUtil.i(TAG, "restoreApps: child $name apkExists=$exists")
|
||||||
@@ -104,12 +107,19 @@ object RestoreOperation {
|
|||||||
val semaphore = Semaphore(concurrencyConfig.maxConcurrency)
|
val semaphore = Semaphore(concurrencyConfig.maxConcurrency)
|
||||||
LogUtil.i(TAG, "restoreApps: ${concurrencyConfig.reason}")
|
LogUtil.i(TAG, "restoreApps: ${concurrencyConfig.reason}")
|
||||||
|
|
||||||
|
val backupCanonical = backupDir.canonicalFile
|
||||||
|
|
||||||
supervisorScope {
|
supervisorScope {
|
||||||
packages.forEachIndexed { index, pkg ->
|
packages.forEachIndexed { index, pkg ->
|
||||||
launch {
|
launch {
|
||||||
if (!coroutineContext.isActive) return@launch
|
if (!coroutineContext.isActive) return@launch
|
||||||
semaphore.withPermit {
|
semaphore.withPermit {
|
||||||
val appBackupDir = File(backupDir, pkg)
|
val appBackupDir = File(backupCanonical, pkg).canonicalFile
|
||||||
|
if (!appBackupDir.path.startsWith(backupCanonical.path + File.separator)) {
|
||||||
|
failAtomic.incrementAndGet()
|
||||||
|
emit(RestoreProgress(index + 1, packages.size, pkg, "appdone", "备份目录路径非法"))
|
||||||
|
return@withPermit
|
||||||
|
}
|
||||||
val dirExists = BackupFileIO.backupPathExists(appBackupDir)
|
val dirExists = BackupFileIO.backupPathExists(appBackupDir)
|
||||||
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
|
LogUtil.i(TAG, "restoreApps: pkg=$pkg appBackupDir=${appBackupDir.absolutePath} exists=$dirExists")
|
||||||
if (!dirExists) {
|
if (!dirExists) {
|
||||||
|
|||||||
Reference in New Issue
Block a user