Patch Reference
Complete reference for ipa-forge patch definition YAML: every operation type, field, version-matching rule, and the hooks block.
This is the complete reference for writing patch definition files for ipa-forge. A patch definition is a single YAML (or JSON) file that describes what to change in an app bundle, keyed by bundle id + version. ipa-forge never embeds app-specific logic — everything it does to an app comes from files like this.
Working examples live in fixtures/patches/:
example.yaml—binary_replace+resource_replaceexample_dylib_inject.yaml—dylib_injectexample_plist_edit.yaml—plist_edit
File structure
target: # which app this definition applies to (required)
bundle_id: "com.example.app"
version: { exact: "1.2.3" } # or { min: "1.0.0", max: "2.0.0" }
patches: # one or more operations (required, non-empty)
- id: "my-op-1" # arbitrary unique id, shown in output
type: binary_replace # one of the types below
# ... type-specific fields ...Definitions are loaded with yaml.safe_load (no arbitrary code execution) and
validated against a pydantic schema; a file that fails validation produces a
single-line actionable error, never a traceback. An empty patches: list is
rejected — a definition with no operations is always a mistake.
The target block — matching
The definition only applies when both conditions hold:
Prop
Type
Version matching
Two forms, mutually exclusive:
version: { exact: "1.2.3" } # string equality against the bundle's version
version: { min: "1.0.0", max: "2.0.0" } # numeric rangeexactcompares the raw strings:exact: "1.0.0"will not match a bundle whose version is1.0.0-beta.min/maxcompare numerically.maxis exclusive —max: "2.0.0"matches1.9.9and1.5.0-betabut not2.0.0.- Range matching strips non-numeric segments, so
1.0.0-betaand1.0.0are considered equal formin/maxpurposes. - Both
minandmaxare optional;{min: "1.0.0"}means "1.0.0 or later".
What happens when the target does not match
If the supplied definition's target does not match the IPA you patched, zero
operations resolve:
forge patch --dry-runwarns ("matches 0 patch operations ... nothing will be applied") and still succeeds — useful for confirming a version gate.- a real (non-dry-run)
forge patchfails with "refusing to produce an unpatched IPA". A typo'd bundle id can never silently produce an unpatched output.
Common fields
Prop
Type
Operation types
binary_replace — deterministic byte patching
Replaces an exact byte sequence inside a Mach-O executable with a fixed replacement.
- id: "zero-marker-bytes"
type: binary_replace
executable: "TestApp" # basename of the Mach-O file inside the bundle
arch: "arm64" # required for fat/universal binaries
pattern: "ca fe f0 0d de ad" # space-separated hex bytes
replacement: "00 00 00 00 00 00"
expected_matches: 1 # optional, default 1Prop
Type
Semantics:
- The search is bounded to one architecture slice: for a fat/universal
binary the pattern is only matched within the selected slice's byte range, so
a patch can never silently land in the wrong architecture. For thin binaries
the whole file is searched and
archis unnecessary. - A universal binary without
archis an error ("an explicitarch:selector is required") — ipa-forge refuses to guess. - Supported arch names:
arm64,armv7,x86_64,i386. - If the pattern occurs more than once, every occurrence is replaced (and
expected_matchesmust match the actual count). - The operation fails during dry run if: the executable isn't in the bundle, the pattern is malformed, the file isn't Mach-O, or the match count differs.
resource_replace — overwrite a bundle file
- id: "swap-asset"
type: resource_replace
path: "asset.txt" # bundle-relative destination
source: "assets/patched_asset.txt" # relative to the definition fileProp
Type
sourcemay also be an absolute path or../reference — the definition, the IPA, and the signing credentials are all supplied by the same trusted user, so sources are intentionally not sandboxed.- Fails in dry run if the source is missing or the destination doesn't exist
(use
resource_addto create new files instead).
resource_add — add a new bundle file
- id: "add-hook-lib"
type: resource_add
path: "Frameworks/libHook.dylib"
source: "assets/libHook.dylib"- Identical fields to
resource_replace. - Creates intermediate directories as needed.
- Fails in dry run if the destination already exists (use
resource_replaceto overwrite) or the source is missing.
resource_remove — delete a bundle file
- id: "remove-obsolete-asset"
type: resource_remove
path: "obsolete.txt"Prop
Type
- Fails in dry run if the destination doesn't exist or is a directory.
dylib_inject — add a dylib load command
Adds an LC_LOAD_DYLIB (or LC_LOAD_WEAK_DYLIB) entry to a Mach-O so the
runtime loads the named dylib.
- id: "inject-hook"
type: dylib_inject
executable: "TestApp"
arch: "arm64" # required for fat/universal binaries
install_name: "@rpath/libHook.dylib" # the load path, not a source file
load_command: "LC_LOAD_DYLIB" # optional; or LC_LOAD_WEAK_DYLIBProp
Type
This operation does not copy the dylib into the bundle
The dylib file must already be present (shipped with the app, or placed there
with a resource_add). The standard pattern is:
patches:
- id: "stage-dylib"
type: resource_add
path: "Frameworks/libHook.dylib"
source: "assets/libHook.dylib"
- id: "link-dylib"
type: dylib_inject
executable: "TestApp"
install_name: "@rpath/libHook.dylib"Outcomes (reported per-operation in the manifest):
Prop
Type
Dylib injection is deliberately the most fragile operation — it rewrites
Mach-O load commands and forces a full re-sign. Prefer binary_replace or
resource operations when they can express the change.
plist_edit — set/remove Info.plist keys
- id: "set-display-name"
type: plist_edit
action: "set" # or "remove"
key: "CFBundleDisplayName"
value: "Patched App" # required when action is set
path: "Info.plist" # optional, bundle-relative, defaults to Info.plistProp
Type
removefails in dry run if the key is not present.- Works on binary or XML plists transparently.
How operations run: the dry-run gate and ordering
The dry-run gate
forge patch never mutates anything until every operation has reported
dry_run_ok. If any operation would fail, the whole patch is rejected before
a single file changes.
When applying, operations run in a fixed order:
Resource operations and plist edits — resource_replace/resource_add/resource_remove
and plist_edit run first; they don't touch Mach-O layout.
Binary patches — binary_replace raw byte edits.
Dylib injection — dylib_inject runs last, because LIEF's load-command
rewrites can shift byte offsets that binary patches depend on.
Order within a group follows the order in the YAML file.
The hooks block — verify runtime hooks against the binary
Dylib-injection tweaks live or die on ObjC runtime hooks: the dylib swizzles
-[Class selector], and when a newer app version renames or removes that
class/selector the hook silently no-ops — the feature just stops working
with no error. The hooks: block declares every hook the patch set relies on
so forge can verify it against the actual binary before anything mutates:
hooks:
- class: "AppConfig"
selector: "isFeatureFlagEnabled"
kind: instance # instance (default) | class
- class: "PlaybackResponse"
selector: "extraDataArray"
added: true # the tweak adds this method itself, it doesn't exist in the app
- class: "SessionService"
selector: "urlFromURL:withAdditionalFragmentParameters:"
required: true # fail the run if this hook can't attachProp
Type
forge patch --dry-run verifies every declared hook against the app's main
binary (class table + method lists + selrefs, chained-fixup aware) and prints
a summary. The forge hooks commands do the same without patching, and every
one of them accepts --app-dir Payload/<App>.app in place of --ipa to skip
re-extraction when iterating on the same app.
Dry run OK -- 4 operation(s) would apply.
Hooks: 151/159 attach (8 issue(s))
! SettingsController -[isHintsDisabled]: unverified -- class exists ... walk missed itHook statuses
Prop
Type
referenced-only is the dead-hook detector: the binary references the
selector (selrefs / NSSelectorFromString / optional protocol sends) but does
not declare it as a method anywhere (__objc_methname has no such name), so
there is no IMP to swizzle and class_getInstanceMethod returns NULL — the
hook cannot attach. This is the classic "the verify says it attaches but the
feature does nothing" trap (some selectors are referenced by the app but
implemented by nothing in a given build). Distinguish it from
unverified, which means the selector is declared as a method somewhere (a
protocol, a category, or a method list the walker did not decode) — the hook
may well attach.
unverified is usually a parser gap, not real drift
When a class or selector the walk missed is still present as a string in some
binary image (the old manual strings <binary> | grep cross-check), the
report says so — "class-name string present … the walk missed it (likely
attaches)" / "selector declared as a method somewhere … (likely attaches)".
The parser under-reports methods on protobuf-generated message classes and
large Swift class tables this way; Swift-mangled (_TtC…) classes absent
from the parsed table are always reported unverified, never mislabeled as
system classes.
Porting to a new app version becomes: bump target.version, run
--dry-run, read the hook report, and fix exactly the hooks the report flags.
forge hooks manifest --dir <dylib-sources> regenerates the hooks: block
from the tweak sources (inline NSClassFromString calls, unambiguous
variable assignments, class names passed through a local resolver helper — a
function whose body calls NSClassFromString invoked as resolver("X") at
the hook call site — and <prefix>HookConfigBool config-flag getters); pass
--inplace patch.yaml to write the block straight into the patch definition.
forge hooks diff --old A.ipa --new B.ipa --patches patch.yaml shows which
hooks regressed between two versions.
forge hooks find <selector> --ipa App.ipa is the reverse lookup — which
classes implement a selector, plus the referenced-only warning when nothing
does. Check it first when a hook "attaches per verify" but does nothing on
device. forge hooks extract --ipa App.ipa --class <name> prints the FULL
method list (no truncation) — pipe large config classes through grep.
A hook not declared in the hooks: block is invisible to --dry-run
--dry-run only checks declared hooks; a hook the tweak source calls but the
block omits produces no error, no warning. forge hooks audit --ipa App.ipa --dir <dylib-sources> --patches patch.yaml closes that gap: it scans the
sources the same way manifest does, then diffs what it found against
what's declared, printing every hook that's missing and exiting 1 if any
are. Run it as a standard part of the loop above (after manifest,
alongside --dry-run), not only when a feature seems broken — this is how
real, load-bearing hooks have been found missing from shipped patch sets
after the fact, with --dry-run green the whole time.
The manifest
Every run produces a structured manifest (--verbose prints it; the GUI shows
it in the result card):
{
"input_sha256": "...",
"bundle_id": "com.example.synthetic",
"version": "1.0.0",
"build": "1",
"patches_applied": [
{"id": "zero-marker-bytes", "status": "applied", "message": "replaced 1 match(es) at [0]"}
],
"files_added": [], "files_modified": [...], "files_removed": [],
"macho_modified": [".../TestApp"],
"profile": {"uuid": "...", "team_id": "...", "expiration": "..."},
"output_sha256": "..."
}Use it to verify exactly what changed (and what didn't) before installing.