diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4630fac..1540ad0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,6 +52,55 @@ jobs: run: mix ci working-directory: . + burrito_linux_regression: + # ci.yaml is also called by main.yaml after pushes and closed release PRs. + # Keep this expensive native build as a pre-merge pull-request gate; the + # release workflow builds every target after the release PR merges. + if: github.event_name == 'pull_request' && github.event.action != 'closed' + name: Burrito Linux shared-loader regression + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: app + shell: bash + steps: + - + uses: actions/checkout@v7 + - + uses: erlef/setup-beam@v1 + with: + otp-version: "29.0.3" + elixir-version: "1.20.3" + disable_problem_matchers: true + - + uses: mlugg/setup-zig@v2.2.1 + with: + version: "0.16.0" + - + run: mix deps.get + - + name: Compile makeup_syntect with its host NIF + env: + MIX_ENV: prod + run: mix deps.compile castore rustler_precompiled makeup_syntect + - + name: Pre-compile mdex_native with its musl NIF + env: + MIX_ENV: prod + TARGET_ABI: musl + run: mix deps.compile mdex_native + - + name: Install and repair the musl NIFs + run: ../ci/prepare_musl_nifs.sh + - + name: Build the native linux_x86_64 target + run: MIX_ENV=prod BURRITO_TARGET=linux_x86_64 mix release lc + - + name: Test the packaged binary across users + timeout-minutes: 2 + run: ../ci/test_burrito_shared_loader.sh ./burrito_out/lc_linux_x86_64 + conventional_commits: if: inputs.skip_commit_validation != true name: Validate Commit Subjects diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 96ee335..03dcf3e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -177,6 +177,14 @@ jobs: name: Smoke-test the ${{ matrix.target }} binary and Markdown runtime timeout-minutes: 1 run: ./burrito_out/lc_${{ matrix.target }} version + - + # Burrito 1.6.0 used one predictable /tmp ELF interpreter for every + # user. Exercise the packaged ERTS as two real unprivileged accounts, + # with an attacker-owned legacy loader already occupying that path. + if: startsWith(matrix.target, 'linux_') + name: Test the ${{ matrix.target }} binary across users + timeout-minutes: 2 + run: ../ci/test_burrito_shared_loader.sh ./burrito_out/lc_${{ matrix.target }} - # Handed off to burrito-package below, which needs every target's # binary gathered back into one place before it can build the diff --git a/app/release/burrito_patches.exs b/app/release/burrito_patches.exs index 6ed371d..d65049f 100644 --- a/app/release/burrito_patches.exs +++ b/app/release/burrito_patches.exs @@ -2,7 +2,9 @@ defmodule LinearCli.Release.BurritoPatches do @moduledoc false @launcher_path Path.join(["burrito", "src", "erlang_launcher.zig"]) + @wrapper_path Path.join(["burrito", "src", "wrapper.zig"]) @upstream_pr "https://github.com/burrito-elixir/burrito/pull/235" + @musl_issue "https://linear.app/the-rubyists/issue/EXT-17" @always_proxy ~S""" // On Unix: pipe child stdout through us so we can detect EPIPE from @@ -51,17 +53,408 @@ defmodule LinearCli.Release.BurritoPatches do } """ + @legacy_hash_import ~S""" + const Sha1 = std.crypto.hash.Sha1; + const Base64 = std.base64.url_safe_no_pad.Encoder; + """ + + @private_hash_import ~S""" + const Sha1 = std.crypto.hash.Sha1; + const Sha256 = std.crypto.hash.sha2.Sha256; + const Base64 = std.base64.url_safe_no_pad.Encoder; + """ + + @legacy_musl_boot ~S""" + // If on linux, maybe install the musl libc runtime file for our pre-compiled Erlang + if (comptime IS_LINUX) try maybe_install_musl_runtime(io); + + const self_path = try std.process.executablePathAlloc(io, arena); + """ + + @private_musl_boot ~S""" + const self_path = try std.process.executablePathAlloc(io, arena); + """ + + @legacy_post_install ~S""" + } else { + log.debug("Skipping archive unpacking, this machine already has the app installed!", .{}); + } + + // Clean up older versions + """ + + @private_post_install ~S""" + } else { + log.debug("Skipping archive unpacking, this machine already has the app installed!", .{}); + } + + // The downloaded ERTS executables name Burrito's shared /tmp musl loader + // as their ELF interpreter. Move that trust boundary into a private, + // verified per-user directory before any release executable is launched. + if (comptime IS_LINUX) try prepare_musl_runtime(io, arena, install_dir); + + // Clean up older versions + """ + + @legacy_musl_installer ~S""" + fn maybe_install_musl_runtime(io: Io) !void { + if (!std.mem.eql(u8, build_options.MUSL_RUNTIME_PATH, "")) { + // Check if the file was already extracted using std.fs API (cross-platform) + const file_exists = Io.Dir.cwd().statFile(io, build_options.MUSL_RUNTIME_PATH, .{}) catch null; + + if (file_exists != null) { + // File exists + log.debug("The musl runtime file is already present. Continuing.", .{}); + return; + } + + const file = Io.Dir.cwd().createFile(io, build_options.MUSL_RUNTIME_PATH, .{ .read = true }) catch |e| { + log.debug("Failed to extract burrito musl runtime: {}", .{e}); + return; + }; + defer file.close(io); + + const exec_permissions = Io.File.Permissions.fromMode(@intCast(0o754)); + try file.setPermissions(io, exec_permissions); + + const MUSL_RUNTIME_BYTES = @embedFile("musl-runtime.so"); + try file.writePositionalAll(io, MUSL_RUNTIME_BYTES, 0); + + log.debug("Wrote musl runtime file: {s}", .{build_options.MUSL_RUNTIME_PATH}); + } + } + """ + + @private_musl_installer ~S""" + fn prepare_musl_runtime(io: Io, arena: std.mem.Allocator, install_dir: []const u8) !void { + if (std.mem.eql(u8, build_options.MUSL_RUNTIME_PATH, "")) return; + + const linux = std.os.linux; + const uid = linux.geteuid(); + const private_permissions = Io.File.Permissions.fromMode(@intCast(0o700)); + const runtime_dir_path = try std.fmt.allocPrint(arena, "/tmp/.burrito-musl-{d}", .{uid}); + + Io.Dir.cwd().createDir(io, runtime_dir_path, private_permissions) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + var runtime_dir = try Io.Dir.openDirAbsolute(io, runtime_dir_path, .{ + .iterate = true, + .follow_symlinks = false, + }); + defer runtime_dir.close(io); + + try validate_owned_node(runtime_dir.handle, uid, linux.S.IFDIR); + try runtime_dir.setPermissions(io, private_permissions); + + const musl_bytes = @embedFile("musl-runtime.so"); + var digest: [Sha256.digest_length]u8 = undefined; + Sha256.hash(musl_bytes, &digest, .{}); + const digest_prefix = std.fmt.bytesToHex(digest[0..16], .lower); + const digest_hex = std.fmt.bytesToHex(digest, .lower); + const loader_name = try std.fmt.allocPrint(arena, "ld-{s}.so", .{digest_prefix}); + const loader_path = try std.fs.path.join(arena, &.{ runtime_dir_path, loader_name }); + const marker_bytes = try std.fmt.allocPrint(arena, "v1\n{s}\n{s}\n", .{ digest_hex, loader_path }); + + install_or_validate_loader(io, &runtime_dir, loader_name, musl_bytes, uid, private_permissions) catch |err| { + logger.err("Refusing to use an untrusted private musl runtime at {s}: {t}", .{ loader_path, err }); + return err; + }; + + try patch_release_interpreters( + io, + arena, + install_dir, + build_options.MUSL_RUNTIME_PATH, + loader_path, + marker_bytes, + uid, + ); + log.debug("Using private musl runtime: {s}", .{loader_path}); + } + + fn validate_owned_node(handle: std.posix.fd_t, uid: std.os.linux.uid_t, expected_type: u16) !void { + const linux = std.os.linux; + var info = std.mem.zeroes(linux.Statx); + const result = linux.statx( + handle, + "", + linux.AT.EMPTY_PATH, + .{ .TYPE = true, .MODE = true, .UID = true, .NLINK = true }, + &info, + ); + + if (linux.errno(result) != .SUCCESS or + !info.mask.TYPE or + !info.mask.MODE or + !info.mask.UID or + info.uid != uid or + info.mode & linux.S.IFMT != expected_type) + { + return error.UntrustedMuslRuntime; + } + } + + fn install_or_validate_loader( + io: Io, + runtime_dir: *Io.Dir, + loader_name: []const u8, + expected_bytes: []const u8, + uid: std.os.linux.uid_t, + permissions: Io.File.Permissions, + ) !void { + if (validate_loader(io, runtime_dir, loader_name, expected_bytes, uid)) return else |err| switch (err) { + error.FileNotFound => {}, + else => return err, + } + + var atomic_file = try runtime_dir.createFileAtomic(io, loader_name, .{ .permissions = permissions }); + defer atomic_file.deinit(io); + try atomic_file.file.writePositionalAll(io, expected_bytes, 0); + try atomic_file.file.setPermissions(io, permissions); + + atomic_file.link(io) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + try validate_loader(io, runtime_dir, loader_name, expected_bytes, uid); + } + + fn validate_loader( + io: Io, + runtime_dir: *Io.Dir, + loader_name: []const u8, + expected_bytes: []const u8, + uid: std.os.linux.uid_t, + ) !void { + const linux = std.os.linux; + const file = try runtime_dir.openFile(io, loader_name, .{ + .allow_directory = false, + .follow_symlinks = false, + }); + defer file.close(io); + + try validate_owned_node(file.handle, uid, linux.S.IFREG); + const info = try file.stat(io); + + if (info.permissions.toMode() & 0o777 != 0o700 or info.size != expected_bytes.len) { + return error.UntrustedMuslRuntime; + } + + const actual_bytes = try std.heap.page_allocator.alloc(u8, expected_bytes.len); + defer std.heap.page_allocator.free(actual_bytes); + + if (try file.readPositionalAll(io, actual_bytes, 0) != actual_bytes.len or + !std.mem.eql(u8, actual_bytes, expected_bytes)) + { + return error.UntrustedMuslRuntime; + } + } + + fn patch_release_interpreters( + io: Io, + arena: std.mem.Allocator, + install_dir: []const u8, + legacy_path: []const u8, + private_path: []const u8, + expected_marker: []const u8, + uid: std.os.linux.uid_t, + ) !void { + const marker_name = ".burrito-musl-interpreters-v1"; + const marker_permissions = Io.File.Permissions.fromMode(@intCast(0o600)); + var release_dir = try Io.Dir.openDirAbsolute(io, install_dir, .{ + .iterate = true, + .follow_symlinks = false, + }); + defer release_dir.close(io); + + if (try interpreter_marker_is_valid( + io, + &release_dir, + marker_name, + expected_marker, + uid, + )) { + log.debug("The release already uses the private musl runtime.", .{}); + return; + } + + var walker = try release_dir.walk(arena); + defer walker.deinit(); + + while (try walker.next(io)) |entry| { + if (entry.kind == .file) { + try patch_elf_interpreter(io, entry.dir, entry.basename, legacy_path, private_path); + } + } + + var atomic_marker = try release_dir.createFileAtomic(io, marker_name, .{ + .permissions = marker_permissions, + .replace = true, + }); + defer atomic_marker.deinit(io); + try atomic_marker.file.writePositionalAll(io, expected_marker, 0); + try atomic_marker.file.setPermissions(io, marker_permissions); + try atomic_marker.replace(io); + + if (!try interpreter_marker_is_valid( + io, + &release_dir, + marker_name, + expected_marker, + uid, + )) return error.UntrustedMuslRuntime; + } + + fn interpreter_marker_is_valid( + io: Io, + release_dir: *Io.Dir, + marker_name: []const u8, + expected_bytes: []const u8, + uid: std.os.linux.uid_t, + ) !bool { + const linux = std.os.linux; + const marker = release_dir.openFile(io, marker_name, .{ + .allow_directory = false, + .follow_symlinks = false, + }) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer marker.close(io); + + try validate_owned_node(marker.handle, uid, linux.S.IFREG); + const info = try marker.stat(io); + + if (info.permissions.toMode() & 0o777 != 0o600 or info.size != expected_bytes.len) { + return false; + } + + const actual_bytes = try std.heap.page_allocator.alloc(u8, expected_bytes.len); + defer std.heap.page_allocator.free(actual_bytes); + + return try marker.readPositionalAll(io, actual_bytes, 0) == actual_bytes.len and + std.mem.eql(u8, actual_bytes, expected_bytes); + } + + fn patch_elf_interpreter( + io: Io, + dir: Io.Dir, + basename: []const u8, + legacy_path: []const u8, + private_path: []const u8, + ) !void { + const file = try dir.openFile(io, basename, .{ + .allow_directory = false, + .follow_symlinks = false, + }); + defer file.close(io); + + const info = try file.stat(io); + if (info.size < 64 or info.size > std.math.maxInt(usize)) return; + + var header: [64]u8 = undefined; + if (try file.readPositionalAll(io, &header, 0) != header.len or + !std.mem.eql(u8, header[0..4], "\x7fELF") or + header[4] != 2 or + header[5] != 1) + { + return; + } + + const program_offset = std.mem.readInt(u64, header[32..40], .little); + const program_entry_size = std.mem.readInt(u16, header[54..56], .little); + const program_count = std.mem.readInt(u16, header[56..58], .little); + + if (program_count == 0) return; + + if (program_entry_size < 56 or + program_offset > info.size or + program_count > (info.size - program_offset) / program_entry_size) + { + return error.InvalidElfProgramHeaders; + } + + for (0..program_count) |index| { + const entry_offset = program_offset + index * program_entry_size; + var program_header: [56]u8 = undefined; + if (try file.readPositionalAll(io, &program_header, entry_offset) != program_header.len) { + return error.InvalidElfProgramHeaders; + } + + if (std.mem.readInt(u32, program_header[0..4], .little) != 3) continue; + + const interpreter_offset = std.mem.readInt(u64, program_header[8..16], .little); + const interpreter_size = std.mem.readInt(u64, program_header[32..40], .little); + + if (interpreter_size == 0 or + interpreter_size > 4096 or + interpreter_offset > info.size or + interpreter_size > info.size - interpreter_offset) + { + return error.InvalidElfInterpreter; + } + + const interpreter = try std.heap.page_allocator.alloc(u8, @intCast(interpreter_size)); + defer std.heap.page_allocator.free(interpreter); + + if (try file.readPositionalAll(io, interpreter, interpreter_offset) != interpreter.len) { + return error.InvalidElfInterpreter; + } + + const path_end = std.mem.indexOfScalar(u8, interpreter, 0) orelse + return error.InvalidElfInterpreter; + const current_path = interpreter[0..path_end]; + + if (std.mem.eql(u8, current_path, private_path)) return; + if (!std.mem.eql(u8, current_path, legacy_path)) return; + if (private_path.len + 1 > interpreter.len) return error.MuslRuntimePathTooLong; + + const contents = try std.heap.page_allocator.alloc(u8, @intCast(info.size)); + defer std.heap.page_allocator.free(contents); + + if (try file.readPositionalAll(io, contents, 0) != contents.len) { + return error.InvalidElfFile; + } + + const interpreter_start: usize = @intCast(interpreter_offset); + @memset(contents[interpreter_start .. interpreter_start + interpreter.len], 0); + @memcpy(contents[interpreter_start .. interpreter_start + private_path.len], private_path); + + var atomic_file = try dir.createFileAtomic(io, basename, .{ + .permissions = info.permissions, + .replace = true, + }); + defer atomic_file.deinit(io); + try atomic_file.file.writePositionalAll(io, contents, 0); + try atomic_file.file.setPermissions(io, info.permissions); + try atomic_file.replace(io); + return; + } + } + """ + @doc false def patch_release(%Mix.Release{} = release) do launcher_path = Path.join(Mix.Project.deps_path(), @launcher_path) - source = File.read!(launcher_path) - patched = patch_source!(source) + wrapper_path = Path.join(Mix.Project.deps_path(), @wrapper_path) + launcher_source = File.read!(launcher_path) + wrapper_source = File.read!(wrapper_path) + patched_launcher = patch_source!(launcher_source) + patched_wrapper = patch_wrapper_source!(wrapper_source) - if patched != source do - File.write!(launcher_path, patched) + if patched_launcher != launcher_source do + File.write!(launcher_path, patched_launcher) Mix.shell().info("Patched Burrito to inherit stdout when connected to a terminal") end + if patched_wrapper != wrapper_source do + File.write!(wrapper_path, patched_wrapper) + Mix.shell().info("Patched Burrito to use a private verified musl runtime") + end + release end @@ -95,10 +488,61 @@ defmodule LinearCli.Release.BurritoPatches do restore_newlines(patched, newline) end + @doc false + def patch_wrapper_source!(source) when is_binary(source) do + newline = newline_style(source) + normalized_source = normalize_newlines(source) + + legacy_parts = [ + normalize_newlines(@legacy_hash_import), + normalize_newlines(@legacy_musl_boot), + normalize_newlines(@legacy_post_install), + normalize_newlines(@legacy_musl_installer) + ] + + private_parts = [ + normalize_newlines(@private_hash_import), + normalize_newlines(@private_musl_boot), + normalize_newlines(@private_post_install), + normalize_newlines(@private_musl_installer) + ] + + private_only_parts = List.delete_at(private_parts, 1) + + patched = + cond do + contains_all?(normalized_source, private_parts) and + contains_none?(normalized_source, legacy_parts) -> + normalized_source + + contains_all?(normalized_source, legacy_parts) and + contains_none?(normalized_source, private_only_parts) -> + Enum.zip_reduce(legacy_parts, private_parts, normalized_source, fn legacy, + private, + acc -> + String.replace(acc, legacy, private) + end) + + true -> + raise """ + Burrito's musl wrapper no longer matches the expected source. Refusing to + build without checking whether the private runtime-loader fix is still needed. + This temporary patch tracks #{@musl_issue}. + """ + end + + restore_newlines(patched, newline) + end + defp contains_both?(source, first, second) do String.contains?(source, first) and String.contains?(source, second) end + defp contains_all?(source, snippets), do: Enum.all?(snippets, &String.contains?(source, &1)) + + defp contains_none?(source, snippets), + do: Enum.all?(snippets, &(not String.contains?(source, &1))) + defp newline_style(source) do if String.contains?(source, "\r\n"), do: :crlf, else: :lf end diff --git a/app/test/linear_cli/release/burrito_patches_test.exs b/app/test/linear_cli/release/burrito_patches_test.exs index da0be80..6afa911 100644 --- a/app/test/linear_cli/release/burrito_patches_test.exs +++ b/app/test/linear_cli/release/burrito_patches_test.exs @@ -36,12 +36,58 @@ defmodule LinearCli.Release.BurritoPatchesTest do assert BurritoPatches.patch_source!(patched) == patched end + test "moves the Linux musl runtime into a verified per-user directory" do + wrapper_path = Path.expand("../../../deps/burrito/src/wrapper.zig", __DIR__) + source = File.read!(wrapper_path) + patched = BurritoPatches.patch_wrapper_source!(source) + + assert patched =~ ~s|"/tmp/.burrito-musl-{d}"| + assert patched =~ "linux.geteuid()" + assert patched =~ ".follow_symlinks = false" + assert patched =~ "info.uid != uid" + assert patched =~ "!std.mem.eql(u8, actual_bytes, expected_bytes)" + assert patched =~ "runtime_dir.createFileAtomic" + assert patched =~ "atomic_file.link(io)" + assert patched =~ "patch_release_interpreters" + assert patched =~ ~s|!std.mem.eql(u8, header[0..4], "\\x7fELF")| + assert patched =~ "std.mem.eql(u8, current_path, legacy_path)" + assert patched =~ "atomic_file.replace(io)" + assert patched =~ ~s|".burrito-musl-interpreters-v1"| + assert patched =~ ~s|"v1\\n{s}\\n{s}\\n"| + assert patched =~ "interpreter_marker_is_valid" + assert patched =~ "atomic_marker.replace(io)" + assert patched =~ "info.permissions.toMode() & 0o777 != 0o600" + refute patched =~ "fn maybe_install_musl_runtime" + refute patched =~ "createFile(io, build_options.MUSL_RUNTIME_PATH" + assert BurritoPatches.patch_wrapper_source!(patched) == patched + end + test "fails closed when Burrito changes the launcher implementation" do assert_raise RuntimeError, ~r/no longer matches the expected source/, fn -> BurritoPatches.patch_source!("a different upstream implementation") end end + test "fails closed when Burrito changes or partially patches the musl implementation" do + assert_raise RuntimeError, ~r/musl wrapper no longer matches the expected source/, fn -> + BurritoPatches.patch_wrapper_source!("a different upstream implementation") + end + + wrapper_path = Path.expand("../../../deps/burrito/src/wrapper.zig", __DIR__) + + partially_patched = + wrapper_path + |> File.read!() + |> String.replace( + "const Sha1 = std.crypto.hash.Sha1;\n", + "const Sha1 = std.crypto.hash.Sha1;\nconst Sha256 = std.crypto.hash.sha2.Sha256;\n" + ) + + assert_raise RuntimeError, ~r/musl wrapper no longer matches the expected source/, fn -> + BurritoPatches.patch_wrapper_source!(partially_patched) + end + end + test "matches LF Burrito source when the release hook was checked out with CRLF" do patch_module_path = Path.expand("../../../release/burrito_patches.exs", __DIR__) @@ -70,4 +116,15 @@ defmodule LinearCli.Release.BurritoPatchesTest do refute patched =~ ~r/(? File.read!() |> String.replace(~r/\r?\n/, "\r\n") + patched = BurritoPatches.patch_wrapper_source!(source) + + assert patched =~ "\r\n" + refute patched =~ ~r/(?&2 + exit 64 +fi + +binary=$(realpath "$1") + +if [ ! -x "$binary" ]; then + printf 'Burrito binary is not executable: %s\n' "$binary" >&2 + exit 1 +fi + +case "$(uname -m)" in + x86_64) + runtime_hash=71c35316aff45bbfd243d8eb9bfc4a58b6eb97cee09514cd2030e145b68107fb + ;; + aarch64) + runtime_hash=6b558025200a5ed1308e2ce2675217afec71b6c5a9d561e52262ca948d59905e + ;; + *) + printf 'Unsupported Linux architecture: %s\n' "$(uname -m)" >&2 + exit 1 + ;; +esac + +sudo -n true + +test_root=$(mktemp -d /tmp/lc-shared-loader-test.XXXXXX) +chmod 0711 "$test_root" +user_a="lcx17a$$" +user_b="lcx17b$$" +legacy_loader="/tmp/libc-musl-${runtime_hash}.so" +legacy_backup="" +private_runtime_dirs=() + +cleanup() { + sudo rm -f -- "$legacy_loader" + + for runtime_dir in "${private_runtime_dirs[@]}"; do + sudo rm -rf -- "$runtime_dir" + done + + if [ -n "$legacy_backup" ] && sudo test -e "$legacy_backup"; then + sudo mv -- "$legacy_backup" "$legacy_loader" + fi + + sudo userdel "$user_a" 2>/dev/null || true + sudo userdel "$user_b" 2>/dev/null || true + sudo rm -rf -- "$test_root" +} + +trap cleanup EXIT + +if sudo test -e "$legacy_loader"; then + legacy_backup="$test_root/original-legacy-loader" + sudo mv -- "$legacy_loader" "$legacy_backup" +fi + +binary_copy="$test_root/lc" +sudo install -m 0755 -- "$binary" "$binary_copy" + +for user in "$user_a" "$user_b"; do + user_dir="$test_root/$user" + sudo mkdir -- "$user_dir" + sudo useradd --no-create-home --home-dir "$user_dir" --shell /bin/bash "$user" + sudo chown "$user:$user" "$user_dir" + private_runtime_dirs+=("/tmp/.burrito-musl-$(id -u "$user")") +done + +# Recreate the affected-release state: user A owns a predictable shared +# loader at 0754, and the bytes are explicitly not Burrito's embedded loader. +sudo -u "$user_a" sh -c 'printf %s untrusted-prepositioned-loader > "$1"' sh "$legacy_loader" +sudo -u "$user_a" chmod 0754 "$legacy_loader" + +run_version() { + local user=$1 + local user_dir="$test_root/$user" + + sudo -H -u "$user" env \ + XDG_DATA_HOME="$user_dir/data" \ + "$binary_copy" version +} + +find_erts_binary() { + local user=$1 + local name=$2 + + sudo find "$test_root/$user/data" -type f -path "*/erts-*/bin/$name" -print -quit +} + +interpreter_for() { + sudo readelf -l "$1" | + sed -n 's/.*Requesting program interpreter: \(.*\)]/\1/p' +} + +private_runtime_for() { + local user=$1 + local uid + + uid=$(id -u "$user") || return 1 + printf '/tmp/.burrito-musl-%s/ld-%s.so\n' "$uid" "${runtime_hash:0:32}" +} + +assert_equal() { + local actual=$1 + local expected=$2 + local description=$3 + + if [ "$actual" != "$expected" ]; then + printf '%s mismatch\nexpected: %s\nactual: %s\n' \ + "$description" "$expected" "$actual" >&2 + return 1 + fi +} + +assert_private_runtime() { + local user=$1 + local expected_interpreter=$2 + local uid + local erlexec + local beam + local erlexec_interpreter + local beam_interpreter + local runtime_dir_metadata + local loader_metadata + local loader_hash + + uid=$(id -u "$user") || return 1 + erlexec=$(find_erts_binary "$user" erlexec) || return 1 + beam=$(find_erts_binary "$user" beam.smp) || return 1 + + if [ -z "$erlexec" ] || [ -z "$beam" ]; then + printf 'Could not find both ERTS executables for %s\n' "$user" >&2 + return 1 + fi + + erlexec_interpreter=$(interpreter_for "$erlexec") || return 1 + beam_interpreter=$(interpreter_for "$beam") || return 1 + runtime_dir_metadata=$(sudo stat -c '%u:%a:%F' "$(dirname "$expected_interpreter")") || return 1 + loader_metadata=$(sudo stat -c '%u:%a:%F' "$expected_interpreter") || return 1 + loader_hash=$(sudo sha256sum "$expected_interpreter" | cut -d ' ' -f 1) || return 1 + + assert_equal "$erlexec_interpreter" "$expected_interpreter" "erlexec interpreter" || return 1 + assert_equal "$beam_interpreter" "$expected_interpreter" "beam.smp interpreter" || return 1 + assert_equal "$runtime_dir_metadata" "$uid:700:directory" "private runtime directory metadata" || return 1 + assert_equal "$loader_metadata" "$uid:700:regular file" "private loader metadata" || return 1 + assert_equal "$loader_hash" "$runtime_hash" "private loader hash" || return 1 +} + +run_version "$user_a" +runtime_a=$(private_runtime_for "$user_a") +assert_private_runtime "$user_a" "$runtime_a" + +# The UID-scoped path is short enough to fit the existing ELF interpreter +# segment, so its name is deterministic. An attacker may pre-position it, but +# ownership validation must fail closed without executing the attacker's file. +uid_b=$(id -u "$user_b") +attacker_runtime=$(private_runtime_for "$user_b") +attacker_runtime_dir=$(dirname "$attacker_runtime") +sudo -u "$user_a" mkdir -m 0755 -- "$attacker_runtime_dir" +sudo -u "$user_a" sh -c 'printf %s untrusted-private-loader > "$1"' sh "$attacker_runtime" + +if run_version "$user_b" >"$test_root/prepositioned-private.log" 2>&1; then + printf 'Burrito trusted an attacker-owned private runtime path\n' >&2 + exit 1 +fi + +grep -Fq UntrustedMuslRuntime "$test_root/prepositioned-private.log" +sudo rm -rf -- "$attacker_runtime_dir" + +run_version "$user_b" +runtime_b=$(private_runtime_for "$user_b") +assert_private_runtime "$user_b" "$runtime_b" + +# Prove the assertion helper itself cannot silently succeed after a failed +# check, including when called from an `if` condition where errexit is disabled. +sudo chmod 0701 "$runtime_b" +if assert_private_runtime "$user_b" "$runtime_b" >"$test_root/assertion-negative.log" 2>&1; then + printf 'Private-runtime assertions accepted an invalid loader mode\n' >&2 + exit 1 +fi +grep -Fq 'private loader metadata mismatch' "$test_root/assertion-negative.log" +sudo chmod 0700 "$runtime_b" +assert_private_runtime "$user_b" "$runtime_b" + +if [ "$runtime_a" = "$runtime_b" ]; then + printf 'Both users selected the same private runtime: %s\n' "$runtime_a" >&2 + exit 1 +fi + +legacy_contents=$(sudo cat "$legacy_loader") +legacy_metadata=$(sudo stat -c '%U:%a' "$legacy_loader") +assert_equal "$legacy_contents" untrusted-prepositioned-loader "legacy loader contents" +assert_equal "$legacy_metadata" "$user_a:754" "legacy loader metadata" + +# A valid version/hash/path marker avoids reopening the full extracted release +# on every warm launch. An unreadable unrelated file proves the fast path is +# used; corrupting the marker must force a rescan and expose that read error. +erlexec_b=$(find_erts_binary "$user_b" erlexec) +erts_bin_dir=$(dirname "$erlexec_b") +erts_dir=$(dirname "$erts_bin_dir") +install_dir_b=$(dirname "$erts_dir") +marker_b="$install_dir_b/.burrito-musl-interpreters-v1" +marker_metadata=$(sudo stat -c '%u:%a:%F' "$marker_b") +marker_contents=$(sudo cat "$marker_b") +expected_marker=$(printf 'v1\n%s\n%s\n' "$runtime_hash" "$runtime_b") +assert_equal "$marker_metadata" "$uid_b:600:regular file" "interpreter marker metadata" +assert_equal "$marker_contents" "$expected_marker" "interpreter marker contents" + +walk_sentinel="$install_dir_b/unreadable-walk-sentinel" +sudo -u "$user_b" touch "$walk_sentinel" +sudo -u "$user_b" chmod 000 "$walk_sentinel" +run_version "$user_b" + +sudo -u "$user_b" sh -c 'printf %s corrupt-marker > "$1"' sh "$marker_b" +sudo -u "$user_b" chmod 0600 "$marker_b" + +if run_version "$user_b" >"$test_root/invalid-marker.log" 2>&1; then + printf 'Burrito trusted an invalid interpreter marker\n' >&2 + exit 1 +fi +grep -Fq AccessDenied "$test_root/invalid-marker.log" + +sudo rm -f -- "$walk_sentinel" +run_version "$user_b" +marker_contents=$(sudo cat "$marker_b") +assert_equal "$marker_contents" "$expected_marker" "repaired interpreter marker contents" +assert_private_runtime "$user_b" "$runtime_b" + +# Replace the hostile object with the real loader bytes in the state left by +# an affected release: user A owns a valid shared loader at 0754. Reinstalling +# user B's payload must still ignore that object and choose user B's directory. +sudo rm -f -- "$legacy_loader" +sudo -u "$user_a" cp -- "$runtime_a" "$legacy_loader" +sudo -u "$user_a" chmod 0754 "$legacy_loader" +sudo rm -rf -- "$test_root/$user_b/data" "$(dirname "$runtime_b")" +run_version "$user_b" +runtime_b=$(private_runtime_for "$user_b") +assert_private_runtime "$user_b" "$runtime_b" +legacy_hash=$(sudo sha256sum "$legacy_loader" | cut -d ' ' -f 1) +legacy_metadata=$(sudo stat -c '%U:%a' "$legacy_loader") +assert_equal "$legacy_hash" "$runtime_hash" "stale legacy loader hash" +assert_equal "$legacy_metadata" "$user_a:754" "stale legacy loader metadata" + +# `/tmp` can be cleared while Burrito's extracted release remains. The next +# launch must recreate and revalidate the private loader without re-extracting. +sudo rm -rf -- "$(dirname "$runtime_b")" +run_version "$user_b" +assert_private_runtime "$user_b" "$runtime_b" + +printf 'Burrito shared-loader regression passed for %s and %s\n' "$user_a" "$user_b"