5 Commits
v1.0 ... v1.1

Author SHA1 Message Date
zx
5ebab86a44 whoopsies 2024-09-08 22:32:43 -04:00
zx
c8197a8c47 feat: .cyan files 2024-09-08 22:31:26 -04:00
zx
1e9607d9e1 feat: change app icons 2024-09-08 19:29:59 -04:00
zx
30fa189197 fix: validate minimum version 2024-09-08 18:54:35 -04:00
zx
5c0363b5a9 feat: plist operations 2024-09-08 18:48:05 -04:00
7 changed files with 220 additions and 24 deletions

View File

@@ -1,41 +1,47 @@
# pyzule-rw / cyan
rewriting [pyzule](https://github.com/asdfzxcvbn/pyzule), but actually good this time! btw.. cyan/pyzule looks best with a monospace font !! :D
a rewrite of [pyzule](https://github.com/asdfzxcvbn/pyzule) that doesn't (completely) suck !!
this is pretty much just a hobby project that i'll work on when i'm bored. pyzule in its current state is kinda ugly, but it works and i haven't really had the motivation to work on it. there really isn't any need for improvements, anyway..
`cyan` will ~~target python v3.12 (finally!)~~, be pep8-compliant (hopefully..), and be type-hinted!
cyan supports **linux, macOS, WSL, and jailbroken iOS!** all either x86_64 or arm64/aarch64 !!
## features
more coming soon! i'm trying to copy pyzule in order to deprecate it in favor of cyan, which is 1000x better
- generate and use [shareable .cyan files](https://github.com/asdfzxcvbn/cyan-gen) to configure IPAs!
- inject deb, dylib, framework, bundle, and appex files/folders
- automatically fix dependencies on CydiaSubstrate **(cyan uses [ElleKit](https://github.com/evelyneee/ellekit/)!)**, Cephei*, and Orion
- copy any unknown file/folder types to app root
- change app name, version, bundle id, and minimum os version
- remove UISupportedDevices
- remove watch app
- change the app icon
- fakesign the output ipa/app
- thin all binaries to arm64, it can LARGELY reduce app size sometimes!
- remove all app extensions (or just encrypted ones!)
## install instructions
cyan works on linux, macOS, WSL, and jailbroken iOS!
first, make sure you have [ar](https://command-not-found.com/ar) and [tar](https://command-not-found.com/tar) installed
also obviously install python, version 3.9 or greater is required (the version available on the procursus repo for iOS)
**if you want to inject dylibs AND ARE NOT ON iOS,** make sure you install lief (you only have to do this once): `pip install -U lief`
**if you want to change app icons (iOS NOT supported),** also make sure you install pillow: `pip install Pillow`
then finally, to install or update cyan, just `pip install --force-reinstall git+https://github.com/asdfzxcvbn/pyzule-rw.git#egg=cyan`
## todo
## acknowledgements
- [Al4ise](https://github.com/Al4ise) for the original [Azule](https://github.com/Al4ise/Azule)
- [lief-project](https://github.com/lief-project) for [LIEF](https://github.com/lief-project/LIEF)
### todo
[x] refactor: dont prepare, just copy and fix as you go
[2] feat: plist operations (-n, -v, -b, -m, -l, -r)
[x] feat: plist operations (-l and -r wont be implemented)
[x] feat: remove watch app
@@ -45,5 +51,9 @@ then finally, to install or update cyan, just `pip install --force-reinstall git
[x] feat: plugin operations (-q, -e)
[] feat: .cyan files (lol rip .pyzule files)
[x] feat: change app icon
[x] feat: .cyan files (lol rip .pyzule files)
:D

View File

@@ -21,11 +21,35 @@ def main() -> None:
"-o", "--output", metavar="output",
help="if unspecified, overwrites input"
)
parser.add_argument(
"-z", "--cyan", metavar="cyan",
help="the .cyan file to use"
)
parser.add_argument(
"-f", metavar="file", nargs="+",
help="a tweak to inject/item to be added to the bundle"
)
parser.add_argument(
"-n", metavar="name",
help="modify the app's name"
)
parser.add_argument(
"-v", metavar="version",
help="modify the app's version"
)
parser.add_argument(
"-b", metavar="bundle id",
help="modify the app's bundle id"
)
parser.add_argument(
"-m", metavar="minimum",
help="modify the app's minimum OS version"
)
parser.add_argument(
"-k", metavar="icon",
help="modify the app's icon"
)
parser.add_argument(
"-u", "--remove-supported-devices", action="store_true",
@@ -71,7 +95,7 @@ def main() -> None:
)
parser.add_argument(
"-v", "--version", action="version", version="cyan v1.0"
"--version", action="version", version="cyan v1.0"
)
from cyan import logic

View File

@@ -38,6 +38,10 @@ def main(parser: ArgumentParser) -> None:
else:
sys.exit("[!] main binary is encrypted; exiting")
if args.cyan is not None:
changing = vars(args)
tbhutils.parse_cyan(changing, tmpdir)
# this goes before injection,
# since user might inject their own extensions
if args.remove_extensions:
@@ -47,6 +51,16 @@ def main(parser: ArgumentParser) -> None:
if args.f is not None:
app.executable.inject(args.f, tmpdir)
if args.n is not None:
app.plist.change_name(args.n)
if args.v is not None:
app.plist.change_version(args.v)
if args.b is not None:
app.plist.change_bundle_id(args.b)
if args.m is not None:
app.plist.change_minimum_version(args.m)
if args.k is not None:
app.change_icon(args.k, tmpdir)
if args.remove_supported_devices:
app.plist.remove_uisd()

View File

@@ -1,6 +1,7 @@
import os
import shutil
from glob import glob
from uuid import uuid4
from typing import Optional, Literal
from .executable import Executable
@@ -9,7 +10,7 @@ from .plist import Plist
class AppBundle:
def __init__(self, path: str):
self.path = path
self.plist = Plist(f"{path}/Info.plist")
self.plist = Plist(f"{path}/Info.plist", path)
self.executable = Executable(
f"{path}/{self.plist['CFBundleExecutable']}",
@@ -101,3 +102,45 @@ class AppBundle:
else:
print("[*] removed encrypted plugins:", ", ".join(removed))
def change_icon(self, path: str, tmpdir: str) -> None:
try:
from PIL import Image
except Exception:
return print("[?] pillow is not installed, -k is not available")
tmpath = f"{tmpdir}/icon.png"
if not path.endswith(".png"):
with Image.open(path) as img:
img.save(tmpath, "PNG")
else:
shutil.copyfile(path, tmpath)
uid = f"cyan_{uuid4().hex[:7]}a" # can't have it end with a num
i60 = f"{uid}60x60"
i76 = f"{uid}76x76"
with Image.open(tmpath) as img:
img.resize((120, 120)).save(f"{self.path}/{i60}@2x.png", "PNG")
img.resize((152, 152)).save(f"{self.path}/{i76}@2x~ipad.png", "PNG")
if "CFBundleIcons" not in self.plist:
self.plist["CFBundleIcons"] = {}
if "CFBundleIcons~ipad" not in self.plist:
self.plist["CFBundleIcons~ipad"] = {}
self.plist["CFBundleIcons"] = self.plist["CFBundleIcons"] | {
"CFBundlePrimaryIcon": {
"CFBundleIconFiles": [i60],
"CFBundleIconName": uid
}
}
self.plist["CFBundleIcons~ipad"] = self.plist["CFBundleIcons~ipad"] | {
"CFBundlePrimaryIcon": {
"CFBundleIconFiles": [i60, i76],
"CFBundleIconName": uid
}
}
self.plist.save()
print("[*] updated app icon")

View File

@@ -1,16 +1,23 @@
import sys
import plistlib
from typing import Any
from glob import glob
from typing import Optional, Any
class Plist:
def __init__(self, path: str):
def __init__(
self, path: str, app_path: Optional[str] = None, throw: bool = True
):
try:
with open(path, "rb") as f:
self.data: dict[str, Any] = plistlib.load(f)
except Exception:
sys.exit(f"[!] couldn't read {path}")
if throw:
sys.exit(f"[!] couldn't read {path}")
else:
return None
self.path = path
self.app_path = app_path
def __getitem__(self, key: str) -> Any:
return self.data.get(key, None)
@@ -18,6 +25,9 @@ class Plist:
def __setitem__(self, key: str, val: Any) -> None:
self.data[key] = val
def __contains__(self, key: str) -> bool:
return key in self.data
def save(self) -> None:
with open(self.path, "wb") as f:
plistlib.dump(self.data, f)
@@ -25,33 +35,89 @@ class Plist:
def remove(self, key: str) -> bool:
try:
del self.data[key]
self.save()
return True
except KeyError:
return False
def change(self, key: str, val: Any) -> bool:
def change(self, val: Any, *keys: str) -> bool:
try:
if self[key] == val:
if all(self[key] == val for key in keys):
return False
except KeyError:
self[key] = val
for key in keys:
self[key] = val
self.save()
return True
def remove_uisd(self) -> None:
if self.remove("UISupportedDevices"):
self.save()
print("[*] removed UISupportedDevices")
else:
print("[?] no UISupportedDevices")
def enable_documents(self) -> None:
c1 = self.change("UISupportsDocumentBrowser", True)
c2 = self.change("UIFileSharingEnabled", True)
c1 = self.change(True, "UISupportsDocumentBrowser")
c2 = self.change(True, "UIFileSharingEnabled")
if c1 or c2:
self.save()
print("[*] enabled documents support")
else:
print("[?] documents support was already enabled")
def change_name(self, name: str) -> None:
if self.change("CFBundleName", "CFBundleDisplayName", name):
print(f"[*] changed name to \"{name}\"")
changed = 0
for lproj in glob(f"{self.app_path}/*.lproj"):
try:
pl = Plist(f"{lproj}/InfoPlist.strings", None, False)
pl["CFBundleDisplayName"] = name
pl["CFBundleName"] = name
pl.save()
changed += 1
except Exception:
pass # file might not exist
if changed != 0:
print(f"[*] changed \033[96m{changed}\033[0m localized names")
else:
print(f"[?] name was already \"{name}\"")
def change_version(self, version: str) -> None:
if self.change("CFBundleVersion", "CFBundleShortVersionString", version):
print(f"[*] changed version to \"{version}\"")
else:
print(f"[?] version was already \"{version}\"")
def change_bundle_id(self, bundle_id: str) -> None:
orig = self["CFBundleIdentifier"]
if self.change("CFBundleIdentifier", bundle_id):
print(f"[*] changed bundle id to \"{bundle_id}\"")
changed = 0
# change all other bundle ids
for ext in glob(f"{self.app_path}/*/*.appex"):
try:
pl = Plist(f"{ext}/Info.plist", None, False)
current = pl["CFBundleIdentifier"]
pl["CFBundleIdentifier"] = current.replace(orig, bundle_id)
pl.save()
changed += 1
except Exception:
pass # how tf would it not exist? idk
if changed != 0:
print(f"[*] changed \033[96m{changed}\033[0m other bundle ids")
else:
print(f"[?] bundle id was already \"{bundle_id}\"")
def change_minimum_version(self, minimum: str) -> None:
if self.change("MinimumOSVersion", minimum):
print(f"[*] changed minimum version to \"{minimum}\"")
else:
print(f"[?] minimum version was already \"{minimum}\"")

View File

@@ -1,13 +1,14 @@
import os
import sys
import json
import shutil
import zipfile
import platform
import subprocess
from uuid import uuid4
from typing import Optional
from glob import glob, iglob
from argparse import Namespace
from typing import Optional, Any
def validate_inputs(args: Namespace) -> Optional[str]:
@@ -48,6 +49,18 @@ def validate_inputs(args: Namespace) -> Optional[str]:
print(f"[?] - {ne}")
sys.exit(1)
if (
args.m is not None
and any(char not in "0123456789." for char in args.m)
):
sys.exit(f"[!] invalid OS version: {args.m}")
if args.k is not None and not os.path.isfile(args.k):
sys.exit(f"[!] {args.k} does not exist")
if args.cyan is not None and not os.path.isfile(args.cyan):
sys.exit(f"[!] {args.cyan} does not exist")
def get_app(path: str, tmpdir: str, is_ipa: bool) -> str:
payload = f"{tmpdir}/Payload"
@@ -170,3 +183,29 @@ def make_ipa(tmpdir: str, output: str, level: int) -> None:
if weird != 0:
print(f"[?] was unable to zip {weird} file(s) due to timestamps")
def parse_cyan(args: dict[str, Any], tmpdir: str) -> None:
print("[*] parsing .cyan file..")
with zipfile.ZipFile(args["cyan"]) as zf:
DOT_PATH = f"{tmpdir}/cyan"
os.mkdir(DOT_PATH)
with zf.open("config.json") as f:
config = json.load(f)
if "f" in config:
NAMES = [n for n in zf.namelist() if n.startswith("inject/")]
zf.extractall(DOT_PATH, NAMES)
# ensure not None
args["f"] = args["f"] if args["f"] is not None else {}
for e in os.scandir(f"{DOT_PATH}/inject"):
args["f"][e.name] = e.path
del config["f"]
if "k" in config:
args["k"] = zf.extract("icon.idk", DOT_PATH)
del config["k"]
for k, v in config.items():
args[k] = v

View File

@@ -2,10 +2,10 @@ from setuptools import setup
setup(
name="cyan",
version="1.0",
version="1.1",
description="finally, pyzule doesn't suck",
author="zx",
author_email="hi@zxcvbn.fyi",
author_email="zx@hrzn.email",
packages=["cyan", "cyan.tbhtypes"],
# install_requires=["lief"],
python_requires=">=3.9",