mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 06:31:54 +00:00
fix(root): enforce command deadlines while draining output (#6241)
* fix(root): bound command execution and output collection * test(root): cover command deadlines and output limits
This commit is contained in:
1 parent
c48fbd3fc8
commit
60ab1af384
4 files changed
+90
-27
No files matched your search
@@ -6,7 +6,6 @@ import com.v2ray.ang.root.RootManager.refresh
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Detects whether the device grants root (`su`) access.
|
||||
@@ -43,21 +42,17 @@ object RootManager {
|
||||
|
||||
private fun probe(): Boolean {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", "id -u")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
|
||||
val finished = process.waitFor(10, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroy()
|
||||
val result = RootProcessRunner.run(listOf("su", "-c", "id -u"), 10000, 1024)
|
||||
if (result.code == -1) {
|
||||
LogUtil.w(AppConfig.TAG, "RootManager: su probe timed out")
|
||||
return false
|
||||
}
|
||||
val isRoot = process.exitValue() == 0 && output.lineSequence().lastOrNull()?.trim() == "0"
|
||||
val isRoot = result.code == 0 && result.output.lineSequence().filter { it.isNotBlank() }.lastOrNull()?.trim() == "0"
|
||||
LogUtil.i(AppConfig.TAG, "RootManager: root available = $isRoot")
|
||||
isRoot
|
||||
} catch (e: Exception) {
|
||||
LogUtil.w(AppConfig.TAG, "RootManager: no root access (${e.message})")
|
||||
if (e is InterruptedException) Thread.currentThread().interrupt()
|
||||
LogUtil.w(AppConfig.TAG, "RootManager: root probe failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.v2ray.ang.root
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Owns one bounded root command. Neither inherited stdout nor output volume extends its deadline. */
|
||||
internal object RootProcessRunner {
|
||||
data class Outcome(val code: Int, val output: String)
|
||||
|
||||
fun run(command: List<String>, timeoutMillis: Long, outputLimit: Int = 65536): Outcome {
|
||||
val process = ProcessBuilder(command).redirectErrorStream(true).start()
|
||||
val captured = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(4096)
|
||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis)
|
||||
var code = -1
|
||||
try {
|
||||
// Process.waitFor(timeout, unit) is API 26. Polling supports API 24–25 too.
|
||||
// Read only available bytes: a detached helper may retain stdout after su exits.
|
||||
while (System.nanoTime() < deadline) {
|
||||
val available = process.inputStream.available()
|
||||
if (available > 0) {
|
||||
val size = process.inputStream.read(buffer, 0, minOf(buffer.size, available))
|
||||
val remaining = outputLimit - captured.size()
|
||||
if (size > 0 && remaining > 0) captured.write(buffer, 0, minOf(size, remaining))
|
||||
}
|
||||
try {
|
||||
code = process.exitValue()
|
||||
if (process.inputStream.available() == 0) break
|
||||
} catch (_: IllegalThreadStateException) {
|
||||
if (available == 0) Thread.sleep(10)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (code == -1) process.destroy()
|
||||
process.inputStream.close()
|
||||
process.outputStream.close()
|
||||
process.errorStream.close()
|
||||
}
|
||||
return Outcome(code, captured.toString("UTF-8"))
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Minimal root command runner backed by the `su` binary.
|
||||
@@ -24,32 +23,29 @@ object RootShell {
|
||||
val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
|
||||
val file = File(dir, name).apply {
|
||||
writeText(script)
|
||||
setExecutable(true, false)
|
||||
setReadable(false, false)
|
||||
setReadable(true, true)
|
||||
setWritable(false, false)
|
||||
setWritable(true, true)
|
||||
}
|
||||
val safePath = file.absolutePath.replace("'", "'\\''")
|
||||
return exec("sh '$safePath'")
|
||||
// Android toybox timeout owns the root shell, so killing the su client cannot leave
|
||||
// a timed-out setup shell installing rules after rollback has already started.
|
||||
return exec("timeout -s TERM -k 2 25 sh '$safePath'")
|
||||
}
|
||||
|
||||
private fun exec(command: String, timeoutSeconds: Long = 30): Result {
|
||||
return try {
|
||||
val process = ProcessBuilder("su", "-c", command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().use { it.readText() }
|
||||
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||
if (!finished) {
|
||||
process.destroy()
|
||||
LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command")
|
||||
return Result(-1, output)
|
||||
}
|
||||
val result = Result(process.exitValue(), output)
|
||||
val outcome = RootProcessRunner.run(listOf("su", "-c", command), timeoutSeconds * 1000)
|
||||
val result = Result(outcome.code, outcome.output)
|
||||
if (!result.success) {
|
||||
LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}")
|
||||
LogUtil.w(AppConfig.TAG, "RootShell: root command exited ${result.code}: ${result.output.trim()}")
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e)
|
||||
Result(-1, e.message ?: e.javaClass.simpleName)
|
||||
if (e is InterruptedException) Thread.currentThread().interrupt()
|
||||
LogUtil.e(AppConfig.TAG, "RootShell: root command failed", e)
|
||||
Result(-1, e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.v2ray.ang.root
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RootProcessRunnerTest {
|
||||
@Test fun `a silent blocked command respects its deadline`() {
|
||||
val start = System.nanoTime()
|
||||
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5"), 150)
|
||||
assertEquals(-1, result.code)
|
||||
assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500)
|
||||
}
|
||||
@Test fun `inherited stdout cannot hold a completed command open`() {
|
||||
val start = System.nanoTime()
|
||||
val result = RootProcessRunner.run(listOf("sh", "-c", "sleep 5 & echo complete"), 250)
|
||||
assertEquals(0, result.code)
|
||||
assertTrue(result.output.contains("complete"))
|
||||
assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) < 1500)
|
||||
}
|
||||
@Test fun `large output is drained without unbounded retention`() {
|
||||
val result = RootProcessRunner.run(listOf("sh", "-c", "yes x | head -c 1048576"), 3000, 4096)
|
||||
assertEquals(0, result.code)
|
||||
assertEquals(4096, result.output.length)
|
||||
}
|
||||
@Test fun `failure status and diagnostic output survive`() {
|
||||
val result = RootProcessRunner.run(listOf("sh", "-c", "echo lock-unavailable >&2; exit 4"), 1000)
|
||||
assertEquals(4, result.code)
|
||||
assertTrue(result.output.contains("lock-unavailable"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user