diff --git a/R4Ghidra/src/main/java/r4ghidra/repl/sandbox/R2Sandbox.java b/R4Ghidra/src/main/java/r4ghidra/repl/sandbox/R2Sandbox.java new file mode 100644 index 0000000..4bf1d5b --- /dev/null +++ b/R4Ghidra/src/main/java/r4ghidra/repl/sandbox/R2Sandbox.java @@ -0,0 +1,134 @@ +package r4ghidra.repl.sandbox; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** Capability policy for REPL operations; grains name allowed resources, as in radare2. */ +public final class R2Sandbox { + public static final int NONE = 0; + public static final int SOCKET = 1; + public static final int DISK = 2; + public static final int FILES = 4; + public static final int EXEC = 8; + public static final int ENVIRON = 16; + public static final int NETWORK = 32; + public static final int HIDDEN = 64; + public static final int ALL = SOCKET | DISK | FILES | EXEC | ENVIRON | NETWORK | HIDDEN; + + private static final Map GRAINS = new LinkedHashMap<>(); + + static { + GRAINS.put("socket", SOCKET); + GRAINS.put("disk", DISK); + GRAINS.put("files", FILES); + GRAINS.put("exec", EXEC); + GRAINS.put("environ", ENVIRON); + GRAINS.put("network", NETWORK); + GRAINS.put("hidden", HIDDEN); + } + + private boolean enabled = true; + private int grains = NONE; + private boolean locked; + + public synchronized boolean isEnabled() { + return enabled; + } + + public synchronized int getGrains() { + return grains; + } + + public synchronized void setEnabled(boolean value) { + if (locked && !value) { + throw new IllegalArgumentException("Cannot disable a locked sandbox"); + } + enabled = value; + } + + public synchronized void setGrains(int value) { + if ((value & ~ALL) != 0) { + throw new IllegalArgumentException("Unknown sandbox grain bits"); + } + if (locked && (value & grains) != value) { + throw new IllegalArgumentException("Cannot increase locked sandbox permissions"); + } + grains = value; + } + + /** Freeze the current permissions as a ceiling for untrusted commands, such as HTTP requests. */ + public synchronized void lock() { + enabled = true; + locked = true; + } + + public synchronized boolean isRestricted(int resources) { + return enabled && (grains & resources) != resources; + } + + /** Parse comma-separated permissions, with a leading ! starting from all permissions. */ + public static int parseGrains(String expression) { + String expr = expression.trim().toLowerCase(Locale.ROOT); + int result = expr.startsWith("!") ? ALL : NONE; + for (String part : expr.split(",", -1)) { + String name = part.trim(); + boolean remove = name.startsWith("!"); + if (remove) { + name = name.substring(1).trim(); + } + Integer grain = + switch (name) { + case "all" -> ALL; + case "none" -> NONE; + case "file" -> FILES; + default -> GRAINS.get(name); + }; + if (grain == null) { + throw new IllegalArgumentException("Invalid sandbox grain: " + part.trim()); + } + if (remove) { + result &= ~grain; + } else if (grain == NONE) { + result = NONE; + } else { + result |= grain; + } + } + return result; + } + + public static String formatGrains(int grains) { + if (grains == NONE) { + return "none"; + } + if (grains == ALL) { + return "all"; + } + StringBuilder result = new StringBuilder(); + GRAINS.forEach( + (name, grain) -> { + if ((grains & grain) != 0) { + if (result.length() > 0) { + result.append(','); + } + result.append(name); + } + }); + return result.toString(); + } + + public static String getHelp() { + return "Usage: e cfg.sandbox.grain=permission[,permission...]\n" + + " all / none allow all / no resources\n" + + " files read files, list directories and inspect file existence\n" + + " disk write, append and delete files (also requires files)\n" + + " exec execute shell commands, external pipes and process exit\n" + + " environ read or modify environment variables\n" + + " hidden access dotfiles and hidden directories (also requires files)\n" + + " socket/network reserved for socket operations / non-local network access\n" + + " !name remove a permission; leading ! starts from all\n" + + " Example: e cfg.sandbox.grain=files,disk\n" + + " JavaScript requires all permissions because it has unrestricted JVM access.\n"; + } +} diff --git a/R4Ghidra/src/test/java/r4ghidra/repl/handlers/R2JsCommandHandlerTest.java b/R4Ghidra/src/test/java/r4ghidra/repl/handlers/R2JsCommandHandlerTest.java new file mode 100644 index 0000000..be4a275 --- /dev/null +++ b/R4Ghidra/src/test/java/r4ghidra/repl/handlers/R2JsCommandHandlerTest.java @@ -0,0 +1,90 @@ +package r4ghidra.repl.handlers; + +import java.util.List; +import org.json.JSONArray; +import org.json.JSONObject; +import org.json.JSONTokener; +import r4ghidra.repl.R2Command; +import r4ghidra.repl.R2Context; + +/** Regression tests for JSON output from JavaScript values. */ +public final class R2JsCommandHandlerTest { + public static void run(R2Context context) throws Exception { + int sandboxFlags = context.getSandboxFlags(); + context.setSandboxFlags(R2Context.R_SANDBOX_GRAIN_NONE); + try { + R2JsCommandHandler handler = new R2JsCommandHandler(); + for (String value : + List.of("", "plain", "\"quoted\"", "C:\\new\\test\\", "\\n", "λ 😀", "\u2028\u2029")) { + checkString(handler, context, JSONObject.quote(value), value); + } + for (int i = 0; i < 32; i++) { + checkString(handler, context, "String.fromCharCode(" + i + ")", String.valueOf((char) i)); + } + checkString( + handler, + context, + "String.fromCharCode(92,34,44,34,105,110,106,101,99,116,101,100,34,58,116,114,117,101)", + "\\\",\"injected\":true"); + + check(json(handler, context, "42").equals("42\n"), "number output"); + check(json(handler, context, "true").equals("true\n"), "boolean output"); + String nested = json(handler, context, "({text: 'a\\nb\\tc\\\\d', values: [1, true, null]})"); + JSONObject object = new JSONObject(nested); + check(object.getString("text").equals("a\nb\tc\\d"), "nested string"); + check(object.getJSONArray("values").similar(new JSONArray("[1,true,null]")), "nested array"); + + check( + new JSONArray(json(handler, context, "['a\\nb', 'c\\\\d']")) + .toList() + .equals(List.of("a\nb", "c\\d")), + "array output"); + + // Serializing an object must never evaluate its textual representation as source code. + json(handler, context, "var serializedTextExecuted = false"); + json(handler, context, "new java.lang.StringBuilder('(serializedTextExecuted = true)')"); + check( + json(handler, context, "serializedTextExecuted").equals("false\n"), + "object text was evaluated"); + + // Circular values retain the handler's string fallback. + checkString( + handler, context, "var cyclic = {}; cyclic.self = cyclic; cyclic", "[object Object]"); + + // Force the fallback with a host value whose textual representation needs escaping. + checkString( + handler, + context, + "JSON.stringify = function() { throw new Error('forced'); };" + + "new java.lang.StringBuilder('a\\nb\\tc\\\\d\\\"')", + "a\nb\tc\\d\""); + System.out.println("JavaScript JSON regression tests passed"); + } finally { + context.setSandboxFlags(sandboxFlags); + } + } + + private static String json(R2JsCommandHandler handler, R2Context context, String script) + throws Exception { + return handler.execute(new R2Command("js", "j", List.of(script), null), context); + } + + private static void checkString( + R2JsCommandHandler handler, R2Context context, String script, String expected) + throws Exception { + String output = json(handler, context, script); + check(output.endsWith("\n"), "missing output newline"); + for (int i = 0; i < output.length() - 1; i++) { + check(output.charAt(i) >= 32, "unescaped control character in JSON output"); + } + JSONTokener tokener = new JSONTokener(output); + check(expected.equals(tokener.nextValue()), "JSON string did not round-trip: " + output); + check(tokener.nextClean() == 0, "extra data after JSON string: " + output); + } + + private static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/R4Ghidra/src/test/java/r4ghidra/repl/sandbox/R2SandboxIntegrationTest.java b/R4Ghidra/src/test/java/r4ghidra/repl/sandbox/R2SandboxIntegrationTest.java new file mode 100644 index 0000000..1e4f9d3 --- /dev/null +++ b/R4Ghidra/src/test/java/r4ghidra/repl/sandbox/R2SandboxIntegrationTest.java @@ -0,0 +1,193 @@ +package r4ghidra.repl.sandbox; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import r4ghidra.repl.R2Context; +import r4ghidra.repl.R2REPLImpl; +import r4ghidra.repl.config.R2EvalConfig; +import r4ghidra.repl.filesystem.R2FileSystem; +import r4ghidra.repl.filesystem.R2FileSystemException; +import r4ghidra.repl.handlers.R2JsCommandHandler; + +/** Exercises the real parser and filesystem against temporary files and a Ghidra context. */ +public final class R2SandboxIntegrationTest { + public static void run(R2REPLImpl repl, Path directory) throws Exception { + Files.createDirectories(directory); + Path plain = directory.resolve("plain.txt"); + Files.writeString(plain, "fixture"); + Path script = directory.resolve("script with spaces.r2"); + Files.writeString(script, "echo from-script\n"); + Path js = directory.resolve("script.r2.js"); + Files.writeString(js, "1+1"); + R2Context context = repl.getContext(); + R2EvalConfig config = context.getEvalConfig(); + R2FileSystem fs = context.getFileSystem(); + repl.registerCommand("js", new R2JsCommandHandler()); + + check(config.get("cfg.sandbox.grain").equals("none"), "default grain"); + for (String command : + List.of( + "!echo denied", + "!!echo denied", + ".!echo denied", + "echo denied | cat", + "%", + "%*", + "%j", + "%PATH", + "%R4GHIDRA_SANDBOX_TEST=denied", + ". '" + script + "'", + ". '" + js + "'", + "js 1+1", + "q 23", + "q!!")) { + denied(repl, command); + } + check(repl.executeCommand("!").contains("echo denied"), "history stays available"); + check(!fs.fileExists(plain.toString()), "blocked metadata"); + blocked(() -> fs.readFile(plain.toString())); + blocked(() -> fs.readBytes(plain.toString())); + blocked(() -> fs.listFiles(directory.toString())); + + // Memory files remain usable with no host permissions, including script loading. + fs.writeFile("$sandbox", "echo memory-script\n"); + check(repl.executeCommand(". $sandbox").equals("memory-script\n"), "memory script"); + repl.executeCommand("echo memory > $output"); + check(fs.readFile("$output").equals("memory\n"), "memory redirection"); + fs.writeBytes("$binary", new byte[] {0, (byte) 255}); + check(fs.readBytes("$binary")[1] == (byte) 255, "memory bytes"); + + check(repl.executeCommand("e cfg.sandbox.grain=?").contains("!name"), "grain help"); + check(config.get("cfg.sandbox.grain").equals("none"), "help does not change policy"); + repl.executeCommand("e sandbox.grain=files"); + check(config.get("sandbox.grain").equals("files"), "short alias"); + check(config.get("cfg.sandbox.grain").equals("files"), "canonical grain"); + check(fs.readFile(plain.toString()).equals("fixture"), "files permits text reads"); + check(fs.readBytes(plain.toString()).length == 7, "files permits binary reads"); + check(fs.fileExists(plain.toString()), "files permits metadata"); + check(fs.listFiles(directory.toString()).contains(plain.toString()), "files permits listing"); + check(repl.executeCommand(". '" + script + "'").equals("from-script\n"), "disk script"); + blocked(() -> fs.writeFile(plain.toString(), "changed")); + blocked(() -> fs.writeBytes(plain.toString(), new byte[] {1})); + blocked(() -> fs.appendFile(plain.toString(), "changed")); + blocked(() -> fs.deleteFile(plain.toString())); + denied(repl, "echo changed > " + plain); + denied(repl, "echo changed >> " + plain); + check(Files.readString(plain).equals("fixture"), "blocked writes leave file intact"); + denied(repl, "js 1+1"); + + // Reject invalid values before they can change either config or the active policy. + for (String invalid : List.of("rw", "files,garbage", "files,", "")) { + String result = repl.executeCommand("e cfg.sandbox.grain=" + invalid); + check(result.startsWith("Error:"), "invalid grain rejected: " + result); + check(config.get("cfg.sandbox.grain").equals("files"), "invalid grain preserves config"); + blocked(() -> fs.writeFile(plain.toString(), "changed")); + } + check(repl.executeCommand("e cfg.sandbox=typo").startsWith("Error:"), "invalid boolean"); + check(config.getBoolean("cfg.sandbox"), "invalid boolean preserves sandbox"); + + repl.executeCommand("e cfg.sandbox.grain = files, disk"); + fs.writeFile(plain.toString(), "one"); + fs.appendFile(plain.toString(), "two"); + check(fs.readFile(plain.toString()).equals("onetwo"), "disk writes and append"); + fs.writeBytes(plain.toString(), new byte[] {0, (byte) 255}); + check(fs.readBytes(plain.toString())[1] == (byte) 255, "disk bytes"); + fs.deleteFile(plain.toString()); + check(!Files.exists(plain), "disk deletion"); + repl.executeCommand("e cfg.sandbox.grain=disk"); + blocked(() -> fs.writeFile(plain.toString(), "changed")); + check(!Files.exists(plain), "disk without files cannot create files"); + + testHiddenFiles(repl, directory); + + repl.executeCommand("e cfg.sandbox.grain=exec"); + check( + repl.executeCommand("!!printf '%s' 'hello world'").equals("hello world\n"), + "shell arguments"); + check(repl.executeCommand("!true").contains("code: 0"), "interactive shell"); + check(repl.executeCommand(".!echo echo from-shell").equals("from-shell\n"), "shell script"); + check(repl.executeCommand("echo piped | cat").equals("piped\n"), "external pipe"); + denied(repl, "%PATH"); + denied(repl, "js 1+1"); + // Recheck the permission after the left command, before spawning the pipe process. + denied(repl, "e cfg.sandbox.grain=none | cat"); + check(config.get("cfg.sandbox.grain").equals("none"), "pipe left side narrowed permissions"); + denied(repl, "e cfg.sandbox=false | cat"); + check(config.getBoolean("cfg.sandbox"), "denied pipe never executes its left side"); + + repl.executeCommand("e cfg.sandbox.grain=environ"); + String environment = repl.executeCommand("%j"); + check(new org.json.JSONObject(environment).length() > 0, "environment grain"); + denied(repl, "!!echo denied"); + + // Turning the local sandbox off and on preserves the chosen grain. + repl.executeCommand("e cfg.sandbox=off"); + check(!context.isSandboxed(R2Context.R_SANDBOX_GRAIN_ALL), "sandbox off"); + repl.executeCommand("e cfg.sandbox=ON"); + check(config.get("cfg.sandbox.grain").equals("environ"), "grain survives toggle"); + denied(repl, "!!echo denied"); + repl.executeCommand("e cfg.sandbox.grain=all"); + check(repl.executeCommand("js 1+1").equals("2\n"), "explicit unrestricted JavaScript"); + check(repl.executeCommand(". '" + js + "'").equals("2\n"), "JavaScript file"); + + context.setSandboxFlags(R2Context.R_SANDBOX_GRAIN_ALL & ~R2Context.R_SANDBOX_GRAIN_FILES); + check(config.get("cfg.sandbox.grain").equals("files"), "restriction API synchronizes config"); + // Suppressed listeners must not bypass sandbox validation or enforcement. + config.set("cfg.sandbox.grain", "none", false); + blocked(() -> fs.readFile(script.toString())); + System.out.println("Sandbox command integration tests passed"); + } + + private static void testHiddenFiles(R2REPLImpl repl, Path directory) throws Exception { + R2FileSystem fs = repl.getContext().getFileSystem(); + Path hidden = Files.createDirectories(directory.resolve(".hidden")); + Path secret = hidden.resolve("secret"); + Files.writeString(secret, "secret"); + Path alias = directory.resolve("alias"); + Files.createSymbolicLink(alias, hidden); + repl.executeCommand("e cfg.sandbox.grain=files,disk"); + for (Path path : List.of(secret, alias.resolve("secret"))) { + blocked(() -> fs.readFile(path.toString())); + blocked(() -> fs.readBytes(path.toString())); + blocked(() -> fs.writeFile(path.toString(), "changed")); + blocked(() -> fs.deleteFile(path.toString())); + check(!fs.fileExists(path.toString()), "hidden metadata blocked"); + } + blocked(() -> fs.writeBytes(alias.resolve("new").toString(), new byte[] {1})); + blocked(() -> fs.listFiles(alias.toString())); + List visible = fs.listFiles(directory.toString()); + check( + !visible.contains(hidden.toString()) && !visible.contains(alias.toString()), + "hidden listing filtered"); + check(Files.readString(secret).equals("secret"), "hidden file untouched"); + check(!Files.exists(hidden.resolve("new")), "hidden target not created"); + repl.executeCommand("e cfg.sandbox.grain=files,disk,hidden"); + check(fs.readFile(secret.toString()).equals("secret"), "hidden grant"); + check(fs.readFile(alias.resolve("secret").toString()).equals("secret"), "hidden symlink grant"); + } + + private static void denied(R2REPLImpl repl, String command) { + String result = repl.executeCommand(command); + check(result.startsWith("Error:") && result.contains("sandbox"), command + ": " + result); + } + + private static void blocked(Operation operation) throws Exception { + try { + operation.run(); + throw new AssertionError("Sandbox operation allowed"); + } catch (R2FileSystemException expected) { + // Expected. + } + } + + private interface Operation { + void run() throws Exception; + } + + private static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/R4Ghidra/src/test/java/r4ghidra/repl/sandbox/R2SandboxTest.java b/R4Ghidra/src/test/java/r4ghidra/repl/sandbox/R2SandboxTest.java new file mode 100644 index 0000000..94fe10d --- /dev/null +++ b/R4Ghidra/src/test/java/r4ghidra/repl/sandbox/R2SandboxTest.java @@ -0,0 +1,58 @@ +package r4ghidra.repl.sandbox; + +import java.util.List; + +/** Runs without Ghidra or a test framework. */ +public final class R2SandboxTest { + public static void main(String[] args) { + check(R2Sandbox.parseGrains("none") == 0, "none"); + check(R2Sandbox.parseGrains("all") == R2Sandbox.ALL, "all"); + check(R2Sandbox.parseGrains("file,disk") == (R2Sandbox.FILES | R2Sandbox.DISK), "file alias"); + check( + R2Sandbox.parseGrains("!disk,!exec") == (R2Sandbox.ALL & ~R2Sandbox.DISK & ~R2Sandbox.EXEC), + "leading negation"); + check(R2Sandbox.parseGrains("all,none,environ") == R2Sandbox.ENVIRON, "none resets"); + check(R2Sandbox.parseGrains("all,!all,files") == R2Sandbox.FILES, "remove all"); + check(R2Sandbox.parseGrains(" FILES , !disk ") == R2Sandbox.FILES, "spaces and case"); + for (int mask = 0; mask <= R2Sandbox.ALL; mask++) { + check(R2Sandbox.parseGrains(R2Sandbox.formatGrains(mask)) == mask, "round trip " + mask); + } + for (String invalid : + List.of("", "rw", "unknown", "files,", ",exec", "files,,exec", "!!disk")) { + rejects(() -> R2Sandbox.parseGrains(invalid)); + } + R2Sandbox policy = new R2Sandbox(); + check(policy.isRestricted(R2Sandbox.ALL), "default denies host resources"); + policy.setGrains(R2Sandbox.FILES); + check(!policy.isRestricted(R2Sandbox.FILES), "allow reads"); + check(policy.isRestricted(R2Sandbox.FILES | R2Sandbox.DISK), "write requires both grains"); + rejects(() -> policy.setGrains(128)); + check(policy.getGrains() == R2Sandbox.FILES, "invalid bits preserve policy"); + policy.setEnabled(false); + check(!policy.isRestricted(R2Sandbox.ALL), "disabled policy"); + policy.setEnabled(true); + check(policy.isRestricted(R2Sandbox.DISK), "re-enabling preserves grain"); + policy.lock(); + rejects(() -> policy.setEnabled(false)); + rejects(() -> policy.setGrains(R2Sandbox.ALL)); + policy.setGrains(R2Sandbox.NONE); + rejects(() -> policy.setGrains(R2Sandbox.FILES)); + check(policy.isEnabled() && policy.getGrains() == 0, "locked policy can only narrow"); + System.out.println("Sandbox policy regression tests passed"); + } + + private static void rejects(Runnable action) { + try { + action.run(); + throw new AssertionError("Invalid policy accepted"); + } catch (IllegalArgumentException expected) { + // Expected. + } + } + + private static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +}