Identifying the Code Behind an Apple Crash

Published on Jun 24, 2026

Identifying the Code Behind an Apple Crash

This is the workflow I use to go from an Apple crash report to the Swift source line that likely caused the crash.

The crash report is the important file. It is usually an .ips file copied from a device, downloaded from App Store Connect, exported from Xcode Organizer, or sent by a tester. If the crash came with a feedback JSON file, that JSON is useful metadata, but the stack trace and binary images live in the .ips.

The same process works for any Apple platform app as long as you have the matching dSYM. It also works whether you have one crash report or several reports for the same problem.

Files You Need

Create a small working folder and put the files for the investigation in it:

mkdir -p "$HOME/Downloads/crash-investigation"
cd "$HOME/Downloads/crash-investigation"

The folder might contain:

feedback.json
AppName-2026-06-21-212351.ips
AppName-2026-06-21-212557.ips

There can be one .ips file or many. There can be one feedback JSON file, many JSON files, or none.

You also need the dSYM for the exact build that produced the crash. Local Xcode archives are the first place to look. If the app was distributed through App Store Connect, you can also download the dSYM for that build from App Store Connect.

Existing Tools

You do not have to parse and symbolicate crash reports by hand every time.

Start with Apple's tools when they work:

  • Xcode can open and symbolicate crash reports when it can find the matching archive or dSYM.
  • dwarfdump checks whether a dSYM UUID matches the crashed binary.
  • atos maps raw addresses back to symbols when you provide the right dSYM, architecture, load address, and addresses.

Apple's documentation covers both the JSON crash report format and adding symbol names to crash reports.

There are also community tools:

  • MacSymbolicator is a Mac app with a CLI. It supports .crash and .ips reports, can print UUIDs, and can use one or more dSYM paths.
  • AppleCrashScripts is a small Swift-based toolset that can convert modern JSON .ips reports and symbolicate them.
  • pycrashreport is a Python parser for Apple crash reports. It is useful when you want a library or CLI that extracts the important crash fields into a clearer view.

For a one-off crash, a symbolicator app may be faster. The manual commands in this post are still useful when you need to understand what the tool is doing, debug a failed symbolication, or automate a narrow workflow for several reports.

1. Read the Metadata

If you have feedback JSON files, inspect them first:

for json in ./*.json; do
  [ -e "$json" ] || continue
  echo "== $json =="
  jq . "$json"
done

The feedback JSON is not the full crash report. It is useful for fields such as:

  • app version and build number
  • device model
  • OS version
  • app uptime
  • tester comment
  • timestamp

Then compare that metadata with the .ips headers:

for ips in ./*.ips; do
  [ -e "$ips" ] || continue
  echo "== $ips =="
  head -40 "$ips"
done

You are trying to confirm that the files describe the same app build and event:

CFBundleShortVersionString: 1.2.3
CFBundleVersion: 456
device: iPhone16,2
OS: iPhone OS 18.5
app: com.example.AppName

Tester comments can be useful clues, but they are optional. If you do not have feedback JSON, skip this part and start from the .ips.

2. Parse the .ips Structure

An .ips file often contains:

  1. a one-line JSON header
  2. the full crash JSON object

Some exports contain only the crash JSON object.

The script below uses only Python's standard library: json, sys, and pathlib. No crash-report package is required because an .ips crash report is JSON once you skip the optional one-line header.

python3 - ./*.ips <<'PY'
import json
import sys
from pathlib import Path
 
 
def load_report(path: Path):
    text = path.read_text()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return json.loads(text[text.find("\n") + 1 :])
 
 
for arg in sys.argv[1:]:
    path = Path(arg)
    if not path.exists():
        continue
 
    report = load_report(path)
 
    print()
    print(path.name)
    print("incident:", report.get("incident"))
    print("procName:", report.get("procName"))
    print("bundleID:", report.get("bundleID"))
    print("exception:", report.get("exception"))
    print("termination:", report.get("termination", {}).get("namespace"))
    print("reason:", report.get("termination", {}).get("reasons", [""])[0])
    print("faultingThread:", report.get("faultingThread"))
PY

The output gives you the crash category before you look at individual frames:

exception: EXC_BAD_ACCESS / SIGSEGV
termination namespace: SIGNAL
reason: Segmentation fault: 11
faultingThread: 4

Different reports have different exception and termination values. A memory access crash, a force-unwrap crash, a watchdog kill, and an out-of-memory termination all lead you toward different evidence. Treat the crash reason as a triage clue, not as the final diagnosis.

3. Find the App Image UUID and Load Address

The dSYM lookup depends on the app binary UUID. That UUID comes from the usedImages array in the .ips file. Each entry in usedImages describes one binary image loaded into the crashed process:

  • name: binary name
  • uuid: binary UUID
  • base: load address for that image
  • arch: CPU architecture, when present

The app binary is often usedImages[0], but do not rely on that blindly. The safer approach is to print the images and find the entry whose name matches the report's procName.

python3 - ./*.ips <<'PY'
import json
import sys
from pathlib import Path
 
 
def load_report(path: Path):
    text = path.read_text()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return json.loads(text[text.find("\n") + 1 :])
 
 
for arg in sys.argv[1:]:
    path = Path(arg)
    if not path.exists():
        continue
 
    report = load_report(path)
    proc_name = report.get("procName")
 
    print()
    print("==", path.name, "==")
    print("procName:", proc_name)
 
    for index, image in enumerate(report.get("usedImages", [])):
        name = image.get("name")
        marker = " <-- likely app image" if name == proc_name else ""
        print(
            index,
            name,
            "uuid=" + str(image.get("uuid")),
            "base=" + hex(image.get("base", 0)),
            "arch=" + str(image.get("arch")),
            marker,
        )
PY

Example output:

procName: AppName
0 AppName uuid=12345678-90ab-cdef-1234-567890abcdef base=0x1048ac000 arch=arm64  <-- likely app image
1 UIKitCore uuid=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee base=0x1a0000000 arch=arm64
2 libswiftCore.dylib uuid=bbbbbbbb-cccc-dddd-eeee-ffffffffffff base=0x1b0000000 arch=arm64

For the app image, keep:

app image index: 0
uuid: 12345678-90ab-cdef-1234-567890abcdef
base: 0x1048ac000
arch: arm64

The absolute address for an app frame is:

absolute address = app image base + frame.imageOffset

4. Find the Matching dSYM

The dSYM must match the app binary UUID exactly. Search local Xcode archives first:

find "$HOME/Library/Developer/Xcode/Archives" -name '*.dSYM' -print

Check UUIDs:

dwarfdump --uuid '/path/to/AppName.app.dSYM'

To search for a known UUID:

CRASH_UUID="12345678-90ab-cdef-1234-567890abcdef"
 
find "$HOME/Library/Developer/Xcode/Archives" -name '*.dSYM' -print0 |
  while IFS= read -r -d '' dsym; do
    if dwarfdump --uuid "$dsym" | grep -qi "$CRASH_UUID"; then
      echo "$dsym"
    fi
  done

A matching dSYM prints the same UUID:

UUID: 12345678-90AB-CDEF-1234-567890ABCDEF (arm64) .../AppName.app.dSYM/Contents/Resources/DWARF/AppName

If you cannot find a local dSYM, download the dSYM for the exact build from the place where you distributed the app.

5. Extract App Frames from Interesting Threads

Start with:

  • faultingThread
  • the main thread
  • threads whose frames mention the same subsystem
  • threads blocked on locks, semaphores, file IO, networking, or database work

This script prints app frames as absolute addresses for interesting threads. Set APP_IMAGE_INDEX to the image index you found in step 3.

APP_IMAGE_INDEX=0 python3 - ./*.ips <<'PY'
import json
import os
import sys
from pathlib import Path
 
 
def load_report(path: Path):
    text = path.read_text()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return json.loads(text[text.find("\n") + 1 :])
 
 
app_image_index = int(os.environ["APP_IMAGE_INDEX"])
 
for arg in sys.argv[1:]:
    path = Path(arg)
    if not path.exists():
        continue
 
    report = load_report(path)
    base = report["usedImages"][app_image_index]["base"]
    faulting_thread = report.get("faultingThread")
 
    print()
    print("==", path.name, "==")
 
    for index, thread in enumerate(report.get("threads", [])):
        queue = thread.get("queue")
        frames = thread.get("frames", [])
        has_app_frame = any(
            frame.get("imageIndex") == app_image_index for frame in frames
        )
        interesting = (
            thread.get("triggered")
            or index == faulting_thread
            or queue == "com.apple.main-thread"
            or has_app_frame
        )
 
        if not interesting:
            continue
 
        print()
        print("thread", index, "queue:", queue, "triggered:", thread.get("triggered"))
        for frame in frames[:90]:
            if frame.get("imageIndex") == app_image_index:
                print(frame["imageOffset"], hex(base + frame["imageOffset"]))
            elif frame.get("symbol"):
                print(frame["symbol"])
PY

The non-app symbols around your app frames matter. For example:

objc_exception_throw
swift_unexpectedError
dispatch_semaphore_wait
__psynch_mutexwait
sqlite3_step

Those names tell you whether you are probably looking at an exception, Swift runtime trap, lock contention, database work, IO, or some other category.

6. Symbolicate App Addresses with atos

Use:

  • the dSYM DWARF file
  • the architecture from the app image, usually arm64
  • the app image load address from the .ips
  • the absolute addresses from the previous step

The dSYM path passed to atos is the DWARF file inside the .dSYM, not just the .dSYM directory:

atos \
  -arch arm64 \
  -o '/path/to/AppName.app.dSYM/Contents/Resources/DWARF/AppName' \
  -l 0x1048ac000 \
  0x1048da4b4 0x1048d7e88 0x1048d8190

Symbolicated output should point back to your app's functions and, when debug information is available, source files and line numbers:

AppFeatureReducer.reduce(into:action:) (AppFeatureReducer.swift:153)
closure #1 in AppDetailView.body.getter (AppDetailView.swift:88)
static NetworkClient.liveValue.getter (NetworkClient.swift:42)

That changes the investigation from "the app crashed somewhere in Apple frameworks" to a source-level hypothesis you can check.

7. Read the Stack Bottom-Up

The top app frame tells you where execution ended. The lower frames often explain how the app got there.

For a user-action crash, the lower frames might show a button, menu, or gesture:

ButtonAction.callAsFunction()
UIAction performWithSender
closure #1 in AppDetailView.body.getter
AppFeatureReducer.reduce(into:action:)

For a background crash, the lower frames might show a queue, task, database callback, network callback, notification, or timer:

_dispatch_call_block_and_release
closure #1 in SyncClient.start()
DatabaseWriter.write()
AppStore.save(_:)

Read upward from the oldest app frame toward the newest app frame and ask:

  1. What event started this work?
  2. Which app code handled it?
  3. Which state, dependency, database, file, or network operation was involved?
  4. Does the exception or termination reason match that path?
  5. Do other threads support the same story?

8. Map Symbolicated Frames to Source

Use rg to find the implicated code:

rg -n "AppFeatureReducer|AppDetailView|NetworkClient|save\\(" /path/to/your/source -g '*.swift'

Then inspect the surrounding code, not only the exact line printed by atos. Useful things to look for include:

  • force unwraps and forced casts
  • assumptions about optional data
  • state mutation during view rendering
  • work started from callbacks or notifications
  • dependency initialization inside hot paths
  • blocking work on the main thread
  • locks, semaphores, and synchronous dispatch
  • database writes or file IO during UI updates

The right fix should explain the symbolicated frames, the exception or termination reason, and any available user feedback.

9. Fix the Likely Cause, Not Just the Top Frame

The top frame is a starting point, not proof. A good fix should address the code path implied by the whole stack.

For example:

  • If the crash is EXC_BAD_ACCESS, check ownership, unsafe pointers, C APIs, object lifetimes, and concurrency boundaries.
  • If the crash is a Swift trap, check force unwraps, forced casts, failed preconditions, array indexes, and try!.
  • If the app was killed by a watchdog, look for main-thread blocking, lock contention, synchronous dispatch, or long-running work during app lifecycle callbacks.
  • If the app was terminated for memory, look for large allocations, image/video processing, caches, and repeated work across threads.

Make the smallest architecture-consistent change that explains the report. Then build the touched targets and, when possible, reproduce the triggering path.

10. Verify

Build the touched targets for your app:

cd /path/to/your/project
swift build

If the app is built with Xcode, build the relevant scheme too:

xcodebuild build \
  -scheme AppName \
  -destination 'generic/platform=iOS' \
  -quiet

If the build fails in unrelated generated code or a third-party package before your local code compiles, record that separately. The important thing is to keep the crash fix and unrelated build blockers distinct.

Checklist

Use this checklist for future Apple crash reports:

  1. Put the .ips files and any related feedback JSON files in one folder.
  2. Read feedback JSON with jq, if available.
  3. Read .ips headers and crash objects.
  4. Match app version, build, device, OS, and timestamp.
  5. Identify exception and termination reason.
  6. Print usedImages and find the image whose name matches procName.
  7. Extract that app image's UUID, base address, image index, and architecture.
  8. Find the exact matching dSYM with dwarfdump --uuid.
  9. Convert app frame offsets to absolute addresses.
  10. Symbolicate with atos -l <base>.
  11. Read user-action or background-work stacks bottom-up.
  12. Compare blocked or related threads for shared locks, IO, or async work.
  13. Map symbolicated frames to source with rg.
  14. Form a hypothesis that explains the stack, crash reason, and available user feedback.
  15. Make the smallest architecture-consistent fix.
  16. Build the touched targets and record any unrelated build blockers.