610 lines
16 KiB
Python
610 lines
16 KiB
Python
|
import argparse
|
||
|
import hashlib
|
||
|
import os
|
||
|
from pathlib import Path
|
||
|
import platform
|
||
|
import shutil
|
||
|
import struct
|
||
|
import subprocess
|
||
|
from typing import List, Optional, Tuple
|
||
|
|
||
|
parser = argparse.ArgumentParser(description="Fixup for MacOS application bundle")
|
||
|
parser.add_argument("input_directory", help="Input directory (Application path)")
|
||
|
parser.add_argument("executable_sub_path", help="Main executable sub path")
|
||
|
|
||
|
# Use Apple LLVM on Darwin, otherwise standard LLVM.
|
||
|
if platform.system() == "Darwin":
|
||
|
OTOOL = "otool"
|
||
|
INSTALL_NAME_TOOL = "install_name_tool"
|
||
|
else:
|
||
|
OTOOL = shutil.which("llvm-otool")
|
||
|
if OTOOL is None:
|
||
|
for llvm_ver in [15, 14, 13]:
|
||
|
otool_path = shutil.which(f"llvm-otool-{llvm_ver}")
|
||
|
if otool_path is not None:
|
||
|
OTOOL = otool_path
|
||
|
INSTALL_NAME_TOOL = shutil.which(f"llvm-install-name-tool-{llvm_ver}")
|
||
|
break
|
||
|
else:
|
||
|
INSTALL_NAME_TOOL = shutil.which("llvm-install-name-tool")
|
||
|
|
||
|
|
||
|
args = parser.parse_args()
|
||
|
|
||
|
|
||
|
def get_dylib_id(dylib_path: Path) -> str:
|
||
|
res = subprocess.check_output([OTOOL, "-D", str(dylib_path.absolute())]).decode(
|
||
|
"utf-8"
|
||
|
)
|
||
|
|
||
|
return res.split("\n")[1]
|
||
|
|
||
|
|
||
|
def get_dylib_dependencies(dylib_path: Path) -> List[str]:
|
||
|
output = (
|
||
|
subprocess.check_output([OTOOL, "-L", str(dylib_path.absolute())])
|
||
|
.decode("utf-8")
|
||
|
.split("\n")[1:]
|
||
|
)
|
||
|
|
||
|
res = []
|
||
|
|
||
|
for line in output:
|
||
|
line = line.strip()
|
||
|
index = line.find(" (compatibility version ")
|
||
|
if index == -1:
|
||
|
continue
|
||
|
|
||
|
line = line[:index]
|
||
|
|
||
|
res.append(line)
|
||
|
|
||
|
return res
|
||
|
|
||
|
|
||
|
def replace_dylib_id(dylib_path: Path, new_id: str):
|
||
|
subprocess.check_call(
|
||
|
[INSTALL_NAME_TOOL, "-id", new_id, str(dylib_path.absolute())]
|
||
|
)
|
||
|
|
||
|
|
||
|
def change_dylib_link(dylib_path: Path, old: str, new: str):
|
||
|
subprocess.check_call(
|
||
|
[INSTALL_NAME_TOOL, "-change", old, new, str(dylib_path.absolute())]
|
||
|
)
|
||
|
|
||
|
|
||
|
def add_dylib_rpath(dylib_path: Path, rpath: str):
|
||
|
subprocess.check_call(
|
||
|
[INSTALL_NAME_TOOL, "-add_rpath", rpath, str(dylib_path.absolute())]
|
||
|
)
|
||
|
|
||
|
|
||
|
def fixup_dylib(
|
||
|
dylib_path: Path,
|
||
|
replacement_path: str,
|
||
|
search_path: List[str],
|
||
|
content_directory: Path,
|
||
|
):
|
||
|
dylib_id = get_dylib_id(dylib_path)
|
||
|
new_dylib_id = replacement_path + "/" + os.path.basename(dylib_id)
|
||
|
replace_dylib_id(dylib_path, new_dylib_id)
|
||
|
|
||
|
dylib_dependencies = get_dylib_dependencies(dylib_path)
|
||
|
dylib_new_mapping = {}
|
||
|
|
||
|
for dylib_dependency in dylib_dependencies:
|
||
|
if (
|
||
|
not dylib_dependency.startswith("@executable_path")
|
||
|
and not dylib_dependency.startswith("/usr/lib")
|
||
|
and not dylib_dependency.startswith("/System/Library")
|
||
|
):
|
||
|
dylib_dependency_name = os.path.basename(dylib_dependency)
|
||
|
library_found = False
|
||
|
for library_base_path in search_path:
|
||
|
lib_path = Path(os.path.join(library_base_path, dylib_dependency_name))
|
||
|
|
||
|
if lib_path.exists():
|
||
|
target_replacement_path = get_path_related_to_target_exec(
|
||
|
content_directory, lib_path
|
||
|
)
|
||
|
|
||
|
dylib_new_mapping[dylib_dependency] = (
|
||
|
target_replacement_path
|
||
|
+ "/"
|
||
|
+ os.path.basename(dylib_dependency)
|
||
|
)
|
||
|
library_found = True
|
||
|
|
||
|
if not library_found:
|
||
|
raise Exception(
|
||
|
f"{dylib_id}: Cannot find dependency {dylib_dependency_name} for fixup"
|
||
|
)
|
||
|
|
||
|
for key in dylib_new_mapping:
|
||
|
change_dylib_link(dylib_path, key, dylib_new_mapping[key])
|
||
|
|
||
|
|
||
|
FILE_TYPE_ASSEMBLY = 1
|
||
|
|
||
|
ALIGN_REQUIREMENTS = 4096
|
||
|
|
||
|
|
||
|
def parse_embedded_string(data: bytes) -> Tuple[bytes, str]:
|
||
|
first_byte = data[0]
|
||
|
|
||
|
if (first_byte & 0x80) == 0:
|
||
|
size = first_byte
|
||
|
data = data[1:]
|
||
|
else:
|
||
|
second_byte = data[1]
|
||
|
|
||
|
assert (second_byte & 0x80) == 0
|
||
|
|
||
|
size = (second_byte << 7) | (first_byte & 0x7F)
|
||
|
|
||
|
data = data[2:]
|
||
|
|
||
|
res = data[:size].decode("utf-8")
|
||
|
data = data[size:]
|
||
|
|
||
|
return (data, res)
|
||
|
|
||
|
|
||
|
def write_embedded_string(file, string: str):
|
||
|
raw_str = string.encode("utf-8")
|
||
|
raw_str_len = len(raw_str)
|
||
|
|
||
|
assert raw_str_len < 0x7FFF
|
||
|
|
||
|
if raw_str_len > 0x7F:
|
||
|
file.write(struct.pack("b", raw_str_len & 0x7F | 0x80))
|
||
|
file.write(struct.pack("b", raw_str_len >> 7))
|
||
|
else:
|
||
|
file.write(struct.pack("b", raw_str_len))
|
||
|
|
||
|
file.write(raw_str)
|
||
|
|
||
|
|
||
|
class BundleFileEntry(object):
|
||
|
offset: int
|
||
|
size: int
|
||
|
compressed_size: int
|
||
|
file_type: int
|
||
|
relative_path: str
|
||
|
data: bytes
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
offset: int,
|
||
|
size: int,
|
||
|
compressed_size: int,
|
||
|
file_type: int,
|
||
|
relative_path: str,
|
||
|
data: bytes,
|
||
|
) -> None:
|
||
|
self.offset = offset
|
||
|
self.size = size
|
||
|
self.compressed_size = compressed_size
|
||
|
self.file_type = file_type
|
||
|
self.relative_path = relative_path
|
||
|
self.data = data
|
||
|
|
||
|
def write(self, file):
|
||
|
self.offset = file.tell()
|
||
|
|
||
|
if (
|
||
|
self.file_type == FILE_TYPE_ASSEMBLY
|
||
|
and (self.offset % ALIGN_REQUIREMENTS) != 0
|
||
|
):
|
||
|
padding_size = ALIGN_REQUIREMENTS - (self.offset % ALIGN_REQUIREMENTS)
|
||
|
file.write(b"\0" * padding_size)
|
||
|
self.offset += padding_size
|
||
|
|
||
|
file.write(self.data)
|
||
|
|
||
|
def write_header(self, file):
|
||
|
file.write(
|
||
|
struct.pack(
|
||
|
"QQQb", self.offset, self.size, self.compressed_size, self.file_type
|
||
|
)
|
||
|
)
|
||
|
write_embedded_string(file, self.relative_path)
|
||
|
|
||
|
|
||
|
class BundleManifest(object):
|
||
|
major: int
|
||
|
minor: int
|
||
|
bundle_id: str
|
||
|
deps_json: BundleFileEntry
|
||
|
runtimeconfig_json: BundleFileEntry
|
||
|
flags: int
|
||
|
files: List[BundleFileEntry]
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
major: int,
|
||
|
minor: int,
|
||
|
bundle_id: str,
|
||
|
deps_json: BundleFileEntry,
|
||
|
runtimeconfig_json: BundleFileEntry,
|
||
|
flags: int,
|
||
|
files: List[BundleFileEntry],
|
||
|
) -> None:
|
||
|
self.major = major
|
||
|
self.minor = minor
|
||
|
self.bundle_id = bundle_id
|
||
|
self.deps_json = deps_json
|
||
|
self.runtimeconfig_json = runtimeconfig_json
|
||
|
self.flags = flags
|
||
|
self.files = files
|
||
|
|
||
|
def write(self, file) -> int:
|
||
|
for bundle_file in self.files:
|
||
|
bundle_file.write(file)
|
||
|
|
||
|
bundle_header_offset = file.tell()
|
||
|
file.write(struct.pack("iiI", self.major, self.minor, len(self.files)))
|
||
|
write_embedded_string(file, self.bundle_id)
|
||
|
|
||
|
if self.deps_json is not None:
|
||
|
deps_json_location_offset = self.deps_json.offset
|
||
|
deps_json_location_size = self.deps_json.size
|
||
|
else:
|
||
|
deps_json_location_offset = 0
|
||
|
deps_json_location_size = 0
|
||
|
|
||
|
if self.runtimeconfig_json is not None:
|
||
|
runtimeconfig_json_location_offset = self.runtimeconfig_json.offset
|
||
|
runtimeconfig_json_location_size = self.runtimeconfig_json.size
|
||
|
else:
|
||
|
runtimeconfig_json_location_offset = 0
|
||
|
runtimeconfig_json_location_size = 0
|
||
|
|
||
|
file.write(
|
||
|
struct.pack("qq", deps_json_location_offset, deps_json_location_size)
|
||
|
)
|
||
|
file.write(
|
||
|
struct.pack(
|
||
|
"qq",
|
||
|
runtimeconfig_json_location_offset,
|
||
|
runtimeconfig_json_location_size,
|
||
|
)
|
||
|
)
|
||
|
file.write(struct.pack("q", self.flags))
|
||
|
|
||
|
for bundle_file in self.files:
|
||
|
bundle_file.write_header(file)
|
||
|
|
||
|
return bundle_header_offset
|
||
|
|
||
|
|
||
|
def read_file_entry(
|
||
|
raw_data: bytes, header_bytes: bytes
|
||
|
) -> Tuple[bytes, BundleFileEntry]:
|
||
|
(
|
||
|
offset,
|
||
|
size,
|
||
|
compressed_size,
|
||
|
file_type,
|
||
|
) = struct.unpack("QQQb", header_bytes[:0x19])
|
||
|
(header_bytes, relative_path) = parse_embedded_string(header_bytes[0x19:])
|
||
|
|
||
|
target_size = compressed_size
|
||
|
|
||
|
if target_size == 0:
|
||
|
target_size = size
|
||
|
|
||
|
return (
|
||
|
header_bytes,
|
||
|
BundleFileEntry(
|
||
|
offset,
|
||
|
size,
|
||
|
compressed_size,
|
||
|
file_type,
|
||
|
relative_path,
|
||
|
raw_data[offset : offset + target_size],
|
||
|
),
|
||
|
)
|
||
|
|
||
|
|
||
|
def get_dotnet_bundle_data(data: bytes) -> Optional[Tuple[int, int, BundleManifest]]:
|
||
|
offset = data.find(hashlib.sha256(b".net core bundle\n").digest())
|
||
|
|
||
|
if offset == -1:
|
||
|
return None
|
||
|
|
||
|
raw_header_offset = data[offset - 8 : offset]
|
||
|
(header_offset,) = struct.unpack("q", raw_header_offset)
|
||
|
header_bytes = data[header_offset:]
|
||
|
|
||
|
(
|
||
|
major,
|
||
|
minor,
|
||
|
files_count,
|
||
|
) = struct.unpack("iiI", header_bytes[:0xC])
|
||
|
header_bytes = header_bytes[0xC:]
|
||
|
|
||
|
(header_bytes, bundle_id) = parse_embedded_string(header_bytes)
|
||
|
|
||
|
# v2 header
|
||
|
(
|
||
|
deps_json_location_offset,
|
||
|
deps_json_location_size,
|
||
|
) = struct.unpack("qq", header_bytes[:0x10])
|
||
|
(
|
||
|
runtimeconfig_json_location_offset,
|
||
|
runtimeconfig_json_location_size,
|
||
|
) = struct.unpack("qq", header_bytes[0x10:0x20])
|
||
|
(flags,) = struct.unpack("q", header_bytes[0x20:0x28])
|
||
|
header_bytes = header_bytes[0x28:]
|
||
|
|
||
|
files = []
|
||
|
|
||
|
deps_json = None
|
||
|
runtimeconfig_json = None
|
||
|
|
||
|
for _ in range(files_count):
|
||
|
(header_bytes, file_entry) = read_file_entry(data, header_bytes)
|
||
|
|
||
|
files.append(file_entry)
|
||
|
|
||
|
if file_entry.offset == deps_json_location_offset:
|
||
|
deps_json = file_entry
|
||
|
elif file_entry.offset == runtimeconfig_json_location_offset:
|
||
|
runtimeconfig_json = file_entry
|
||
|
|
||
|
file_entry = files[0]
|
||
|
|
||
|
return (
|
||
|
file_entry.offset,
|
||
|
header_offset,
|
||
|
BundleManifest(
|
||
|
major, minor, bundle_id, deps_json, runtimeconfig_json, flags, files
|
||
|
),
|
||
|
)
|
||
|
|
||
|
|
||
|
LC_SYMTAB = 0x2
|
||
|
LC_SEGMENT_64 = 0x19
|
||
|
LC_CODE_SIGNATURE = 0x1D
|
||
|
|
||
|
|
||
|
def fixup_linkedit(file, data: bytes, new_size: int):
|
||
|
offset = 0
|
||
|
|
||
|
(
|
||
|
macho_magic,
|
||
|
macho_cputype,
|
||
|
macho_cpusubtype,
|
||
|
macho_filetype,
|
||
|
macho_ncmds,
|
||
|
macho_sizeofcmds,
|
||
|
macho_flags,
|
||
|
macho_reserved,
|
||
|
) = struct.unpack("IiiIIIII", data[offset : offset + 0x20])
|
||
|
|
||
|
offset += 0x20
|
||
|
|
||
|
linkedit_offset = None
|
||
|
symtab_offset = None
|
||
|
codesign_offset = None
|
||
|
|
||
|
for _ in range(macho_ncmds):
|
||
|
(cmd, cmdsize) = struct.unpack("II", data[offset : offset + 8])
|
||
|
|
||
|
if cmd == LC_SEGMENT_64:
|
||
|
(
|
||
|
cmd,
|
||
|
cmdsize,
|
||
|
segname_raw,
|
||
|
vmaddr,
|
||
|
vmsize,
|
||
|
fileoff,
|
||
|
filesize,
|
||
|
maxprot,
|
||
|
initprot,
|
||
|
nsects,
|
||
|
flags,
|
||
|
) = struct.unpack("II16sQQQQiiII", data[offset : offset + 72])
|
||
|
segname = segname_raw.decode("utf-8").split("\0")[0]
|
||
|
|
||
|
if segname == "__LINKEDIT":
|
||
|
linkedit_offset = offset
|
||
|
elif cmd == LC_SYMTAB:
|
||
|
symtab_offset = offset
|
||
|
elif cmd == LC_CODE_SIGNATURE:
|
||
|
codesign_offset = offset
|
||
|
|
||
|
offset += cmdsize
|
||
|
pass
|
||
|
|
||
|
assert linkedit_offset is not None and symtab_offset is not None
|
||
|
|
||
|
# If there is a codesign section, clean it up.
|
||
|
if codesign_offset is not None:
|
||
|
(
|
||
|
codesign_cmd,
|
||
|
codesign_cmdsize,
|
||
|
codesign_dataoff,
|
||
|
codesign_datasize,
|
||
|
) = struct.unpack("IIII", data[codesign_offset : codesign_offset + 16])
|
||
|
file.seek(codesign_offset)
|
||
|
file.write(b"\0" * codesign_cmdsize)
|
||
|
|
||
|
macho_ncmds -= 1
|
||
|
macho_sizeofcmds -= codesign_cmdsize
|
||
|
file.seek(0)
|
||
|
file.write(
|
||
|
struct.pack(
|
||
|
"IiiIIIII",
|
||
|
macho_magic,
|
||
|
macho_cputype,
|
||
|
macho_cpusubtype,
|
||
|
macho_filetype,
|
||
|
macho_ncmds,
|
||
|
macho_sizeofcmds,
|
||
|
macho_flags,
|
||
|
macho_reserved,
|
||
|
)
|
||
|
)
|
||
|
|
||
|
file.seek(codesign_dataoff)
|
||
|
file.write(b"\0" * codesign_datasize)
|
||
|
|
||
|
(
|
||
|
symtab_cmd,
|
||
|
symtab_cmdsize,
|
||
|
symtab_symoff,
|
||
|
symtab_nsyms,
|
||
|
symtab_stroff,
|
||
|
symtab_strsize,
|
||
|
) = struct.unpack("IIIIII", data[symtab_offset : symtab_offset + 24])
|
||
|
|
||
|
symtab_strsize = new_size - symtab_stroff
|
||
|
|
||
|
new_symtab = struct.pack(
|
||
|
"IIIIII",
|
||
|
symtab_cmd,
|
||
|
symtab_cmdsize,
|
||
|
symtab_symoff,
|
||
|
symtab_nsyms,
|
||
|
symtab_stroff,
|
||
|
symtab_strsize,
|
||
|
)
|
||
|
|
||
|
file.seek(symtab_offset)
|
||
|
file.write(new_symtab)
|
||
|
|
||
|
(
|
||
|
linkedit_cmd,
|
||
|
linkedit_cmdsize,
|
||
|
linkedit_segname_raw,
|
||
|
linkedit_vmaddr,
|
||
|
linkedit_vmsize,
|
||
|
linkedit_fileoff,
|
||
|
linkedit_filesize,
|
||
|
linkedit_maxprot,
|
||
|
linkedit_initprot,
|
||
|
linkedit_nsects,
|
||
|
linkedit_flags,
|
||
|
) = struct.unpack("II16sQQQQiiII", data[linkedit_offset : linkedit_offset + 72])
|
||
|
|
||
|
linkedit_filesize = new_size - linkedit_fileoff
|
||
|
linkedit_vmsize = linkedit_filesize
|
||
|
|
||
|
new_linkedit = struct.pack(
|
||
|
"II16sQQQQiiII",
|
||
|
linkedit_cmd,
|
||
|
linkedit_cmdsize,
|
||
|
linkedit_segname_raw,
|
||
|
linkedit_vmaddr,
|
||
|
linkedit_vmsize,
|
||
|
linkedit_fileoff,
|
||
|
linkedit_filesize,
|
||
|
linkedit_maxprot,
|
||
|
linkedit_initprot,
|
||
|
linkedit_nsects,
|
||
|
linkedit_flags,
|
||
|
)
|
||
|
file.seek(linkedit_offset)
|
||
|
file.write(new_linkedit)
|
||
|
|
||
|
|
||
|
def write_bundle_data(
|
||
|
output,
|
||
|
old_bundle_base_offset: int,
|
||
|
new_bundle_base_offset: int,
|
||
|
bundle: BundleManifest,
|
||
|
) -> int:
|
||
|
# Write bundle data
|
||
|
bundle_header_offset = bundle.write(output)
|
||
|
total_size = output.tell()
|
||
|
|
||
|
# Patch the header position
|
||
|
offset = file_data.find(hashlib.sha256(b".net core bundle\n").digest())
|
||
|
output.seek(offset - 8)
|
||
|
output.write(struct.pack("q", bundle_header_offset))
|
||
|
|
||
|
return total_size - new_bundle_base_offset
|
||
|
|
||
|
|
||
|
input_directory: Path = Path(args.input_directory)
|
||
|
content_directory: Path = Path(os.path.join(args.input_directory, "Contents"))
|
||
|
executable_path: Path = Path(os.path.join(content_directory, args.executable_sub_path))
|
||
|
|
||
|
|
||
|
def get_path_related_to_other_path(a: Path, b: Path) -> str:
|
||
|
temp = b
|
||
|
|
||
|
parts = []
|
||
|
|
||
|
while temp != a:
|
||
|
temp = temp.parent
|
||
|
parts.append(temp.name)
|
||
|
|
||
|
parts.remove(parts[-1])
|
||
|
parts.reverse()
|
||
|
|
||
|
return "/".join(parts)
|
||
|
|
||
|
|
||
|
def get_path_related_to_target_exec(input_directory: Path, path: Path):
|
||
|
return "@executable_path/../" + get_path_related_to_other_path(
|
||
|
input_directory, path
|
||
|
)
|
||
|
|
||
|
|
||
|
search_path = [
|
||
|
Path(os.path.join(content_directory, "Frameworks")),
|
||
|
Path(os.path.join(content_directory, "Resources/lib")),
|
||
|
]
|
||
|
|
||
|
|
||
|
for path in content_directory.rglob("**/*.dylib"):
|
||
|
current_search_path = [path.parent]
|
||
|
current_search_path.extend(search_path)
|
||
|
|
||
|
fixup_dylib(
|
||
|
path,
|
||
|
get_path_related_to_target_exec(content_directory, path),
|
||
|
current_search_path,
|
||
|
content_directory,
|
||
|
)
|
||
|
|
||
|
for path in content_directory.rglob("**/*.so"):
|
||
|
current_search_path = [path.parent]
|
||
|
current_search_path.extend(search_path)
|
||
|
|
||
|
fixup_dylib(
|
||
|
path,
|
||
|
get_path_related_to_target_exec(content_directory, path),
|
||
|
current_search_path,
|
||
|
content_directory,
|
||
|
)
|
||
|
|
||
|
|
||
|
with open(executable_path, "rb") as input:
|
||
|
file_data = input.read()
|
||
|
|
||
|
|
||
|
(bundle_base_offset, bundle_header_offset, bundle) = get_dotnet_bundle_data(file_data)
|
||
|
|
||
|
add_dylib_rpath(executable_path, "@executable_path/../Frameworks/")
|
||
|
|
||
|
# Recent "vanilla" version of LLVM (LLVM 13 and upper) seems to really dislike how .NET package its assemblies.
|
||
|
# As a result, after execution of install_name_tool it will have "fixed" the symtab resulting in a missing .NET bundle...
|
||
|
# To mitigate that, we check if the bundle offset inside the binary is valid after install_name_tool and readd .NET bundle if not.
|
||
|
output_file_size = os.stat(executable_path).st_size
|
||
|
if output_file_size < bundle_header_offset:
|
||
|
print("LLVM broke the .NET bundle, readding bundle data...")
|
||
|
with open(executable_path, "r+b") as output:
|
||
|
file_data = output.read()
|
||
|
bundle_data_size = write_bundle_data(
|
||
|
output, bundle_base_offset, output_file_size, bundle
|
||
|
)
|
||
|
|
||
|
# Now patch the __LINKEDIT section
|
||
|
new_size = output_file_size + bundle_data_size
|
||
|
fixup_linkedit(output, file_data, new_size)
|