mirror of
https://github.com/NohamR/pyzule-rw.git
synced 2026-10-10 18:39:41 +00:00
chore: python package
This commit is contained in:
43
cyan/__main__.py
Executable file
43
cyan/__main__.py
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
# cyan, aka pyzule-rw; by zx, 2024
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if sys.version_info < (3, 12):
|
||||
sys.exit("[!] please upgrade to python 3.12 or higher")
|
||||
elif sys.platform == "win32":
|
||||
sys.exit("[!] windows is not supported")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="cyan, an azule \"clone\" for modifying iOS apps"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-i", "--input", metavar="input", required=True,
|
||||
help="the app to be modified (.app/.ipa)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output", metavar="output",
|
||||
help="if unspecified, overwrites input"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-f", metavar="file", nargs="+",
|
||||
help="a tweak to inject/item to be added to the bundle"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--ignore-encrypted", action="store_true",
|
||||
help="skip main binary encryption check"
|
||||
)
|
||||
|
||||
from cyan import logic
|
||||
logic.main(parser)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
68
cyan/logic.py
Normal file
68
cyan/logic.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from subprocess import run
|
||||
from argparse import ArgumentParser
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from cyan import tbhutils, tbhtypes
|
||||
|
||||
|
||||
def main(parser: ArgumentParser) -> None:
|
||||
args = parser.parse_args()
|
||||
args.i = os.path.normpath(args.input)
|
||||
|
||||
if args.output is not None:
|
||||
args.o = os.path.normpath(args.output)
|
||||
if not (args.o.endswith(".app") or args.o.endswith(".ipa")):
|
||||
print("[?] output's file extension not specified; will create ipa")
|
||||
args.o += ".ipa"
|
||||
else:
|
||||
args.o = args.i
|
||||
|
||||
# this also modifies some args, like -f,
|
||||
# to ensure there are no duplicates, etc
|
||||
arg_err = tbhutils.validate_inputs(args)
|
||||
if arg_err is not None:
|
||||
parser.error(arg_err)
|
||||
|
||||
INPUT_IS_IPA = True if args.i.endswith(".ipa") else False
|
||||
OUTPUT_IS_IPA = True if args.o.endswith(".ipa") else False
|
||||
|
||||
with TemporaryDirectory() as tmpdir, tbhtypes.LeavingCM():
|
||||
app_path, plist_path = tbhutils.get_app(args.i, tmpdir, INPUT_IS_IPA)
|
||||
app = tbhtypes.AppBundle(app_path, plist_path)
|
||||
|
||||
if app.executable.is_encrypted():
|
||||
if args.ignore_encrypted:
|
||||
print("[?] main binary is encrypted, ignoring")
|
||||
else:
|
||||
sys.exit("[!] main binary is encrypted; exiting")
|
||||
|
||||
if args.f is not None:
|
||||
app.executable.inject(args.f, tmpdir)
|
||||
|
||||
# done!
|
||||
if OUTPUT_IS_IPA:
|
||||
print("[*] generating ipa..")
|
||||
|
||||
run(
|
||||
["bash", f"{tbhtypes.Executable.install_dir}/tools/compressIPA.sh"],
|
||||
env={
|
||||
"TMPDIR": tmpdir,
|
||||
"OUTPUT": args.o
|
||||
}
|
||||
)
|
||||
|
||||
print(f"[*] generated ipa at {args.o}")
|
||||
else:
|
||||
# create subdirectories if necessary
|
||||
if "/" in args.o:
|
||||
os.makedirs(os.path.dirname(args.o), exist_ok=True)
|
||||
|
||||
if os.path.isdir(args.o):
|
||||
shutil.rmtree(args.o)
|
||||
|
||||
shutil.move(app_path, args.o)
|
||||
print(f"[*] generated app at {args.o}")
|
||||
|
||||
10
cyan/tbhtypes/__init__.py
Normal file
10
cyan/tbhtypes/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .app_bundle import AppBundle
|
||||
from .executable import Executable
|
||||
from .leaving_cm import LeavingCM
|
||||
|
||||
__all__ = [
|
||||
"AppBundle",
|
||||
"Executable",
|
||||
"LeavingCM"
|
||||
]
|
||||
|
||||
14
cyan/tbhtypes/app_bundle.py
Normal file
14
cyan/tbhtypes/app_bundle.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from cyan.tbhutils import get_plist
|
||||
from .executable import Executable
|
||||
|
||||
|
||||
class AppBundle:
|
||||
def __init__(self, path: str, plist_path: str):
|
||||
self.path = path
|
||||
self.plist = get_plist(plist_path)
|
||||
|
||||
self.executable = Executable(
|
||||
f"{path}/{self.plist["CFBundleExecutable"]}",
|
||||
path
|
||||
)
|
||||
|
||||
0
cyan/tbhtypes/dylib.py
Normal file
0
cyan/tbhtypes/dylib.py
Normal file
218
cyan/tbhtypes/executable.py
Normal file
218
cyan/tbhtypes/executable.py
Normal file
@@ -0,0 +1,218 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
import lief
|
||||
|
||||
from cyan import tbhutils
|
||||
|
||||
|
||||
class Executable:
|
||||
install_dir, specific = tbhutils.get_tools_dir()
|
||||
nt = f"{specific}/install_name_tool"
|
||||
ldid = f"{specific}/ldid"
|
||||
otool = f"{specific}/otool"
|
||||
|
||||
starters = ("\t/Library/", "\t@rpath", "\t@executable_path")
|
||||
|
||||
def __init__(self, path: str, bundle_path: Optional[str] = None):
|
||||
if not os.path.isfile(path):
|
||||
sys.exit(f"[!] {path} does not exist (executable)")
|
||||
|
||||
self.path = path
|
||||
self.bundle_path = bundle_path
|
||||
|
||||
self.bn = os.path.basename(path)
|
||||
self.inj: Optional[lief.MachO.Binary] = None
|
||||
|
||||
def is_encrypted(self) -> bool:
|
||||
proc = subprocess.run(
|
||||
[self.otool, "-l", self.path],
|
||||
capture_output=True
|
||||
)
|
||||
|
||||
# print(proc.stdout)
|
||||
return b"cryptid 1" in proc.stdout
|
||||
|
||||
def inject(self, tweaks: dict[str, str], tmpdir: str) -> None:
|
||||
# we only inject into the main executable
|
||||
assert self.bundle_path is not None
|
||||
|
||||
has_entitlements = False
|
||||
ENT_PATH = f"{self.bundle_path}/cyan.entitlements"
|
||||
PLUGINS_DIR = f"{self.bundle_path}/PlugIns"
|
||||
FRAMEWORKS_DIR = f"{self.bundle_path}/Frameworks"
|
||||
|
||||
with open(ENT_PATH, "wb") as entf:
|
||||
proc = subprocess.run(
|
||||
[self.ldid, "-e", self.path],
|
||||
capture_output=True
|
||||
)
|
||||
|
||||
entf.write(proc.stdout)
|
||||
|
||||
if os.path.getsize(ENT_PATH) > 0:
|
||||
has_entitlements = True
|
||||
|
||||
# iirc, injecting doesnt work (sometimes) if the file isn't signed
|
||||
self.fakesign(False)
|
||||
|
||||
if any(t.endswith(".appex") for t in tweaks):
|
||||
os.makedirs(PLUGINS_DIR, exist_ok=True)
|
||||
|
||||
if any(
|
||||
t.endswith(k)
|
||||
for t in tweaks
|
||||
for k in (".deb", ".dylib", ".framework")
|
||||
):
|
||||
os.makedirs(FRAMEWORKS_DIR, exist_ok=True)
|
||||
|
||||
# some apps really dont have this lol
|
||||
subprocess.run(
|
||||
[self.nt, "-add_rpath", "@executable_path/Frameworks", self.path],
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
# need ~~two~~ THREE loops, one for copying all files to tmpdir
|
||||
print("[*] preparing; this may take a while, sorry")
|
||||
for bn, path in dict(tweaks).items():
|
||||
if bn.endswith(".deb"):
|
||||
tbhutils.extract_deb(path, tweaks, tmpdir)
|
||||
continue
|
||||
|
||||
try:
|
||||
tweaks[bn] = shutil.copytree(path, f"{tmpdir}/{bn}")
|
||||
except NotADirectoryError:
|
||||
tweaks[bn] = shutil.copy2(path, tmpdir)
|
||||
|
||||
# print(f"[*] prepared {bn}")
|
||||
|
||||
needed: set[str] = set()
|
||||
common = {
|
||||
# substrate could show up as
|
||||
# CydiaSubstrate.framework, libsubstrate.dylib, CydiaSubstrate.dylib
|
||||
# and probably even more. it's crazy.
|
||||
|
||||
"ubstrate.": "CydiaSubstrate",
|
||||
"Orion.framework": "Orion",
|
||||
"Cephei.framework": "Cephei",
|
||||
"CepheiUI.framework": "CepheiUI",
|
||||
"CepheiPrefs.framework": "CepheiPrefs"
|
||||
}
|
||||
|
||||
# another loop for fixing dylib dependencies
|
||||
for dbn, path in tweaks.items():
|
||||
if not dbn.endswith(".dylib"):
|
||||
continue
|
||||
|
||||
dylib = Executable(path)
|
||||
dylib.fakesign()
|
||||
|
||||
# fix dependencies
|
||||
for dep in dylib.get_dependencies():
|
||||
for cname in (common | tweaks):
|
||||
if cname in dep:
|
||||
if cname.endswith(".framework"):
|
||||
npath = f"@rpath/{cname}/{cname[:-10]}"
|
||||
else:
|
||||
npath = f"@rpath/{cname}"
|
||||
|
||||
self.change_dependency(dep, npath)
|
||||
if cname in common:
|
||||
needed.add(cname)
|
||||
|
||||
# avoid printing that we "fixed" something to itself lol
|
||||
if dep != npath:
|
||||
print(f"[*] fixed dependency in {dbn}: {dep} -> {npath}")
|
||||
|
||||
## "sub"-loop, just adding the needed common deps
|
||||
if "ubstrate." in needed:
|
||||
del common["ubstrate."] # lol rip
|
||||
common["CydiaSubstrate.framework"] = "CydiaSubstrate"
|
||||
|
||||
needed.remove("ubstrate.")
|
||||
needed.add("CydiaSubstrate.framework")
|
||||
|
||||
if "Orion.framework" in needed:
|
||||
needed.add("CydiaSubstrate.framework")
|
||||
|
||||
for missing in needed:
|
||||
ip = f"{FRAMEWORKS_DIR}/{missing}"
|
||||
existed = tbhutils.delete_if_exists(ip, missing)
|
||||
shutil.copytree(f"{self.install_dir}/extras/{missing}", ip)
|
||||
|
||||
if not existed:
|
||||
print(f"[*] auto-injected {missing}")
|
||||
|
||||
# and FINALLY, one for actually injecting
|
||||
for bn, path in tweaks.items():
|
||||
if bn.endswith(".appex"):
|
||||
fpath = f"{PLUGINS_DIR}/{bn}"
|
||||
existed = tbhutils.delete_if_exists(fpath, bn)
|
||||
shutil.copytree(path, fpath)
|
||||
elif bn.endswith(".dylib"):
|
||||
fpath = f"{FRAMEWORKS_DIR}/{bn}"
|
||||
existed = tbhutils.delete_if_exists(fpath, bn)
|
||||
self.insert_cmd(f"@rpath/{bn}")
|
||||
shutil.copy2(path, FRAMEWORKS_DIR)
|
||||
elif bn.endswith(".framework"):
|
||||
fpath = f"{FRAMEWORKS_DIR}/{bn}"
|
||||
existed = tbhutils.delete_if_exists(fpath, bn)
|
||||
self.insert_cmd(f"@rpath/{bn}/{bn[:-10]}")
|
||||
shutil.copytree(path, fpath)
|
||||
else:
|
||||
fpath = f"{self.bundle_path}/{bn}"
|
||||
existed = tbhutils.delete_if_exists(fpath, bn)
|
||||
try:
|
||||
shutil.copytree(path, fpath)
|
||||
except NotADirectoryError:
|
||||
shutil.copy2(path, self.bundle_path)
|
||||
|
||||
if not existed:
|
||||
print(f"[*] injected {bn}")
|
||||
|
||||
# FINALLY !!
|
||||
if self.inj is not None:
|
||||
self.inj.write(self.path)
|
||||
|
||||
if has_entitlements:
|
||||
subprocess.run(["ldid", f"-S{ENT_PATH}", self.path])
|
||||
print("[*] restored entitlements")
|
||||
|
||||
def fakesign(self, keep_entitlements: bool = True) -> None:
|
||||
cmd = [self.ldid, "-S"]
|
||||
if keep_entitlements:
|
||||
cmd.append("-M")
|
||||
|
||||
subprocess.run(cmd + [self.path])
|
||||
|
||||
def change_dependency(self, old: str, new: str) -> None:
|
||||
subprocess.run([self.nt, "-change", old, new, self.path])
|
||||
|
||||
def insert_cmd(self, cmd: str) -> None:
|
||||
if self.inj is None:
|
||||
self.inj = lief.parse(self.path) # type: ignore
|
||||
|
||||
self.inj.add(lief.MachO.DylibCommand.weak_lib(cmd)) # type: ignore
|
||||
|
||||
def get_dependencies(self) -> list[str]:
|
||||
proc = subprocess.run(
|
||||
[self.otool, "-L", self.path],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
# split at [2:] to avoid otool's line and dylib's id
|
||||
tmp = proc.stdout.strip().split("\n")[2:]
|
||||
for ind, dep in enumerate(tmp):
|
||||
if "(architecture " in dep: # avoid checking duplicate deps
|
||||
tmp = tmp[:ind]
|
||||
|
||||
deps: list[str] = []
|
||||
for dep in tmp:
|
||||
if any(dep.startswith(s) for s in self.starters):
|
||||
deps.append(dep.split()[0]) # split() removes whitespace
|
||||
|
||||
return deps
|
||||
|
||||
7
cyan/tbhtypes/leaving_cm.py
Normal file
7
cyan/tbhtypes/leaving_cm.py
Normal file
@@ -0,0 +1,7 @@
|
||||
class LeavingCM:
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, i, d, c): # type: ignore
|
||||
print("[*] deleting temporary directory..")
|
||||
|
||||
152
cyan/tbhutils.py
Normal file
152
cyan/tbhutils.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import zipfile
|
||||
import platform
|
||||
import plistlib
|
||||
import subprocess
|
||||
from glob import glob
|
||||
from argparse import Namespace
|
||||
from importlib import resources # type: ignore
|
||||
from typing import Optional, Any
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
def validate_inputs(args: Namespace) -> Optional[str]:
|
||||
if not (
|
||||
args.i.endswith(".ipa")
|
||||
or args.i.endswith(".app")
|
||||
):
|
||||
return "the input file must be an ipa/app"
|
||||
|
||||
if not os.path.exists(args.i):
|
||||
return f"{args.i} does not exist"
|
||||
|
||||
if os.path.exists(args.o):
|
||||
try:
|
||||
overwrite = input(
|
||||
f"[<] {args.o} already exists, overwrite it? [Y/n] "
|
||||
if args.output is not None
|
||||
else "[<] no output was specified. overwrite the input? [Y/n] "
|
||||
).strip().lower()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit("[>] bye!")
|
||||
|
||||
if overwrite not in ("y", "yes", ""):
|
||||
print("[>] quitting.")
|
||||
sys.exit(0)
|
||||
|
||||
if args.f is not None:
|
||||
# dictionary ensures unique names
|
||||
args.f = {os.path.basename(f): os.path.normpath(f) for f in args.f}
|
||||
nonexistent = [f for f in args.f.values() if not os.path.exists(f)]
|
||||
|
||||
if len(nonexistent) != 0:
|
||||
print("[!] please ensure the following file(s) exist:")
|
||||
for ne in nonexistent:
|
||||
print(f"[?] - {ne}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_app(path: str, tmpdir: str, is_ipa: bool) -> tuple[str, str]:
|
||||
payload = f"{tmpdir}/Payload"
|
||||
|
||||
if is_ipa:
|
||||
print("[*] extracting ipa..")
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path) as ipa:
|
||||
names = ipa.namelist()
|
||||
|
||||
if not any(name.startswith("Payload/") for name in names):
|
||||
raise KeyError
|
||||
elif not any(name.endswith(".app/Info.plist") for name in names):
|
||||
sys.exit("[!] no Info.plist, invalid app")
|
||||
|
||||
ipa.extractall(tmpdir)
|
||||
app = glob(f"{payload}/*.app")[0]
|
||||
plist = f"{app}/Info.plist"
|
||||
except (KeyError, IndexError):
|
||||
sys.exit("[!] couldn't find either Payload or app folder, invalid ipa")
|
||||
except zipfile.BadZipFile:
|
||||
sys.exit(f"[!] {path} is not a zipfile (ipa)")
|
||||
|
||||
print("[*] extracted ipa")
|
||||
else:
|
||||
if not os.path.isfile((plist := f"{path}/Info.plist")):
|
||||
sys.exit("[!] no Info.plist, invalid app")
|
||||
|
||||
print("[*] copying app..")
|
||||
shutil.copytree(path, (app := f"{payload}/{os.path.basename(path)}"))
|
||||
print("[*] copied app")
|
||||
|
||||
return app, plist
|
||||
|
||||
|
||||
def get_tools_dir() -> tuple[str, str]:
|
||||
mach = platform.machine()
|
||||
system = platform.system()
|
||||
|
||||
if "iPhone" in mach or "iPad" in mach:
|
||||
mach = "arm64"
|
||||
|
||||
with resources.files() as files: # type: ignore
|
||||
return (
|
||||
str(files), # type: ignore
|
||||
str(files / "tools" / system / mach) # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def get_plist(path: str) -> dict[str, Any]:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return plistlib.load(f)
|
||||
except Exception:
|
||||
sys.exit(f"[!] couldn't read {path}")
|
||||
|
||||
|
||||
def delete_if_exists(path: str, bn: str) -> bool:
|
||||
is_file = os.path.isfile(path)
|
||||
|
||||
try:
|
||||
if is_file:
|
||||
os.remove(path)
|
||||
else:
|
||||
shutil.rmtree(path)
|
||||
|
||||
print(f"[?] {bn} already existed, replacing")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def extract_deb(deb: str, tweaks: dict[str, str], tmpdir: str) -> None:
|
||||
with TemporaryDirectory(prefix=tmpdir + "/", delete=False) as t2:
|
||||
if platform.system() == "Linux":
|
||||
tool = ["ar", "-x", deb, f"--output={t2}"]
|
||||
else:
|
||||
tool = ["tar", "-xf", deb, f"--directory={t2}"]
|
||||
|
||||
try:
|
||||
subprocess.run(tool, check=True)
|
||||
except Exception:
|
||||
sys.exit(f"[!] couldn't extract {os.path.basename(deb)}")
|
||||
|
||||
# it's not always "data.tar.gz"
|
||||
data_tar = glob(f"{t2}/data.*")[0]
|
||||
subprocess.run(["tar", "-xf", data_tar, f"--directory={t2}"])
|
||||
|
||||
for hi in sum((
|
||||
glob(f"{t2}/**/*.dylib", recursive=True),
|
||||
glob(f"{t2}/**/*.bundle", recursive=True),
|
||||
glob(f"{t2}/**/*.appex", recursive=True),
|
||||
glob(f"{t2}/**/*.framework", recursive=True)
|
||||
), []): # type: ignore
|
||||
if os.path.islink(hi):
|
||||
continue # symlinks are broken iirc
|
||||
|
||||
tweaks[os.path.basename(hi)] = hi
|
||||
|
||||
print(f"[*] extracted {os.path.basename(deb)}")
|
||||
del tweaks[deb]
|
||||
|
||||
Reference in New Issue
Block a user