Skip to content

Instantly share code, notes, and snippets.

@apfelchips
Last active August 16, 2025 18:49
Show Gist options
  • Save apfelchips/5336fd75a18bd9137266abd356f31c6f to your computer and use it in GitHub Desktop.
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
#!/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