diff options
Diffstat (limited to 'tunnel/src/main/java/com/wireguard/android/util/RootShell.java')
-rw-r--r-- | tunnel/src/main/java/com/wireguard/android/util/RootShell.java | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/tunnel/src/main/java/com/wireguard/android/util/RootShell.java b/tunnel/src/main/java/com/wireguard/android/util/RootShell.java new file mode 100644 index 00000000..1fc2c9f2 --- /dev/null +++ b/tunnel/src/main/java/com/wireguard/android/util/RootShell.java @@ -0,0 +1,211 @@ +/* + * Copyright © 2017-2019 WireGuard LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.wireguard.android.util; + +import android.content.Context; +import androidx.annotation.Nullable; +import android.util.Log; + +import com.wireguard.android.util.RootShell.RootShellException.Reason; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.UUID; + +/** + * Helper class for running commands as root. + */ + +public class RootShell { + private static final String SU = "su"; + private static final String TAG = "WireGuard/" + RootShell.class.getSimpleName(); + + private final File localBinaryDir; + private final File localTemporaryDir; + private final Object lock = new Object(); + private final String preamble; + @Nullable private Process process; + @Nullable private BufferedReader stderr; + @Nullable private OutputStreamWriter stdin; + @Nullable private BufferedReader stdout; + + public RootShell(final Context context) { + localBinaryDir = new File(context.getCodeCacheDir(), "bin"); + localTemporaryDir = new File(context.getCacheDir(), "tmp"); + preamble = String.format("export CALLING_PACKAGE=%s PATH=\"%s:$PATH\" TMPDIR='%s'; id -u\n", + context.getPackageName(), localBinaryDir, localTemporaryDir); + } + + private static boolean isExecutableInPath(final String name) { + final String path = System.getenv("PATH"); + if (path == null) + return false; + for (final String dir : path.split(":")) + if (new File(dir, name).canExecute()) + return true; + return false; + } + + private boolean isRunning() { + synchronized (lock) { + try { + // Throws an exception if the process hasn't finished yet. + if (process != null) + process.exitValue(); + return false; + } catch (final IllegalThreadStateException ignored) { + // The existing process is still running. + return true; + } + } + } + + /** + * Run a command in a root shell. + * + * @param output Lines read from stdout are appended to this list. Pass null if the + * output from the shell is not important. + * @param command Command to run as root. + * @return The exit value of the command. + */ + public int run(@Nullable final Collection<String> output, final String command) + throws IOException, RootShellException { + synchronized (lock) { + /* Start inside synchronized block to prevent a concurrent call to stop(). */ + start(); + final String marker = UUID.randomUUID().toString(); + final String script = "echo " + marker + "; echo " + marker + " >&2; (" + command + + "); ret=$?; echo " + marker + " $ret; echo " + marker + " $ret >&2\n"; + Log.v(TAG, "executing: " + command); + stdin.write(script); + stdin.flush(); + String line; + int errnoStdout = Integer.MIN_VALUE; + int errnoStderr = Integer.MAX_VALUE; + int markersSeen = 0; + while ((line = stdout.readLine()) != null) { + if (line.startsWith(marker)) { + ++markersSeen; + if (line.length() > marker.length() + 1) { + errnoStdout = Integer.valueOf(line.substring(marker.length() + 1)); + break; + } + } else if (markersSeen > 0) { + if (output != null) + output.add(line); + Log.v(TAG, "stdout: " + line); + } + } + while ((line = stderr.readLine()) != null) { + if (line.startsWith(marker)) { + ++markersSeen; + if (line.length() > marker.length() + 1) { + errnoStderr = Integer.valueOf(line.substring(marker.length() + 1)); + break; + } + } else if (markersSeen > 2) { + Log.v(TAG, "stderr: " + line); + } + } + if (markersSeen != 4) + throw new RootShellException(Reason.SHELL_MARKER_COUNT_ERROR, markersSeen); + if (errnoStdout != errnoStderr) + throw new RootShellException(Reason.SHELL_EXIT_STATUS_READ_ERROR); + Log.v(TAG, "exit: " + errnoStdout); + return errnoStdout; + } + } + + public void start() throws IOException, RootShellException { + if (!isExecutableInPath(SU)) + throw new RootShellException(Reason.NO_ROOT_ACCESS); + synchronized (lock) { + if (isRunning()) + return; + if (!localBinaryDir.isDirectory() && !localBinaryDir.mkdirs()) + throw new RootShellException(Reason.CREATE_BIN_DIR_ERROR); + if (!localTemporaryDir.isDirectory() && !localTemporaryDir.mkdirs()) + throw new RootShellException(Reason.CREATE_TEMP_DIR_ERROR); + try { + final ProcessBuilder builder = new ProcessBuilder().command(SU); + builder.environment().put("LC_ALL", "C"); + try { + process = builder.start(); + } catch (final IOException e) { + // A failure at this stage means the device isn't rooted. + final RootShellException rse = new RootShellException(Reason.NO_ROOT_ACCESS); + rse.initCause(e); + throw rse; + } + stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8); + stdout = new BufferedReader(new InputStreamReader(process.getInputStream(), + StandardCharsets.UTF_8)); + stderr = new BufferedReader(new InputStreamReader(process.getErrorStream(), + StandardCharsets.UTF_8)); + stdin.write(preamble); + stdin.flush(); + // Check that the shell started successfully. + final String uid = stdout.readLine(); + if (!"0".equals(uid)) { + Log.w(TAG, "Root check did not return correct UID: " + uid); + throw new RootShellException(Reason.NO_ROOT_ACCESS); + } + if (!isRunning()) { + String line; + while ((line = stderr.readLine()) != null) { + Log.w(TAG, "Root check returned an error: " + line); + if (line.contains("Permission denied")) + throw new RootShellException(Reason.NO_ROOT_ACCESS); + } + throw new RootShellException(Reason.SHELL_START_ERROR, process.exitValue()); + } + } catch (final IOException | RootShellException e) { + stop(); + throw e; + } + } + } + + public void stop() { + synchronized (lock) { + if (process != null) { + process.destroy(); + process = null; + } + } + } + + public static class RootShellException extends Exception { + public enum Reason { + NO_ROOT_ACCESS, + SHELL_MARKER_COUNT_ERROR, + SHELL_EXIT_STATUS_READ_ERROR, + SHELL_START_ERROR, + CREATE_BIN_DIR_ERROR, + CREATE_TEMP_DIR_ERROR + } + private final Reason reason; + private final Object[] format; + public RootShellException(final Reason reason, final Object ...format) { + this.reason = reason; + this.format = format; + } + public boolean isIORelated() { + return reason != Reason.NO_ROOT_ACCESS; + } + public Reason getReason() { + return reason; + } + public Object[] getFormat() { + return format; + } + } +} |