Last active
August 16, 2025 18:49
-
-
Save apfelchips/5336fd75a18bd9137266abd356f31c6f to your computer and use it in GitHub Desktop.
rename IPA files by parsing the contained info.plist and analyzing the main macho binary
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
set -euo pipefail | |
IFS=$'\n\t' | |
# src: https://gist.github.com/apfelchips/5336fd75a18bd9137266abd356f31c6f | |
# required commands: xmllint unzip plutil llvm-objdump | |
# plutil is provided by the gnustep-base-runtime package on debian / ubuntu | |
# llvm-objdump from the llvm package | |
RENAME=1 | |
VERBOSE=0 | |
# realpath polyfill | |
abspath(){ | |
[ ${1:+x} ] || return 1 | |
p=$1 | |
until [ _"${p%/}" = _"$p" ]; do | |
p=${p%/} | |
done | |
[ -e "$p" ] && p=$1 | |
[ -d "$1" ] && p=$p/ | |
set 10 "$(pwd)" "${OLDPWD:-}" | |
PWD= | |
CDPATH="" cd -P "$2" && while [ "$1" -gt 0 ]; do | |
set "$1" "$2" "$3" "${p%/*}" | |
[ _"$p" = _"$4" ] || { | |
CDPATH="" cd -P "${4:-/}" || break | |
p=${p##*/} | |
} | |
[ ! -L "$p" ] && p=${PWD%/}${p:+/}$p && set "$@" "${p:-/}" && break | |
set $(($1-1)) "$2" "$3" "$p" | |
p=$(ls -dl "$p") || break | |
p=${p#*" $4 -> "} | |
done 2> /dev/null | |
cd "$2" && OLDPWD=$3 && [ ${5+x} ] && printf '%s\n' "$5" | |
} | |
get_binary_type(){ | |
local ipa_file_path="${1}" | |
local executable_file_path="${2}" | |
# https://github.com/golang/go/blob/master/src/debug/macho/macho.go#L33 | |
# https://en.wikipedia.org/wiki/Mach-O#Multi-architecture_binaries | |
local binary_magic=$( unzip -p "${ipa_file_path}" "${executable_file_path}" | xxd -l 4 -p - || if [[ $? -eq 141 ]]; then true; else exit $?; fi ) | |
# Magic numbers for Mach-O headers | |
MH_MAGIC="feedface" # 32-bit | |
MH_CIGAM="cefaedfe" # 32-bit (reversed endian) | |
MH_MAGIC_64="feedfacf" # 64-bit | |
MH_CIGAM_64="cffaedfe" # 64-bit (reversed endian) | |
FAT_MAGIC="cafebabe" # Universal binary | |
FAT_CIGAM="bebafeca" # Universal binary (reversed endian) | |
FAT_MAGIC_64="cafebabf" # Universal binary (64-bit) | |
FAT_CIGAM_64="bfbafeca" # Universal binary (64-bit, reversed endian) | |
local binary_type='' | |
case "${binary_magic}" in | |
"${MH_MAGIC}" | "${MH_CIGAM}") | |
binary_type="32bit" | |
;; | |
"${MH_CIGAM_64}" | "${MH_MAGIC_64}") | |
binary_type="64bit" | |
;; | |
"${FAT_MAGIC}" | "${FAT_CIGAM}" | "{$FAT_MAGIC_64}" | "${FAT_CIGAM_64}") | |
binary_type="Universal" | |
;; | |
*) | |
return 1 | |
;; | |
esac | |
echo "${binary_type}" | |
} | |
get_binary_status(){ | |
local ipa_file_path="${1}" | |
local executable_file_path="${2}" | |
local binary_headers='' | |
PATH="/Library/Developer/CommandLineTools/usr/bin:${PATH}" | |
binary_headers=$( unzip -p "${ipa_file_path}" "${executable_file_path}" | llvm-objdump --non-verbose --macho --all-headers - || echo '' ) | |
if [ -z "${binary_headers}" ]; then | |
echo 'CORRUPTED' | |
return | |
fi | |
local cryptid=0 | |
cryptid=$( echo "${binary_headers}" | grep -A 10 'LC_ENCRYPTION_INFO' | grep 'cryptid' | awk '{print $NF}' | head -n1 ) | |
if [ -z "${cryptid}" ] || [ "${cryptid}" -eq 0 ] ; then | |
echo 'Decrypted' | |
else | |
echo 'Encrypted' | |
fi | |
} | |
usage(){ | |
echo "usage: $(basename $0) [-d] [-v] file..." | |
echo " -d: dry-run" | |
echo " -v: verbose" | |
echo " example: find . -type f -name '*.ipa' | $(basename $0)" | |
exit 1 | |
} | |
while getopts "dv" opt; do | |
case ${opt} in | |
d) | |
RENAME=0 | |
;; | |
v) | |
VERBOSE=$(( ${VERBOSE} + 1 )) | |
;; | |
\?) | |
usage | |
;; | |
esac | |
done | |
shift $((OPTIND -1)) | |
if [ "${VERBOSE}" -ge 2 ]; then | |
set -x | |
fi | |
FAILED_ITEMS=() | |
INPUT_ITEMS="" | |
if [ -p /dev/stdin ]; then | |
INPUT_ITEMS="$(cat /dev/stdin)" | |
else | |
INPUT_ITEMS=$(IFS="$' \t\n'"; for FILE in "$@"; do echo "${FILE}"; done) | |
fi | |
if [ -z "${INPUT_ITEMS}" ]; then | |
usage | |
fi | |
for IPA_FILE in ${INPUT_ITEMS}; do | |
if [ ! -f "${IPA_FILE}" ] || [ -L "${IPA_FILE}" ]; then | |
FAILED_ITEMS+=("${IPA_FILE}") | |
continue | |
fi | |
IPA_FILE_PATH=$(abspath "${IPA_FILE}") | |
IPA_FILE_NAME=$(basename "${IPA_FILE_PATH}") | |
if ! file --mime-type -b "${IPA_FILE_PATH}" | grep -iq -e '^application/x-ios-app$' -e '^application/zip$'; then | |
echo "${IPA_FILE} is not a valid iOS App Store Package" | |
FAILED_ITEMS+=("${IPA_FILE_PATH}") | |
continue | |
fi | |
echo "Processing File: ${IPA_FILE_PATH}" | |
INFO_FILE_IN_IPA_PATH=$(unzip -Z1 "${IPA_FILE_PATH}" | grep -i -E "^Payload/[^/]*\.app/Info\.plist$" | head -n1 || true) | |
if [ -z "${INFO_FILE_IN_IPA_PATH}" ]; then | |
FAILED_ITEMS+=("${IPA_FILE_PATH}") | |
continue | |
fi | |
INFO_FILE_CONTENT=$(unzip -p "${IPA_FILE_PATH}" "${INFO_FILE_IN_IPA_PATH}" | plutil -convert xml1 -o - -- -) | |
BUNDLE_ID=$(xmllint --xpath "/plist/dict/key[text()='CFBundleIdentifier']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null) | |
BUNDLE_NAME=$(xmllint --xpath "/plist/dict/key[text()='CFBundleName']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
EXECUTABLE_NAME=$(xmllint --xpath "/plist/dict/key[text()='CFBundleExecutable']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null) | |
EXECUTABLE_FILE_IN_IPA_PATH=$(unzip -Z1 "${IPA_FILE_PATH}" | grep -i -E "^Payload/[^/]*\.app/${EXECUTABLE_NAME}\$" | head -n1) | |
BINARY_TYPE=$(get_binary_type "${IPA_FILE_PATH}" "${EXECUTABLE_FILE_IN_IPA_PATH}") | |
BINARY_STATUS=$(get_binary_status "${IPA_FILE_PATH}" "${EXECUTABLE_FILE_IN_IPA_PATH}") | |
DEVICE_FAMILY=() | |
DEVICE_FAMILY_COUNT=$(xmllint --xpath "count(/plist/dict/key[.='UIDeviceFamily']/following-sibling::array[1]/integer)" - <<< $INFO_FILE_CONTENT || echo 0) | |
counter=1 | |
while [ "$counter" -le "${DEVICE_FAMILY_COUNT}" ]; do | |
DEVICE_ID=$(xmllint --xpath "(/plist/dict/key[.='UIDeviceFamily']/following-sibling::array[1]/integer)[${counter}]/text()" - <<< $INFO_FILE_CONTENT) | |
case "${DEVICE_ID}" in | |
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW11 | |
1) DEVICE_FAMILY+=("iPhoneOS");; | |
2) DEVICE_FAMILY+=("iPadOS");; | |
3) DEVICE_FAMILY+=("tvOS");; | |
4) DEVICE_FAMILY+=("watchOS");; | |
5) DEVICE_FAMILY+=("HomePodOS");; | |
6) DEVICE_FAMILY+=("macOS");; | |
*) | |
esac | |
((counter++)) | |
done | |
DEVICE_FAMILY=$(IFS=+ ; echo "${DEVICE_FAMILY[*]:-'iOS'}") | |
LONG_VERSION_STRING=$(xmllint --xpath "/plist/dict/key[text()='CFBundleVersion']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
SHORT_VERSION_STRING=$(xmllint --xpath "/plist/dict/key[text()='CFBundleShortVersionString']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
VERSION_STRING=${SHORT_VERSION_STRING:-"${LONG_VERSION_STRING}"} | |
MIN_OS_VERSION=$(xmllint --xpath "/plist/dict/key[text()='MinimumOSVersion']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
# CLUTCH_VERSION=$( echo "${IPA_FILE}" | awk 'match(tolower($0), /clutch-\d+\.\d+\.\d+/) {print substr($0, RSTART, RLENGTH)}' | cut -d'-' -f2 || true) | |
CRC32=$(crc32 "${IPA_FILE_PATH}" | awk '{print $1}' || true) | |
NEW_NAME=$(printf "${BUNDLE_ID}_v${VERSION_STRING:-'UNKNOWN'}_${BUNDLE_NAME:-'UNKNOWN'}_${DEVICE_FAMILY:-UNKNOWN}${MIN_OS_VERSION:-'0'}_${BINARY_TYPE:-'UNKNOWN'}_${BINARY_STATUS:-'UNKNOWN'}_${CRC32}.ipa" | tr '\n' '-' | tr -d '\r' | tr ' ' '-' | tr -dc '[:alnum:]()._@+-') | |
cat <<EOF | |
CFBundleName: ${BUNDLE_NAME:-'N/A'} | |
CFBundleIdentifier: ${BUNDLE_ID:-"N/A"} | |
CFBundleVersion: ${LONG_VERSION_STRING:-"N/A"} | |
CFBundleShortVersionString: ${SHORT_VERSION_STRING:-"N/A"} | |
UIDeviceFamily: ${DEVICE_FAMILY-"N/A"} | |
MinimumOSVersion: ${MIN_OS_VERSION:-"N/A"} | |
Type: ${BINARY_TYPE:-'N/A'} | |
Status: ${BINARY_STATUS:-'N/A'} | |
crc32: ${CRC32} | |
EOF | |
if [ $VERBOSE -ge 1 ]; then | |
xmllint --format - <<< $INFO_FILE_CONTENT | |
fi | |
if [ "${IPA_FILE_NAME}" != "${NEW_NAME}" ]; then | |
echo "New Name: ${IPA_FILE_NAME} --> ${NEW_NAME}" | |
if ((RENAME)); then | |
IPA_DIRNAME=$(dirname "${IPA_FILE_PATH}") | |
mv -n "${IPA_FILE_PATH}" "${IPA_DIRNAME}/${NEW_NAME}" | |
fi | |
else | |
echo "Keeping current name: ${IPA_FILE_NAME}" | |
fi | |
echo "---" | |
done | |
if [ "${#FAILED_ITEMS[@]}" -ne 0 ]; then | |
echo "### FAILED ITEMS ###" | |
for ERROR in "${FAILED_ITEMS[@]}"; do | |
echo "${ERROR}" | |
done | |
exit 1 | |
fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment