staticer 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2142 @@
1
+ """ELF (Linux) dependency embedding.
2
+
3
+ Implements the reloc-bearing ("PIC path") case -- a dependency .so is
4
+ appended as an in-place extension of the host ELF executable's own
5
+ highest-vaddr PT_LOAD segment (deliberately *not* a new PT_LOAD segment of
6
+ its own -- see ``ElfEmbedder.embed()``'s docstring for why that breaks
7
+ AT_PHDR), its own relocations are applied against the new addresses, the
8
+ host's references to it (GOT/PLT slots) are patched to the embedded
9
+ addresses, and its DT_NEEDED entry is neutralized -- and the fixed-base
10
+ ("non-PIC path") case, where a dependency's own ELF header says it can
11
+ only ever be loaded at one specific address and gets its own, separate
12
+ PT_LOAD segment reserved at exactly that address instead (see the base-
13
+ address-reservation comment inside ``embed()``, by ``fixed_base_segments``,
14
+ for the full mechanism and its one real limitation). Unwind-metadata
15
+ preservation and the real generated entrypoint stub are later tasks; see
16
+ ``ElfEmbedder.embed()``'s docstring for exactly what's simplified here.
17
+
18
+ ``pyelftools`` is read-only, so every byte this module *writes* is built
19
+ by hand from the fixed, standardized ELF32/ELF64 struct layouts (ELF
20
+ headers, program headers, dynamic-tag entries, and REL/RELA relocation
21
+ entries are all fixed-size, unlike PE's variable-length optional header).
22
+ pyelftools is used only for the read side: walking the PT_DYNAMIC segment
23
+ to get DT_NEEDED/relocation tables/dynamic symbols without needing (and
24
+ without disturbing) section headers at all.
25
+ """
26
+
27
+ import io
28
+ import shutil
29
+ import struct
30
+ from pathlib import Path
31
+ from typing import Dict, List, Optional, Set, Tuple
32
+
33
+ from elftools.elf.elffile import ELFFile
34
+
35
+ from .common.depgraph import build_dependency_graph, embed_order
36
+ from .stubs.linux.elf_stub import build_linux_init_array_stub, build_linux_stub
37
+
38
+ # -- ELF constants pyelftools doesn't expose as plain ints for our needs --
39
+ # (it hands back Enum-mapped strings for e.g. p_type/d_tag, which is fine
40
+ # for reading but useless for re-packing bytes we write ourselves).
41
+
42
+ PT_LOAD = 1
43
+ PT_DYNAMIC = 2
44
+ PT_PHDR = 6
45
+ PT_GNU_EH_FRAME = 0x6474E550
46
+ PF_X, PF_W, PF_R = 1, 2, 4
47
+
48
+ DT_NULL = 0
49
+ DT_NEEDED = 1
50
+
51
+ DT_DEBUG = 21 # harmless/ignored by every runtime linker; see _remove_needed
52
+
53
+ # Relocation type numbers below happen to share the same values on
54
+ # EM_386 and EM_X86_64 for every type this module handles.
55
+ R_NONE = 0
56
+ R_ABS_PLUS_ADDEND = 1 # R_X86_64_64 / R_386_32: value = S (+A for RELA)
57
+ R_COPY = 5 # R_X86_64_COPY / R_386_COPY: copy `st_size` bytes from the definition
58
+ R_GLOB_DAT = 6
59
+ R_JUMP_SLOT = 7
60
+ R_RELATIVE = 8
61
+
62
+ _SUPPORTED_MACHINES = ("EM_X86_64", "EM_386")
63
+
64
+ _PAGE = 0x1000
65
+
66
+ # -- DWARF exception-handling pointer-encoding bits (DW_EH_PE_*), just the
67
+ # ones GCC's own default .eh_frame_hdr layout actually uses -- see
68
+ # _parse_eh_frame_hdr/_build_eh_frame_hdr.
69
+ _DW_EH_PE_UDATA4 = 0x03
70
+ _DW_EH_PE_SDATA4 = 0x0B
71
+ _DW_EH_PE_PCREL = 0x10
72
+ _DW_EH_PE_DATAREL = 0x30
73
+ _DW_EH_PE_OMIT = 0xFF
74
+
75
+ STB_GLOBAL = 1
76
+ STB_WEAK = 2
77
+ STT_FUNC = 2
78
+
79
+
80
+ def list_elf_imports(path: Path) -> List[str]:
81
+ """Names from the DT_NEEDED entries of an ELF file's dynamic section,
82
+ read via its PT_DYNAMIC segment (so this works even without section
83
+ headers); empty list if it has none."""
84
+ with open(path, "rb") as f:
85
+ elf = ELFFile(f)
86
+ dyn = _dynamic_segment(elf)
87
+ if dyn is None:
88
+ return []
89
+ return [tag.needed for tag in dyn.iter_tags("DT_NEEDED")]
90
+
91
+
92
+ def resolve_elf(name: str, search_dirs: List[Path]) -> Optional[Path]:
93
+ """Scan search_dirs in order for a file that `name` denotes.
94
+
95
+ `name` comes straight from the ELF file being processed's own
96
+ DT_NEEDED entries (an attacker-controlled dependency name, if the
97
+ input binary is untrusted) -- it must never be allowed to escape the
98
+ `search_dirs` entry it's being matched against via path-traversal
99
+ components (``..``) or an absolute path (CWE-22), or `--lib-dir`'s
100
+ whole purpose (scoping embedding to one chosen folder) is defeated.
101
+
102
+ A DT_NEEDED entry is *usually* a bare filename (e.g. "libc.so.6"),
103
+ but it legitimately isn't always one: a .so with no DT_SONAME of its
104
+ own that was linked by passing its full path on the linker command
105
+ line gets that literal path recorded as the NEEDED string by ld --
106
+ this project's own test fixtures do exactly that (`build_exe` passes
107
+ `str(lib)`, an absolute `tmp_path`-rooted path, with no
108
+ `-Wl,-soname=...` ever set). An earlier version of this check
109
+ rejected any such non-bare-filename name outright, which (correctly)
110
+ blocked a genuine traversal/absolute escape attempt but also
111
+ (incorrectly) blocked this entirely legitimate same-directory case,
112
+ since ``directory / name`` only ever *joins* when `name` is relative
113
+ -- for an absolute `name` it discards `directory` and evaluates to
114
+ `name` itself, so the real containment check below is what has to
115
+ carry the whole security property, not a cheap name-shape guard in
116
+ front of it.
117
+
118
+ So: no shape-based rejection up front. Every `name` -- bare,
119
+ relative-with-components, or absolute -- is joined against each
120
+ `directory` in turn (a no-op join for an absolute `name`, same as
121
+ Path's own semantics) and the resulting candidate is accepted only
122
+ if it both exists as a file and *resolves* (symlinks and all) to
123
+ somewhere inside that same `directory`'s own resolved form. A
124
+ genuine traversal (``../../etc/passwd``) or an absolute escape
125
+ (``/etc/passwd``) still fails that resolved-containment check against
126
+ every `search_dirs` entry, exactly as before -- only a `name` that
127
+ truly denotes a file inside one of them, however it's spelled, is
128
+ ever accepted. Either way a rejection behaves exactly like "not found
129
+ in this directory" -- falling through to the next search_dirs entry,
130
+ or returning None if none match -- never a hard error."""
131
+ if not name:
132
+ return None
133
+ for directory in search_dirs:
134
+ candidate = directory / name
135
+ try:
136
+ resolved = candidate.resolve()
137
+ if not resolved.is_relative_to(directory.resolve()):
138
+ continue
139
+ except (OSError, ValueError):
140
+ continue
141
+ if candidate.is_file():
142
+ return candidate
143
+ return None
144
+
145
+
146
+ def _dynamic_segment(elf: ELFFile):
147
+ return next(elf.iter_segments(type="PT_DYNAMIC"), None)
148
+
149
+
150
+ def _real_symtab_count(dyn, reloc_tables: dict) -> int:
151
+ """The true number of entries in `dyn`'s own DT_SYMTAB, more reliable
152
+ than pyelftools' own ``DynamicSegment.num_symbols()`` for a host that
153
+ exports no symbols at all (the common case for an ordinary
154
+ executable, which usually only *imports*).
155
+
156
+ ``num_symbols()`` derives its count from the DT_GNU_HASH table (or
157
+ DT_HASH), which only indexes *exported/defined* symbols -- GNU_HASH's
158
+ own lookup degrades, for a host with nothing hashed at all, to
159
+ returning its own `symoffset` bias value as if that were the total
160
+ symbol count (see pyelftools' ``GNUHashTable.get_number_of_symbols``:
161
+ ``if max_idx < self.params['symoffset']: return self.params['symoffset']``),
162
+ silently undercounting every purely-undefined symbol past that bias.
163
+ Confirmed empirically against a real `-fno-plt`/`-no-pie` host on
164
+ real WSL Linux: with `__cxa_finalize`/`_ITM_registerTMCloneTable`/
165
+ `_ITM_deregisterTMCloneTable` at dynsym indices 1-3 (all undefined,
166
+ all referenced by real relocations), `num_symbols()` reported 1 --
167
+ appending new symbol-table growth past that wrong, too-small index
168
+ silently overwrote/discarded those three real, already-occupied
169
+ entries, corrupting what their own pre-existing relocations' symbol
170
+ indices resolved to and segfaulting at process startup.
171
+
172
+ Falls back to one past the highest symbol index any of the host's
173
+ OWN existing relocations (main table + JMPREL, if present) already
174
+ reference, whenever that's larger than what `num_symbols()` reports
175
+ -- every such index is, by construction, a real, already-occupied
176
+ dynsym slot that must not be overwritten."""
177
+ count = dyn.num_symbols()
178
+ for table in reloc_tables.values():
179
+ for reloc in table.iter_relocations():
180
+ count = max(count, reloc["r_info_sym"] + 1)
181
+ return count
182
+
183
+
184
+ _EI_CLASS_OFFSET = 4 # fixed byte offset in e_ident, identical for ELF32/ELF64
185
+ _ELFCLASS64 = 2
186
+
187
+
188
+ def _peek_elfclass64(path: Path) -> bool:
189
+ """Whether `path` is a 64-bit ELF file (ELFCLASS64), read directly
190
+ from the single e_ident[EI_CLASS] byte rather than a full ELF parse
191
+ -- used only to pick a same-bitness-first multiarch search-dir order
192
+ (see `ElfEmbedder._search_dirs`); any malformed/unreadable input
193
+ here is caught properly, with a real diagnostic, by the full parse
194
+ `embed()` already does right after, so this deliberately doesn't
195
+ duplicate that error handling -- a bool default of False (32-bit)
196
+ on a read failure just means the search order picked here doesn't
197
+ matter, since `embed()` is about to fail loudly on the same file
198
+ for an unrelated, more informative reason moments later."""
199
+ try:
200
+ with open(path, "rb") as f:
201
+ f.seek(_EI_CLASS_OFFSET)
202
+ return f.read(1) == bytes([_ELFCLASS64])
203
+ except OSError:
204
+ return False
205
+
206
+
207
+ def _align_up(value: int, alignment: int) -> int:
208
+ if alignment <= 1:
209
+ return value
210
+ return (value + alignment - 1) // alignment * alignment
211
+
212
+
213
+ # -- raw Phdr packing (pyelftools doesn't write; layouts are fixed by ABI) --
214
+
215
+ def _pack_phdr(is64: bool, p_type: int, p_flags: int, p_offset: int, p_vaddr: int,
216
+ p_paddr: int, p_filesz: int, p_memsz: int, p_align: int) -> bytes:
217
+ if is64:
218
+ return struct.pack("<IIQQQQQQ", p_type, p_flags, p_offset, p_vaddr,
219
+ p_paddr, p_filesz, p_memsz, p_align)
220
+ # Elf32_Phdr has a different field order: p_flags comes *after*
221
+ # p_filesz/p_memsz, unlike Elf64_Phdr where it's right after p_type.
222
+ return struct.pack("<IIIIIIII", p_type, p_offset, p_vaddr, p_paddr,
223
+ p_filesz, p_memsz, p_flags, p_align)
224
+
225
+
226
+ def _remove_needed(dyn, raw: bytearray, ptr_size: int, needed_name: str) -> bool:
227
+ """Convert the DT_NEEDED entry for `needed_name` into a DT_DEBUG entry,
228
+ in place. DT_DEBUG is ignored by every real-world runtime linker, and
229
+ (crucially) this doesn't shrink/grow the dynamic array or disturb its
230
+ DT_NULL terminator's position -- unlike turning it into DT_NULL
231
+ directly, which would truncate the whole table at that point for any
232
+ tag scanner that stops at the first DT_NULL (every real one does)."""
233
+ dyn_entry_size = 2 * ptr_size
234
+ for n in range(dyn.num_tags()):
235
+ tag = dyn.get_tag(n)
236
+ if tag.entry.d_tag == "DT_NEEDED" and tag.needed == needed_name:
237
+ entry_off = dyn["p_offset"] + n * dyn_entry_size
238
+ fmt = "<Q" if ptr_size == 8 else "<I"
239
+ struct.pack_into(fmt, raw, entry_off, DT_DEBUG)
240
+ return True
241
+ return False
242
+
243
+
244
+ _VERNEED_ENTRY_SIZE = 16 # Elf32_Verneed/Elf64_Verneed share one layout (see _VERNEED_FMT)
245
+ _VERNEED_FMT = "<HHIII" # vn_version, vn_cnt, vn_file, vn_aux, vn_next (all fixed-width, class-independent)
246
+
247
+
248
+ def _remove_verneed_entry(dyn, raw: bytearray, ptr_size: int, vaddr_to_offset, needed_name: str) -> None:
249
+ """Remove `needed_name`'s own top-level DT_VERNEED (symbol-versioning
250
+ "needed version" list) entry, if the host has one, splicing the
251
+ linked list around it in place -- the version-requirement analogue of
252
+ _remove_needed, needed for the same reason but discovered only
253
+ against real system binaries (none of this project's own small
254
+ fixtures use GNU symbol versioning, so nothing exercised this path
255
+ before Task 10b): ld.so's _dl_check_map_versions walks the WHOLE
256
+ top-level Verneed list unconditionally at load time and asserts a
257
+ loaded module exists for *every* entry's own vn_file SONAME --
258
+ unconditionally, regardless of whether any relocation still actually
259
+ references a version from it. Leaving a stale entry for an embedded
260
+ (DT_NEEDED-neutralized) dependency in place makes that assertion fail
261
+ at process startup ("Inconsistency detected by ld.so: dl-version.c:
262
+ ...: _dl_check_map_versions: Assertion `needed != NULL' failed"),
263
+ confirmed against a real `bash`/`libtinfo.so.6` embed on real Linux
264
+ (WSL) before this fix -- it ran `embed()` successfully and loaded via
265
+ a plain run but crashed immediately in the dynamic linker itself,
266
+ before any user code ran.
267
+
268
+ The corresponding Vernaux (per-version) entries hanging off the
269
+ removed node are simply left as dead bytes in place, unchanged -- the
270
+ same leave-it-dead-in-place pattern _remove_needed already uses for a
271
+ neutralized DT_NEEDED entry -- since nothing reaches them anymore
272
+ once their own Verneed node is spliced out of the list; a Verneed
273
+ entry's own vn_file (the only thing this function reads from each
274
+ entry) must always name one of the host's own *direct* DT_NEEDED
275
+ entries per the ELF ABI (a version can only ever be required from a
276
+ library the host itself directly depends on), so this is only ever
277
+ called for a direct import -- see this method's one call site."""
278
+ verneed_tag = next(dyn.iter_tags("DT_VERNEED"), None)
279
+ verneednum_tag = next(dyn.iter_tags("DT_VERNEEDNUM"), None)
280
+ strtab_tag = next(dyn.iter_tags("DT_STRTAB"), None)
281
+ if verneed_tag is None or verneednum_tag is None or strtab_tag is None:
282
+ return
283
+ count = verneednum_tag.entry.d_val
284
+ if count == 0:
285
+ return
286
+ strtab_vaddr = strtab_tag.entry.d_val
287
+
288
+ def _cstr_at(vaddr: int) -> str:
289
+ off = vaddr_to_offset(vaddr, 1)
290
+ end = raw.index(b"\x00", off)
291
+ return raw[off:end].decode("ascii")
292
+
293
+ # Walk the chain, recording each entry's own (vaddr, file offset,
294
+ # resolved vn_file name) -- vn_next entries are threaded by byte
295
+ # offset *from each entry's own vaddr*, not a flat array, so this
296
+ # can't just iterate `count` fixed-size slots directly.
297
+ entries = []
298
+ cur_vaddr = verneed_tag.entry.d_val
299
+ for _ in range(count):
300
+ off = vaddr_to_offset(cur_vaddr, _VERNEED_ENTRY_SIZE)
301
+ _, _, vn_file, _, vn_next = struct.unpack_from(_VERNEED_FMT, raw, off)
302
+ name = _cstr_at(strtab_vaddr + vn_file)
303
+ entries.append({"vaddr": cur_vaddr, "offset": off, "name": name})
304
+ if vn_next == 0:
305
+ break
306
+ cur_vaddr += vn_next
307
+
308
+ kept = [e for e in entries if e["name"] != needed_name]
309
+ if len(kept) == len(entries):
310
+ return # this host has no Verneed entry for `needed_name` at all
311
+
312
+ # Re-thread vn_next across only the kept entries (last one's becomes
313
+ # 0, the "no more entries" terminator) and update DT_VERNEEDNUM/the
314
+ # head DT_VERNEED tag's own d_val in place -- same append-nothing,
315
+ # edit-existing-fields-in-place pattern as _remove_needed/
316
+ # _set_dyn_tag_value, since nothing here needs new file content, just
317
+ # different field values at already-existing offsets.
318
+ for i, e in enumerate(kept):
319
+ new_next = 0 if i == len(kept) - 1 else kept[i + 1]["vaddr"] - e["vaddr"]
320
+ struct.pack_into("<I", raw, e["offset"] + 12, new_next & 0xFFFFFFFF)
321
+ if kept:
322
+ _set_dyn_tag_value(dyn, raw, ptr_size, "DT_VERNEED", kept[0]["vaddr"])
323
+ _set_dyn_tag_value(dyn, raw, ptr_size, "DT_VERNEEDNUM", len(kept))
324
+
325
+
326
+ def _find_dyn_tag_file_offset(dyn, ptr_size: int, tag_name: str) -> Optional[int]:
327
+ dyn_entry_size = 2 * ptr_size
328
+ for n in range(dyn.num_tags()):
329
+ if dyn.get_tag(n).entry.d_tag == tag_name:
330
+ return dyn["p_offset"] + n * dyn_entry_size
331
+ return None
332
+
333
+
334
+ def _set_dyn_tag_value(dyn, raw: bytearray, ptr_size: int, tag_name: str, value: int) -> None:
335
+ entry_off = _find_dyn_tag_file_offset(dyn, ptr_size, tag_name)
336
+ if entry_off is None:
337
+ raise ValueError(f"expected dynamic tag {tag_name!r} not found")
338
+ fmt = "<Q" if ptr_size == 8 else "<I"
339
+ struct.pack_into(fmt, raw, entry_off + ptr_size, value)
340
+
341
+
342
+ def _read_int(buf, offset: int, size: int) -> int:
343
+ return int.from_bytes(buf[offset:offset + size], "little")
344
+
345
+
346
+ def _write_int(buf: bytearray, offset: int, size: int, value: int) -> None:
347
+ buf[offset:offset + size] = (value & ((1 << (size * 8)) - 1)).to_bytes(size, "little")
348
+
349
+
350
+ def _pack_relative_entries(entries: List[Tuple[int, int]], is64: bool, is_rela: bool) -> bytes:
351
+ """Pack (vaddr, value) pairs as new R_*_RELATIVE REL/RELA entries
352
+ (symbol index 0, so r_info reduces to just the type).
353
+
354
+ `value` is packed as an *unsigned* bit pattern (struct format 'Q'/'I',
355
+ not 'q'/'i'): the ELF ABI only cares about the addend's raw bits, and
356
+ `value` is already masked into unsigned range by every caller (see
357
+ `new_reloc_entries.append(...)` call sites) -- a signed format would
358
+ raise OverflowError for any legitimate 32-bit address >= 2**31 (common
359
+ for e.g. mmap-assigned bases), even though the bit pattern itself is
360
+ perfectly valid."""
361
+ out = bytearray()
362
+ for vaddr, value in entries:
363
+ if is64:
364
+ if is_rela:
365
+ out += struct.pack("<QQQ", vaddr, R_RELATIVE, value & 0xFFFFFFFFFFFFFFFF)
366
+ else:
367
+ out += struct.pack("<QQ", vaddr, R_RELATIVE)
368
+ else:
369
+ if is_rela:
370
+ out += struct.pack("<III", vaddr, R_RELATIVE, value & 0xFFFFFFFF)
371
+ else:
372
+ out += struct.pack("<II", vaddr, R_RELATIVE)
373
+ return bytes(out)
374
+
375
+
376
+ def _pack_glob_dat_entries(entries: List[Tuple[int, int]], is64: bool, is_rela: bool) -> bytes:
377
+ """Pack (slot_vaddr, sym_index) pairs as new R_*_GLOB_DAT REL/RELA
378
+ entries -- used to forward an embedded dependency's remaining
379
+ external symbol references (functions/data it calls via its own,
380
+ now-dead PT_DYNAMIC that no embedded dependency provides -- e.g.
381
+ `malloc`, `__cxa_throw`) into the host's own dynamic section, so
382
+ ld.so resolves them normally, by name, at load time -- exactly as it
383
+ would if the host had referenced them directly -- rather than the
384
+ value being something *this module* computes (unlike
385
+ _pack_relative_entries' RELATIVE entries, which always encode an
386
+ address this module already resolved itself; a GLOB_DAT entry here
387
+ instead carries a *symbol index* for ld.so's own lookup to resolve,
388
+ since the real address -- e.g. malloc's -- isn't known until load
389
+ time and isn't ours to compute). Addend is always 0: the whole
390
+ resolved value replaces the slot's content outright, there's nothing
391
+ to add it to."""
392
+ out = bytearray()
393
+ for vaddr, sym_idx in entries:
394
+ if is64:
395
+ r_info = (sym_idx << 32) | R_GLOB_DAT
396
+ if is_rela:
397
+ out += struct.pack("<QQq", vaddr, r_info, 0)
398
+ else:
399
+ out += struct.pack("<QQ", vaddr, r_info)
400
+ else:
401
+ r_info = (sym_idx << 8) | R_GLOB_DAT
402
+ if is_rela:
403
+ out += struct.pack("<IIi", vaddr, r_info, 0)
404
+ else:
405
+ out += struct.pack("<II", vaddr, r_info)
406
+ return bytes(out)
407
+
408
+
409
+ def _pack_undef_sym(is64: bool, name_off: int, weak: bool = False) -> bytes:
410
+ """A new Elf32_Sym/Elf64_Sym entry for a symbol this module itself
411
+ never defines (SHN_UNDEF, st_value/st_size 0) -- used only so ld.so's
412
+ relocation processing has a name to look up for a forwarded external
413
+ reference (see _pack_glob_dat_entries); STT_FUNC matches what a real
414
+ external function import's own symbol table entry would normally
415
+ carry (ld.so's lookup doesn't actually care about the type for OUR
416
+ copy of it -- only the *name* and the fact that it's undefined here
417
+ -- but matching convention costs nothing).
418
+
419
+ `weak` preserves the ORIGINAL reference's own binding (STB_WEAK vs
420
+ STB_GLOBAL) rather than always hardcoding STB_GLOBAL -- this is what
421
+ makes a forwarded weak-undefined reference behave like real ld.so
422
+ semantics instead of this module's own: ld.so's symbol lookup
423
+ tolerates an unresolved STB_WEAK reference (silently leaves it at 0)
424
+ but raises a fatal "undefined symbol" error for an unresolved
425
+ STB_GLOBAL one, exactly matching what a dependency's own weak
426
+ reference (e.g. crtstuff's __cxa_finalize/__gmon_start__-style
427
+ optional hooks) would have done had it never been embedded at all."""
428
+ st_info = ((STB_WEAK if weak else STB_GLOBAL) << 4) | STT_FUNC
429
+ if is64:
430
+ # st_name, st_info, st_other, st_shndx, st_value, st_size
431
+ return struct.pack("<IBBHQQ", name_off, st_info, 0, 0, 0, 0)
432
+ # Elf32_Sym has a different field order: st_value/st_size come right
433
+ # after st_name, with st_info/st_other/st_shndx packed at the end.
434
+ return struct.pack("<IIIBBH", name_off, 0, 0, st_info, 0, 0)
435
+
436
+
437
+ def _decode_eh_pe_sdata4(encoding: int, raw: int, field_vaddr: int, hdr_vaddr: int) -> int:
438
+ """Decode one DW_EH_PE-encoded 4-byte field from .eh_frame_hdr, as
439
+ used by every encoding GCC's own default layout actually emits for
440
+ this structure's fields: `sdata4`/`udata4` format (this module
441
+ doesn't need to tell those apart -- both are 4 bytes, and the
442
+ unsigned-vs-signed distinction only matters for a value that could
443
+ legitimately be negative *before* adding a base, which none of
444
+ these are once decoded) combined with either a `pcrel` base
445
+ (relative to this field's own vaddr) or a `datarel` base (relative
446
+ to the start of .eh_frame_hdr itself). Raises for anything else --
447
+ failing loudly rather than silently misinterpreting unwind data
448
+ matches this module's existing architecture-mismatch/unsupported-
449
+ relocation-type precedent elsewhere."""
450
+ width_bits = encoding & 0x0F
451
+ if width_bits not in (_DW_EH_PE_UDATA4, _DW_EH_PE_SDATA4):
452
+ raise ValueError(f"unsupported .eh_frame_hdr value encoding {hex(encoding)}")
453
+ base_bits = encoding & 0x70
454
+ if base_bits == _DW_EH_PE_PCREL:
455
+ return field_vaddr + raw
456
+ if base_bits == _DW_EH_PE_DATAREL:
457
+ return hdr_vaddr + raw
458
+ raise ValueError(f"unsupported .eh_frame_hdr base encoding {hex(encoding)}")
459
+
460
+
461
+ def _parse_eh_frame_hdr(hdr_bytes: bytes, hdr_vaddr: int) -> List[Tuple[int, int]]:
462
+ """-> [(initial_loc, fde_addr), ...] -- absolute vaddrs, decoded from
463
+ a .eh_frame_hdr's binary-search table per whatever DW_EH_PE_*
464
+ encodings its own 4-byte encoding header declares (see
465
+ _decode_eh_pe_sdata4), not any one hardcoded scheme. This is the
466
+ *only* thing ever read from a dependency's .eh_frame/.eh_frame_hdr --
467
+ the FDE/CIE bytes themselves never need touching at all (see
468
+ ElfEmbedder.embed()'s docstring for why: every PC-relative offset a
469
+ FDE encodes -- to its own function, to its CIE, to its LSDA -- stays
470
+ numerically correct under a uniform shift, and a dependency's whole
471
+ mapped image, .eh_frame included, is always relocated as one single
472
+ uniform shift by this module)."""
473
+ version, eh_frame_ptr_enc, fde_count_enc, table_enc = hdr_bytes[0:4]
474
+ if version != 1:
475
+ raise ValueError(f"unsupported .eh_frame_hdr version {version}")
476
+ pos = 4
477
+ if eh_frame_ptr_enc != _DW_EH_PE_OMIT:
478
+ pos += 4 # eh_frame_ptr itself is never needed by this module
479
+ if fde_count_enc == _DW_EH_PE_OMIT:
480
+ return []
481
+ if (fde_count_enc & 0x0F) != _DW_EH_PE_UDATA4:
482
+ raise ValueError(f"unsupported .eh_frame_hdr fde_count encoding {hex(fde_count_enc)}")
483
+ fde_count = struct.unpack_from("<I", hdr_bytes, pos)[0]
484
+ pos += 4
485
+ entries = []
486
+ for i in range(fde_count):
487
+ off = pos + i * 8
488
+ iloc_raw, fde_raw = struct.unpack_from("<ii", hdr_bytes, off)
489
+ initial_loc = _decode_eh_pe_sdata4(table_enc, iloc_raw, hdr_vaddr + off, hdr_vaddr)
490
+ fde_addr = _decode_eh_pe_sdata4(table_enc, fde_raw, hdr_vaddr + off + 4, hdr_vaddr)
491
+ entries.append((initial_loc, fde_addr))
492
+ return entries
493
+
494
+
495
+ def _build_eh_frame_hdr(hdr_vaddr: int, eh_frame_vaddr: int, entries: List[Tuple[int, int]]) -> bytes:
496
+ """Inverse of _parse_eh_frame_hdr, always emitting GCC's own default
497
+ encoding scheme (`pcrel`/`sdata4` for the eh_frame_ptr field,
498
+ `udata4` for fde_count, `datarel`/`sdata4` for the table -- the same
499
+ scheme _parse_eh_frame_hdr already knows how to read, so a hdr this
500
+ function builds is always itself re-parseable) regardless of what
501
+ encoding scheme the *input* entries were originally read with.
502
+ `entries` must already be sorted ascending by initial_loc -- the ABI
503
+ requires this for the binary search the table exists to support to
504
+ actually work."""
505
+ eh_frame_ptr_field_vaddr = hdr_vaddr + 4
506
+ out = bytearray([1, 0x1B, 0x03, 0x3B])
507
+ out += struct.pack("<i", eh_frame_vaddr - eh_frame_ptr_field_vaddr)
508
+ out += struct.pack("<I", len(entries))
509
+ for initial_loc, fde_addr in entries:
510
+ out += struct.pack("<ii", initial_loc - hdr_vaddr, fde_addr - hdr_vaddr)
511
+ return bytes(out)
512
+
513
+
514
+ class ElfEmbedder:
515
+ def __init__(self, input_path: Path, output_path: Path, exclude: Set[str], lib_dir: Optional[Path]):
516
+ self.input_path = Path(input_path)
517
+ self.output_path = Path(output_path)
518
+ self.exclude = exclude
519
+ self.lib_dir = lib_dir
520
+
521
+ def _search_dirs(self) -> List[Path]:
522
+ if self.lib_dir is not None:
523
+ return [self.lib_dir]
524
+ dirs = [
525
+ self.input_path.parent,
526
+ Path("/lib"), Path("/usr/lib"), Path("/lib64"), Path("/usr/lib64"),
527
+ ]
528
+ # Debian/Ubuntu-style "multiarch" layout: real shared objects live
529
+ # in an architecture-qualified subdirectory (e.g. the real
530
+ # libc.so.6 at /usr/lib/x86_64-linux-gnu/libc.so.6), never directly
531
+ # under /usr/lib itself -- discovered against real system binaries
532
+ # (Task 10b): without this, lib_dir=None (every resolvable import
533
+ # is a candidate, per this method's own docstring) silently
534
+ # resolved *zero* real dependencies on any such system, even
535
+ # libc.so.6, making the whole lib_dir=None/"full closure" path
536
+ # dead code there. /lib is a symlink to /usr/lib on these systems,
537
+ # so checking both costs nothing and also covers non-symlinked
538
+ # layouts (e.g. a system where only one of the two is qualified).
539
+ #
540
+ # Deliberately only the two *exact* triplets this module's own
541
+ # `_SUPPORTED_MACHINES` ever allows (EM_X86_64/EM_386) -- NOT a
542
+ # wildcard glob over every "*-linux-gnu*" directory present. An
543
+ # earlier version of this fix used such a glob and broke for
544
+ # real on a host that also had a foreign-architecture multiarch
545
+ # directory installed (e.g. aarch64-linux-gnu, from an unrelated
546
+ # cross-compilation/container toolchain already on the system):
547
+ # resolve_elf's own plain by-name file search has no architecture
548
+ # awareness at all, so a same-named file (e.g. "libc.so.6") in
549
+ # the *wrong*-architecture directory could get resolved first
550
+ # purely by directory sort order, pulling in a whole
551
+ # foreign-architecture dependency subtree that then failed this
552
+ # method's own (correct) architecture-mismatch check several
553
+ # steps later -- confirmed for real against a real multi-
554
+ # toolchain WSL box while building this fix. Hardcoding exactly
555
+ # the two supported triplets sidesteps that regardless of
556
+ # whatever *other* architectures' multiarch directories happen
557
+ # to also be installed.
558
+ #
559
+ # Moderate final-review finding: an earlier version of this
560
+ # method always listed "x86_64-linux-gnu" before
561
+ # "i386-linux-gnu" unconditionally, regardless of the HOST's own
562
+ # bitness -- resolve_elf's by-name search (see its own docstring)
563
+ # has no architecture awareness either, so for a genuinely
564
+ # 32-bit host/full-closure embed, a same-named file (e.g.
565
+ # "libc.so.6") sitting in the wrong-architecture (64-bit)
566
+ # multiarch directory would get resolved FIRST, pulled into the
567
+ # dependency graph, and only THEN rejected by this method's own
568
+ # (correct) architecture-mismatch check -- making 32-bit
569
+ # lib_dir=None/"full closure" embedding unusable in practice
570
+ # whenever both multiarch directories happen to carry a
571
+ # same-named file (the ordinary case on any real multiarch-
572
+ # enabled distro). Peeking at the host's own ELFCLASS (a single
573
+ # byte, e_ident[EI_CLASS] at a fixed offset -- no need for a full
574
+ # ELF parse just to pick a search order) and listing the
575
+ # matching-bitness triplet first fixes this: a 32-bit host now
576
+ # finds its own real i386-linux-gnu libc.so.6 before the
577
+ # wrong-architecture x86_64-linux-gnu one could ever shadow it.
578
+ host_is64 = _peek_elfclass64(self.input_path)
579
+ triplets = ("x86_64-linux-gnu", "i386-linux-gnu") if host_is64 else ("i386-linux-gnu", "x86_64-linux-gnu")
580
+ for triplet in triplets:
581
+ for base in (Path("/usr/lib"), Path("/lib")):
582
+ candidate = base / triplet
583
+ if candidate.is_dir():
584
+ dirs.append(candidate)
585
+ return dirs
586
+
587
+ def embed(self) -> None:
588
+ """Embed every embeddable dependency (per Task 1's dependency
589
+ graph) into the host, reloc-bearing (PIC) path only.
590
+
591
+ Mechanism, per dependency (in dependencies-first order):
592
+ 1. Copy its mapped image (every PT_LOAD segment, laid out at its
593
+ own vaddr-relative offset) into a new blob at a freshly chosen
594
+ vaddr `X`.
595
+ 2. Apply the dependency's own .rela.dyn/.rel.dyn relocations
596
+ against that new placement (delta = X - dep_base, where
597
+ dep_base is the lowest vaddr among the dependency's own
598
+ PT_LOAD segments -- the ELF analogue of PE's ImageBase, except
599
+ ELF vaddrs are already absolute-in-their-own-space, with no
600
+ separate base field to add back in).
601
+ 3. For each of the host's relocations against this dependency's
602
+ exported symbols (GOT/PLT slots -- R_*_JUMP_SLOT/R_*_GLOB_DAT
603
+ entries, whichever the compiler used), resolve the matching
604
+ export and patch the slot in place with the resolved absolute
605
+ address, exactly as eager (`-z now`) binding would; record
606
+ that slot (and every relocation applied in step 2) as a *new*
607
+ host R_*_RELATIVE entry, so the whole thing stays correct if
608
+ the final image is itself relocated at load time (ASLR).
609
+ Neutralize (zero out -- R_*_NONE) the original relocation
610
+ entry in place, so ld.so never tries to re-resolve a symbol
611
+ that no longer has a provider.
612
+ 4. Convert the dependency's DT_NEEDED entry to a harmless
613
+ DT_DEBUG entry in place (see _remove_needed).
614
+
615
+ Deliberately NOT done: calling the dependency's own DT_INIT/
616
+ DT_INIT_ARRAY entries (the ELF analogue of PE's DllMain(...,
617
+ DLL_PROCESS_ATTACH, ...) call, which pe_embedder's docstring lists
618
+ as its own step 6). An earlier version of this method did exactly
619
+ that -- collect those function pointers (already relocated to
620
+ their new addresses, since init_array slots are themselves
621
+ R_*_RELATIVE targets) and call them from a generated stub
622
+ installed as the new e_entry, mirroring pe_embedder's approach.
623
+ Verified by actually building and running the embedded output on
624
+ real Linux (not just reasoning about it): on x86 (EM_386), this
625
+ reliably segfaults. crtstuff's own init_array entries
626
+ (`frame_dummy`/`register_tm_clones`, injected into *every* GCC-
627
+ built shared object regardless of user code -- the same
628
+ toolchain-boilerplate pattern as the weak GLOB_DAT symbols
629
+ documented below) are compiled as 32-bit PIC code, which computes
630
+ its own GOT-relative addressing via a `call __x86.get_pc_thunk.bx`
631
+ / `add ebx, <offset>` sequence at entry; calling them "cold" from
632
+ a plain `mov eax, imm32; call eax` (no attempt to establish
633
+ whatever calling-convention precondition that sequence depends
634
+ on) corrupts something, with a concrete observed crash signature
635
+ of `SIGSEGV, error 6 (write, not-present), si_addr=1` a few
636
+ instructions into glibc's own post-relocation startup path. x86-64
637
+ (EM_X86_64) uses RIP-relative addressing instead and happened not
638
+ to crash in testing, but calling crtstuff-internal functions
639
+ directly is risky enough on its own merits (undocumented
640
+ preconditions, no argc/argv/envp despite glibc's own _dl_init
641
+ passing them by convention -- see glibc's elf/dl-init.c) that
642
+ shipping it on the strength of "didn't crash on one machine" would
643
+ just be trading a *known* bug for an *unknown* one. The task
644
+ brief's own instructions for this method don't actually list an
645
+ entrypoint stub as a required step (unlike Task 3's PE brief,
646
+ which explicitly does) -- so, given it's proven actively unsafe as
647
+ a naive port of PE's approach, it's left for Task 8 to design
648
+ properly (which will need to either establish the x86-32 PIC
649
+ calling convention precondition correctly, or find a safer hook
650
+ point than "call directly from a synthetic entrypoint stub").
651
+ e_entry is therefore never touched by this method.
652
+
653
+ Simplifications for this task (explicitly deferred -- see Task 8):
654
+ - The dependency's *own* further external requirements aren't
655
+ supported: if it has any PLT/JMPREL relocations at all, or any
656
+ RELA/REL entry referencing a symbol that's undefined within
657
+ itself, embedding fails loudly rather than silently leaving an
658
+ unresolvable reference in the output. Unlike PE's import-table
659
+ merge (step 5 in pe_embedder's docstring), nothing here merges
660
+ a dependency's remaining imports into the host's own dynamic
661
+ section -- the brief for this task doesn't ask for it, and the
662
+ fixtures this task is scoped to (single-function .so's with no
663
+ further library needs) never exercise it.
664
+ - Unlike pe_embedder, this doesn't treat "the dependency has no
665
+ relocation data at all" as the fixed-base/non-PIC signal: in
666
+ PE, a DLL with no base-relocation directory at all reliably
667
+ means a fixed-base build. In ELF, a *simple* PIC .so with no
668
+ absolute self-pointers (e.g. this task's own `lib.c` fixture)
669
+ just as reliably produces an *empty* .rela.dyn -- that's the
670
+ normal, fully-position-independent case, not a warning sign.
671
+ The actual fixed-base signal this method keys off instead is
672
+ the dependency's own `e_type` (see the base-address-reservation
673
+ comment further down, by `fixed_base_segments`, for the full
674
+ mechanism and its one real limitation).
675
+ - step 3's new-relocation-entries go into a brand new, bigger
676
+ REL(A) table at a new location (growing the existing one in
677
+ place isn't generally safe -- same reasoning as pe_embedder's
678
+ import-directory rebuild); the original entries are preserved
679
+ byte-for-byte as that new table's prefix.
680
+ - The host's extended PT_LOAD segment (dependency blobs +
681
+ combined relocation table, all appended as a single in-place
682
+ extension of it -- see below) has RWX ORed into its existing
683
+ flags, mirroring pe_embedder's blanket `_SCN_CODE_RWX` section
684
+ characteristics -- a real W^X split is out of scope for this
685
+ placeholder.
686
+ """
687
+ search_dirs = self._search_dirs()
688
+ graph = build_dependency_graph(
689
+ self.input_path, list_elf_imports, resolve_elf, search_dirs, self.lib_dir, self.exclude
690
+ )
691
+ order = embed_order(graph)
692
+ # Names the *host* itself directly lists in its own DT_NEEDED --
693
+ # as opposed to a node that's only reachable *transitively*
694
+ # (needed by some other embedded dependency, not by the host).
695
+ # Only a direct name has anything to neutralize in the host's own
696
+ # dynamic section at all; see its one use, further down, for why
697
+ # treating every node in `order` as if it had one is wrong.
698
+ host_direct_import_names = set(list_elf_imports(self.input_path))
699
+
700
+ if not order:
701
+ self.output_path.write_bytes(self.input_path.read_bytes())
702
+ shutil.copymode(self.input_path, self.output_path)
703
+ return
704
+
705
+ raw = bytearray(self.input_path.read_bytes())
706
+ # A read-only snapshot of the *original* bytes: every vaddr/offset
707
+ # we look up via `elf` below stays valid against `raw` for the rest
708
+ # of this method, since we only ever append to `raw` or edit
709
+ # existing bytes in place -- never move or delete anything.
710
+ elf = ELFFile(io.BytesIO(bytes(raw)))
711
+
712
+ host_machine = elf["e_machine"]
713
+ if host_machine not in _SUPPORTED_MACHINES:
714
+ raise ValueError(
715
+ f"unsupported host architecture {host_machine!r}; only "
716
+ f"{_SUPPORTED_MACHINES} are supported"
717
+ )
718
+ is64 = elf.elfclass == 64
719
+ ptr_size = 8 if is64 else 4
720
+
721
+ host_dyn = _dynamic_segment(elf)
722
+ if host_dyn is None:
723
+ raise ValueError(f"'{self.input_path}' has no PT_DYNAMIC segment to embed into")
724
+
725
+ host_load_segments = list(elf.iter_segments(type="PT_LOAD"))
726
+ if not host_load_segments:
727
+ raise ValueError(f"'{self.input_path}' has no PT_LOAD segments")
728
+
729
+ host_reloc_tables = host_dyn.get_relocation_tables()
730
+ dyn_table_key = "RELA" if "RELA" in host_reloc_tables else ("REL" if "REL" in host_reloc_tables else None)
731
+ if dyn_table_key is None:
732
+ raise ValueError(
733
+ f"'{self.input_path}' has no DT_RELA/DT_REL table to extend; embedding "
734
+ "into a host with no dynamic-relocation table isn't supported"
735
+ )
736
+ dyn_tag_name = "DT_RELA" if dyn_table_key == "RELA" else "DT_REL"
737
+ dyn_sz_tag_name = "DT_RELASZ" if dyn_table_key == "RELA" else "DT_RELSZ"
738
+ dyn_table = host_reloc_tables[dyn_table_key]
739
+ dyn_is_rela = dyn_table.is_RELA()
740
+ ptr_mask = (1 << (ptr_size * 8)) - 1
741
+
742
+ def _host_dyn_tag_val(tag_name: str) -> Optional[int]:
743
+ tag = next(host_dyn.iter_tags(tag_name), None)
744
+ return tag.entry.d_val if tag is not None else None
745
+
746
+ # Needed only if some embedded dependency turns out to have a
747
+ # remaining external symbol reference to forward (see
748
+ # `external_symbol_index`, below) -- read unconditionally here
749
+ # since they're cheap dynamic-tag lookups, not real work.
750
+ host_symtab_count = _real_symtab_count(host_dyn, host_reloc_tables)
751
+ host_symtab_vaddr = _host_dyn_tag_val("DT_SYMTAB")
752
+ host_strtab_vaddr = _host_dyn_tag_val("DT_STRTAB")
753
+ host_strsz = _host_dyn_tag_val("DT_STRSZ")
754
+ host_versym_vaddr = _host_dyn_tag_val("DT_VERSYM")
755
+
756
+ def vaddr_to_offset(vaddr: int, size: int) -> int:
757
+ off = next(elf.address_offsets(vaddr, size), None)
758
+ if off is None:
759
+ raise ValueError(
760
+ f"address {hex(vaddr)} isn't covered by any PT_LOAD segment of '{self.input_path}'"
761
+ )
762
+ return off
763
+
764
+ new_sections: List[Tuple[int, bytes]] = [] # (vaddr, data), contiguous in placement order
765
+ new_reloc_entries: List[Tuple[int, int]] = [] # (host vaddr, value) -> new R_*_RELATIVE entries
766
+ combined_exports: Dict[str, Tuple[int, int, int]] = {} # export name -> (X, offset-within-dep, st_size)
767
+ # Each embedded dependency's own blob bytes, keyed by its own `X`
768
+ # (the same key `combined_exports` values' first element uses) --
769
+ # needed only for a host R_*_COPY relocation's data (see the
770
+ # R_COPY handling in the patch_specs loop, further down): at that
771
+ # point `raw` doesn't contain any embedded dependency's bytes yet
772
+ # (everything in `new_sections`/`fixed_base_segments` is only
773
+ # actually appended into `raw` by the "lay everything out" block,
774
+ # well after this), so a COPY relocation's source bytes have to
775
+ # come from here instead.
776
+ dep_blob_by_base: Dict[int, bytes] = {}
777
+ # (host vaddr, bytes-to-write) pairs for R_*_COPY relocations (see
778
+ # the R_COPY handling in the patch_specs loop) -- writing has to
779
+ # be deferred until after the "lay everything out" block, further
780
+ # down: a COPY relocation's target is frequently inside the
781
+ # host's own *BSS* (confirmed for real against bash/libtinfo.so.6
782
+ # on real Linux, WSL -- its `UP` symbol lands there), which has no
783
+ # file bytes at all yet at the point the patch_specs loop runs
784
+ # (the host's own `ext` PT_LOAD segment's filesz only grows to
785
+ # cover its full memsz -- backfilling that BSS gap with explicit
786
+ # zero bytes -- once "lay everything out" actually extends `raw`;
787
+ # see `ext_skew`'s own declaration, and this list's one other use
788
+ # further down, for exactly where/why).
789
+ deferred_copy_writes: List[Tuple[int, bytes]] = []
790
+ # A symbol an embedded dependency itself still references
791
+ # externally (through its own, now-dead PT_DYNAMIC/PLT) that no
792
+ # embedded dependency's own exports satisfy -- e.g. `malloc`,
793
+ # `__cxa_throw` (C++ exceptions always need at least the former,
794
+ # even in the simplest possible throw/catch: __cxa_allocate_
795
+ # exception calls it internally -- there's no way to support
796
+ # unwind-metadata preservation's own flagship scenario, an
797
+ # exception crossing the embedding boundary, without this).
798
+ # Forwarded into the host's own dynamic section (new DT_SYMTAB/
799
+ # DT_STRTAB/DT_VERSYM entries, further down, past the per-
800
+ # dependency loop) as a brand new, genuinely-external,
801
+ # R_*_GLOB_DAT-resolved import -- exactly as if the *host* had
802
+ # referenced it directly -- rather than something this module
803
+ # tries to resolve itself (unlike `combined_exports`, there's no
804
+ # static address to resolve here at all: the real provider, e.g.
805
+ # libc, is loaded and relocated by ld.so at a load-time-chosen
806
+ # address this module can't know). Keyed by name; value is this
807
+ # symbol's index in the *grown* host .dynsym (assigned in
808
+ # insertion order, starting right after the host's existing
809
+ # entries -- see where it's grown, further down).
810
+ external_symbol_index: Dict[str, int] = {}
811
+ # Whether every occurrence seen so far of a given forwarded
812
+ # name was itself a *weak* undefined reference (STB_WEAK) --
813
+ # if ANY occurrence is a strong (STB_GLOBAL) reference, the
814
+ # forwarded symbol must behave like a strong one (ld.so must
815
+ # raise if nothing resolves it), so this is ANDed across every
816
+ # occurrence rather than just keyed off the first one seen. See
817
+ # _pack_undef_sym's own docstring for why this distinction is
818
+ # preserved at all, instead of hard-zeroing every weak-undefined
819
+ # reference the way an earlier version of this method did.
820
+ external_symbol_is_weak: Dict[str, bool] = {}
821
+ new_glob_dat_entries: List[Tuple[int, int]] = [] # (slot vaddr, symbol index) -> new R_*_GLOB_DAT entries
822
+ # (initial_loc, fde_addr) pairs, absolute vaddrs already shifted
823
+ # by each dependency's own delta -- collected so the host's own
824
+ # .eh_frame_hdr binary-search table can be grown to cover them
825
+ # too (see the merge block past the per-dependency loop, and
826
+ # ElfEmbedder.embed()'s docstring, for why nothing here ever
827
+ # needs to touch the FDE/CIE bytes themselves).
828
+ collected_eh_entries: List[Tuple[int, int]] = []
829
+ # Resolved absolute VAs of every embedded dependency's own
830
+ # DT_INIT_ARRAY entries (its .init_array constructors -- already
831
+ # relocated to their final addresses by the time they're read,
832
+ # since .init_array slots are themselves normal R_*_RELATIVE
833
+ # targets processed like any other), flattened in
834
+ # dependencies-first order, for the generated stub to call.
835
+
836
+ init_array_calls: List[int] = []
837
+
838
+ # -- base-address reservation for fixed-base (non-PIC) dependencies --
839
+ # A dependency whose own ELF header says ET_EXEC (not ET_DYN) was
840
+ # built assuming it will *always* load at exactly its own PT_LOAD
841
+ # segments' own p_vaddr -- unlike a PIC .so (ET_DYN), which is
842
+ # designed to tolerate loading at any delta and records every
843
+ # absolute self-pointer that needs fixing up for that delta in its
844
+ # own .rela.dyn/.rel.dyn, a fixed-base dependency's code may contain
845
+ # absolute address references baked directly into its instructions
846
+ # with *no* corresponding relocation entry at all (real-world
847
+ # example, confirmed against this project's own toolchain: an
848
+ # internal-data access compiled with `-fno-pic` for a plain ET_EXEC
849
+ # target produces a bare `mov 0x<absolute addr>, %eax`-style
850
+ # instruction with zero relocation tracking, whereas the *same*
851
+ # access compiled into a `-shared` object -- PIC or not -- always
852
+ # gets at least an R_*_RELATIVE entry, because any `.so` must
853
+ # support loading away from its preferred base, and the linker
854
+ # arranges for that regardless of the `-f{,no-}pic` codegen flag).
855
+ # This is the ELF analogue of why a PE DLL with no base-relocation
856
+ # directory can only be loaded at exactly its declared ImageBase:
857
+ # there's no delta the loader could apply to such a dependency's
858
+ # untracked absolute references and have them stay correct, so the
859
+ # only place such a dependency can go is its own already-fixed
860
+ # address.
861
+ #
862
+ # So, for these nodes, `X` below is forced to the dependency's own
863
+ # `dep_base` (delta = X - dep_base = 0, a no-op for whatever
864
+ # relocations it *does* still have) rather than freshly allocated
865
+ # from `next_vaddr`, and that fixed range has to be reserved up
866
+ # front against both the host's own existing PT_LOAD ranges and
867
+ # every other such dependency's own reservation -- mirroring
868
+ # pe_embedder's same-named mechanism for a reloc-table-less DLL.
869
+ # Because that fixed address generally has nothing to do with where
870
+ # the host's own content ends, it can't just extend `ext` like
871
+ # everything else in this method does (see `fixed_base_segments`'s
872
+ # use, further down, for the brand-new-PT_LOAD-segment mechanism
873
+ # that needs instead, and why growing the program header array to
874
+ # add it is still AT_PHDR-safe).
875
+ #
876
+ # Soundness note (also true of pe_embedder's mechanism, and not
877
+ # solved here -- doesn't seem to be solvable at all without a way
878
+ # to enumerate a dependency's *untracked* absolute references,
879
+ # which by construction there's no record of): this only stays
880
+ # correct if the final combined image itself never gets relocated
881
+ # as a whole at load time (e.g. ASLR). If the host is non-PIE
882
+ # (ET_EXEC itself -- the realistic pairing for a genuinely
883
+ # fixed-base dependency, and what this task's own tests use), the
884
+ # kernel always maps every PT_LOAD segment at exactly its file
885
+ # p_vaddr and this is completely sound; if the host is PIE, the
886
+ # whole image (including whatever's reserved here) moves together
887
+ # under ASLR and a fixed-base dependency's untracked references
888
+ # silently go stale by the same amount the rest of the image moved.
889
+ fixed_base_segments: List[Tuple[int, bytes]] = [] # (X, blob) -- each gets its own new PT_LOAD entry
890
+ reserved_ranges: List[Tuple[int, int, str]] = [
891
+ (seg["p_vaddr"], seg["p_vaddr"] + seg["p_memsz"], f"the host image ('{self.input_path}')")
892
+ for seg in host_load_segments
893
+ ]
894
+
895
+ # Every newly-built blob/reloc-table is appended as an
896
+ # *extension* of the host's own highest-vaddr PT_LOAD segment,
897
+ # rather than as a new PT_LOAD segment of its own -- see the large
898
+ # comment further down, right before this same `ext`/`ext_idx` pair
899
+ # is used to actually grow it, for why a brand new segment (with a
900
+ # freshly chosen vaddr/offset pair, with no particular reason to
901
+ # share whatever segment-specific skew AT_PHDR's derivation
902
+ # actually keys off) risks landing AT_PHDR on a bogus address.
903
+ all_host_segments = [elf.get_segment(i) for i in range(elf.num_segments())]
904
+ host_load_indices = [i for i, s in enumerate(all_host_segments) if s["p_type"] == "PT_LOAD"]
905
+ ext_idx = max(host_load_indices, key=lambda i: all_host_segments[i]["p_vaddr"] + all_host_segments[i]["p_memsz"])
906
+ ext = all_host_segments[ext_idx]
907
+ max_file_end = max(s["p_offset"] + s["p_filesz"] for s in all_host_segments)
908
+ if ext["p_offset"] + ext["p_filesz"] != max_file_end:
909
+ raise ValueError(
910
+ f"'{self.input_path}': its highest-vaddr PT_LOAD segment isn't also "
911
+ "the last one in the file; extending it in place isn't supported"
912
+ )
913
+ ext_align = ext["p_align"] if ext["p_align"] >= _PAGE else _PAGE
914
+ # The skew this segment's new (extended) content must keep -- any
915
+ # vaddr V within it is at file offset V - ext_skew, and vice versa.
916
+ ext_skew = ext["p_vaddr"] - ext["p_offset"]
917
+ # New content must start at a vaddr that (a) doesn't collide with
918
+ # `ext`'s own existing memsz, and (b) whose corresponding file
919
+ # offset (vaddr - ext_skew) is past the *actual* end of the file
920
+ # -- which, for any ordinary unstripped binary, extends further
921
+ # than `ext`'s own rounded-up memsz: .comment/.symtab/.strtab/
922
+ # .shstrtab/the section header table are normal trailing content
923
+ # that no PT_* segment covers at all (the check above only
924
+ # confirms `ext` is the last *segment-covered* content, which
925
+ # isn't the same as the last *byte in the file*). Taking the max
926
+ # of both lower bounds before rounding -- rather than assuming
927
+ # `ext`'s own page-rounding slack always happens to cover
928
+ # whatever trailing content exists -- means this never
929
+ # spuriously rejects a completely normal binary just because its
930
+ # one page of rounding slack happens to be smaller than its
931
+ # trailing symbol-table/debug-info content.
932
+ min_vaddr_for_memsz = ext["p_vaddr"] + ext["p_memsz"]
933
+ min_vaddr_for_eof = len(raw) + ext_skew
934
+ next_vaddr = _align_up(max(min_vaddr_for_memsz, min_vaddr_for_eof), ext_align)
935
+
936
+ # The host's own PT_DYNAMIC entry -- needed only if some embedded
937
+ # dependency turns out to have its own further, non-embedded
938
+ # DT_NEEDED import to forward into the host's own dynamic section
939
+ # (see the B2-fix block past the per-dependency loop, by
940
+ # `extra_needed_names`). Always exists (host_dyn above is None-
941
+ # checked already), found the same way as `ext_idx`/`phdr_idx`.
942
+ host_dynamic_idx = next(
943
+ i for i, s in enumerate(all_host_segments) if s["p_type"] == "PT_DYNAMIC"
944
+ )
945
+
946
+ # The host's own PT_GNU_EH_FRAME entry (if any), found the same
947
+ # way as `ext_idx`/`phdr_idx` -- needed only if some embedded
948
+ # dependency turns out to have unwind info to merge (see the
949
+ # merge block past the per-dependency loop).
950
+ host_gnu_eh_frame_idx = next(
951
+ (i for i, s in enumerate(all_host_segments) if s["p_type"] == "PT_GNU_EH_FRAME"), None
952
+ )
953
+
954
+ # Every symbol name the HOST's own dynamic relocation table already
955
+ # has an R_*_COPY entry for, mapped to that entry's own target
956
+ # vaddr -- pre-scanned up front, before any dependency is
957
+ # processed, because of what an R_*_COPY relocation actually
958
+ # *means* per the ELF ABI: once ld.so processes it, the copied-to
959
+ # address in the *executable* becomes THE one true storage for
960
+ # that global, and every other module's own access to it --
961
+ # including the *defining* shared object's own internal GOT-
962
+ # indirect accesses to its own global, exactly the same
963
+ # same-module-but-interposable pattern this method's own
964
+ # `_resolve_dep_reference` docstring already discusses for a
965
+ # plain exported mutable data symbol -- has to be redirected
966
+ # there too, not left pointing at the shared object's own
967
+ # original (now dead) storage. Confirmed for real (a `bash`/
968
+ # `libtinfo.so.6` embed on real Linux, WSL, crashing deep inside
969
+ # bash's own `parse_and_execute` with a garbage pointer) before
970
+ # this fix: libtinfo's own internal reference to its "UP" global
971
+ # (copy-relocated into bash itself, since old-style termcap
972
+ # globals like UP/PC/BC are always accessed this way) was being
973
+ # resolved to libtinfo's own embedded-blob-local copy instead,
974
+ # silently diverging from the value anything else (bash itself,
975
+ # or libtinfo's own tgetent()-equivalent setup code) reads/writes
976
+ # through the host's copy -- needed *before* the per-dependency
977
+ # loop below so a dependency's own self-reference to such a
978
+ # symbol (processed inline, during that very loop) already
979
+ # redirects correctly, not just cross-dependency references
980
+ # resolved via `combined_exports`.
981
+ host_copy_targets: Dict[str, int] = {}
982
+ for copy_reloc in host_reloc_tables[dyn_table_key].iter_relocations():
983
+ if copy_reloc["r_info_type"] != R_COPY:
984
+ continue
985
+ copy_sym = host_dyn.get_symbol(copy_reloc["r_info_sym"])
986
+ if copy_sym.name:
987
+ host_copy_targets[copy_sym.name] = copy_reloc["r_offset"]
988
+
989
+ def _resolve_dep_reference(sym, blob: bytearray, blob_offset: int, X: int, dep_base: int,
990
+ node_name: str, addend: int) -> None:
991
+ """Resolve a dependency's own reference to `sym` (anything
992
+ that isn't a plain load-bias-only R_*_RELATIVE entry, which
993
+ callers handle inline themselves without needing this) and
994
+ record whatever new *host*-level relocation entry keeps it
995
+ correct, exactly mirroring how the embedded dependency's own
996
+ relocation would have been processed had it still been
997
+ independently loaded:
998
+ - a self-reference, or a reference to an *earlier*
999
+ dependency's own export (dependencies-first order means
1000
+ those are already in `combined_exports` by the time a
1001
+ later dependency is processed) -- resolve directly, same
1002
+ as step 3's host GOT/PLT patch further down (a new
1003
+ R_*_RELATIVE entry).
1004
+ - a *named*, still-unresolved reference (weak or strong)
1005
+ -- forward it into the host's own dynamic section (see
1006
+ `external_symbol_index`'s own declaration for why; a new
1007
+ R_*_GLOB_DAT entry, not RELATIVE, since the real address
1008
+ is only known at load time, via ld.so's own by-name
1009
+ lookup, not something this module resolves itself),
1010
+ preserving its original STB_WEAK/STB_GLOBAL binding (see
1011
+ `_pack_undef_sym`) so ld.so's own real weak-symbol
1012
+ semantics apply -- a weak reference that's genuinely
1013
+ unresolved anywhere else, at the real process's load
1014
+ time, stays silently 0 exactly as it would have if this
1015
+ dependency had never been embedded, while a strong one
1016
+ still raises a fatal "undefined symbol" error.
1017
+ - an *unnamed* weak reference -- can't be forwarded (no
1018
+ name to look up by), so it's zeroed directly, exactly as
1019
+ ld.so itself would resolve a weak reference with nothing
1020
+ defining it.
1021
+ - an unnamed STRONG reference -- fails loudly; nothing
1022
+ can resolve it and there's no name to even try."""
1023
+ if sym.name and sym.name in host_copy_targets:
1024
+ # Takes priority over everything else below, including a
1025
+ # same-dependency self-reference to its own defined
1026
+ # symbol (the `new_val = X + ...` fallback at the bottom)
1027
+ # -- see `host_copy_targets`'s own declaration for why.
1028
+ new_val = (host_copy_targets[sym.name] + addend) & ptr_mask
1029
+ _write_int(blob, blob_offset, ptr_size, new_val)
1030
+ new_reloc_entries.append((X + blob_offset, new_val))
1031
+ return
1032
+ if sym.name and sym.name in combined_exports:
1033
+ exp_X, exp_off, _ = combined_exports[sym.name]
1034
+ new_val = (exp_X + exp_off + addend) & ptr_mask
1035
+ _write_int(blob, blob_offset, ptr_size, new_val)
1036
+ new_reloc_entries.append((X + blob_offset, new_val))
1037
+ return
1038
+ if sym["st_shndx"] == "SHN_UNDEF":
1039
+ is_weak = sym["st_info"]["bind"] == "STB_WEAK"
1040
+ if not sym.name:
1041
+ if is_weak:
1042
+ _write_int(blob, blob_offset, ptr_size, 0)
1043
+ return
1044
+ raise ValueError(
1045
+ f"dependency '{node_name}' has an unresolved, unnamed external "
1046
+ "symbol reference; can't forward it to the host"
1047
+ )
1048
+ if sym.name not in external_symbol_index:
1049
+ external_symbol_index[sym.name] = host_symtab_count + len(external_symbol_index)
1050
+ external_symbol_is_weak[sym.name] = is_weak
1051
+ else:
1052
+ external_symbol_is_weak[sym.name] = external_symbol_is_weak[sym.name] and is_weak
1053
+ _write_int(blob, blob_offset, ptr_size, 0)
1054
+ new_glob_dat_entries.append((X + blob_offset, external_symbol_index[sym.name]))
1055
+ return
1056
+ new_val = (X + (sym["st_value"] - dep_base) + addend) & ptr_mask
1057
+ _write_int(blob, blob_offset, ptr_size, new_val)
1058
+ new_reloc_entries.append((X + blob_offset, new_val))
1059
+
1060
+ # -- per-dependency: copy, relocate, and remove its DT_NEEDED -----
1061
+ for node in order:
1062
+ with open(node.path, "rb") as depf:
1063
+ dep = ELFFile(depf)
1064
+ if dep["e_machine"] != host_machine:
1065
+ raise ValueError(
1066
+ f"architecture mismatch embedding '{node.name}': host "
1067
+ f"'{self.input_path}' is {host_machine}, dependency "
1068
+ f"'{node.path}' is {dep['e_machine']}"
1069
+ )
1070
+
1071
+ dep_load_segments = list(dep.iter_segments(type="PT_LOAD"))
1072
+ if not dep_load_segments:
1073
+ raise ValueError(f"dependency '{node.name}' has no PT_LOAD segments")
1074
+ dep_base = min(seg["p_vaddr"] for seg in dep_load_segments)
1075
+ blob_size = _align_up(
1076
+ max(seg["p_vaddr"] + seg["p_memsz"] for seg in dep_load_segments) - dep_base, _PAGE
1077
+ )
1078
+ # Minor final-review finding: p_memsz is an attacker-
1079
+ # controlled field (it's read directly from a dependency
1080
+ # file this module doesn't otherwise trust) with no
1081
+ # inherent upper bound of its own -- a crafted/malformed
1082
+ # dependency could declare a multi-exabyte p_memsz,
1083
+ # turning `bytearray(blob_size)` just below into an
1084
+ # unbounded-allocation DoS before any other validation
1085
+ # ever runs. Real shared objects never need anywhere
1086
+ # close to this; failing loudly on an obviously-absurd
1087
+ # size (comfortably above any legitimate real-world .so,
1088
+ # by many orders of magnitude) costs nothing for a
1089
+ # genuine dependency and avoids attempting the
1090
+ # allocation at all for an adversarial one.
1091
+ _MAX_SANE_BLOB_SIZE = 1 << 34 # 16 GiB
1092
+ if blob_size > _MAX_SANE_BLOB_SIZE:
1093
+ raise ValueError(
1094
+ f"dependency '{node.name}' declares an implausibly large mapped "
1095
+ f"size ({blob_size} bytes); refusing to allocate it"
1096
+ )
1097
+ blob = bytearray(blob_size)
1098
+ for seg in dep_load_segments:
1099
+ data = seg.data()
1100
+ start = seg["p_vaddr"] - dep_base
1101
+ blob[start:start + len(data)] = data
1102
+
1103
+ is_fixed_base = dep["e_type"] == "ET_EXEC"
1104
+ if is_fixed_base and elf["e_type"] == "ET_DYN":
1105
+ # Moderate final-review finding: a fixed-base
1106
+ # dependency's own untracked absolute references
1107
+ # (see the big comment above, by `fixed_base_segments`,
1108
+ # for exactly what those are and why) only stay
1109
+ # correct if the final combined image's load address
1110
+ # never shifts as a whole -- true for a non-PIE
1111
+ # (ET_EXEC) host (the kernel always maps every PT_LOAD
1112
+ # segment at exactly its declared p_vaddr), but NOT for
1113
+ # an ET_DYN host (a PIE executable, or this method's
1114
+ # own B6-ELF .so-input case): under ASLR, the whole
1115
+ # image -- including the fixed-base dependency's own
1116
+ # untracked references -- moves together by the same
1117
+ # load bias, silently going stale by exactly that
1118
+ # amount. Unlike PE (pe_embedder.py can clear
1119
+ # DYNAMIC_BASE/HIGH_ENTROPY_VA on its own output to
1120
+ # neutralize this for a fixed-base DLL dependency),
1121
+ # ELF has no per-image "disable ASLR" flag this module
1122
+ # can set on its own output -- PIE-ness is baked into
1123
+ # e_type/the kernel's own ASLR policy, not a flag this
1124
+ # module could flip afterward. Rather than silently
1125
+ # producing an output that corrupts itself the moment
1126
+ # ASLR picks a non-zero load address (the exact
1127
+ # silent-corruption case the design spec's anti-goal
1128
+ # forbids), fail loudly here instead.
1129
+ raise ValueError(
1130
+ f"dependency '{node.name}' is a fixed-base (non-PIC) ELF object, but "
1131
+ f"'{self.input_path}' is a PIE/ET_DYN image; a fixed-base dependency's "
1132
+ "own untracked absolute references would silently go stale under ASLR "
1133
+ "once the combined image's load address shifts -- embedding a fixed-base "
1134
+ "dependency is only supported into a non-PIE (ET_EXEC) host"
1135
+ )
1136
+ if is_fixed_base:
1137
+ X = dep_base
1138
+ lo, hi = X, X + blob_size
1139
+ for r_lo, r_hi, label in reserved_ranges:
1140
+ if lo < r_hi and r_lo < hi:
1141
+ raise ValueError(
1142
+ f"base address conflict at {hex(max(lo, r_lo))} between "
1143
+ f"dependency '{node.name}' (fixed base {hex(X)}) and {label}"
1144
+ )
1145
+ reserved_ranges.append((lo, hi, f"dependency '{node.name}' (fixed base {hex(X)})"))
1146
+ else:
1147
+ X = next_vaddr
1148
+ delta = X - dep_base
1149
+
1150
+ dep_dyn = _dynamic_segment(dep)
1151
+ if dep_dyn is None:
1152
+ raise ValueError(f"dependency '{node.name}' has no PT_DYNAMIC segment")
1153
+
1154
+ dep_reloc_tables = dep_dyn.get_relocation_tables()
1155
+
1156
+ for key in ("RELA", "REL"):
1157
+ table = dep_reloc_tables.get(key)
1158
+ if table is None:
1159
+ continue
1160
+ is_rela = table.is_RELA()
1161
+ for reloc in table.iter_relocations():
1162
+ rtype = reloc["r_info_type"]
1163
+ if rtype == R_NONE:
1164
+ continue
1165
+ target_vaddr = reloc["r_offset"]
1166
+ blob_offset = target_vaddr - dep_base
1167
+ if blob_offset < 0 or blob_offset + ptr_size > blob_size:
1168
+ raise ValueError(
1169
+ f"relocation target {hex(target_vaddr)} in dependency "
1170
+ f"'{node.name}' falls outside its own PT_LOAD segments"
1171
+ )
1172
+ if rtype == R_RELATIVE:
1173
+ base_value = (
1174
+ reloc["r_addend"] if is_rela else _read_int(blob, blob_offset, ptr_size)
1175
+ )
1176
+ new_val = (base_value + delta) & ptr_mask
1177
+ _write_int(blob, blob_offset, ptr_size, new_val)
1178
+ new_reloc_entries.append((X + blob_offset, new_val))
1179
+ continue
1180
+ if rtype not in (R_ABS_PLUS_ADDEND, R_GLOB_DAT):
1181
+ raise ValueError(
1182
+ f"unsupported relocation type {rtype} in dependency '{node.name}'"
1183
+ )
1184
+ sym = dep_dyn.get_symbol(reloc["r_info_sym"])
1185
+ addend = reloc["r_addend"] if is_rela else 0
1186
+ # See _resolve_dep_reference's own docstring for
1187
+ # the self/earlier-dependency-export vs. weak vs.
1188
+ # genuinely-external cases it handles -- R_*_GLOB_DAT
1189
+ # here (rather than the more usual
1190
+ # R_*_ABS_PLUS_ADDEND) for a symbol *defined* within
1191
+ # this same dependency means it's one of its own
1192
+ # exports (default/global visibility): GCC can't
1193
+ # assume a reference to a global-bound, exported
1194
+ # mutable data symbol is necessarily *its own*
1195
+ # definition (another loaded object could preempt/
1196
+ # interpose it), so even a same-module reference
1197
+ # goes through the GOT, resolved via symbol lookup
1198
+ # rather than a plain load-bias-only fixup. Confirmed
1199
+ # by a real crash (ValueError, not a silent
1200
+ # miscompute) building this task's own
1201
+ # `lib_ctor_b.c` fixture, whose non-`static`
1202
+ # `b_ctor_ran` global triggers exactly this -- with
1203
+ # no actual interposition happening here (this
1204
+ # dependency's own copy is the only one ever
1205
+ # loaded), resolving it to its own address is
1206
+ # correct either way.
1207
+ _resolve_dep_reference(sym, blob, blob_offset, X, dep_base, node.name, addend)
1208
+
1209
+ # A dependency's own PLT entries (DT_JMPREL) are always
1210
+ # R_*_JUMP_SLOT, always against an undefined (external)
1211
+ # symbol by construction -- *unless* it's a symbol some
1212
+ # earlier-processed (dependencies-first order) embedded
1213
+ # dependency itself exports, which _resolve_dep_reference
1214
+ # also handles. An earlier version of this method rejected
1215
+ # a dependency with *any* JMPREL entries outright ("further
1216
+ # external requirements aren't supported yet"); unwind-
1217
+ # metadata preservation's own flagship scenario -- a C++
1218
+ # exception crossing the embedding boundary -- makes that
1219
+ # genuinely unworkable to keep: even the simplest possible
1220
+ # `throw` calls `__cxa_throw`, which calls `malloc`
1221
+ # internally, through the dependency's own PLT. Verified by
1222
+ # actually forwarding a synthetic external import this way
1223
+ # and running the result on real Linux (WSL) before wiring
1224
+ # this in, for both 32- and 64-bit -- including the
1225
+ # .gnu.version/DT_VERSYM-array-growth part, which silent
1226
+ # static reasoning alone couldn't settle (ld.so's relocation
1227
+ # processing indexes it by the *same* symbol-table index as
1228
+ # the new symbol, so leaving it ungrown risks an out-of-
1229
+ # bounds read deciding a bogus required symbol version).
1230
+ jmprel = dep_reloc_tables.get("JMPREL")
1231
+ if jmprel is not None:
1232
+ is_rela = jmprel.is_RELA()
1233
+ for reloc in jmprel.iter_relocations():
1234
+ rtype = reloc["r_info_type"]
1235
+ if rtype == R_NONE:
1236
+ continue
1237
+ if rtype not in (R_JUMP_SLOT, R_ABS_PLUS_ADDEND, R_GLOB_DAT):
1238
+ raise ValueError(
1239
+ f"unsupported PLT relocation type {rtype} in dependency '{node.name}'"
1240
+ )
1241
+ target_vaddr = reloc["r_offset"]
1242
+ blob_offset = target_vaddr - dep_base
1243
+ if blob_offset < 0 or blob_offset + ptr_size > blob_size:
1244
+ raise ValueError(
1245
+ f"PLT relocation target {hex(target_vaddr)} in dependency "
1246
+ f"'{node.name}' falls outside its own PT_LOAD segments"
1247
+ )
1248
+ sym = dep_dyn.get_symbol(reloc["r_info_sym"])
1249
+ addend = reloc["r_addend"] if is_rela else 0
1250
+ _resolve_dep_reference(sym, blob, blob_offset, X, dep_base, node.name, addend)
1251
+
1252
+ for i in range(dep_dyn.num_symbols()):
1253
+ sym = dep_dyn.get_symbol(i)
1254
+ if (
1255
+ sym.name
1256
+ and sym["st_shndx"] != "SHN_UNDEF"
1257
+ and sym["st_info"]["type"] in ("STT_FUNC", "STT_OBJECT")
1258
+ and sym["st_info"]["bind"] in ("STB_GLOBAL", "STB_WEAK")
1259
+ ):
1260
+ combined_exports[sym.name] = (X, sym["st_value"] - dep_base, sym["st_size"])
1261
+
1262
+ # This dependency's own .init_array constructors -- already
1263
+ # relocated to their final absolute addresses in `blob` by
1264
+ # the RELA/REL loop above, since each slot is itself a
1265
+ # normal R_*_RELATIVE target -- for the generated stub
1266
+ # (build_linux_stub) to call. Deliberately *not* collecting/
1267
+ # calling DT_INIT (legacy `_init`, a single crtstuff-
1268
+ # provided entry point, separate from DT_INIT_ARRAY): on a
1269
+ # modern GCC/glibc toolchain it's boilerplate with an empty
1270
+ # body for a plain `-shared` build (DT_INIT_ARRAY -- what
1271
+ # `__attribute__((constructor))` actually populates, and
1272
+ # what this task's own fixtures exercise -- carries every
1273
+ # real constructor instead), and Task 4's own docstring
1274
+ # already found calling *other* crtstuff-provided init
1275
+ # machinery directly, cold, to be unsafe on x86 -- no
1276
+ # reason to take on that same risk for a part no fixture
1277
+ # here needs.
1278
+ dep_init_array_vaddr = next(
1279
+ (t.entry.d_val for t in dep_dyn.iter_tags("DT_INIT_ARRAY")), None
1280
+ )
1281
+ dep_init_array_sz = next(
1282
+ (t.entry.d_val for t in dep_dyn.iter_tags("DT_INIT_ARRAYSZ")), None
1283
+ )
1284
+ if dep_init_array_vaddr is not None and dep_init_array_sz:
1285
+ for i in range(dep_init_array_sz // ptr_size):
1286
+ off = (dep_init_array_vaddr - dep_base) + i * ptr_size
1287
+ init_array_calls.append(_read_int(blob, off, ptr_size))
1288
+
1289
+ # This dependency's own unwind info, if it has any -- see
1290
+ # _parse_eh_frame_hdr's docstring for why only these two
1291
+ # numbers per FDE (never the FDE/CIE bytes themselves) ever
1292
+ # need reading, and the merge block past this loop for how
1293
+ # they get folded into the host's own .eh_frame_hdr.
1294
+ dep_eh_hdr_seg = next(dep.iter_segments(type="PT_GNU_EH_FRAME"), None)
1295
+ if dep_eh_hdr_seg is not None:
1296
+ hdr_vaddr = dep_eh_hdr_seg["p_vaddr"]
1297
+ hdr_off_in_blob = hdr_vaddr - dep_base
1298
+ hdr_bytes = bytes(blob[hdr_off_in_blob: hdr_off_in_blob + dep_eh_hdr_seg["p_filesz"]])
1299
+ for initial_loc, fde_addr in _parse_eh_frame_hdr(hdr_bytes, hdr_vaddr):
1300
+ collected_eh_entries.append((initial_loc + delta, fde_addr + delta))
1301
+
1302
+ # Only a node the *host* directly lists in its own
1303
+ # DT_NEEDED has anything to neutralize there at all -- a
1304
+ # transitive-only node (needed by some other embedded
1305
+ # dependency, e.g. this task's own lib_ctor_a->lib_ctor_b
1306
+ # fixture) has no entry of its own in the host's dynamic
1307
+ # section to begin with; its DT_NEEDED edge lives inside
1308
+ # *that other dependency's* own (already-copied-into-
1309
+ # `blob`, now-inert) PT_DYNAMIC segment instead, which
1310
+ # nothing ever reads again once that dependency is no
1311
+ # longer independently loaded as a module -- so there's
1312
+ # nothing to do here for it, and treating its absence as
1313
+ # an error (as an earlier version of this method did)
1314
+ # was itself the bug: it assumed every embedded node is
1315
+ # always a *direct* host import, which a real transitive
1316
+ # chain disproves.
1317
+ if node.name in host_direct_import_names:
1318
+ if not _remove_needed(host_dyn, raw, ptr_size, node.name):
1319
+ raise ValueError(f"expected DT_NEEDED entry for '{node.name}' not found in host")
1320
+ _remove_verneed_entry(host_dyn, raw, ptr_size, vaddr_to_offset, node.name)
1321
+
1322
+ dep_blob_by_base[X] = bytes(blob)
1323
+ if is_fixed_base:
1324
+ fixed_base_segments.append((X, bytes(blob)))
1325
+ else:
1326
+ new_sections.append((X, bytes(blob)))
1327
+ next_vaddr = _align_up(X + blob_size, _PAGE)
1328
+
1329
+ # -- B2 fix: every embedded dependency's OWN DT_NEEDED entries that
1330
+ # aren't themselves embedded need to become new entries in the
1331
+ # HOST's own DT_NEEDED, or ld.so never loads them at all --
1332
+ # even though `_resolve_dep_reference` already forwards the
1333
+ # dependency's remaining symbol references into the host's
1334
+ # dynamic section as plain by-name GLOB_DAT lookups (see its
1335
+ # own docstring), that only works if the library that actually
1336
+ # *defines* those symbols is also loaded. An embedded
1337
+ # dependency's own DT_NEEDED entry has no further use once it's
1338
+ # no longer independently loaded as a module (see the
1339
+ # `_remove_needed`-call comment just above) for an *embedded*
1340
+ # target -- but when that target was never resolved into the
1341
+ # graph at all (excluded, or simply outside `lib_dir`/
1342
+ # unresolvable), it's a real, still-needed runtime dependency
1343
+ # that this method must now take over advertising, since the
1344
+ # dependency that used to advertise it is gone. Order names are
1345
+ # deduplicated and kept in a deterministic (dependency, then
1346
+ # that dependency's own DT_NEEDED) order, skipping anything
1347
+ # that's itself embedded or that the host already has its own,
1348
+ # still-active DT_NEEDED entry for (no embedded node overlaps
1349
+ # this set, since those get neutralized above).
1350
+ order_names = {node.name for node in order}
1351
+ host_still_needed_names = host_direct_import_names - order_names
1352
+ extra_needed_names: List[str] = []
1353
+ _seen_extra_needed: Set[str] = set()
1354
+ for node in order:
1355
+ for needed in list_elf_imports(node.path):
1356
+ if (
1357
+ needed in order_names
1358
+ or needed in host_still_needed_names
1359
+ or needed in _seen_extra_needed
1360
+ ):
1361
+ continue
1362
+ _seen_extra_needed.add(needed)
1363
+ extra_needed_names.append(needed)
1364
+
1365
+ # -- forward embedded dependencies' remaining external symbol
1366
+ # references into the host's own dynamic section (see
1367
+ # `external_symbol_index`'s own declaration for the full "why")
1368
+ # -- grow .dynsym/.dynstr (and .gnu.version/DT_VERSYM, if the
1369
+ # host has one, kept in lockstep since ld.so's relocation
1370
+ # processing indexes it by the very same symbol-table index) as
1371
+ # brand new, bigger combined tables, same append-past-the-end-
1372
+ # and-repoint-the-DT_*-tag pattern already used for the combined
1373
+ # relocation table just below (and PE's own import-directory
1374
+ # rebuild) -- growing any of these three in place isn't
1375
+ # generally safe either, for the same reason. `extra_needed_names`'
1376
+ # own strings (see the B2-fix block just above) are folded into
1377
+ # this same .dynstr growth, since both need "append new strings
1378
+ # past the end of a brand-new, bigger .dynstr" -- even if
1379
+ # `external_symbol_index` itself is empty, needing room for a
1380
+ # new DT_NEEDED's own name string is reason enough to grow
1381
+ # .dynstr (but NOT .dynsym/.gnu.version, which a plain
1382
+ # DT_NEEDED name has no entry in at all).
1383
+ needed_name_offsets: Dict[str, int] = {}
1384
+ dynstr_vaddr = host_strtab_vaddr
1385
+ if external_symbol_index or extra_needed_names:
1386
+ if host_strtab_vaddr is None or host_strsz is None:
1387
+ raise ValueError(
1388
+ f"'{self.input_path}' is missing DT_STRTAB/DT_STRSZ; can't forward an "
1389
+ "embedded dependency's external symbol/DT_NEEDED references into it"
1390
+ )
1391
+ old_dynstr_off = vaddr_to_offset(host_strtab_vaddr, host_strsz)
1392
+ old_dynstr_bytes = bytes(raw[old_dynstr_off: old_dynstr_off + host_strsz])
1393
+ new_dynstr_bytes = bytearray(old_dynstr_bytes)
1394
+
1395
+ if external_symbol_index:
1396
+ if host_symtab_vaddr is None:
1397
+ raise ValueError(
1398
+ f"'{self.input_path}' is missing DT_SYMTAB; can't forward an "
1399
+ "embedded dependency's external symbol references into it"
1400
+ )
1401
+ sym_entry_size = 24 if is64 else 16
1402
+ old_dynsym_off = vaddr_to_offset(host_symtab_vaddr, host_symtab_count * sym_entry_size)
1403
+ old_dynsym_bytes = bytes(
1404
+ raw[old_dynsym_off: old_dynsym_off + host_symtab_count * sym_entry_size]
1405
+ )
1406
+ new_dynsym_bytes = bytearray(old_dynsym_bytes)
1407
+ for name in external_symbol_index: # dict preserves insertion order == assigned index order
1408
+ name_off = len(new_dynstr_bytes)
1409
+ new_dynstr_bytes += name.encode("ascii") + b"\x00"
1410
+ new_dynsym_bytes += _pack_undef_sym(
1411
+ is64, name_off, weak=external_symbol_is_weak.get(name, False)
1412
+ )
1413
+
1414
+ dynsym_vaddr = next_vaddr
1415
+ new_sections.append((dynsym_vaddr, bytes(new_dynsym_bytes)))
1416
+ next_vaddr = _align_up(dynsym_vaddr + len(new_dynsym_bytes), ptr_size)
1417
+
1418
+ for name in extra_needed_names:
1419
+ needed_name_offsets[name] = len(new_dynstr_bytes)
1420
+ new_dynstr_bytes += name.encode("ascii") + b"\x00"
1421
+
1422
+ dynstr_vaddr = next_vaddr
1423
+ new_sections.append((dynstr_vaddr, bytes(new_dynstr_bytes)))
1424
+ next_vaddr = _align_up(dynstr_vaddr + len(new_dynstr_bytes), _PAGE)
1425
+
1426
+ if external_symbol_index:
1427
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, "DT_SYMTAB", dynsym_vaddr)
1428
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, "DT_STRTAB", dynstr_vaddr)
1429
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, "DT_STRSZ", len(new_dynstr_bytes))
1430
+
1431
+ if external_symbol_index and host_versym_vaddr is not None:
1432
+ old_versym_off = vaddr_to_offset(host_versym_vaddr, host_symtab_count * 2)
1433
+ old_versym_bytes = bytes(raw[old_versym_off: old_versym_off + host_symtab_count * 2])
1434
+ # 0 (VER_NDX_LOCAL) per new entry: "no version requirement"
1435
+ # for this (our own, newly forwarded) reference -- an
1436
+ # ordinary unversioned lookup, verified end-to-end against
1437
+ # a real `malloc` import on real Linux (WSL, both 32- and
1438
+ # 64-bit) before wiring this in; see this method's docstring.
1439
+ # Only grown when `external_symbol_index` itself is
1440
+ # non-empty -- a plain new DT_NEEDED (see
1441
+ # `extra_needed_names`) adds no new .dynsym entry at all,
1442
+ # so .gnu.version (indexed 1:1 by .dynsym entry) needs no
1443
+ # corresponding growth for it.
1444
+ new_versym_bytes = old_versym_bytes + b"\x00\x00" * len(external_symbol_index)
1445
+ versym_vaddr = next_vaddr
1446
+ new_sections.append((versym_vaddr, new_versym_bytes))
1447
+ next_vaddr = _align_up(versym_vaddr + len(new_versym_bytes), _PAGE)
1448
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, "DT_VERSYM", versym_vaddr)
1449
+
1450
+ # -- patch the host's GOT/PLT references to embedded exports -----
1451
+ #
1452
+ # B5: whether the HOST itself was built with eager (`-z now`/
1453
+ # BIND_NOW) binding matters only for how a *PLT* (DT_JMPREL)
1454
+ # relocation entry gets neutralized below -- see the JMPREL-
1455
+ # specific branch, further down, for why. Checked via either of
1456
+ # the two real-world ways a linker records it (DF_BIND_NOW in
1457
+ # DT_FLAGS, or the more modern DF_1_NOW in DT_FLAGS_1 -- confirmed
1458
+ # against real `readelf -d` output on this project's own toolchain,
1459
+ # which emits the latter); the legacy, presence-only DT_BIND_NOW
1460
+ # tag is deliberately not checked too -- no modern linker emits it
1461
+ # (GNU ld hasn't since the mid-2000s), and a host that genuinely
1462
+ # used only that legacy mechanism would, worst case, just take the
1463
+ # (already-correct-today) eager-binding code path below instead of
1464
+ # the lazy one, not silently corrupt anything.
1465
+ DF_BIND_NOW = 0x8
1466
+ DF_1_NOW = 0x1
1467
+ host_bind_now = bool((_host_dyn_tag_val("DT_FLAGS") or 0) & DF_BIND_NOW) or bool(
1468
+ (_host_dyn_tag_val("DT_FLAGS_1") or 0) & DF_1_NOW
1469
+ )
1470
+
1471
+ patch_specs = [(dyn_table_key, dyn_tag_name)]
1472
+ if "JMPREL" in host_reloc_tables:
1473
+ patch_specs.append(("JMPREL", "DT_JMPREL"))
1474
+
1475
+ for key, tag_name in patch_specs:
1476
+ table = host_reloc_tables[key]
1477
+ is_rela = table.is_RELA()
1478
+ # File offset of the *table itself* (what the DT_* tag's value
1479
+ # points at) -- not to be confused with the DT_* tag entry's
1480
+ # own location inside .dynamic (that's what
1481
+ # _find_dyn_tag_file_offset is for, used elsewhere to edit a
1482
+ # tag's d_val in place).
1483
+ _, table_file_offset = host_dyn.get_table_offset(tag_name)
1484
+ entry_size = table.entry_size
1485
+ for idx, reloc in enumerate(table.iter_relocations()):
1486
+ rtype = reloc["r_info_type"]
1487
+ if rtype == R_COPY:
1488
+ # A direct-data-symbol import (e.g. ncurses/termcap's
1489
+ # legacy `extern char *UP;`-style globals, confirmed
1490
+ # against a real `bash`/`libtinfo.so.6` embed on real
1491
+ # Linux (WSL) -- none of this project's own fixtures
1492
+ # exercise a copy relocation at all): the *value*
1493
+ # (`st_size` raw bytes, copied verbatim from the
1494
+ # definition) has to be written into the host's own
1495
+ # reserved space at `r_offset` right now, since ld.so
1496
+ # itself would normally do this copy at load time by
1497
+ # looking up the defining module by name -- which no
1498
+ # longer exists once that dependency is embedded,
1499
+ # otherwise reproducing exactly the undefined-symbol
1500
+ # load failure this fix was written against.
1501
+ sym = host_dyn.get_symbol(reloc["r_info_sym"])
1502
+ if not sym.name or sym.name not in combined_exports:
1503
+ continue
1504
+ X, offset_in_dep, dep_size = combined_exports[sym.name]
1505
+ size = sym["st_size"] or dep_size
1506
+ src = dep_blob_by_base[X][offset_in_dep:offset_in_dep + size]
1507
+ if len(src) != size:
1508
+ raise ValueError(
1509
+ f"COPY relocation for '{sym.name}' needs {size} bytes "
1510
+ f"at offset {offset_in_dep} in its defining dependency, "
1511
+ f"but only {len(src)} are available -- symbol size/offset "
1512
+ f"mismatch, refusing a silent partial copy"
1513
+ )
1514
+ target_vaddr = reloc["r_offset"]
1515
+ if not (ext["p_vaddr"] <= target_vaddr < ext["p_vaddr"] + ext["p_memsz"]):
1516
+ raise ValueError(
1517
+ f"R_*_COPY target {hex(target_vaddr)} in '{self.input_path}' falls "
1518
+ "outside its own extendable PT_LOAD segment; not supported"
1519
+ )
1520
+ deferred_copy_writes.append((target_vaddr, bytes(src)))
1521
+ # If the copied value is itself a pointer-sized field
1522
+ # whose value lands inside this very same dependency's
1523
+ # own embedded blob range, it's one of *our* addresses
1524
+ # (e.g. a `char *` into the dependency's own relocated
1525
+ # .rodata) and needs the same ASLR-safety new
1526
+ # R_*_RELATIVE entry as every other such address this
1527
+ # method introduces -- unlike a NULL or an address
1528
+ # pointing somewhere else entirely (e.g. still-external
1529
+ # data), which needs no fixup at all.
1530
+ #
1531
+ # Known architectural limitation (documented, not
1532
+ # fixed -- see PLAN.md's "Known problems" entry for
1533
+ # this): this only ever checks the copied value AS A
1534
+ # WHOLE, and only when it's exactly one pointer wide.
1535
+ # A larger COPY-relocated object (e.g. a struct with
1536
+ # its OWN interior pointer field, not just scalar
1537
+ # data) could have such a field pointing into the
1538
+ # dependency's own blob too, needing the exact same
1539
+ # fixup at that field's own offset -- but nothing
1540
+ # here can tell, from `size`/`src`'s raw bytes alone,
1541
+ # which sub-offsets of a >ptr_size object are even
1542
+ # pointers at all. ELF's own COPY-relocation symbol
1543
+ # info carries only `st_size` (a byte count), no
1544
+ # type/layout metadata; doing this correctly in
1545
+ # general needs DWARF type info this module doesn't
1546
+ # use anywhere else. Every real COPY-relocated symbol
1547
+ # this module has actually been tested against (this
1548
+ # project's own fixtures, and bash/libtinfo.so.6's
1549
+ # termcap globals in Task 10b) happens to be
1550
+ # pointer-sized or smaller, so this gap is currently
1551
+ # unexercised in practice.
1552
+ if size == ptr_size:
1553
+ copied_value = _read_int(src, 0, ptr_size)
1554
+ if X <= copied_value < X + len(dep_blob_by_base[X]):
1555
+ new_reloc_entries.append((reloc["r_offset"], copied_value))
1556
+
1557
+ entry_off = table_file_offset + idx * entry_size
1558
+ raw[entry_off:entry_off + entry_size] = b"\x00" * entry_size
1559
+ continue
1560
+
1561
+ if rtype not in (R_JUMP_SLOT, R_GLOB_DAT, R_ABS_PLUS_ADDEND):
1562
+ continue
1563
+ sym = host_dyn.get_symbol(reloc["r_info_sym"])
1564
+ if sym["st_shndx"] != "SHN_UNDEF" or not sym.name or sym.name not in combined_exports:
1565
+ continue
1566
+ X, offset_in_dep, _ = combined_exports[sym.name]
1567
+ addend = reloc["r_addend"] if is_rela else 0
1568
+ resolved = (X + offset_in_dep + addend) & ((1 << (ptr_size * 8)) - 1)
1569
+ target_off = vaddr_to_offset(reloc["r_offset"], ptr_size)
1570
+ _write_int(raw, target_off, ptr_size, resolved)
1571
+
1572
+ if key == "JMPREL" and rtype == R_JUMP_SLOT and not host_bind_now:
1573
+ # B5 fix: a lazy-bound host (the ABI default -- most
1574
+ # real-world ELF binaries, though this project's own
1575
+ # toolchain happens to default to BIND_NOW) still has
1576
+ # ld.so walk *every* DT_JMPREL entry at load time, via
1577
+ # a completely separate code path from the one that
1578
+ # processes the main DT_RELA/DT_REL table
1579
+ # (`elf_machine_lazy_rel`, not the generic relocation
1580
+ # dispatch `_remove_needed`'s own R_*_NONE-zeroing
1581
+ # trick already relies on elsewhere) -- its only job
1582
+ # for a R_*_JUMP_SLOT entry is to add the image's own
1583
+ # load bias to whatever's *already* sitting in the GOT
1584
+ # slot (deferring the real by-name symbol lookup to
1585
+ # the PLT0 trampoline on first call, under genuine
1586
+ # laziness) -- it does NOT consult the entry's own
1587
+ # symbol index to do this. Confirmed empirically (real
1588
+ # WSL Linux, both bitnesses): zeroing this entry to
1589
+ # R_*_NONE -- correct for the eager/BIND_NOW path,
1590
+ # where DT_JMPREL is instead processed through the
1591
+ # *generic* dispatch, which does tolerate R_*_NONE --
1592
+ # crashes a lazy-bound host at process startup with
1593
+ # "unexpected PLT reloc type 0x00", since
1594
+ # elf_machine_lazy_rel's own switch has no such
1595
+ # tolerance. The fix: leave this entry's bytes
1596
+ # completely untouched (still nominally R_*_JUMP_SLOT,
1597
+ # against its original, now-dead symbol -- never read
1598
+ # by elf_machine_lazy_rel for this type) and skip
1599
+ # adding a new host R_*_RELATIVE entry for the GOT slot
1600
+ # too, since `elf_machine_lazy_rel`'s own load-bias add
1601
+ # already does exactly that job for this specific slot
1602
+ # -- adding a second, independent bias-add via a new
1603
+ # RELATIVE entry in the *main* table would double-apply
1604
+ # the load bias on a PIE host (every fixture this
1605
+ # module is tested against), corrupting the address.
1606
+ continue
1607
+
1608
+ new_reloc_entries.append((reloc["r_offset"], resolved))
1609
+
1610
+ entry_off = table_file_offset + idx * entry_size
1611
+ raw[entry_off:entry_off + entry_size] = b"\x00" * entry_size
1612
+
1613
+ # -- B6-ELF: is the INPUT itself a shared object, rather than an
1614
+ # executable? -- ld.so never calls e_entry for anything but the
1615
+ # process's own main executable; a .so loaded via dlopen (or as
1616
+ # an ordinary DT_NEEDED dependency) has e_entry completely
1617
+ # ignored, so installing the generated init-call stub as the
1618
+ # new e_entry (as the EXE-input branch just below does) would
1619
+ # silently never run it at all. ET_EXEC always means "the
1620
+ # input is an ordinary non-PIE executable" -- no ambiguity. An
1621
+ # ET_DYN input, though, is ambiguous by itself: a modern PIE
1622
+ # executable is ALSO ET_DYN, indistinguishable from a plain
1623
+ # shared library by e_type alone. The real-world disambiguator
1624
+ # GNU ld actually emits is DF_1_PIE in DT_FLAGS_1 (confirmed
1625
+ # against this project's own toolchain's `readelf -d` output on
1626
+ # one of its own PIE fixtures: "Flags: NOW PIE") -- set only
1627
+ # for an ET_DYN that's actually meant to be executed directly,
1628
+ # never for an ordinary .so. Its absence on an ET_DYN input is
1629
+ # this method's signal that the input is a genuine shared
1630
+ # object, and the e_entry-based mechanism isn't applicable.
1631
+ DF_1_PIE = 0x08000000
1632
+ host_is_pie_exe = (elf["e_type"] == "ET_DYN") and bool((_host_dyn_tag_val("DT_FLAGS_1") or 0) & DF_1_PIE)
1633
+ host_is_so_input = (elf["e_type"] == "ET_DYN") and not host_is_pie_exe
1634
+
1635
+ # -- generated entrypoint/init-array stub (calls each embedded
1636
+ # dependency's own .init_array constructors, in dependencies-
1637
+ # first order) -- see build_linux_stub's/
1638
+ # build_linux_init_array_stub's own docstrings for exactly what
1639
+ # each does and doesn't do (the dlopen/dlsym hook
1640
+ # `hooked_names` implies isn't implemented yet, for the
1641
+ # EXE-input stub). Only installed when there's actually
1642
+ # something for it to do (`init_array_calls` non-empty) --
1643
+ # e_entry/DT_INIT_ARRAY are otherwise left completely
1644
+ # untouched, exactly as before this task, rather than
1645
+ # installing a stub that would just immediately return/
1646
+ # tail-jump with no other effect. Must run *before* the
1647
+ # combined relocation table just below, since the stub's own
1648
+ # embedded absolute-VA immediates need new R_*_RELATIVE entries
1649
+ # of their own (for ASLR-safety -- see build_linux_stub's
1650
+ # docstring), and that table is built once, from whatever's in
1651
+ # `new_reloc_entries` at that point.
1652
+ entry_stub_vaddr = None # only set in the EXE/PIE-exe branch below
1653
+ if init_array_calls and host_is_so_input:
1654
+ init_array_stub_vaddr = next_vaddr
1655
+ stub_bytes, stub_reloc_offsets = build_linux_init_array_stub(is64=is64, init_calls=init_array_calls)
1656
+ new_sections.append((init_array_stub_vaddr, stub_bytes))
1657
+ next_vaddr = _align_up(init_array_stub_vaddr + len(stub_bytes), _PAGE)
1658
+ for off in stub_reloc_offsets:
1659
+ new_reloc_entries.append((init_array_stub_vaddr + off, _read_int(stub_bytes, off, ptr_size)))
1660
+
1661
+ # Inject it as a brand-new, prepended entry in the host's own
1662
+ # DT_INIT_ARRAY -- the dynamic linker's normal, built-in
1663
+ # mechanism for calling a shared object's own constructors
1664
+ # when it's loaded (unlike e_entry, which it never touches
1665
+ # for anything but the main executable). Prepended (runs
1666
+ # before the host's own pre-existing constructors) to match
1667
+ # this method's own "dependencies-first" ordering convention
1668
+ # elsewhere. Only the array's *content* needs to grow at a
1669
+ # new location (re-pointing the already-existing DT_INIT_ARRAY/
1670
+ # DT_INIT_ARRAYSZ tags' own d_val fields in place, same
1671
+ # pattern as DT_SYMTAB/DT_STRTAB/DT_RELA growth elsewhere in
1672
+ # this method) -- no new *tag* needs adding to the dynamic
1673
+ # array itself, unlike B2's DT_NEEDED fix, since every
1674
+ # GCC-built .so already carries crtstuff's own DT_INIT_ARRAY/
1675
+ # DT_INIT_ARRAYSZ tags (frame_dummy/register_tm_clones) even
1676
+ # with no user-written constructor at all. A .so genuinely
1677
+ # built with neither tag at all (e.g. assembled by hand, or
1678
+ # by a toolchain that omits crtstuff) is explicitly out of
1679
+ # scope -- failing loudly here rather than also implementing
1680
+ # "add two brand-new dynamic tags from scratch" for a case
1681
+ # this project's own fixtures (and, empirically, every real
1682
+ # GCC-built .so) never hits.
1683
+ host_init_array_vaddr = _host_dyn_tag_val("DT_INIT_ARRAY")
1684
+ host_init_array_sz = _host_dyn_tag_val("DT_INIT_ARRAYSZ")
1685
+ if host_init_array_vaddr is None or not host_init_array_sz:
1686
+ raise ValueError(
1687
+ f"'{self.input_path}' is a shared object with no existing DT_INIT_ARRAY "
1688
+ "of its own; injecting an embedded dependency's constructor call into "
1689
+ "a .so input with no constructors of its own isn't supported"
1690
+ )
1691
+ old_ia_off = vaddr_to_offset(host_init_array_vaddr, host_init_array_sz)
1692
+ old_ia_bytes = bytes(raw[old_ia_off: old_ia_off + host_init_array_sz])
1693
+ ptr_fmt = "<Q" if is64 else "<I"
1694
+ new_ia_vaddr = next_vaddr
1695
+ new_ia_bytes = struct.pack(ptr_fmt, init_array_stub_vaddr) + old_ia_bytes
1696
+ new_sections.append((new_ia_vaddr, new_ia_bytes))
1697
+ next_vaddr = _align_up(new_ia_vaddr + len(new_ia_bytes), ptr_size)
1698
+
1699
+ # Every slot in the new array -- our own new one AND each of
1700
+ # the host's own original entries, now copied to a new
1701
+ # location -- is itself a pointer value that needs the
1702
+ # image's own load bias applied at load time, exactly like
1703
+ # every other absolute-VA this method writes; the host's own
1704
+ # original entries already had their OWN R_*_RELATIVE entries
1705
+ # for their OLD location (left stale/dead in place, same
1706
+ # pattern as every other abandoned old location in this
1707
+ # method), which doesn't help them at their NEW one.
1708
+ new_reloc_entries.append((new_ia_vaddr, init_array_stub_vaddr))
1709
+ for i in range(host_init_array_sz // ptr_size):
1710
+ old_val = _read_int(old_ia_bytes, i * ptr_size, ptr_size)
1711
+ new_reloc_entries.append((new_ia_vaddr + ptr_size + i * ptr_size, old_val))
1712
+
1713
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, "DT_INIT_ARRAY", new_ia_vaddr)
1714
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, "DT_INIT_ARRAYSZ", len(new_ia_bytes))
1715
+ elif init_array_calls:
1716
+ original_entry_va = elf["e_entry"]
1717
+ entry_stub_vaddr = next_vaddr
1718
+ stub_bytes, stub_reloc_offsets = build_linux_stub(
1719
+ [node.name for node in order],
1720
+ set(),
1721
+ is64=is64,
1722
+ init_calls=init_array_calls,
1723
+ original_entry=original_entry_va,
1724
+ )
1725
+ new_sections.append((entry_stub_vaddr, stub_bytes))
1726
+ next_vaddr = _align_up(entry_stub_vaddr + len(stub_bytes), _PAGE)
1727
+ for off in stub_reloc_offsets:
1728
+ new_reloc_entries.append((entry_stub_vaddr + off, _read_int(stub_bytes, off, ptr_size)))
1729
+
1730
+ # -- combined relocation table -------------------------------------
1731
+ if new_reloc_entries or new_glob_dat_entries:
1732
+ _, orig_table_offset = host_dyn.get_table_offset(dyn_tag_name)
1733
+ orig_size_tag = next(host_dyn.iter_tags(dyn_sz_tag_name), None)
1734
+ orig_table_size = orig_size_tag.entry.d_val if orig_size_tag is not None else 0
1735
+ orig_table_bytes = bytes(raw[orig_table_offset:orig_table_offset + orig_table_size])
1736
+
1737
+ new_entries_bytes = _pack_relative_entries(new_reloc_entries, is64, dyn_is_rela)
1738
+ new_glob_dat_bytes = _pack_glob_dat_entries(new_glob_dat_entries, is64, dyn_is_rela)
1739
+ combined_bytes = orig_table_bytes + new_entries_bytes + new_glob_dat_bytes
1740
+
1741
+ reloc_table_vaddr = next_vaddr
1742
+ new_sections.append((reloc_table_vaddr, combined_bytes))
1743
+ next_vaddr = _align_up(reloc_table_vaddr + len(combined_bytes), _PAGE)
1744
+
1745
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, dyn_tag_name, reloc_table_vaddr)
1746
+ _set_dyn_tag_value(host_dyn, raw, ptr_size, dyn_sz_tag_name, len(combined_bytes))
1747
+
1748
+ # -- B2 fix, continued: grow the host's own dynamic array itself
1749
+ # with one new DT_NEEDED entry per name in `extra_needed_names`
1750
+ # (see that variable's own declaration, earlier, for the full
1751
+ # "why") --
1752
+ #
1753
+ # Must run *after* every other `_set_dyn_tag_value` call above
1754
+ # (the external-symbol-forwarding block's DT_SYMTAB/DT_STRTAB/
1755
+ # DT_STRSZ/DT_VERSYM, and this combined-relocation-table block's
1756
+ # own DT_RELA/DT_RELASZ): those all edit the dynamic array's
1757
+ # entries *in place*, at their ORIGINAL file location (via
1758
+ # `host_dyn`, parsed once against the original bytes) -- reading
1759
+ # that same, by-now-fully-updated array's current byte content
1760
+ # here and copying it into a brand new, bigger array (rather than
1761
+ # growing it in place, same reasoning as every other table this
1762
+ # method grows) preserves every one of those edits. Doing this
1763
+ # any earlier would mean a later `_set_dyn_tag_value` call writes
1764
+ # into the OLD array's now-abandoned bytes instead of the new one
1765
+ # ld.so will actually read via the updated PT_DYNAMIC program
1766
+ # header entry below.
1767
+ #
1768
+ # New entries are spliced in just before the array's own
1769
+ # terminating DT_NULL (which must stay last -- every real-world
1770
+ # loader stops scanning at the first one), rather than appended
1771
+ # past it, mirroring `_remove_needed`'s own "never disturb the
1772
+ # DT_NULL terminator's position" rule for the same reason.
1773
+ if extra_needed_names:
1774
+ dyn_entry_size = 2 * ptr_size
1775
+ old_dyn_count = host_dyn.num_tags()
1776
+ old_dyn_bytes = bytes(raw[host_dyn["p_offset"]: host_dyn["p_offset"] + old_dyn_count * dyn_entry_size])
1777
+ null_index = next(
1778
+ (n for n in range(old_dyn_count) if host_dyn.get_tag(n).entry.d_tag == "DT_NULL"), None
1779
+ )
1780
+ if null_index is None:
1781
+ raise ValueError(f"'{self.input_path}': its dynamic section has no DT_NULL terminator")
1782
+
1783
+ # d_val for a DT_NEEDED entry is a plain .dynstr *offset* (not
1784
+ # an absolute vaddr), exactly like `needed_name_offsets` was
1785
+ # already recorded as.
1786
+ needed_fmt = "<QQ" if is64 else "<II"
1787
+ new_entries = bytearray()
1788
+ for name in extra_needed_names:
1789
+ new_entries += struct.pack(needed_fmt, DT_NEEDED, needed_name_offsets[name])
1790
+
1791
+ head = old_dyn_bytes[: null_index * dyn_entry_size]
1792
+ tail = old_dyn_bytes[null_index * dyn_entry_size:]
1793
+ new_dyn_bytes = bytes(head) + bytes(new_entries) + bytes(tail)
1794
+
1795
+ dynamic_vaddr = next_vaddr
1796
+ new_sections.append((dynamic_vaddr, new_dyn_bytes))
1797
+ next_vaddr = _align_up(dynamic_vaddr + len(new_dyn_bytes), _PAGE)
1798
+
1799
+ host_dynamic_seg = all_host_segments[host_dynamic_idx]
1800
+ dynamic_phdr_update = _pack_phdr(
1801
+ is64, PT_DYNAMIC, host_dynamic_seg["p_flags"], dynamic_vaddr - ext_skew, dynamic_vaddr,
1802
+ dynamic_vaddr, len(new_dyn_bytes), len(new_dyn_bytes), host_dynamic_seg["p_align"],
1803
+ )
1804
+ else:
1805
+ dynamic_phdr_update = None
1806
+
1807
+ # -- merge embedded dependencies' unwind info into the host's own
1808
+ # .eh_frame_hdr binary-search table --
1809
+ #
1810
+ # Once a dependency is embedded, it no longer has a PT_GNU_EH_FRAME
1811
+ # segment -- or module -- of its own: dl_iterate_phdr (what
1812
+ # _Unwind_Find_FDE actually walks to find unwind info for a given
1813
+ # PC) only ever sees the *host's* program headers now, so an
1814
+ # unwind through a PC that's now inside embedded dependency code
1815
+ # only succeeds if the *host's* own .eh_frame_hdr table has an
1816
+ # entry covering it. The dependency's FDEs themselves need no
1817
+ # changes at all to stay valid (see _parse_eh_frame_hdr's
1818
+ # docstring) -- only the table of (initial_loc, fde_addr) pairs
1819
+ # that lets the unwinder *find* them needs to grow to list them,
1820
+ # each already shifted by its own dependency's `delta` (collected
1821
+ # into `collected_eh_entries` during the per-dependency loop
1822
+ # above). A brand new, bigger table is built here (same append-
1823
+ # and-repoint pattern as everything else in this method) rather
1824
+ # than growing the existing one in place, and the host's *existing*
1825
+ # PT_GNU_EH_FRAME program-header entry is overwritten in place to
1826
+ # point at it -- unlike the phdr-array-growth case further down,
1827
+ # this only ever *edits* an already-existing entry, never adds a
1828
+ # new one, so it's needed in both the fixed_base_segments and
1829
+ # non-fixed_base_segments branches below alike.
1830
+ eh_hdr_update: Optional[bytes] = None
1831
+ if collected_eh_entries:
1832
+ if host_gnu_eh_frame_idx is None:
1833
+ raise ValueError(
1834
+ f"'{self.input_path}' has no PT_GNU_EH_FRAME segment to merge "
1835
+ "an embedded dependency's unwind info into"
1836
+ )
1837
+ host_eh_hdr_seg = all_host_segments[host_gnu_eh_frame_idx]
1838
+ host_hdr_vaddr = host_eh_hdr_seg["p_vaddr"]
1839
+ host_hdr_size = host_eh_hdr_seg["p_filesz"]
1840
+ host_hdr_file_off = vaddr_to_offset(host_hdr_vaddr, host_hdr_size)
1841
+ host_hdr_bytes = bytes(raw[host_hdr_file_off: host_hdr_file_off + host_hdr_size])
1842
+ host_eh_entries = _parse_eh_frame_hdr(host_hdr_bytes, host_hdr_vaddr)
1843
+ # The host's own .eh_frame start never moves (nothing in this
1844
+ # method ever relocates the host's *own*, pre-existing
1845
+ # content) -- re-derive it from the host's own hdr rather than
1846
+ # hardcoding anything, and keep using it as the regenerated
1847
+ # hdr's own eh_frame_ptr field too (that field is otherwise
1848
+ # unused by the dl_iterate_phdr/hdr-table-based unwind path
1849
+ # this relies on; see _build_eh_frame_hdr).
1850
+ eh_frame_ptr_enc = host_hdr_bytes[1]
1851
+ if eh_frame_ptr_enc == _DW_EH_PE_OMIT:
1852
+ raise ValueError(
1853
+ f"'{self.input_path}': its .eh_frame_hdr has no eh_frame_ptr field"
1854
+ )
1855
+ host_eh_frame_vaddr = _decode_eh_pe_sdata4(
1856
+ eh_frame_ptr_enc, struct.unpack_from("<i", host_hdr_bytes, 4)[0], host_hdr_vaddr + 4, host_hdr_vaddr
1857
+ )
1858
+
1859
+ combined_entries = sorted(host_eh_entries + collected_eh_entries, key=lambda e: e[0])
1860
+ new_hdr_vaddr = next_vaddr
1861
+ new_hdr_bytes = _build_eh_frame_hdr(new_hdr_vaddr, host_eh_frame_vaddr, combined_entries)
1862
+ new_sections.append((new_hdr_vaddr, new_hdr_bytes))
1863
+ next_vaddr = _align_up(new_hdr_vaddr + len(new_hdr_bytes), _PAGE)
1864
+
1865
+ eh_hdr_update = _pack_phdr(
1866
+ is64, PT_GNU_EH_FRAME, host_eh_hdr_seg["p_flags"], new_hdr_vaddr - ext_skew, new_hdr_vaddr,
1867
+ new_hdr_vaddr, len(new_hdr_bytes), len(new_hdr_bytes), host_eh_hdr_seg["p_align"],
1868
+ )
1869
+
1870
+ # -- reserve room for a grown program header array, if any fixed-
1871
+ # base dependency needs its own new PT_LOAD entry (see the large
1872
+ # comment above, by `fixed_base_segments`, for why those can't
1873
+ # just extend `ext` like everything above this point does) --
1874
+ # placed as one more piece of `ext`'s own extension, same as the
1875
+ # relocation table just above, so it shares the extension's skew
1876
+ # and keeps AT_PHDR's computation correct even though e_phoff is
1877
+ # about to move (empirically confirmed via LD_SHOW_AUXV=1
1878
+ # against a real host while building this mechanism -- see the
1879
+ # comment further down, by `data_start_vaddr`, for exactly what
1880
+ # that showed and how it corrects this file's own older claim
1881
+ # about how the kernel derives AT_PHDR). Its *content*
1882
+ # can't be filled in yet -- it needs to record `ext`'s own
1883
+ # final, grown p_filesz/p_memsz, which isn't known until after
1884
+ # this whole extension (including this array's own reserved
1885
+ # bytes) is actually laid out below -- so a same-sized
1886
+ # zero-filled placeholder goes here for now and gets overwritten
1887
+ # in place, once that size is known, right before this method
1888
+ # returns.
1889
+ if fixed_base_segments:
1890
+ old_phnum = elf.num_segments()
1891
+ new_phnum = old_phnum + len(fixed_base_segments)
1892
+ phdr_array_size = new_phnum * elf["e_phentsize"]
1893
+ phdr_array_vaddr = next_vaddr
1894
+ new_sections.append((phdr_array_vaddr, b"\x00" * phdr_array_size))
1895
+ next_vaddr = _align_up(phdr_array_vaddr + phdr_array_size, _PAGE)
1896
+
1897
+ # -- lay everything out as an in-place extension of the host's own
1898
+ # `ext` PT_LOAD segment (see where ext/ext_idx/ext_skew/ext_align
1899
+ # were derived, above the per-dependency loop) --
1900
+ #
1901
+ # The straightforward-looking alternative -- append everything at
1902
+ # the end of the file in a brand new PT_LOAD segment of its own --
1903
+ # is risky: AT_PHDR's value is derived from e_phoff through a
1904
+ # translation that depends on *some* segment's own p_vaddr/
1905
+ # p_offset skew. An earlier version of this comment claimed that
1906
+ # skew always comes from the *first* PT_LOAD segment specifically,
1907
+ # with the kernel never re-deriving one per segment -- empirical
1908
+ # testing while building the fixed-base mechanism further up in
1909
+ # this method (LD_SHOW_AUXV=1 against a real host) contradicts
1910
+ # that: `ext` (the segment e_phoff gets moved into, there) does
1911
+ # *not* share the first PT_LOAD segment's skew for that host, yet
1912
+ # AT_PHDR still came out correct relative to *ext's own* skew. So
1913
+ # the precise mechanism this comment used to assert isn't right --
1914
+ # what's still true, and still the reason to prefer extending an
1915
+ # existing segment over adding a new one, is that *some*
1916
+ # segment-specific skew is involved, and there's no particular
1917
+ # reason a brand new segment, placed at a freshly chosen vaddr/
1918
+ # offset pair, would happen to share whatever skew that
1919
+ # translation actually keys off. Moving e_phoff into one without
1920
+ # knowing that risks AT_PHDR landing on a bogus address the moment
1921
+ # it happens. glibc's `_dl_aux_init`/`__libc_start_main` walk that
1922
+ # (potentially garbage) phdr array looking for PT_TLS/
1923
+ # PT_GNU_RELRO/PT_GNU_STACK for the main executable -- silently
1924
+ # skipping TLS/RELRO/stack-flag setup, or segfaulting outright if
1925
+ # the bogus address isn't even mapped.
1926
+ #
1927
+ # Extending an *existing* segment in place sidesteps this
1928
+ # completely: e_phoff/e_phnum (and every other segment's entry)
1929
+ # stay byte-for-byte untouched, so AT_PHDR's computation doesn't
1930
+ # change at all. Growing `ext`'s p_filesz/p_memsz all the way from
1931
+ # its own original p_offset up to the new end of file is always
1932
+ # safe *placement*-wise (nothing one of our new blobs writes can
1933
+ # collide with some other segment's bytes), *as long as* `ext` is
1934
+ # confirmed above to also be the last segment's content in the
1935
+ # file -- but it is NOT safe *content*-wise to leave whatever was
1936
+ # already sitting past `ext`'s own original filesz unchanged: an
1937
+ # earlier version of this comment claimed those bytes (e.g. a
1938
+ # stale section header table/.comment/.note content -- normal
1939
+ # for any unstripped-of-everything binary) were "inert... that
1940
+ # nothing else maps", which was true only in the *original* file,
1941
+ # where `ext`'s filesz genuinely stopped short of them. The
1942
+ # moment this method grows `ext`'s filesz to cover that same
1943
+ # span, the kernel DOES map it -- as real, non-zero file content,
1944
+ # not the zero-filled BSS the ABI promises for anything between
1945
+ # p_filesz and p_memsz. Confirmed for real (a `bash`/
1946
+ # `libtinfo.so.6` embed on real Linux, WSL) before this fix: a
1947
+ # handful of bash's own ordinary BSS globals happened to fall
1948
+ # inside that exact span (p_filesz..p_memsz, with real leftover
1949
+ # trailing bytes already occupying the first ~2KB of it on a
1950
+ # real, not-fully-stripped `/bin/bash`) and came up initialized
1951
+ # to that garbage instead of zero, crashing deep inside bash's
1952
+ # own `parse_and_execute` on first use. Zeroing that whole span
1953
+ # -- from `ext`'s own original filesz end through whatever the
1954
+ # file's actual length already is -- before growing filesz over
1955
+ # it restores the zero-BSS guarantee for it; losing those inert
1956
+ # bytes' own original content costs nothing, since nothing in
1957
+ # the original file's own PT_LOAD segments ever mapped them
1958
+ # either.
1959
+ ext_filesz_end_offset = ext["p_offset"] + ext["p_filesz"]
1960
+ if ext_filesz_end_offset < len(raw):
1961
+ raw[ext_filesz_end_offset:len(raw)] = b"\x00" * (len(raw) - ext_filesz_end_offset)
1962
+
1963
+ data_start_vaddr = new_sections[0][0]
1964
+ data_start_offset = data_start_vaddr - ext_skew
1965
+ if data_start_offset < len(raw):
1966
+ raise ValueError("internal error: new ELF content would overlap existing file content")
1967
+ raw.extend(b"\x00" * (data_start_offset - len(raw)))
1968
+
1969
+ # Now that `raw` covers `ext`'s whole original memsz as real file
1970
+ # bytes (the backfill just above) -- not just its original,
1971
+ # possibly-smaller filesz -- any deferred R_*_COPY write's target
1972
+ # (validated above to be within `ext`) is guaranteed in range;
1973
+ # see `deferred_copy_writes`'s own declaration for why this can't
1974
+ # happen any earlier.
1975
+ for copy_target_vaddr, copy_src in deferred_copy_writes:
1976
+ copy_target_off = copy_target_vaddr - ext_skew
1977
+ raw[copy_target_off:copy_target_off + len(copy_src)] = copy_src
1978
+
1979
+ cursor_vaddr = data_start_vaddr
1980
+ for vaddr, data in new_sections:
1981
+ pad = vaddr - cursor_vaddr
1982
+ if pad < 0:
1983
+ raise ValueError("internal error: overlapping generated ELF segments")
1984
+ raw.extend(b"\x00" * pad)
1985
+ raw.extend(data)
1986
+ cursor_vaddr = vaddr + len(data)
1987
+
1988
+ new_filesz = len(raw) - ext["p_offset"]
1989
+ extended_entry = _pack_phdr(
1990
+ is64, PT_LOAD, ext["p_flags"] | PF_R | PF_W | PF_X, ext["p_offset"], ext["p_vaddr"],
1991
+ ext["p_paddr"], new_filesz, new_filesz, ext["p_align"],
1992
+ )
1993
+
1994
+ # `reserved_ranges` (seeded above, before the per-dependency loop)
1995
+ # only ever checked a fixed-base node's reservation against the
1996
+ # host's own *original* PT_LOAD ranges and other fixed-base
1997
+ # nodes' reservations -- never against `ext`, since fixed-base
1998
+ # nodes don't participate in `next_vaddr`'s progression and
1999
+ # `ext`'s own eventual grown extent (every ordinary dependency's
2000
+ # blob, the combined relocation table, the phdr-array placeholder
2001
+ # -- everything laid out just above) isn't known until right now,
2002
+ # well after every fixed-base node already passed its own check.
2003
+ # A fixed-base reservation that looked clear of the host at the
2004
+ # time could still end up virtually overlapping wherever `ext`
2005
+ # actually grew to by the time this method is done -- the same
2006
+ # silent-overlap anti-goal `reserved_ranges`'s own checks exist to
2007
+ # catch everywhere else, just caught late instead of never (same
2008
+ # class of gap as pe_embedder's alignment-gap-filler fix).
2009
+ if fixed_base_segments:
2010
+ ext_final_lo, ext_final_hi = ext["p_vaddr"], ext["p_vaddr"] + new_filesz
2011
+ for X, blob in fixed_base_segments:
2012
+ lo, hi = X, X + len(blob)
2013
+ if lo < ext_final_hi and ext_final_lo < hi:
2014
+ raise ValueError(
2015
+ f"base address conflict at {hex(max(lo, ext_final_lo))} between a "
2016
+ f"fixed-base dependency (base {hex(X)}) and the host's own extended "
2017
+ f"image (grown to {hex(ext_final_lo)}-{hex(ext_final_hi)})"
2018
+ )
2019
+
2020
+ if not fixed_base_segments:
2021
+ entry_off = elf["e_phoff"] + ext_idx * elf["e_phentsize"]
2022
+ raw[entry_off:entry_off + len(extended_entry)] = extended_entry
2023
+ if eh_hdr_update is not None:
2024
+ eh_entry_off = elf["e_phoff"] + host_gnu_eh_frame_idx * elf["e_phentsize"]
2025
+ raw[eh_entry_off:eh_entry_off + len(eh_hdr_update)] = eh_hdr_update
2026
+ if dynamic_phdr_update is not None:
2027
+ dyn_entry_off = elf["e_phoff"] + host_dynamic_idx * elf["e_phentsize"]
2028
+ raw[dyn_entry_off:dyn_entry_off + len(dynamic_phdr_update)] = dynamic_phdr_update
2029
+ else:
2030
+ # Grow the program header array itself: copy the *original*
2031
+ # array byte-for-byte (still untouched, at its original
2032
+ # location -- only `ext`'s own entry within the copy gets
2033
+ # updated), append one brand-new PT_LOAD entry per fixed-base
2034
+ # dependency, and write the result into the placeholder
2035
+ # reserved for it above (inside `ext`'s own just-finished
2036
+ # extension, so it shares whatever segment-specific skew
2037
+ # AT_PHDR's derivation actually keys off -- see the comment
2038
+ # by that placeholder for what that is and why it's what
2039
+ # keeps AT_PHDR correct here). The *original* array's bytes are
2040
+ # simply abandoned in place (dead, harmless bytes nothing reads
2041
+ # anymore once e_phoff is repointed) -- the same
2042
+ # leave-it-dead-in-place pattern _remove_needed already uses
2043
+ # for a neutralized DT_NEEDED entry.
2044
+ entsize = elf["e_phentsize"]
2045
+ old_phnum = elf.num_segments()
2046
+ old_array_bytes = bytes(raw[elf["e_phoff"]: elf["e_phoff"] + old_phnum * entsize])
2047
+ new_array = bytearray(old_array_bytes)
2048
+ new_array[ext_idx * entsize:(ext_idx + 1) * entsize] = extended_entry
2049
+ if eh_hdr_update is not None:
2050
+ new_array[host_gnu_eh_frame_idx * entsize:(host_gnu_eh_frame_idx + 1) * entsize] = eh_hdr_update
2051
+ if dynamic_phdr_update is not None:
2052
+ new_array[host_dynamic_idx * entsize:(host_dynamic_idx + 1) * entsize] = dynamic_phdr_update
2053
+
2054
+ placeholder_file_offset = phdr_array_vaddr - ext_skew
2055
+
2056
+ # The PT_PHDR entry (if any -- not every ELF file has one, but
2057
+ # every ordinary executable does) is the *other* place the
2058
+ # array's own location/size is recorded, independently of
2059
+ # e_phoff/e_phnum, and it's just as important to keep correct:
2060
+ # glibc's ld.so derives the main executable's load bias not
2061
+ # from e_phoff but from `AT_PHDR - PT_PHDR.p_vaddr` (AT_PHDR
2062
+ # being where the kernel *actually* put the array, which is
2063
+ # correct automatically -- see the e_phoff/e_phnum comment
2064
+ # below). Leaving PT_PHDR's own copy of the array's old
2065
+ # location in place would make that subtraction produce a
2066
+ # bogus nonzero "load bias" out of what should be a
2067
+ # non-relocated (ET_EXEC) image, corrupting every other
2068
+ # address ld.so derives from it -- confirmed by actually
2069
+ # running the result on real Linux (WSL): it crashes inside
2070
+ # ld.so itself, before any user code runs, with this entry
2071
+ # stale, and runs correctly once it's updated here too.
2072
+ phdr_idx = next((i for i, s in enumerate(all_host_segments) if s["p_type"] == "PT_PHDR"), None)
2073
+ if phdr_idx is not None:
2074
+ phdr_seg = all_host_segments[phdr_idx]
2075
+ updated_phdr_entry = _pack_phdr(
2076
+ is64, PT_PHDR, phdr_seg["p_flags"], placeholder_file_offset, phdr_array_vaddr,
2077
+ phdr_array_vaddr, phdr_array_size, phdr_array_size, phdr_seg["p_align"],
2078
+ )
2079
+ new_array[phdr_idx * entsize:(phdr_idx + 1) * entsize] = updated_phdr_entry
2080
+
2081
+ # Each fixed-base dependency's own blob is appended past the
2082
+ # very end of the file as it now stands (which already
2083
+ # includes `ext`'s whole extension, placeholder and all) --
2084
+ # unlike everything in `new_sections`, it doesn't need to be
2085
+ # contiguous with `ext` at all (it gets its own PT_LOAD entry,
2086
+ # with its own independent p_offset/p_vaddr pair), only
2087
+ # page-aligned consistently with its own fixed vaddr, which an
2088
+ # at-most-one-page padding gap is always enough to arrange
2089
+ # (the ELF ABI only requires p_vaddr and p_offset to agree
2090
+ # modulo p_align, not to be numerically equal).
2091
+ for X, blob in fixed_base_segments:
2092
+ offset = len(raw) + ((X - len(raw)) % _PAGE)
2093
+ raw.extend(b"\x00" * (offset - len(raw)))
2094
+ raw.extend(blob)
2095
+ new_array += _pack_phdr(is64, PT_LOAD, PF_R | PF_W | PF_X, offset, X, X, len(blob), len(blob), _PAGE)
2096
+
2097
+ if len(new_array) != phdr_array_size:
2098
+ raise ValueError("internal error: grown ELF program header array size mismatch")
2099
+ raw[placeholder_file_offset:placeholder_file_offset + len(new_array)] = new_array
2100
+
2101
+ # e_phoff/e_phnum are fixed-size fields at a fixed byte offset
2102
+ # in every ELF32/ELF64 header alike (this module writes every
2103
+ # byte it touches from the ABI's own fixed, hand-verified
2104
+ # struct layouts rather than through pyelftools, which is
2105
+ # read-only -- see this module's own top-of-file docstring);
2106
+ # e_phoff is ptr-sized (4 bytes for ELF32, 8 for ELF64) and
2107
+ # immediately follows e_ident/e_type/e_machine/e_version/
2108
+ # e_entry, while e_phnum is a fixed 2-byte field three
2109
+ # ptr-sized fields (e_phoff/e_shoff) plus one 4-byte field
2110
+ # (e_flags) and two 2-byte fields (e_ehsize/e_phentsize)
2111
+ # further along.
2112
+ e_phoff_off = 16 + 2 + 2 + 4 + ptr_size
2113
+ e_phnum_off = e_phoff_off + ptr_size * 2 + 4 + 2 + 2
2114
+ _write_int(raw, e_phoff_off, ptr_size, placeholder_file_offset)
2115
+ _write_int(raw, e_phnum_off, 2, old_phnum + len(fixed_base_segments))
2116
+
2117
+ # e_entry is overwritten to point at the generated stub only when
2118
+ # one was actually built as an EXE/PIE-exe entrypoint stub
2119
+ # (`entry_stub_vaddr` set above, by the init_array_calls-gated
2120
+ # block's EXE/PIE-exe branch); otherwise -- including the B6-ELF
2121
+ # .so-input case, where the init-array stub was installed via
2122
+ # DT_INIT_ARRAY instead, never touching e_entry at all -- it's
2123
+ # left completely untouched. e_entry is a fixed, ptr-sized field
2124
+ # immediately following e_ident/e_type/e_machine/e_version in
2125
+ # every ELF32/ELF64 header alike.
2126
+ if entry_stub_vaddr is not None:
2127
+ e_entry_off = 16 + 2 + 2 + 4
2128
+ _write_int(raw, e_entry_off, ptr_size, entry_stub_vaddr)
2129
+
2130
+ self.output_path.write_bytes(bytes(raw))
2131
+ # Moderate final-review finding: write_bytes() alone creates the
2132
+ # output file with the process's own default (umask-governed)
2133
+ # permissions, losing the input's executable bit -- every test in
2134
+ # this suite has had to work around this by calling
2135
+ # `out.chmod(0o755)` itself right after `embed()` returns,
2136
+ # strongly suggesting this was never actually handled here.
2137
+ # Preserve the input's own full permission bits (not just force
2138
+ # a fixed 0o755) so an input with unusual permissions (e.g. a
2139
+ # setuid/setgid bit, or a more restrictive executable-only mode)
2140
+ # round-trips the same way, matching the CLI's documented
2141
+ # "produces a working replacement for the original binary" goal.
2142
+ shutil.copymode(self.input_path, self.output_path)