⏸️
Before you continue

Consider spending at least 15 minutes investigating the challenge yourself.

The goal of Instrumentation Journey is not to find the answer. The goal is to build the investigation process. Every minute you spend reasoning before reading a solution is a minute that makes you better at the next investigation.


The solution below explains the reasoning process first, then presents the commands. Understanding why a step works matters more than memorizing how to type it.

← Back to Chapter 1
Solution The Observer · Chapter 01

How to find it.

Reasoning first. Commands second.

Build the mental model before touching anything.

Good investigations start with questions, not commands. Before running anything, write down what you already know.

What do we know?
  • The app has a text field that accepts user input.
  • It compares that input against an expected value.
  • The expected value is never shown in the UI.
  • For the comparison to work, the value must exist in memory when it runs.
What are we looking for?
  • A string. The app compares text, so the expected value is text.
  • It probably relates to the achievement name ("Process Observer").
  • It lives in heap memory after the challenge screen loads.
  • It only exists while the app is running.

That's a map. It's small, but it's enough to know where to look and what to look for.

💡
Why this matters The mental model determines which commands are useful. Without it, you'd be running commands randomly and hoping something appears. With it, every step has a reason.

Attach to the running process.

📋
Before you start The app should already be installed on a physical device with Developer Mode enabled. If you haven't set that up yet, the article has the full setup checklist. You can download the IPA directly from the v1.0.0 release.

Connect your device via USB. Open a terminal and start LLDB.

Terminal
$ lldb

List connected devices and confirm yours appears.

LLDB
(lldb) device list
D93D6CA8-5B2E-4A1C-9F3D-... iPhone connected

Select your device, then tell LLDB to wait for the app to launch and attach the moment it does.

LLDB
(lldb) device select "D93D6CA8-5B2E-4A1C-9F3D-..." Device selected.
(lldb) device process attach -n InstrumentationJourney --waitfor
# LLDB is now waiting. Open the app on your device. Process 1234 stopped * thread #1, stop reason = signal SIGSTOP

Open the app on your device. LLDB catches it at launch and pauses the process immediately. Let it resume.

LLDB
(lldb) continue
💡
Why --waitfor? This flag tells LLDB to watch for a process named InstrumentationJourney to appear and attach the instant it starts. You catch the app from the very beginning of its execution.

Explore what the binary exposes.

image lookup searches the symbol table of the loaded binary. The -r flag enables regex matching, and -n searches by symbol name. Together, -rn lets you filter the entire symbol table using a pattern.

Start broad. Ask for everything inside the app module and look for names that sound relevant to the challenge.

LLDB
(lldb) image lookup -rn InstrumentationJourney

The output is long. Scan it for words that relate to what the challenge is asking: things like secret, verify, unlock. Those are the kinds of names a developer would give to code that handles a hidden value and compares it against user input.

Narrow the search progressively. Start with what looks most unusual.

LLDB
(lldb) image lookup -rn 'InstrumentationJourney.*Secret'
InstrumentationJourney`SecretAssembler.init() InstrumentationJourney`SecretAssembler.verify(_:) InstrumentationJourney`SecretAssembler.unlockCode.getter ...

A class called SecretAssembler. It has a verify method and something called unlockCode. That's a clear signal. verify is the comparison. unlockCode is what's being compared against.

Confirm it from another angle. Search for verify to see where the comparison happens.

LLDB
(lldb) image lookup -rn 'InstrumentationJourney.*verify'
InstrumentationJourney`SecretAssembler.verify(_:)

One result. The comparison lives in SecretAssembler. Now look specifically at unlock to find where the expected value is produced.

LLDB
(lldb) image lookup -rn 'InstrumentationJourney.*unlock'
InstrumentationJourney`static InstrumentationJourney.SecretAssembler.unlockCode.getter : Swift.String

A getter named unlockCode that returns a String. That is what produces the value verify compares against. The investigation now has a clear target.


Find the runtime address and intercept the function.

To set a breakpoint on a specific function, you need its address in memory at runtime. Adding -v to the lookup gives verbose output, which includes a Function: range field with exactly that.

LLDB
(lldb) image lookup -v -rn 'SecretAssembler.unlockCode.getter'
Summary: InstrumentationJourney`static InstrumentationJourney.SecretAssembler.unlockCode.getter : Swift.String at SecretAssembler.swift:33 Function: id = {0x...}, name = "static InstrumentationJourney.SecretAssembler.unlockCode.getter : Swift.String" range = [0x000000010269adc8-0x000000010269b210)

The Function: range field shows the start and end addresses of the function in memory. The start address, 0x000000010269adc8, is where execution enters the getter. That is the address to use.

The address inside InstrumentationJourney[0x...] earlier in the output is the file offset, a fixed position within the binary on disk. It changes with ASLR at every launch and is not usable as a breakpoint address. The value in Function: range is the actual runtime address for the current session.

Set a breakpoint at that address using -a, which tells LLDB to place the breakpoint at a specific memory address.

LLDB
(lldb) breakpoint set -a 0x000000010269adc8 Breakpoint 1: address = 0x000000010269adc8
(lldb) continue

Navigate to the challenge screen on your device. The getter runs the moment that screen initializes, and the breakpoint fires.

LLDB
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 frame #0: InstrumentationJourney`static SecretAssembler.unlockCode.getter() at SecretAssembler.swift:33 31 /// Returns the assembled unlock code. 32 /// After first access, "PROCESS_OBSERVER" lives as a Swift String on the heap. -> 33 static var unlockCode: String { 34 if _assembled == nil { 35 let bytes = (s1 + s2).map { $0 ^ key }

The process is paused at line 33, the entry point of unlockCode. LLDB stopped execution before the function ran. You're now inside a Swift frame, which means the expression evaluator has full access to Swift types and can call methods on the live process.

po (print object) evaluates a Swift expression against the running process and prints the result. Use it to call the getter directly.

LLDB
(lldb) po SecretAssembler.unlockCode PROCESS_OBSERVER

That's the unlock code. Resume the process, type the code into the app, and complete the challenge.

LLDB
(lldb) continue
💡
Why does po work here? po uses the language of the current frame. Stopped inside SecretAssembler.unlockCode.getter, LLDB is in a Swift context and can resolve Swift types. It evaluates the expression in the live process, assembles the string, and returns the result. You didn't modify anything. You just asked.

What made this work?

Take a moment to look back at the steps.

We didn't modify the application. We didn't change any code. We didn't patch anything. We attached a debugger to a running process and used it to observe data that existed in memory but was never shown in the UI.

The reason this works is straightforward. For the application to verify your input, it must have the expected value available at the moment of comparison. That value has to live somewhere. A debugger gives you access to that memory.

The technique used here, finding a function through symbol search and intercepting it at runtime, is one of the most fundamental forms of runtime investigation. It doesn't require source code. It doesn't require modifying the binary. It only requires understanding that if a process produces a value, a debugger can observe it.

Observe before changing.

Today the hidden value was an unlock code. In real work, it might be a session token, a feature flag, an API key, or a piece of business logic that only activates under specific conditions. The underlying approach is the same. Attach, observe, understand.

That's what runtime instrumentation is built on.


Back to
Chapter 1: The Observer
Up Next · Chapter 02
The Messenger
Coming in the next release
Discussion

Something didn't match? Got a question?