From 02b5b0ab575fd76a22f0ccff2aafd53c4c856ad3 Mon Sep 17 00:00:00 2001 From: Dan Miller Date: Sat, 24 Oct 2026 18:43:46 -0700 Subject: [PATCH] Fix executable-stack flags in Linux CI bundles before packaging --- .github/workflows/build.yaml | 9 ++ .github/workflows/test.yaml | 15 +++ Dockerfile | 2 +- docs/building.md | 23 ++++ scripts/linux/prepare_release_bundle.py | 76 ++++++++++++ .../tests/test_prepare_release_bundle.py | 113 ++++++++++++++++++ 6 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 scripts/linux/prepare_release_bundle.py create mode 100644 scripts/linux/tests/test_prepare_release_bundle.py diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2d469eda5..883f994b4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -88,6 +88,9 @@ jobs: USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose + - name: Prepare Linux release bundle + run: python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle + - name: Package run: | tar -czf "stack_wallet-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \ @@ -481,6 +484,9 @@ jobs: USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose + - name: Prepare Linux release bundle + run: python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle + - name: Package run: | tar -czf "campfire-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \ @@ -545,6 +551,9 @@ jobs: USE_SYSTEM_SECURE_STORAGE_DEPS: "1" run: flutter build linux --release --verbose + - name: Prepare Linux release bundle + run: python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle + - name: Package run: | tar -czf "stack_duo-linux-x86_64-${{ steps.ver.outputs.version }}.tar.gz" \ diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 87ac6e7f0..221ab330c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,6 +1,21 @@ name: Test on: [pull_request] jobs: + linux-release-bundle: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Install ELF test tools + run: | + sudo apt-get update -q + sudo apt-get install -y --no-install-recommends binutils build-essential patchelf python3 + + - name: Test Linux release bundle preparation + run: python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v + test: runs-on: ubuntu-24.04 permissions: diff --git a/Dockerfile b/Dockerfile index 2dd823fbb..1b7939af0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl file git gnupg python3 sudo unzip xz-utils \ - automake build-essential cmake debhelper libtool meson ninja-build pkg-config rsync \ + automake build-essential cmake debhelper libtool meson ninja-build patchelf pkg-config rsync \ clang libclang-dev llvm \ libgcrypt20-dev libgirepository1.0-dev libgit2-dev libglib2.0-dev libgtk-3-dev \ libjsoncpp-dev liblzma-dev libncurses5-dev libncursesw5-dev \ diff --git a/docs/building.md b/docs/building.md index b99734b4d..d2421f6ea 100644 --- a/docs/building.md +++ b/docs/building.md @@ -187,6 +187,29 @@ flutter pub get flutter run linux ``` +#### Linux release bundle preparation + +After `flutter build linux --release`, run this from the repository root before +archiving the bundle or using it to build an AppImage or Flatpak: + +```sh +sudo apt-get install binutils patchelf python3 +python3 scripts/linux/prepare_release_bundle.py build/linux/x64/release/bundle +``` + +The helper clears the executable-stack flag on the bundled Monero, Wownero and +Salvium wallet libraries, which modern Linux loaders can otherwise reject. It +then checks every ELF in the bundle and fails if any still requests an executable +stack. Other libraries are not patched automatically. Ubuntu 24.04's `patchelf` +supports the required `--clear-execstack` option. CI runs this before producing +the Linux tarball, so both downstream packagers inherit the prepared bundle. + +Standalone regression tests (also require a C compiler): + +```sh +python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v +``` + ## Mac host ### Dependencies diff --git a/scripts/linux/prepare_release_bundle.py b/scripts/linux/prepare_release_bundle.py new file mode 100644 index 000000000..9cacc4db5 --- /dev/null +++ b/scripts/linux/prepare_release_bundle.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Prepare a finished Flutter Linux bundle before tar/AppImage/Flatpak packaging. + +Requires patchelf with --clear-execstack support and readelf (binutils). +Only the known Monero-family wallet libraries are patched; any other ELF that +requests an executable stack fails validation rather than being silently changed. +""" + +import argparse +from pathlib import Path +import shutil +import subprocess +import sys + + +WALLET_LIBRARIES = { + "monero_libwallet2_api_c.so", + "wownero_libwallet2_api_c.so", + "salvium_libwallet2_api_c.so", +} + + +def prepare_bundle(bundle): + if not bundle.is_dir() or not (bundle / "lib").is_dir(): + raise ValueError(f"Not a Flutter Linux bundle (missing lib directory): {bundle}") + + for tool in ("patchelf", "readelf"): + if shutil.which(tool) is None: + raise ValueError(f"Required tool not found: {tool}") + + files = sorted(path for path in bundle.rglob("*") if path.is_file()) + for path in files: + if path.parent == bundle / "lib" and path.name in WALLET_LIBRARIES: + # Never patch a link into the pub cache or another source tree. + if path.is_symlink(): + raise ValueError(f"Wallet library must be a bundled copy, not a symlink: {path}") + print(f"Clearing executable-stack flag: {path}", flush=True) + subprocess.run(["patchelf", "--clear-execstack", str(path)], check=True) + + # Check the executable and all bundled libraries, including native assets. + # A library without PT_GNU_STACK has no explicit executable-stack request; + # do not require that header on unrelated artifacts generated by Dart. + executable_stacks = [] + for path in files: + with path.open("rb") as source: + if source.read(4) != b"\x7fELF": + continue + result = subprocess.run( + ["readelf", "--wide", "--program-headers", str(path)], + check=True, + capture_output=True, + text=True, + ) + for line in result.stdout.splitlines(): + fields = line.split() + # readelf can print flags as either RWE or separate R / E fields. + if fields and fields[0] == "GNU_STACK" and "E" in "".join(fields[6:-1]): + executable_stacks.append(str(path)) + + if executable_stacks: + raise ValueError("Executable stack still requested by:\n" + "\n".join(executable_stacks)) + print(f"Linux bundle executable-stack validation passed: {bundle}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundle", type=Path, help="Finished Flutter Linux bundle directory") + args = parser.parse_args() + try: + prepare_bundle(args.bundle.resolve()) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + parser.exit(1, f"Linux bundle preparation failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/linux/tests/test_prepare_release_bundle.py b/scripts/linux/tests/test_prepare_release_bundle.py new file mode 100644 index 000000000..0468b8dbd --- /dev/null +++ b/scripts/linux/tests/test_prepare_release_bundle.py @@ -0,0 +1,113 @@ +"""Core ELF regression tests; no Flutter, pub cache, network or wallet data. + +Run: python3 -m unittest discover -s scripts/linux/tests -p 'test_*.py' -v +Requires a C compiler, binutils/readelf and patchelf with --clear-execstack. +""" + +import ctypes +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).resolve().parents[1] / "prepare_release_bundle.py" + + +class PrepareReleaseBundleTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.fixtures = tempfile.TemporaryDirectory(prefix="linux-elf-fixtures-") + cls.addClassCleanup(cls.fixtures.cleanup) + cls.exec_so = Path(cls.fixtures.name) / "exec.so" + cls.safe_so = Path(cls.fixtures.name) / "safe.so" + for path, stack in ((cls.exec_so, "execstack"), (cls.safe_so, "noexecstack")): + subprocess.run( + ["cc", "-shared", "-fPIC", f"-Wl,-z,{stack}", + "-x", "c", "-", "-o", str(path)], + input="int fixture_value(void) { return 42; }\n", + text=True, + check=True, + capture_output=True, + ) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="linux bundle 'quoted' ") + self.addCleanup(self.temp.cleanup) + self.bundle = Path(self.temp.name) / "bundle" + (self.bundle / "lib").mkdir(parents=True) + + def run_prepare(self, *, expected=0, env=None): + result = subprocess.run( + [sys.executable, str(SCRIPT), str(self.bundle)], + text=True, + capture_output=True, + env=env, + ) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + return result + + def copy_library(self, name, *, executable=True): + path = self.bundle / "lib" / name + shutil.copy2(self.exec_so if executable else self.safe_so, path) + return path + + def tool_environment(self, patchelf_body): + tools = Path(self.temp.name) / "tools" + tools.mkdir() + patcher = tools / "patchelf" + patcher.write_text("#!/bin/sh\n" + patchelf_body + "\n") + patcher.chmod(0o755) + return {**os.environ, "PATH": str(tools) + os.pathsep + os.environ["PATH"]} + + def test_all_wallets_are_nonexec_and_loadable(self): + for wallet in ("monero", "wownero", "salvium"): + self.copy_library(f"{wallet}_libwallet2_api_c.so") + self.run_prepare() + for path in (self.bundle / "lib").iterdir(): + with self.subTest(library=path.name): + flags = subprocess.check_output( + ["patchelf", "--print-execstack", str(path)], text=True, + ) + self.assertEqual(flags.strip(), "execstack: -") + self.assertEqual(ctypes.CDLL(str(path)).fixture_value(), 42) + + def test_no_wallets_and_unrelated_files_are_unchanged(self): + library = self.copy_library("libunrelated.so", executable=False) + asset = self.bundle / "data.txt" + asset.write_text("not an ELF\n") + original = {path: path.read_bytes() for path in (library, asset)} + self.run_prepare() + for path, content in original.items(): + self.assertEqual(path.read_bytes(), content) + + def test_unexpected_executable_stack_is_rejected_not_patched(self): + for relative in ("lib/libunexpected.so", "stack_wallet", + "lib/native_assets/nested.so"): + with self.subTest(path=relative): + path = self.bundle / relative + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(self.exec_so, path) + before = path.read_bytes() + result = self.run_prepare(expected=1) + self.assertIn("Executable stack still requested", result.stderr) + self.assertIn(str(path), result.stderr) + self.assertEqual(path.read_bytes(), before) + path.unlink() + + def test_patchelf_failure_aborts(self): + self.copy_library("monero_libwallet2_api_c.so") + result = self.run_prepare(expected=1, env=self.tool_environment("exit 23")) + self.assertIn("23", result.stderr) + + def test_successful_noop_patchelf_cannot_bypass_validation(self): + self.copy_library("monero_libwallet2_api_c.so") + result = self.run_prepare(expected=1, env=self.tool_environment("exit 0")) + self.assertIn("Executable stack still requested", result.stderr) + + +if __name__ == "__main__": + unittest.main()