Skip to content
TitanBlogs
Back to Home

Case 006: Inside TikTok — An iOS Reverse-Engineering Study

A practical guide to inspecting an iPhone IPA, following WDA and DeviceCheck clues, and understanding what the evidence actually shows.

Does TikTok recognize an iPhone running WebDriverAgent? Can it remember a device after a reset? And if either is true, does that explain a drop in reach?

These questions sound connected. Answering them takes different evidence. An app binary can reveal a check and the information it collects. It cannot, by itself, tell us how a server ranks a video.

This study walks through the investigation: open an IPA, identify readable code, find candidate signals, and follow them toward a concrete behavior. The examples are small enough to run yourself, with an explanation of what each result means.

Study scope. This is a practical research walkthrough, adapted from our iOS tooling report. No TikTok IPA was supplied or analyzed for this article. The Python and shell examples are analysis utilities; the Swift example demonstrates Apple's API in your own app. None is presented as recovered TikTok source code.

Start with a question you can answer

“Does TikTok detect automation?” is too broad for a first pass. A better starting point is: does this particular app build contain a path that checks for a WDA-related signal, and where does the result go?

WebDriverAgent uses Apple's XCTest APIs to control an iPhone. Appium documents 8100 as its default HTTP server port on a real device. That makes 8100 a useful research lead, although a port number alone cannot identify the process listening on it. WebDriverAgent, XCUITest Driver capabilities.

The operator notes behind this series also mention XCTAutomationSupport, checkAntiSpamState, and touch properties such as radius and tolerance. Treat those as search hypotheses. We do not have a reproducible TikTok trace establishing them here.

For the walkthrough, use a Mac with Python 3.11 or newer, Apple's Xcode Command Line Tools, and an IPA you are authorized to inspect. The later metadata step uses ipsw; function analysis uses Ghidra. Start with a working directory containing the IPA and the scripts below. The name TikTok.ipa is a placeholder for your actual file.

1. Know exactly which app you opened

An IPA is an archive. Inside it, Payload contains the app bundle, which can contain its own frameworks, extensions, and resources. The visible app name is not a reliable way to locate its executable: read CFBundleExecutable from Info.plist. Apple's bundle executable reference.

Save this as inspect_ipa.py. It records the app identity, copies the main executable to a fresh folder, and hashes both the original archive and the extracted binary. It does not launch or modify the app.

import hashlib
import json
import plistlib
import shutil
import sys
import zipfile
from pathlib import Path, PurePosixPath


def sha256(path):
    with path.open("rb") as stream:
        return hashlib.file_digest(stream, "sha256").hexdigest()


if len(sys.argv) != 3:
    raise SystemExit("Usage: python3 inspect_ipa.py INPUT.ipa NEW_FOLDER")

ipa, output = map(Path, sys.argv[1:])
with zipfile.ZipFile(ipa) as archive:
    names = archive.namelist()
    if len(names) != len(set(names)):
        raise SystemExit("Ambiguous archive: duplicate entry names")

    roots = [name for name in names
             if len(PurePosixPath(name).parts) == 3
             and name.startswith("Payload/")
             and PurePosixPath(name).parent.suffix == ".app"
             and PurePosixPath(name).name == "Info.plist"]
    if len(roots) != 1:
        raise SystemExit("Expected exactly one main app Info.plist")

    info = plistlib.loads(archive.read(roots[0]))
    executable = info.get("CFBundleExecutable")
    if (not isinstance(executable, str) or not executable
            or executable in (".", "..") or "/" in executable):
        raise SystemExit("Invalid CFBundleExecutable")

    bundle = str(PurePosixPath(roots[0]).parent)
    member = f"{bundle}/{executable}"
    archive.getinfo(member)  # Fail if the declared executable is missing.
    output.mkdir(parents=True, exist_ok=False)  # Never replace an earlier run.
    binary = output / "app.macho"
    with archive.open(member) as src, binary.open("xb") as dst:
        shutil.copyfileobj(src, dst)

    manifest = {
        "bundle_id": info.get("CFBundleIdentifier"),
        "version": info.get("CFBundleShortVersionString"),
        "build": info.get("CFBundleVersion"),
        "archive_sha256": sha256(ipa),
        "archive_executable": member,
        "binary_sha256": sha256(binary),
        "other_bundle_plists": sorted(name for name in names
            if name.startswith(bundle + "/")
            and name.endswith("/Info.plist") and name != roots[0]),
        "dylibs": sorted(name for name in names
            if name.startswith(bundle + "/") and name.endswith(".dylib")),
    }
    report = json.dumps(manifest, indent=2)
    (output / "manifest.json").write_text(report + "\n", encoding="utf-8")
    print(report)

Run it from that directory:

python3 inspect_ipa.py TikTok.ipa tiktok-study

You now have app.macho and manifest.json. A SHA-256 hash identifies the exact bytes used in your investigation; it does not certify that the app is authentic or safe. Keeping the archive hash and the binary hash separate also makes it clear which artifact a later observation refers to.

The script copies only the main executable into a fixed filename. It also lists other bundle metadata and .dylib files for follow-up. Read those bundles' CFBundleExecutable values and inspect their binaries separately when tracing a feature; relevant code may live in an embedded framework. This is a starting inventory, not a complete recursive Mach-O scanner. The archive access uses Python's standard zipfile module.

2. Check whether the instructions are readable

A decompiler needs machine instructions it can interpret. An App Store IPA can contain encrypted code, even when its resources and some metadata are readable. OWASP describes this limitation in its iOS app extraction guide.

Inspect the extracted binary before opening a decompiler:

binary="tiktok-study/app.macho"
file "$binary"
xcrun lipo -archs "$binary"
xcrun otool -arch all -l "$binary" > tiktok-study/load-commands.txt
xcrun otool -arch all -L "$binary" > tiktok-study/linked-libraries.txt

file identifies the file type. lipo lists its architecture slices: a single file can contain code for more than one CPU architecture. otool -l records the loader metadata, while otool -L records linked libraries. Open the two text files and keep them with your manifest.

In load-commands.txt, look for LC_ENCRYPTION_INFO or LC_ENCRYPTION_INFO_64 in each architecture slice. The fields describe an encrypted range:

FieldHow to interpret it
cryptoffThe range's file offset within that Mach-O slice.
cryptsizeThe range's size in bytes.
cryptidThe encryption identifier; zero declares the range unencrypted.

These fields come from Apple's Mach-O definitions. A nonzero cryptid declares encryption; changing the flag to zero does not decrypt the underlying bytes. A zero value, or the absence of the command, does not rule out custom packing or encrypted data elsewhere.

If the relevant function lies in encrypted bytes, record that limit and continue with readable material. There is no useful “TikTok source recovery” result to claim from ciphertext. Also retain platform and minimum-OS information from the load commands: an ARM64 device binary is not automatically an iOS Simulator build.

3. Search for leads, with precise locations

Now we can look for names associated with the questions. Save this as find_signals.py. It scans the extracted file for a short list of literal byte strings and prints every matching file offset.

import mmap
import sys

signals = (
    b"WebDriverAgent",
    b"XCTAutomationSupport",
    b"checkAntiSpamState",
    b"DeviceCheck",
    b"DCDevice",
)

if len(sys.argv) != 2:
    raise SystemExit("Usage: python3 find_signals.py BINARY")

with open(sys.argv[1], "rb") as stream:
    with mmap.mmap(stream.fileno(), 0, access=mmap.ACCESS_READ) as data:
        for signal in signals:
            start, count = 0, 0
            while (offset := data.find(signal, start)) != -1:
                print(f"{signal.decode():24} file+0x{offset:08x}")
                count += 1
                start = offset + 1
            if count == 0:
                print(f"{signal.decode():24} no literal match")
python3 find_signals.py tiktok-study/app.macho

The search list encodes hypotheses, not findings. A match could be an unused string, a diagnostic message, metadata, or a dependency's code. A missing match could mean the behavior is absent, but it could also mean the name was removed, encoded differently, constructed at runtime, or stored in another component.

Notice that the script does not search for the text 8100. A network port can be represented as the integer 8100, or 0x1fa4 in hexadecimal, without those four text characters appearing anywhere. Even finding that number requires checking the surrounding network call and byte-order conversion before calling it a port check.

The printed offsets locate bytes in the whole file. They are not runtime addresses. In a multi-architecture binary, first account for the slice offset, then use its segment mapping to locate the corresponding address in a disassembler. Preserve the original offset in your notes so another reader can reproduce the lookup.

For readable Objective-C and Swift metadata, ipsw provides another entry point:

ipsw class-dump tiktok-study/app.macho --headers --output tiktok-study/objc
ipsw swift-dump tiktok-study/app.macho --demangle > tiktok-study/swift.txt

These commands can reveal classes, selectors, or type information. A generated header describes structure; it does not contain the original implementation of each method. Check command errors and the architecture being inspected. ipsw class-dump, ipsw swift-dump.

4. Follow one clue until it becomes a behavior

This is where the work becomes reverse engineering. A list of interesting words is only an index.

Import a readable Mach-O into Ghidra through File > Import File, confirm its format and architecture, and run Auto-Analysis. Use the Listing, Decompiler, and cross-references together. Ghidra's introductory guide explains those tools and how analysis creates references between code and data.

For a suspected WDA check, follow a small chain:

  1. Locate the candidate. Find the string, selector, import, or numeric value. Record the binary hash, architecture, and address.
  2. Inspect its references. Identify the function that uses it and check the actual instructions. Decompiler output is a reconstruction; inferred types and names can be wrong.
  3. Trace the result. Determine what value the function returns or writes, and which callers consume it. A connection error, a process lookup, and a constant test mean different things.
  4. Find the destination. If the value enters a report, follow the serializer and the path toward the request. Record the condition controlling that path.

This produces a testable statement: “In build X, function Y constructs value Z when condition C holds.” Only fill in those placeholders after you have evidence.

It still leaves a runtime question: did the path execute in the tested session? A suitable device experiment would record the app build, iOS version, relevant setup, action, and observed event. Static reachability, observed transmission, and a server-side consequence are separate findings. No such TikTok runtime experiment was performed for this article.

5. Understand what DeviceCheck actually gives an app

The device-reset question has a documented starting point. Apple's DeviceCheck service lets a developer maintain two bits of state, with a timestamp, for a device. Apple hosts that state, and it can survive app reinstallation and erasing the device. The developer decides what the bits mean and when to reset them. Apple's DeviceCheck explanation.

Here is real Swift code showing the app-side boundary. It belongs in your own iOS app; it is not decompiled TikTok code.

import DeviceCheck
import Foundation

enum DeviceTokenError: Error {
    case unsupported
    case missingToken
}

func requestDeviceToken(
    completion: @escaping (Result<Data, Error>) -> Void
) {
    let device = DCDevice.current
    guard device.isSupported else {
        completion(.failure(DeviceTokenError.unsupported))
        return
    }
    device.generateToken { token, error in
        if let error = error {
            completion(.failure(error))
        } else if let token = token {
            completion(.success(token))
        } else {
            completion(.failure(DeviceTokenError.missingToken))
        }
    }
}

On success, the app passes the opaque token to its own authenticated backend. That backend uses Apple's server API to query or update the state. This function neither reads the two bits nor decides whether an account is allowed. It also does not provide an app with a universal device identifier. DCDevice documentation.

For a TikTok investigation, a DeviceCheck string is a lead. A call to generateToken would be stronger evidence of API use. Neither establishes what TikTok stores in the bits or whether it uses them for a device ban. That requires evidence about the surrounding flow and its outcome.

The practical lesson for users is narrower: a factory reset is not a reliable way to assume all developer-held device state has disappeared.

What would count as a result?

A good study makes the boundary of each finding visible:

ObservationSupported conclusionStill unresolved
A candidate string appears in a binary.Those bytes exist in that exact artifact.Whether executable code uses them.
A reachable function performs a check.The client contains that logic under identified conditions.Whether the path ran in a particular session.
A controlled trace observes a report being sent.That client sent the observed data in that test.How the server interpreted it.
Reach changes after automation is enabled.The two events occurred together in that observation.Causation, account history, content effects, and other changed conditions.

For every claim, keep the app version and hashes, component and architecture, relevant offsets or addresses, the action that triggered the observation, and the raw evidence. Label analyst-assigned function names and separate them from recovered symbols. This gives the next researcher something concrete to check when the app changes.

At this stage, we have an investigation method and runnable tools, not a finding that TikTok detects a particular setup or penalizes it. The next useful addition is an identified sample and a reproducible trace through one behavior. That is what turns a plausible explanation into a study other people can verify.