appimage_elf: a shared library is not a static PIE; the check tells them apart without name rules (#4337 checker)

The checker treated every ET_DYN without PT_INTERP as a static executable, so the RUNPATH $ORIGIN that Pillow, numpy and sharp libraries carry was
reported as 'static PIE with a damaged dynamic section' (35 files in 0.2.11, 18 Pillow libraries in the published 0.2.10 Control; false positives).
A static PIE is now an ET_DYN with no interpreter, a non-zero entry point and DF_1_PIE in DT_FLAGS_1 and no DT_SONAME; a library (no entry point, or a
SONAME and no PIE flag) is never checked. A candidate whose dynamic section cannot be read is reported as damaged, because the overwrite that crashes
rg also erases the flags, so the test for 'executable' uses the entry point, which the damage does not touch. Tests (tools/test_appimage_elf.py, 16, run
on p1006): shared libraries with and without a SONAME, entry point or RUNPATH pass; a static PIE passes; a static PIE with a 0x5A .dynamic or an added
RUNPATH fails; a real gcc shared library with RUNPATH $ORIGIN passes; a real gcc static PIE passes and its overwritten copy fails. Build tool only.
This commit is contained in:
Niko1221 committed 2026-11-05 05:24:48 +01:00
1 parent 3c4afa8d78
commit ce4197ce81
2 files changed
+87 -10

No files matched your search

+35 -7
View File
@@ -11,6 +11,10 @@ apps/desktop/scripts/repack-appimage.sh puts the staged bytes back; this file is
appimage_elf.py scan <folder> every static PIE under <folder> must have a sound .dynamic (exit 1 if not)
appimage_elf.py compare <staged-folder> <folder> the same files, byte for byte, plus what differs in their ELF headers
What is checked: only static PIE programs. A shared library (.so, Python's and Node's native modules, Pillow, numpy, sharp) is not one, however it is
named: it has no entry point, or a DT_SONAME and no DF_1_PIE flag, and its RPATH/RUNPATH is normal. A static PIE is an ET_DYN with no interpreter, an entry
point, and DF_1_PIE in DT_FLAGS_1; one whose .dynamic cannot be read at all (overwritten) is reported as damaged, since the overwrite takes the flags too.
Standard library only; reads ELF64 little endian (x86-64 and arm64), anything else is ignored.
"""
import os
@@ -19,7 +23,8 @@ import sys
ET_DYN = 3
PT_LOAD, PT_DYNAMIC, PT_INTERP = 1, 2, 3
DT_NULL, DT_NEEDED, DT_RELA, DT_RELASZ, DT_RELAENT, DT_RPATH, DT_RUNPATH = 0, 1, 7, 8, 9, 15, 29
DT_NULL, DT_NEEDED, DT_RELA, DT_RELASZ, DT_RELAENT, DT_SONAME, DT_RPATH, DT_RUNPATH = 0, 1, 7, 8, 9, 14, 15, 29
DT_FLAGS_1, DF_1_PIE = 0x6FFFFFFB, 0x08000000
FILLERS = (0x5A, 0x58) # 'Z' (what the reports found) and 'X' (what patchelf documents)
RUN = 128 # that many equal filler bytes in a row is not code or data
@@ -34,20 +39,43 @@ def read_elf(data):
if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1:
return None
e_type = struct.unpack_from("<H", data, 16)[0]
e_entry = struct.unpack_from("<Q", data, 0x18)[0]
phoff = struct.unpack_from("<Q", data, 0x20)[0]
phentsize, phnum = struct.unpack_from("<HH", data, 0x36)
if phentsize < 56 or phoff + phentsize * phnum > len(data):
return {"type": e_type, "phdrs": [], "broken": "the program header table is outside the file"}
return {"type": e_type, "entry": e_entry, "phdrs": [], "broken": "the program header table is outside the file"}
phdrs = []
for i in range(phnum):
p_type, flags, off, vaddr, _paddr, filesz, memsz, align = struct.unpack_from("<IIQQQQQQ", data, phoff + i * phentsize)
phdrs.append({"type": p_type, "flags": flags, "off": off, "vaddr": vaddr, "filesz": filesz, "memsz": memsz, "align": align})
return {"type": e_type, "phdrs": phdrs, "broken": None}
return {"type": e_type, "entry": e_entry, "phdrs": phdrs, "broken": None}
def is_pie_candidate(info):
"""An ET_DYN with no interpreter, a dynamic segment and an entry point: what a static PIE looks like from outside the dynamic section.
A shared library normally has no entry point (e_entry 0) and is not a candidate; the few that do (the C library itself, the dynamic loader)
have a readable dynamic section and are told apart by it (static_pie_kind). The test uses nothing the damage we look for can erase."""
return bool(info) and info["type"] == ET_DYN and not info["broken"] and info.get("entry", 0) != 0 and not any(p["type"] == PT_INTERP for p in info["phdrs"]) and any(p["type"] == PT_DYNAMIC for p in info["phdrs"])
def static_pie_kind(data, info):
"""'static-pie' for a sound static PIE (DT_FLAGS_1 has DF_1_PIE, no DT_SONAME), 'damaged' for a candidate whose dynamic section cannot be read
(a static PIE with an overwritten .dynamic loses its flags with it, so unreadable is treated as damaged), None for everything else:
shared libraries, which carry a DT_SONAME and no DF_1_PIE, and programs that are not static PIEs."""
if not is_pie_candidate(info):
return None
entries, why = dynamic_entries(data, info)
if why or any(not _known_tag(t) for t, _ in entries):
return "damaged"
tags = {t: v for t, v in entries}
if DT_SONAME in tags:
return None
return "static-pie" if tags.get(DT_FLAGS_1, 0) & DF_1_PIE else None
def is_static_pie(info):
return bool(info) and info["type"] == ET_DYN and not info["broken"] and not any(p["type"] == PT_INTERP for p in info["phdrs"]) \
and any(p["type"] == PT_DYNAMIC for p in info["phdrs"])
"""Kept for callers: a candidate. check_static_pie decides with static_pie_kind."""
return is_pie_candidate(info)
def _mapped(info, vaddr, size):
@@ -85,7 +113,7 @@ def _longest_filler(data, start, end):
def check_static_pie(data):
"""The problems of one file (a list of sentences); empty for a sound static PIE and for anything that is not one."""
info = read_elf(data)
if not is_static_pie(info):
if static_pie_kind(data, info) is None:
return []
problems = []
seg = next(p for p in info["phdrs"] if p["type"] == PT_DYNAMIC)
@@ -123,7 +151,7 @@ def elf_differences(staged, packaged):
ta, tb = [p["type"] for p in a["phdrs"]], [p["type"] for p in b["phdrs"]]
if ta != tb:
out.append(f"program headers {len(ta)} -> {len(tb)} ({ta.count(PT_LOAD)} -> {tb.count(PT_LOAD)} loaded segments)")
if is_static_pie(a) and is_static_pie(b):
if is_pie_candidate(a) and is_pie_candidate(b):
da, _ = dynamic_entries(staged, a)
db, _ = dynamic_entries(packaged, b)
if da != db:
+52 -3
View File
@@ -23,11 +23,11 @@ sys.path.insert(0, str(ROOT / "apps" / "desktop" / "scripts"))
import appimage_elf as E # noqa: E402
def make_static_pie(*, extra_tags=(), rela=True, garbage=False, filler=False, interp=False, loads=2) -> bytes:
def make_static_pie(*, extra_tags=(), rela=True, garbage=False, filler=False, interp=False, loads=2, pie=True, entry=0x1000) -> bytes:
"""A small ELF64 ET_DYN: PT_LOADs, a PT_DYNAMIC inside the last one, optionally a PT_INTERP. Not runnable, only well formed."""
page = 0x1000
dyn = [(E.DT_RELA, 0x1100), (E.DT_RELASZ, 48), (E.DT_RELAENT, 24)] if rela else []
dyn += list(extra_tags) + [(E.DT_NULL, 0)]
dyn += ([(E.DT_FLAGS_1, E.DF_1_PIE)] if pie else []) + list(extra_tags) + [(E.DT_NULL, 0)]
dyn_bytes = b"".join(struct.pack("<qQ", t, v) for t, v in dyn)
if garbage:
dyn_bytes = b"Z" * len(dyn_bytes)
@@ -50,7 +50,7 @@ def make_static_pie(*, extra_tags=(), rela=True, garbage=False, filler=False, in
head = bytearray(64)
head[:4] = b"\x7fELF"
head[4], head[5], head[6] = 2, 1, 1
struct.pack_into("<HHIQQQIHHHHHH", head, 16, E.ET_DYN, 62, 1, 0x1000, 64, 0, 0, 64, 56, len(phdr), 64, 0, 0)
struct.pack_into("<HHIQQQIHHHHHH", head, 16, E.ET_DYN, 62, 1, entry, 64, 0, 0, 64, 56, len(phdr), 64, 0, 0)
out = bytearray(head)
for p_type, flags, off, vaddr, filesz, memsz, align in phdr:
out += struct.pack("<IIQQQQQQ", p_type, flags, off, vaddr, vaddr, filesz, memsz, align)
@@ -95,6 +95,41 @@ class Synthetic(unittest.TestCase):
self.assertEqual(E.check_static_pie(b""), [])
self.assertEqual(E.check_static_pie(b"\x7fELF\x02\x01" + b"\x00" * 100), [], "no program headers: nothing to check")
def test_a_shared_library_with_no_interpreter_passes_however_it_is_named(self):
"""Pillow, numpy and sharp ship .so files with an RUNPATH of $ORIGIN, no PT_INTERP and (usually) no entry point: they were flagged as static PIEs."""
sonames = [(E.DT_SONAME, 1), (E.DT_RUNPATH, 2)]
for lib in (make_static_pie(entry=0, pie=False, extra_tags=sonames), # the common case: no entry point
make_static_pie(entry=0, pie=False, extra_tags=[(E.DT_RUNPATH, 2)]), # no SONAME either (a Node .node module)
make_static_pie(entry=0x1000, pie=False, extra_tags=sonames), # an entry point (libc, ld.so style) but a SONAME and no PIE flag
make_static_pie(entry=0x1000, pie=True, extra_tags=sonames), # even a stray PIE flag: a SONAME makes it a library
make_static_pie(entry=0, pie=False, garbage=True)): # a library with a damaged dynamic section is not this checker's business
self.assertEqual(E.check_static_pie(lib), [])
self.assertEqual(E.static_pie_kind(make_static_pie(entry=0, pie=False, extra_tags=sonames), E.read_elf(make_static_pie(entry=0, pie=False, extra_tags=sonames))), None)
def test_a_static_pie_is_told_from_a_library_and_a_damaged_one_is_found(self):
good = make_static_pie()
self.assertEqual(E.static_pie_kind(good, E.read_elf(good)), "static-pie")
self.assertEqual(E.check_static_pie(good), [])
bad = make_static_pie(garbage=True)
self.assertEqual(E.static_pie_kind(bad, E.read_elf(bad)), "damaged")
self.assertTrue(E.check_static_pie(bad))
# linuxdeploy's own edit: the section survives (moved) with a RUNPATH added; it is still a static PIE and the RUNPATH gives it away
rp = make_static_pie(extra_tags=[(E.DT_RUNPATH, 1)])
self.assertEqual(E.static_pie_kind(rp, E.read_elf(rp)), "static-pie")
self.assertTrue(any("RPATH or RUNPATH" in p for p in E.check_static_pie(rp)))
# no entry point, no PIE flag, not a static PIE: left alone
self.assertEqual(E.check_static_pie(make_static_pie(pie=False)), [], "a sound dynamic section with no PIE flag and no SONAME is not a static PIE")
def test_a_folder_of_libraries_and_one_static_pie(self):
with tempfile.TemporaryDirectory() as tmp:
for i in range(5):
Path(tmp, f"lib{i}.so.1").write_bytes(make_static_pie(entry=0, pie=False, extra_tags=[(E.DT_SONAME, 1), (E.DT_RUNPATH, 2)]))
Path(tmp, "something.node").write_bytes(make_static_pie(entry=0, pie=False, extra_tags=[(E.DT_RUNPATH, 2)]))
Path(tmp, "rg").write_bytes(make_static_pie())
self.assertEqual(E.scan(tmp), {}, "no name rules: only the static PIE is looked at, and it is sound")
Path(tmp, "rg").write_bytes(make_static_pie(garbage=True))
self.assertEqual(list(E.scan(tmp)), ["rg"])
def test_a_truncated_file_is_a_report_not_a_crash(self):
data = make_static_pie()
for cut in (70, 200, 4100, len(data) - 10):
@@ -165,6 +200,20 @@ class RealStaticPie(unittest.TestCase):
self.assertIn("ok alphabetagamma", out.stdout)
self.assertEqual(E.check_static_pie(self.good.read_bytes()), [])
def test_a_real_shared_library_with_a_runpath_passes(self):
src = Path(self.tmp, "lib.c")
src.write_text("int answer(void) { return 42; }" + chr(10))
lib = Path(self.tmp, "libanswer.so.1")
built = subprocess.run(["gcc", "-shared", "-fPIC", "-O2", "-Wl,-soname,libanswer.so.1", "-Wl,-rpath,$ORIGIN", str(src), "-o", str(lib)], capture_output=True)
if built.returncode != 0:
self.skipTest("this gcc cannot build a shared library")
data = lib.read_bytes()
self.assertEqual(E.check_static_pie(data), [], "a real library with RUNPATH $ORIGIN and no interpreter is not a static PIE")
self.assertEqual(E.static_pie_kind(data, E.read_elf(data)), None)
# and the real static PIE of this class is still told apart and still found when damaged
good = self.good.read_bytes()
self.assertEqual(E.static_pie_kind(good, E.read_elf(good)), "static-pie")
def test_a_filled_dynamic_section_does_not_start_and_is_found(self):
data = bytearray(self.good.read_bytes())
info = E.read_elf(bytes(data))