v2.2.3: Os.chmod() syscall + PRoot fallback + explicit sh invocation for SELinux bypass

This commit is contained in:
admin
2026-05-19 20:34:41 +04:00
Unverified
parent f3b2150cb0
commit b921102768
7 changed files with 365 additions and 77 deletions

View File

@@ -7,8 +7,8 @@ android {
applicationId "ai.z.chat"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 15
versionName "2.2.2"
versionCode 16
versionName "2.2.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'

View File

@@ -9,6 +9,8 @@ import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import android.system.Os;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
@@ -129,10 +131,9 @@ public class BootstrapPlugin extends Plugin {
patchPaths(stagingDir);
sendProgress(call, "Setting permissions...", 85);
setPermissions(new File(stagingDir, "bin"));
setPermissions(new File(stagingDir, "libexec"));
setPermissionsRecursive(new File(stagingDir, "bin"));
setPermissionsRecursive(new File(stagingDir, "libexec"));
chmodRecursive(new File(stagingDir, "bin"));
chmodRecursive(new File(stagingDir, "libexec"));
chmodRecursive(new File(stagingDir, "lib"));
new File(homeDir).mkdirs();
new File(prefixDir + "/tmp").mkdirs();
@@ -149,14 +150,9 @@ public class BootstrapPlugin extends Plugin {
throw new RuntimeException("Failed to rename staging to prefix");
}
try {
Runtime rt = Runtime.getRuntime();
rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/bin"}).waitFor();
rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/libexec"}).waitFor();
rt.exec(new String[]{"chmod", "755", prefixDir + "/lib"}).waitFor();
} catch (Exception ce) {
Log.w(TAG, "chmod after rename failed: " + ce.getMessage());
}
chmodRecursive(new File(prefixDir + "/bin"));
chmodRecursive(new File(prefixDir + "/libexec"));
chmodRecursive(new File(prefixDir + "/lib"));
writeEnvFile();
writeProfileFile();
@@ -315,29 +311,20 @@ public class BootstrapPlugin extends Plugin {
}
}
private void setPermissions(File dir) {
if (!dir.exists()) return;
File[] children = dir.listFiles();
if (children == null) return;
for (File f : children) {
if (f.isFile()) {
f.setExecutable(true, false);
f.setReadable(true, false);
}
}
}
private void setPermissionsRecursive(File dir) {
private void chmodRecursive(File dir) {
if (!dir.exists() || !dir.isDirectory()) return;
File[] children = dir.listFiles();
if (children == null) return;
for (File f : children) {
if (f.isDirectory()) {
f.setExecutable(true, false);
setPermissionsRecursive(f);
} else if (f.isFile()) {
f.setExecutable(true, false);
f.setReadable(true, false);
try {
if (f.isDirectory()) {
Os.chmod(f.getAbsolutePath(), 0755);
chmodRecursive(f);
} else {
Os.chmod(f.getAbsolutePath(), 0755);
}
} catch (Exception e) {
Log.w(TAG, "chmod failed: " + f.getAbsolutePath() + ": " + e.getMessage());
}
}
}
@@ -345,13 +332,11 @@ public class BootstrapPlugin extends Plugin {
@PluginMethod
public void fixPermissions(PluginCall call) {
try {
File binDir = new File(prefixDir + "/bin");
if (binDir.exists()) {
Runtime rt = Runtime.getRuntime();
rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/bin"}).waitFor();
rt.exec(new String[]{"chmod", "-R", "755", prefixDir + "/libexec"}).waitFor();
setPermissionsRecursive(binDir);
}
chmodRecursive(new File(prefixDir + "/bin"));
chmodRecursive(new File(prefixDir + "/libexec"));
chmodRecursive(new File(prefixDir + "/lib"));
File etcDir = new File(prefixDir + "/etc");
if (etcDir.exists()) chmodRecursive(etcDir);
call.resolve(new JSObject().put("fixed", true));
} catch (Exception e) {
call.reject("Fix permissions failed: " + e.getMessage());
@@ -438,6 +423,200 @@ public class BootstrapPlugin extends Plugin {
file.delete();
}
@PluginMethod
public void installProot(PluginCall call) {
call.setKeepAlive(true);
new Thread(() -> {
try {
String arch = getArch();
String prootBinPath = binDir + "/proot";
File prootBin = new File(prootBinPath);
if (prootBin.exists()) {
try { Os.chmod(prootBinPath, 0755); } catch (Exception e) {}
call.resolve(new JSObject().put("installed", true).put("path", prootBinPath));
return;
}
String nativeLibDir = null;
try {
nativeLibDir = getContext().getApplicationInfo().nativeLibraryDir;
File nativeProot = new File(nativeLibDir, "libproot.so");
if (nativeProot.exists()) {
call.resolve(new JSObject().put("installed", true).put("path", nativeProot.getAbsolutePath()).put("bundled", true));
return;
}
} catch (Exception e) {}
String packagesUrl = "https://packages.termux.dev/apt/termux-main/dists/stable/main/binary-" + arch + "/Packages";
Log.i(TAG, "Fetching package index: " + packagesUrl);
String packagesContent = downloadToString(packagesUrl);
String prootDebUrl = null;
String prootDebName = null;
String[] blocks = packagesContent.split("\n\n");
for (String block : blocks) {
if (block.contains("Package: proot")) {
for (String line : block.split("\n")) {
if (line.startsWith("Filename:")) {
prootDebName = line.substring("Filename:".length()).trim();
}
}
if (prootDebName != null) break;
}
}
if (prootDebName == null) {
call.reject("proot package not found in repo");
return;
}
prootDebUrl = "https://packages.termux.dev/apt/termux-main/" + prootDebName;
Log.i(TAG, "Downloading proot: " + prootDebUrl);
File debFile = new File(getContext().getCacheDir(), "proot.deb");
downloadFile(prootDebUrl, debFile, null);
Log.i(TAG, "Extracting proot binary from .deb...");
byte[] prootData = extractBinaryFromDeb(debFile, "proot");
if (prootData == null) {
call.reject("Failed to extract proot binary from .deb");
debFile.delete();
return;
}
new File(binDir).mkdirs();
FileOutputStream fos = new FileOutputStream(prootBin);
fos.write(prootData);
fos.close();
Os.chmod(prootBinPath, 0755);
debFile.delete();
Log.i(TAG, "proot installed: " + prootBinPath + " (" + prootData.length + " bytes)");
call.resolve(new JSObject().put("installed", true).put("path", prootBinPath).put("size", prootData.length));
} catch (Exception e) {
Log.e(TAG, "installProot failed", e);
try { call.reject("installProot failed: " + e.getMessage()); } catch (Exception ignored) {}
}
}).start();
}
private String downloadToString(String urlStr) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
reader.close();
conn.disconnect();
return sb.toString();
}
private byte[] extractBinaryFromDeb(File debFile, String binaryName) throws Exception {
FileInputStream fis = new FileInputStream(debFile);
byte[] magic = new byte[8];
if (fis.read(magic) != 8 || !new String(magic).equals("!<arch>\n")) {
fis.close();
throw new RuntimeException("Not a valid .deb (AR) file");
}
while (true) {
byte[] header = new byte[60];
int read = fis.read(header);
if (read < 60) break;
String name = new String(header, 0, 16).trim();
String sizeStr = new String(header, 48, 10).trim();
long entrySize = Long.parseLong(sizeStr);
if (name.startsWith("data.tar")) {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
long remaining = entrySize;
while (remaining > 0) {
int toRead = (int) Math.min(buf.length, remaining);
int r = fis.read(buf, 0, toRead);
if (r <= 0) break;
baos.write(buf, 0, r);
remaining -= r;
}
fis.close();
byte[] tarData = baos.toByteArray();
if (name.contains(".xz")) {
tarData = decompressXz(tarData);
} else if (name.contains(".gz")) {
tarData = decompressGz(tarData);
} else if (name.contains(".zst")) {
throw new RuntimeException("ZSTD compression not supported");
}
return findBinaryInTar(tarData, binaryName);
} else {
long skip = (entrySize + 1) & ~1L;
fis.skip(skip);
}
}
fis.close();
return null;
}
private byte[] decompressGz(byte[] data) throws Exception {
java.util.zip.GZIPInputStream gzis = new java.util.zip.GZIPInputStream(
new java.io.ByteArrayInputStream(data));
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
int r;
while ((r = gzis.read(buf)) > 0) baos.write(buf, 0, r);
gzis.close();
baos.close();
return baos.toByteArray();
}
private byte[] decompressXz(byte[] data) throws Exception {
ProcessBuilder pb = new ProcessBuilder("xz", "-d", "--stdout", "-");
pb.redirectErrorStream(true);
Process p = pb.start();
OutputStream pos = p.getOutputStream();
pos.write(data);
pos.close();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
java.io.InputStream pis = p.getInputStream();
int r;
while ((r = pis.read(buf)) > 0) baos.write(buf, 0, r);
pis.close();
p.waitFor();
return baos.toByteArray();
}
private byte[] findBinaryInTar(byte[] tarData, String binaryName) throws Exception {
int offset = 0;
while (offset + 512 <= tarData.length) {
String headerName = new String(tarData, offset, 100).trim().replace("\0", "");
String sizeStr = new String(tarData, offset + 124, 12).trim().replace("\0", "");
long fileSize = 0;
try { fileSize = Long.parseLong(sizeStr, 8); } catch (Exception e) { break; }
if (headerName.endsWith("/bin/" + binaryName) || headerName.equals(binaryName) ||
headerName.endsWith("/" + binaryName)) {
byte[] result = new byte[(int) fileSize];
System.arraycopy(tarData, offset + 512, result, 0, (int) Math.min(fileSize, tarData.length - offset - 512));
return result;
}
long blocks = (fileSize + 511) / 512;
offset += 512 + (int)(blocks * 512);
}
return null;
}
interface ProgressCallback {
void onProgress(long downloaded, long total);
}

View File

@@ -1,5 +1,6 @@
package ai.z.chat;
import android.system.Os;
import android.util.Log;
import com.getcapacitor.JSObject;
@@ -47,28 +48,39 @@ public class ShellPlugin extends Plugin {
refreshShell();
}
private String prootPath = null;
private void refreshShell() {
File proot = new File(prefixDir + "/bin/proot");
if (proot.exists()) {
try { Os.chmod(proot.getAbsolutePath(), 0755); } catch (Exception e) {}
prootPath = proot.getAbsolutePath();
} else {
String nativeLibDir = getNativeLibDir();
if (nativeLibDir != null) {
File nativeProot = new File(nativeLibDir, "libproot.so");
if (nativeProot.exists()) {
prootPath = nativeProot.getAbsolutePath();
}
}
}
File bash = new File(prefixDir + "/bin/bash");
File sh = new File(prefixDir + "/bin/sh");
if (bash.exists()) {
if (!bash.canExecute()) {
bash.setExecutable(true, false);
try {
Runtime.getRuntime().exec(new String[]{"chmod", "755", bash.getAbsolutePath()}).waitFor();
} catch (Exception e) {}
}
shellPath = bash.getAbsolutePath();
} else if (sh.exists()) {
if (!sh.canExecute()) {
sh.setExecutable(true, false);
try {
Runtime.getRuntime().exec(new String[]{"chmod", "755", sh.getAbsolutePath()}).waitFor();
} catch (Exception e) {}
}
shellPath = sh.getAbsolutePath();
} else {
shellPath = "/system/bin/sh";
try { Os.chmod(bash.getAbsolutePath(), 0755); } catch (Exception e) {}
}
if (sh.exists()) {
try { Os.chmod(sh.getAbsolutePath(), 0755); } catch (Exception e) {}
}
shellPath = "/system/bin/sh";
}
private String getNativeLibDir() {
try {
return getContext().getApplicationInfo().nativeLibraryDir;
} catch (Exception e) { return null; }
}
@PluginMethod
@@ -77,6 +89,7 @@ public class ShellPlugin extends Plugin {
String cwd = call.getString("cwd", currentCwd);
boolean stream = call.getBoolean("stream", false);
int timeout = call.getInt("timeout", 300000);
boolean useProot = call.getBoolean("useProot", false);
if (command.isEmpty()) {
call.reject("No command provided");
@@ -89,7 +102,13 @@ public class ShellPlugin extends Plugin {
refreshShell();
String[] env = buildEnv();
String shell = shellPath != null ? shellPath : "sh";
ProcessBuilder pb = new ProcessBuilder(shell, "-c", command);
String actualCommand = command;
if (useProot && prootPath != null) {
actualCommand = prootPath + " -0 -b /dev -b /proc -b /sys -r " + prefixDir + " /bin/sh -c " + bashEscape(command);
}
ProcessBuilder pb = new ProcessBuilder(shell, "-c", actualCommand);
pb.directory(new File(cwd));
pb.environment().putAll(toEnvMap(env));
pb.redirectErrorStream(true);
@@ -98,16 +117,12 @@ public class ShellPlugin extends Plugin {
try {
process = pb.start();
} catch (java.io.IOException ioe) {
if (shell != null && !shell.equals("/system/bin/sh")) {
Log.w(TAG, "Shell " + shell + " failed, falling back to /system/bin/sh: " + ioe.getMessage());
pb = new ProcessBuilder("/system/bin/sh", "-c", command);
pb.directory(new File(cwd));
pb.environment().putAll(toEnvMap(env));
pb.redirectErrorStream(true);
process = pb.start();
} else {
throw ioe;
}
Log.w(TAG, "Shell " + shell + " failed: " + ioe.getMessage());
pb = new ProcessBuilder("/system/bin/sh", "-c", actualCommand);
pb.directory(new File(cwd));
pb.environment().putAll(toEnvMap(env));
pb.redirectErrorStream(true);
process = pb.start();
}
String processId = String.valueOf(System.currentTimeMillis());
activeProcesses.put(processId, process);
@@ -122,6 +137,10 @@ public class ShellPlugin extends Plugin {
}
}
private String bashEscape(String s) {
return "'" + s.replace("'", "'\\''") + "'";
}
@PluginMethod
public void kill(PluginCall call) {
String processId = call.getString("pid", "");
@@ -158,9 +177,28 @@ public class ShellPlugin extends Plugin {
env.put("PROJECTS", projectsDir);
env.put("CWD", currentCwd);
env.put("PREFIX", prefixDir);
env.put("hasProot", prootPath != null);
env.put("prootPath", prootPath != null ? prootPath : "");
env.put("nativeLibDir", getNativeLibDir());
call.resolve(env);
}
@PluginMethod
public void testExec(PluginCall call) {
String testPath = call.getString("path", prefixDir + "/bin/sh");
try {
File f = new File(testPath);
if (!f.exists()) {
call.resolve(new JSObject().put("exists", false).put("executable", false));
return;
}
boolean canExec = f.canExecute();
call.resolve(new JSObject().put("exists", true).put("executable", canExec).put("path", testPath));
} catch (Exception e) {
call.resolve(new JSObject().put("exists", false).put("executable", false).put("error", e.getMessage()));
}
}
@PluginMethod
public void writeFile(PluginCall call) {
String path = call.getString("path", "");