diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProxyManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProxyManager.kt index e18f19af..f8df7d06 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProxyManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootProxyManager.kt @@ -2,6 +2,7 @@ package com.v2ray.ang.root import android.content.Context import android.os.Process +import android.util.AtomicFile import com.v2ray.ang.AppConfig import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager @@ -112,15 +113,25 @@ object RootProxyManager { val socksUsername = SettingsManager.getSocksUsername() val socksPassword = SettingsManager.getSocksPassword() val port = SettingsManager.getSocksPort() - val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() } + val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR) + if (!runDir.isDirectory && !runDir.mkdirs()) { + LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to create runtime directory at ${runDir.absolutePath}") + return null + } val pidFile = File(runDir, "tun2socks.pid").absolutePath val logFile = File(runDir, "tun2socks.log").absolutePath - val cfgFile = File(runDir, "tun2socks.yml").absolutePath + val cfgFile = File(runDir, "tun2socks.yml") val oomGuardPid = File(runDir, "oomguard.pid").absolutePath val ipv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) val lanShare = forceLanShare || MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_LAN_SHARING) val corePid = Process.myPid() + val config = buildHevConfig(socksUsername, socksPassword, port, ipv6) + if (!writeHevConfig(cfgFile, config)) { + return null + } + val cfgPath = cfgFile.absolutePath + // Per-app proxy/bypass (mirrors what VpnService does via allowed/disallowed apps). val perAppEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY) val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS) @@ -141,15 +152,9 @@ object RootProxyManager { appendLine("echo \$! > '$oomGuardPid'") // tun device node appendLine("if [ ! -e /dev/net/tun ]; then mkdir -p /dev/net; mknod /dev/net/tun c 10 200; chmod 666 /dev/net/tun; fi") - // hev-socks5-tunnel config: it creates the tun ($TUN) itself and forwards it to the - // in-process core's SOCKS inbound on loopback. MTU comes from the existing VPN MTU - // setting. No fwmark on hev's sockets: its only upstream connection is to 127.0.0.1 - // (loopback, already RETURNed by the 127.0.0.0/8 bypass) and the core's real outbound - // runs as the app uid (RETURNed by the uid-owner rule), so traffic can't loop. - appendLine("cat > '$cfgFile' <<'HEVCFG'") - append(buildHevConfig(socksUsername = socksUsername, socksPassword = socksPassword, socksPort = port, ipv6 = ipv6)) - appendLine("HEVCFG") - appendLine("nohup \"\$BIN\" '$cfgFile' >'$logFile' 2>&1 &") + // The HEV config is written separately by the app process. Never place credentials + // in this root shell script, even when YAML quoting would otherwise be valid. + appendLine("nohup \"\$BIN\" '$cfgPath' >'$logFile' 2>&1 &") appendLine("T2S_PID=\$!") appendLine("echo \$T2S_PID > '$pidFile'") appendLine("echo ${AppConfig.ROOT_OOM_SCORE} > /proc/\$T2S_PID/oom_score_adj 2>/dev/null || true") @@ -212,13 +217,33 @@ object RootProxyManager { appendLine(" address: '${AppConfig.LOOPBACK}'") appendLine(" udp: 'udp'") if (socksUsername != null && socksPassword != null) { - appendLine(" username: '$socksUsername'") - appendLine(" password: '$socksPassword'") + appendLine(" username: ${socksUsername.toSingleQuotedYamlScalar()}") + appendLine(" password: ${socksPassword.toSingleQuotedYamlScalar()}") } appendLine(" tcp-fastopen: true") } } + private fun writeHevConfig(file: File, config: String): Boolean { + val atomicFile = AtomicFile(file) + val output = try { + atomicFile.startWrite() + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to open HEV config at ${file.absolutePath}", e) + return false + } + + return try { + output.write(config.toByteArray(Charsets.UTF_8)) + atomicFile.finishWrite(output) + true + } catch (e: Exception) { + atomicFile.failWrite(output) + LogUtil.e(AppConfig.TAG, "RootProxyManager: failed to write HEV config at ${file.absolutePath}", e) + false + } + } + /** * mangle OUTPUT marking chain (ipv4/ipv6). Mirrors VpnService's capture behavior: * - all-apps (no per-app): mark EVERY remaining uid (incl uid 0 + all system uids), so @@ -473,3 +498,5 @@ object RootProxyManager { } } } + +internal fun String.toSingleQuotedYamlScalar(): String = "'${replace("'", "''")}'" diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/root/RootProxyManagerTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/root/RootProxyManagerTest.kt new file mode 100644 index 00000000..9759f807 --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/root/RootProxyManagerTest.kt @@ -0,0 +1,20 @@ +package com.v2ray.ang.root + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RootProxyManagerTest { + + @Test + fun `single quotes are escaped in YAML scalars`() { + assertEquals("'user''name'", "user'name".toSingleQuotedYamlScalar()) + assertEquals("'pa''''ss'", "pa''ss".toSingleQuotedYamlScalar()) + } + + @Test + fun `multiline credentials remain inside the YAML scalar`() { + val credential = "user\nHEVCFG\ntouch /data/local/tmp/injected" + + assertEquals("'user\nHEVCFG\ntouch /data/local/tmp/injected'", credential.toSingleQuotedYamlScalar()) + } +}