(Arrays.asList(functionName.split("::")));
150 | split.removeLast();
151 | return String.join("::", split);
152 | }
153 | }
154 |
155 | public record CommandDriverReference(
156 | Reference reference, Function function, String driverName, long serviceId
157 | ) implements AddressableRowObject {
158 | @Override
159 | public Address getAddress() {
160 | return reference.getFromAddress();
161 | }
162 |
163 | public boolean isResolved() {
164 | return serviceId > 0;
165 | }
166 | }
167 |
168 | }
169 |
--------------------------------------------------------------------------------
/qmi-dissect/dissector/research/ExtractDebugFunctionNames.java:
--------------------------------------------------------------------------------
1 | // Extracts C++ class and function names from debug strings.
2 | // This script is intended for the 'locationd' binary, but could also be applied to other binaries without symbols.
3 | //
4 | // @author: Lukas Arnold
5 | // @category: QMI
6 |
7 | import ghidra.app.script.GhidraScript;
8 | import ghidra.app.util.NamespaceUtils;
9 | import ghidra.app.util.XReferenceUtils;
10 | import ghidra.program.model.listing.CircularDependencyException;
11 | import ghidra.program.model.listing.Data;
12 | import ghidra.program.model.listing.Function;
13 | import ghidra.program.model.symbol.SourceType;
14 | import ghidra.program.util.DefinedDataIterator;
15 | import ghidra.util.exception.DuplicateNameException;
16 | import ghidra.util.exception.InvalidInputException;
17 |
18 | import java.util.concurrent.atomic.AtomicInteger;
19 | import java.util.regex.Matcher;
20 | import java.util.regex.Pattern;
21 |
22 | public class ExtractDebugFunctionNames extends GhidraScript {
23 |
24 | private static final Pattern CLASS_FUNCTION_PATTERN = Pattern.compile("(\\w+)::(\\w+)");
25 |
26 | // We could also create a regular expression for "#bb.e" debug messages,
27 | // but they do not directly refer to the class and
28 | // should be handled with a lower priority than class function patterns.
29 |
30 | @Override
31 | protected void run() throws Exception {
32 | AtomicInteger counter = new AtomicInteger();
33 |
34 | DefinedDataIterator.definedStrings(currentProgram).forEach(data -> {
35 | String string = (String) data.getValue();
36 |
37 | // Ignore TLV debug messages
38 | if (string.startsWith("Recvd")) {
39 | return;
40 | }
41 |
42 | // Handle special strings to rename caller functions without proper debug strings in locationd
43 | // "#bbe.Register PDS" -> BasebandEvent::registerPDS
44 | // "#bb.e, registration action" -> BasebandEvent::registrationAction
45 | if (string.equals("{\"msg%{public}.0s\":\"#bb.e,Register PDS\"}")) {
46 | renameReferences(data, "BasebandEvent", "registerPDSIndications");
47 | return;
48 | } else if (string.equals("{\"msg%{public}.0s\":\"#bb.e,registration action\"}")) {
49 | renameReferences(data, "BasebandEvent", "registrationAction");
50 | return;
51 | }
52 |
53 | // Try to match the function and class name from the string
54 | Matcher matcher = CLASS_FUNCTION_PATTERN.matcher(string);
55 | if (!matcher.find()) {
56 | return;
57 | }
58 |
59 | // If successful, apply the label to the function of all references
60 | applyMatch(data, matcher);
61 |
62 | // Count the number of successful matches
63 | counter.getAndIncrement();
64 | });
65 |
66 | println("Matched " + counter.get() + " strings");
67 | }
68 |
69 | private void applyMatch(Data data, Matcher matcher) {
70 | // Extract the class and function name from the matched object
71 | String className = matcher.group(1);
72 | String functionName = matcher.group(2);
73 |
74 | // Ignore debug messages regarding the standard C++ library
75 | if (className.equals("std")) {
76 | return;
77 | }
78 |
79 | // println("Match: " + className + "::" + functionName);
80 |
81 | // Apply the label to the function of all references
82 | renameReferences(data, className, functionName);
83 | }
84 |
85 | private void renameReferences(Data data, String className, String functionName) {
86 | // Apply the label to the function of all references
87 | XReferenceUtils.getXReferences(data, -1).forEach(reference -> {
88 | // Get the function for a given reference
89 | Function function = getFunctionBefore(reference.getFromAddress());
90 | try {
91 | // Assign the function name
92 | function.setName(functionName, SourceType.ANALYSIS);
93 |
94 | // Check if a class name is set and if apply it to the function
95 | if (className != null) {
96 | // Create a new or get the existing namespace for the class and assign it
97 | function.setParentNamespace(NamespaceUtils.createNamespaceHierarchy(
98 | className, null, currentProgram, SourceType.ANALYSIS));
99 |
100 | // Add a comment of both
101 | function.setComment(className + "::" + functionName);
102 | }
103 | } catch (InvalidInputException | DuplicateNameException | CircularDependencyException e) {
104 | // Print an error if an exception occurred
105 | printerr("Can't apply " + className + "::" + functionName +
106 | "to " + reference.getFromAddress() + ": " + e.getMessage());
107 | }
108 | });
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/qmi-dissect/dissector/research/README.md:
--------------------------------------------------------------------------------
1 | # QMI Research
2 |
3 | You can discover new QMI message identifier using the tools available in this directory.
4 | You can combine two approaches for this task.
5 |
6 | ## Background
7 |
8 | The function `qmi::MessageBase::validateMsgId` is called by a large number of QMI message, the iPhone sends and receives.
9 | Its two parameters are the instance pointer of the `MessageBase` object and the `message_id` as an unsigned short.
10 |
11 | Thus, we can use it to translate previously unknown message ids to strings and better understand the communication between the iPhone application processor and its baseband processor.
12 |
13 | ## Dynamic
14 | The dynamic approach uses [Frida](https://frida.re) to intercept calls to the function `qmi::MessageBase::validateMsgId` from the library `libQMIParserDynamic.dylib` in real-time.
15 | You can try different things on the iPhone to collect as much message ids as possible.
16 | A jailbroken iPhone is required to execute the script.
17 | It is optimized for an iPhone 12 mini with iOS 14.2.1.
18 |
19 | ```bash
20 | frida -U -l explore_frida.ts CommCenter
21 | ```
22 |
23 | Messages of the QMI position determination service (PDS) are handled by the `locationd` process.
24 | Its executable can be found in `/usr/libexec/locationd`.
25 | ```bash
26 | frida -U locationd -l explore_frida.ts
27 | ```
28 |
29 | ## Static
30 | The static approach uses a Ghidra script to scan all references to the function `qmi::MessageBase::validateMsgId` and show respective message ids & calling functions in a table.
31 |
32 | To use it, add this folder as a script directory in Ghidra (so it can detect the file [ExtractQMIMessageIDs.java](./ExtractQMIMessageIDs.java)), point your cursor to the entry point of the function `__auth_stubs::__ZN3qmi11MessageBase13validateMsgIdEt` in your target library and run it using the script manager.
33 |
34 | Good resources to learn Ghidra scripting are
35 | - [Ghidra Javadocs](https://ghidra.re/ghidra_docs/api/ghidra/app/script/GhidraScript.html)
36 | - [sentinelone.com](https://www.sentinelone.com/labs/a-guide-to-ghidra-scripting-development-for-malware-researchers/)
37 | - [HackOvert/GhidraSnippets](https://github.com/HackOvert/GhidraSnippets)
38 | - [garyttierney/intellij-ghidra](https://github.com/garyttierney/intellij-ghidra)
39 |
40 | ### Import
41 |
42 | Based on static approach we can automatically analyze binaries, extract their QMI definitions, and convert them to libqmi data structures which in turn can be used for improving the dissector.
43 |
44 | 1. Get IPSW
45 | 2. `ipsw dyld imports dyld_shared_cache_arm64e /usr/lib/libQMIParserDynamic.dylib`
46 | 3. Put each file in Ghidra
47 | 4. Apply plugin
48 | 5. Run script to import
49 |
50 | Repeat for executables like locationd but apply symbol plugin before
51 |
52 | ## Results
53 |
54 | The results can be used to manually improve the iOS extensions for libqmi, located in the [libqmi-ios-ext](../../../libqmi-ios-ext) directory.
55 |
--------------------------------------------------------------------------------
/qmi-dissect/dissector/research/explore_frida.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | // Library: libQMIParserDynamic.dylib
4 | // @ts-ignore
5 | const libraryName = "libQMIParserDynamic.dylib"
6 |
7 | function listenCallback(functionName: string, argumentNames: string[]): InvocationListenerCallbacks {
8 | return {
9 | onEnter: function (args) {
10 | console.log(`${libraryName}:${functionName} (onEnter)`);
11 |
12 | // Print all arguments with their names
13 | for (let i = 0; i < argumentNames.length; i++) {
14 | console.log(`${argumentNames[i]}: ${args[i]}`);
15 | }
16 |
17 | // Print the backtrace causing the call of the initial instruction
18 | console.log('Backtrace:' +
19 | Thread.backtrace(this.context, Backtracer.ACCURATE)
20 | .map(DebugSymbol.fromAddress).join('\n'));
21 |
22 | console.log('');
23 | },
24 | onLeave: function (returnValue) {
25 | console.log(`${libraryName}:${functionName} (onLeave)`);
26 |
27 | // Print the return value
28 | console.log(`Return Value: ${returnValue}`);
29 |
30 | console.log('');
31 | }
32 | }
33 | }
34 |
35 | // Function: qmi::MessageBase::validateMsgId
36 | Interceptor.attach(
37 | Module.findExportByName('libQMIParserDynamic.dylib', '_ZN3qmi11MessageBase13validateMsgIdEt')!,
38 | listenCallback(
39 | 'qmi::MessageBase::validateMessageID',
40 | ['MessageBase (this)', 'message_id']
41 | )
42 | );
43 |
44 | // Function: qmi::MessageBase::MessageBase (constructor)
45 | Interceptor.attach(
46 | Module.findExportByName('libQMIParserDynamic.dylib', '_ZN3qmi11MessageBaseC1EtNS_5ErrorE')!,
47 | listenCallback(
48 | 'qmi::MessageBase::MessageBase',
49 | ['MessageBase (this)', 'message_id', 'error']
50 | )
51 | );
52 |
53 | // Function: qmi::MutableMessageBase::MutableMessageBase (constructor)
54 | Interceptor.attach(
55 | Module.findExportByName('libQMIParserDynamic.dylib', '_ZN3qmi18MutableMessageBaseC2Et')!,
56 | listenCallback(
57 | 'qmi::MutableMessageBase::MutableMessageBase',
58 | ['MessageBase (this)', 'message_id']
59 | )
60 | );
61 |
--------------------------------------------------------------------------------
/qmi-dissect/dissector/research/explore_frida_constructors.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | // @ts-ignore
4 | const libraryName = "libQMIParserDynamic.dylib"
5 |
6 | function interceptMessageId(matches: ApiResolverMatch[]) {
7 | // Hook each match
8 | for (let match of matches) {
9 | Interceptor.attach(match.address, {
10 | onEnter: function (args) {
11 | // Store the first argument which is a reference to the object itself
12 | this.objectPointer = args[0];
13 | },
14 | onLeave: function (retval) {
15 | // Read the message id from the object (stored at the beginning) after the constructor has finished
16 | const pointer: NativePointer = this.objectPointer
17 | const msgId = pointer.readUShort()
18 | console.log(`${match.name} -> 0x${msgId.toString(16)}`);
19 | }
20 | })
21 | }
22 | }
23 |
24 | // Hook into all constructors of MessageBase or MutableMessageBase
25 | interceptMessageId(new ApiResolver('module').enumerateMatches(`exports:${libraryName}!*MessageBaseC*`))
26 |
--------------------------------------------------------------------------------
/qmi-dissect/dissector/research/utils/CursorLocation.java:
--------------------------------------------------------------------------------
1 | package utils;
2 |
3 | import ghidra.program.model.address.Address;
4 | import ghidra.program.model.listing.Program;
5 | import ghidra.program.util.LabelFieldLocation;
6 | import ghidra.program.util.OperandFieldLocation;
7 | import ghidra.program.util.ProgramLocation;
8 |
9 | public class CursorLocation {
10 |
11 | public static Address findLocation(ProgramLocation currentLocation, Address currentAddress) {
12 | Address targetLocation = findSpecialLocation(currentLocation);
13 | // Ensure that we don't use a refAddress property which was null
14 | return targetLocation != null ? targetLocation : currentAddress;
15 | }
16 |
17 | private static Address findSpecialLocation(ProgramLocation currentLocation) {
18 | if (currentLocation instanceof LabelFieldLocation) {
19 | // The reference address in this case can be sometimes null
20 | return currentLocation.getRefAddress();
21 | } else if (currentLocation instanceof OperandFieldLocation) {
22 | // In this case the address can also be outside the allocated program space
23 | return currentLocation.getRefAddress();
24 | }
25 |
26 | return null;
27 | }
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/qmi-dissect/dissector/research/utils/FunctionNames.java:
--------------------------------------------------------------------------------
1 | package utils;
2 |
3 | import ghidra.app.script.GhidraScript;
4 | import ghidra.program.model.address.Address;
5 | import ghidra.program.model.listing.Function;
6 | import ghidra.program.model.listing.Program;
7 | import ghidra.program.model.symbol.Symbol;
8 |
9 | import java.util.LinkedList;
10 |
11 | public class FunctionNames {
12 |
13 | /**
14 | * Tries to determine the best available 'underscore' function name for the given function.
15 | * As this name exposes the most amount of information about the function.
16 | * This function also tries to resolve unnamed functions (which names start with FUN_)
17 | * by going up in the call hierarchy.
18 | *
19 | * @param function the function to search the name for
20 | * @param script the instance of the script for logging & the current program
21 | * @return the best-possible function name for the function
22 | */
23 | public static String extractFullFunctionName(Function function, GhidraScript script) {
24 | String functionName = function.getName(true);
25 |
26 | // Try to resolve unnamed functions (which names start with FUN_) by looking up in the call hierarchy
27 | // or boring function names.
28 | if (functionName.startsWith("FUN_")) {
29 | String nameFromCallers = findNameFromCallers(function);
30 | if (nameFromCallers != null) {
31 | return nameFromCallers;
32 | }
33 | }
34 |
35 | // If the function's name is already in the underscore format, we can return it.
36 | if (functionName.startsWith("__") && !functionName.equals("__invoke")) {
37 | return functionName;
38 | }
39 |
40 | // If not, we'll try to get its underscore representation.
41 | if (function.getEntryPoint() != null) {
42 | String underscoreFunctionName = getUnderscoreSymbol(function.getEntryPoint(), script.getCurrentProgram());
43 | if (underscoreFunctionName != null) {
44 | return underscoreFunctionName;
45 | }
46 | } else {
47 | script.println("Entry Point of function is null: " + function.getName(true));
48 | }
49 |
50 | // If nothing of our approaches helped to improve the function name, we just return it as is.
51 | return functionName;
52 | }
53 |
54 | /**
55 | * Tries to resolve unnamed functions (which names start with FUN_) by following the call hierarchy.
56 | *
57 | * This approach is useful for binaries without symbols like 'locationd'.
58 | * To annotate those binaries with some names, it's important to run the ExtractDebugFunctionNames beforehand.
59 | *
60 | * We perform this search up until a depth of 6 levels.
61 | *
62 | * @param firstFunction the function to analyze
63 | * @return the name of a function in the hierarchy or null if no better name could be found
64 | */
65 | public static String findNameFromCallers(Function firstFunction) {
66 | LinkedList queue = new LinkedList<>();
67 |
68 | // Add all references to the function to the queue with level zero.
69 | for (Function callingFunction : firstFunction.getCallingFunctions(null)) {
70 | queue.offer(new CallLevel(callingFunction, 0));
71 | }
72 |
73 | // Loop while there are references to search
74 | while (!queue.isEmpty()) {
75 | // Get the first element from the queue
76 | CallLevel callLevel = queue.poll();
77 |
78 | // Check if the function has is named and if yes, return its name combined with its namespace.
79 | String name = callLevel.function.getName();
80 | String namespace = callLevel.function.getParentNamespace().getName();
81 | if (!name.startsWith("FUN_") && !name.startsWith("thunk_FUN_")) {
82 | return namespace + "::" + name;
83 | }
84 |
85 | // Don't continue searching after six levels of call hierarchy.
86 | if (callLevel.level >= 6) {
87 | continue;
88 | }
89 |
90 | // Add all references to this function to the queue with the level increased by one.
91 | for (Function callingFunction : callLevel.function.getCallingFunctions(null)) {
92 | queue.offer(new CallLevel(callingFunction, callLevel.level + 1));
93 | }
94 | }
95 |
96 | // If no better name has been found, return null.
97 | return null;
98 | }
99 |
100 | public record CallLevel(Function function, int level) {
101 | }
102 |
103 | /**
104 | * Searches a symbol starting with two underscores for the given address.
105 | * Null is returned if non can be found.
106 | *
107 | * @param address the address
108 | * @return the symbol name for the address starting with two underscores or null if non can be found
109 | */
110 | public static String getUnderscoreSymbol(Address address, Program program) {
111 | Symbol[] symbols = program.getSymbolTable().getSymbols(address);
112 | for (Symbol symbol : symbols) {
113 | if (symbol.getName().startsWith("__") && !symbol.getName().equals("__invoke")) {
114 | return symbol.getName();
115 | }
116 | }
117 |
118 | return null;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/qmi-dissect/dissector/research/utils/InstructionUtils.java:
--------------------------------------------------------------------------------
1 | package utils;
2 |
3 | import ghidra.program.model.address.Address;
4 | import ghidra.program.model.lang.Register;
5 | import ghidra.program.model.listing.Instruction;
6 | import ghidra.program.model.listing.Program;
7 | import ghidra.program.model.scalar.Scalar;
8 | import ghidra.program.model.symbol.Symbol;
9 |
10 | import java.util.List;
11 | import java.util.Optional;
12 |
13 | public class InstructionUtils {
14 |
15 | public static Optional extractBranchTarget(Instruction instruction) {
16 | Object[] opObjects = instruction.getOpObjects(0);
17 | if (opObjects.length < 1)
18 | return Optional.empty();
19 |
20 | if (!(opObjects[0] instanceof Address address))
21 | return Optional.empty();
22 |
23 | return Optional.of(address);
24 | }
25 |
26 | public static List extractBranchTargetSymbols(Instruction instruction, Program program) {
27 | Object[] opObjects = instruction.getOpObjects(0);
28 | if (opObjects.length < 1 || !(opObjects[0] instanceof Address address)) {
29 | return List.of();
30 | }
31 |
32 | return List.of(program.getSymbolTable().getSymbols(address));
33 | }
34 |
35 | public static Optional extractParameter(Instruction instruction, String targetRegister, int opIndex) {
36 | // Check that an instruction was found
37 | if (instruction == null)
38 | return Optional.empty();
39 |
40 | // Check that the first operand points to the correct memory location
41 | if (targetRegister != null) {
42 | Object[] firstOpObjects = instruction.getOpObjects(0);
43 | if (firstOpObjects.length < 1 || !(firstOpObjects[0] instanceof Register register)) {
44 | return Optional.empty();
45 | }
46 |
47 | if (!register.getName().equals(targetRegister)) {
48 | return Optional.empty();
49 | }
50 | }
51 |
52 | // Check that the second or third operand exists and is a scalar
53 | Object[] targetOpObject = instruction.getOpObjects(opIndex);
54 | if (targetOpObject.length < 1) {
55 | return Optional.empty();
56 | }
57 |
58 | // Get the value of the scalar
59 | return Optional.of(targetOpObject[0]);
60 | }
61 |
62 | public static Optional extractConstantParameter(Instruction instruction, String targetRegister, int opIndex) {
63 | Optional o = extractParameter(instruction, targetRegister, opIndex);
64 |
65 | if (o.isPresent() && o.get() instanceof Scalar scalar) {
66 | return Optional.of(scalar.getValue());
67 | }
68 |
69 | return Optional.empty();
70 | }
71 |
72 | public static boolean compareStore(Instruction instruction, String readRegName, String targetRegName) {
73 | if (instruction == null)
74 | return false;
75 |
76 | if (readRegName != null) {
77 | Object[] opObjectsFirst = instruction.getOpObjects(0);
78 | if (opObjectsFirst.length < 1 || !(opObjectsFirst[0] instanceof Register readRegister)) {
79 | return false;
80 | }
81 | if (!readRegister.getName().equals(readRegName)) {
82 | return false;
83 | }
84 | }
85 |
86 | if (targetRegName != null) {
87 | Object[] opObjectsSecond = instruction.getOpObjects(1);
88 | if (opObjectsSecond.length < 1 || !(opObjectsSecond[0] instanceof Register targetRegister)) {
89 | return false;
90 | }
91 | if (!targetRegister.getName().equals(targetRegName)) {
92 | return false;
93 | }
94 | }
95 |
96 | return true;
97 | }
98 |
99 | }
100 |
--------------------------------------------------------------------------------
/qmi-dissect/logarchive/.gitignore:
--------------------------------------------------------------------------------
1 | # RustRover
2 | .idea
3 |
4 | # Generated by Cargo
5 | # will have compiled files and executables
6 | debug/
7 | target/
8 |
9 | # These are backup files generated by rustfmt
10 | **/*.rs.bk
11 |
12 | # MSVC Windows builds of rustc generate these, which store debugging information
13 | *.pdb
14 |
15 | # Outputs
16 | *.csv
--------------------------------------------------------------------------------
/qmi-dissect/logarchive/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "unifiedlog_parser"
3 | version = "0.1.0"
4 | edition = "2021"
5 |
6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7 |
8 | [dependencies]
9 | simplelog = "0.12.2"
10 | csv = "1.3.0"
11 | chrono = "0.4.38"
12 | log = "0.4.22"
13 | macos-unifiedlogs = { git = "https://github.com/mandiant/macos-UnifiedLogs", branch = "main" }
14 | clap = { version = "4.5.20", features = ["derive"] }
--------------------------------------------------------------------------------
/qmi-dissect/logarchive/README.md:
--------------------------------------------------------------------------------
1 | # Logarchive Parser
2 |
3 | This is a parser for .logarchive files focused on QMI packets based on [macos-UnifiedLogs](https://github.com/mandiant/macos-UnifiedLogs).
4 |
5 | The resulting .csv file only contains log messages related to QMI packets.
6 |
7 | ## Build
8 |
9 | Install the [Rust toolchain](https://www.rust-lang.org/tools/install) on your system.
10 |
11 | ```sh
12 | # Build the executable
13 | cargo build --release
14 | ```
15 |
16 | ## Usage
17 |
18 | ```sh
19 | # Read logarchive from a sysdiagnose into the file parsed-qmi-logarchive.csv.
20 | # The console may show warnings or errors while running this command, however they don't affect the packet log messages.
21 | ./target/release/unifiedlog_parser -i ~/sysdiagnose_2024.04.12_11-19-45+0200_iPhone-OS_iPhone_21E236/system_logs.logarchive -o parsed-qmi-logarchive.csv
22 | ```
23 |
--------------------------------------------------------------------------------
/qmi-dissect/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iphone-qmi-wireshark-agent",
3 | "version": "1.0.0",
4 | "description": "Frida agent written in TypeScript",
5 | "private": true,
6 | "main": "agent/index.ts",
7 | "scripts": {
8 | "build": "frida-compile agent/index.ts -o _agent.js -c",
9 | "watch": "frida-compile agent/index.ts -o _agent.js -w"
10 | },
11 | "devDependencies": {
12 | "@types/frida-gum": "^18.7.1",
13 | "@types/node": "~20.9.5",
14 | "frida-compile": "^10.2.5"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/qmi-dissect/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "lib": ["es2020"],
5 | "allowJs": true,
6 | "noEmit": true,
7 | "strict": true,
8 | "esModuleInterop": true
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/qmi-dissect/watch_cellguard.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | # Watch and capture QMI packets from a .cells2 file exported by the CellGuard iOS app
4 | # Inspired / parts from https://github.com/seemoo-lab/aristoteles/blob/master/tools/watch_frida.py (MIT License)
5 |
6 | import argparse
7 | import base64
8 | from datetime import datetime
9 | import tempfile
10 | import zipfile
11 | from enum import Enum
12 | from pathlib import Path
13 | from typing import Optional
14 |
15 | import pandas as pd
16 | from tqdm import tqdm
17 |
18 | from wireshark import Wireshark
19 |
20 |
21 | class Protocol(Enum):
22 | QMI = "QMI"
23 | ARI = "ARI"
24 |
25 |
26 | class WatchCellGuard(Wireshark):
27 | """ Inspect QMI packets in Wireshark extracted from a CellGuard export file. """
28 |
29 | def __init__(self, verbose: bool, file: Path, start: Optional[int], end: Optional[int], protocols: [Protocol]):
30 | super().__init__(verbose)
31 | self.read_completed = False
32 | self.file_path = file.absolute()
33 | self.parameter_start = datetime.fromtimestamp(start) if start else None
34 | self.parameter_end = datetime.fromtimestamp(end) if end else None
35 | self.protocols = [p.value for p in protocols]
36 |
37 | def start_input_monitor(self) -> bool:
38 | if self.read_completed:
39 | print(f"Input was already read")
40 | return True
41 |
42 | print(f"Reading QMI packets from the CellGuard export...")
43 |
44 | with tempfile.TemporaryDirectory() as tmp_dir:
45 | # Extract packets.csv from cells2 archive
46 | with zipfile.ZipFile(self.file_path) as zf:
47 | try:
48 | packets_in_zip = zf.getinfo('packets.csv')
49 | except KeyError:
50 | print(f'The CellGuard export contains no packets.csv!')
51 | return False
52 |
53 | packets_csv_path = Path(zf.extract(packets_in_zip, tmp_dir))
54 | print(f"Extracted packets.csv to {packets_csv_path}")
55 |
56 | # Read packets from packet.csv
57 | df: pd.DataFrame = pd.read_csv(packets_csv_path, header=0, index_col=False)
58 |
59 | df['collected'] = pd.to_datetime(df['collected'], unit='s')
60 |
61 | # Filter packets
62 | df = df.loc[df['proto'].isin(self.protocols)]
63 |
64 | if self.parameter_start:
65 | df = df.loc[df['collected'] >= self.parameter_start]
66 |
67 | if self.parameter_end:
68 | df = df.loc[df['collected'] <= self.parameter_end]
69 |
70 | # Check if the export contains any QMI packets
71 | packet_count = len(df.index)
72 | if packet_count == 0:
73 | print(f'The CellGuard export contains no {self.protocols} packets (within the selected parameters)!')
74 | return False
75 |
76 | # Sort by timestamp
77 | df.sort_values(by=['collected'], inplace=True)
78 |
79 | # Get start & end time
80 | start_time = df['collected'].iloc[1]
81 | end_time = df['collected'].iloc[-1]
82 | self.start_time = start_time.timestamp()
83 |
84 | print(f'First packet: {start_time} UTC')
85 | print(f'Last packet: {end_time} UTC')
86 |
87 | for packet in tqdm(df.itertuples(index=False), total=packet_count):
88 | # noinspection PyUnresolvedReferences
89 | data = base64.decodebytes(bytes(packet.data, 'utf-8'))
90 | # noinspection PyUnresolvedReferences
91 | collected = packet.collected
92 | self.feed_wireshark(data, collected.timestamp())
93 |
94 | self.read_completed = True
95 | print(f"{packet_count} QMI packets have been successfully imported into Wireshark.")
96 | return True
97 |
98 | def check_input_monitor(self) -> bool:
99 | return True
100 |
101 |
102 | def main():
103 | arg_parser = argparse.ArgumentParser(
104 | description='Reads a .cells2 file exported by CellGuard iOS app '
105 | 'and redirects the binary QMI packets to Wireshark.')
106 | arg_parser.add_argument('-f', '--file', required=True, type=Path, help='The cells file to process')
107 | arg_parser.add_argument('-v', '--verbose', action='store_true', help='Print verbose logs')
108 |
109 | arg_parser.add_argument('-qmi', '--qmi', action='store_true', help='Only import QMI packets')
110 | arg_parser.add_argument('-ari', '--ari', action='store_true', help='Only import ARI packets')
111 |
112 | arg_parser.add_argument('--start', type=int, help='Only process packets younger than the given UNIX timestamp.')
113 | arg_parser.add_argument('--end', type=int, help='Only process packets older than the given UNIX timestamp.')
114 |
115 | args = arg_parser.parse_args()
116 |
117 | file_path: Path = args.file
118 | if not file_path.is_file():
119 | print('No file is present at the given path!')
120 | exit(1)
121 |
122 | if file_path.suffix != '.cells2':
123 | print('The given file is not a CellGuard export JSON file as it has the wrong file extension!')
124 | exit(1)
125 |
126 | if args.start and args.end and args.start > args.end:
127 | print('The start timestamp may not be larger than the end timestamp.')
128 | exit(1)
129 |
130 | protocols = []
131 | if args.qmi:
132 | protocols.append(Protocol.QMI)
133 | if args.ari:
134 | protocols.append(Protocol.ARI)
135 |
136 | # If nothing is selected, use both protocols
137 | if len(protocols) == 0:
138 | protocols.append(Protocol.QMI)
139 | protocols.append(Protocol.ARI)
140 |
141 | watcher = WatchCellGuard(args.verbose, file_path, args.start, args.end, protocols)
142 | watcher.start_monitor()
143 |
144 |
145 | # Call script
146 | if __name__ == "__main__":
147 | main()
148 |
--------------------------------------------------------------------------------
/qmi-dissect/watch_frida.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | # Watch and capture QMI packets by intercepting the packets with frida
4 | # You need to have permissions for executing the dumpcap (you have to be part of the "wireshark" group or run this as sudo)
5 | # Inspired / parts from https://github.com/seemoo-lab/aristoteles/blob/master/tools/watch_frida.py (MIT License)
6 |
7 | import argparse
8 | from pathlib import Path
9 |
10 | import frida
11 |
12 | from wireshark import Wireshark
13 |
14 |
15 | class WatchFrida(Wireshark):
16 | """ Inspect QMI packets in Wireshark extracted using Frida. """
17 |
18 | def __init__(self, verbose: bool, direction_bit: bool):
19 | super().__init__(verbose)
20 | self.frida_script = None
21 | self.direction_bit = direction_bit
22 |
23 | def _spawn_frida_script(self):
24 | frida_session = frida.get_usb_device(1).attach("CommCenter")
25 |
26 | frida_script_file = Path('_agent.js')
27 | if not frida_script_file.exists():
28 | print("Please compile the agent using 'npm run build'")
29 | return False
30 |
31 | self.frida_script = frida_session.create_script(frida_script_file.read_text())
32 | self.frida_script.load()
33 |
34 | print(" * Initialized functions with Frida.")
35 |
36 | return True
37 |
38 | def on_msg(self, message, data):
39 | if message['type'] == 'send':
40 | # Baseband --QMI-> iPhone's application processor
41 | if message['payload'] == 'qmi_read':
42 | # If enabled, use the first byte to track the source of the message
43 | if self.direction_bit:
44 | data = b'\x00' + data
45 | hex_str = data.hex()
46 | self.feed_wireshark(hex_str)
47 | if self.verbose:
48 | print("incoming qmi read message:")
49 | print(hex_str)
50 | # iPhone's application processor --QMI-> Baseband
51 | if message['payload'] == 'qmi_send':
52 | # If enabled, use the first byte to track the source of the message
53 | if self.direction_bit:
54 | data = b'\x01' + data
55 | hex_str = data.hex()
56 | self.feed_wireshark(hex_str)
57 | if self.verbose:
58 | print('incoming qmi send message:')
59 | print(hex_str)
60 |
61 | def start_input_monitor(self) -> bool:
62 | if self.frida_script is None:
63 | if not self._spawn_frida_script():
64 | print("Unable to initialize Frida script")
65 | return False
66 |
67 | self.frida_script.on('message', self.on_msg)
68 | return True
69 |
70 | def check_input_monitor(self) -> bool:
71 | if self.frida_script.is_destroyed:
72 | # Script is destroyed
73 | print("_pollTimer: Frida script has been destroyed")
74 | self.frida_script = None
75 | return False
76 | else:
77 | return True
78 |
79 | def kill_input_monitor(self):
80 | if self.frida_script is not None:
81 | print("Killing Frida script...")
82 | self.frida_script.unload()
83 | self.frida_script = None
84 |
85 | def kill_monitor(self):
86 | super().kill_monitor()
87 | if self.frida_script is not None:
88 | print("Killing Frida script...")
89 | self.frida_script.unload()
90 | self.frida_script = None
91 |
92 |
93 | # Call script
94 | if __name__ == "__main__":
95 | arg_parser = argparse.ArgumentParser(
96 | description="Intercepts QMI messages using frida and pipes the output to wireshark for live monitoring.")
97 | arg_parser.add_argument('-v', '--verbose', action='store_true', help='Print verbose logs')
98 | arg_parser.add_argument(
99 | '--directionbit',
100 | action='store_true',
101 | help='Add a direction bit to the packets sent to Wireshark. '
102 | 'Warning: Requires a special build of the Wireshark dissector.'
103 | )
104 | args = arg_parser.parse_args()
105 |
106 | watcher = WatchFrida(args.verbose, args.directionbit)
107 |
108 | watcher.start_monitor()
109 |
--------------------------------------------------------------------------------
/qmi-dissect/watch_syslog.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | # Watch and capture QMI packets from the running idevicesyslog
4 | # You need to have permissions for executing the dumpcap (you have to be part of the "wireshark" group or run this as sudo)
5 | # Inspired / parts from https://github.com/seemoo-lab/aristoteles/blob/master/tools/watch_frida.py (MIT License)
6 |
7 | import argparse
8 | import io
9 | import os
10 | import re
11 | import subprocess
12 |
13 | from wireshark import Wireshark
14 | from shutil import which
15 |
16 |
17 | class WatchSyslog(Wireshark):
18 | """ Inspect QMI packets in Wireshark extracted using the idevicesyslog utility. """
19 |
20 | def __init__(self, verbose: bool):
21 | super().__init__(verbose)
22 | self.syslog_process = None
23 |
24 | def _spawn_device_syslog(self) -> bool:
25 | if which("idevicesyslog") is None:
26 | print("idevicesyslog not found!")
27 | return False
28 |
29 | DEVNULL = open(os.devnull, "wb")
30 |
31 | self.syslog_process = subprocess.Popen(
32 | "idevicesyslog",
33 | stdout=subprocess.PIPE,
34 | stderr=DEVNULL,
35 | )
36 |
37 | return True
38 |
39 | def check_input_monitor(self) -> bool:
40 | if self.syslog_process.poll() == 0:
41 | print("_pollTimer: Syslog has terminated")
42 | self.syslog_process = None
43 | return False
44 | else:
45 | return True
46 |
47 | def start_input_monitor(self) -> bool:
48 | if self.syslog_process is None:
49 | if not self._spawn_device_syslog():
50 | print("Unable to start Syslog")
51 | return False
52 |
53 | for line in io.TextIOWrapper(self.syslog_process.stdout, encoding="utf-8"):
54 | bin_content = re.search(r".*CommCenter.*Bin=\['(.*)']", line)
55 | if bin_content is not None:
56 | self.feed_wireshark(bin_content.group(1).lower().replace(" ", ""))
57 |
58 | return True
59 |
60 | def kill_input_monitor(self) -> None:
61 | if self.syslog_process is not None:
62 | print("Killing Syslog process...")
63 | try:
64 | self.syslog_process.terminate()
65 | self.syslog_process.wait()
66 | except OSError:
67 | print("Error during syslog process termination")
68 | self.syslog_process = None
69 |
70 |
71 | # Call script
72 | if __name__ == "__main__":
73 | arg_parser = argparse.ArgumentParser(
74 | description="Attaches to the idevicesyslog and pipes the output to wireshark for live monitoring.")
75 | arg_parser.add_argument('-v', '--verbose', action='store_true', help='Print verbose logs')
76 | args = arg_parser.parse_args()
77 |
78 | watcher = WatchSyslog(args.verbose)
79 |
80 | watcher.start_monitor()
81 |
--------------------------------------------------------------------------------
/qmi-dissect/wireshark.py:
--------------------------------------------------------------------------------
1 | import os
2 | import struct
3 | import subprocess
4 | import time
5 | from threading import Timer
6 | from typing import Optional
7 |
8 |
9 | class Wireshark:
10 | """ An abstract class allowing to send binary packets to a Wireshark instance. """
11 |
12 | def __init__(self, verbose: bool):
13 | """
14 | Initialize the Wireshark class.
15 |
16 | @param verbose: whether verbose messages should be logged
17 | """
18 | self.running = False
19 | self.wireshark_process = None
20 | self.poll_timer = None
21 | self.pcap_data_link_type = 147 # Is DLT_USER_0
22 | self.start_time = time.time()
23 | self.verbose = verbose
24 |
25 | def _spawn_wireshark(self) -> bool:
26 | """
27 | Initializes the pipe to Wireshark and starts it
28 |
29 | @return: whether Wireshark could be started
30 | """
31 | # Global Header Values
32 | # https://wiki.wireshark.org/Development/LibpcapFileFormat
33 | PCAP_GLOBAL_HEADER_FMT = "@ I H H i I I I "
34 | PCAP_MAGICAL_NUMBER = 2712847316
35 | PCAP_MJ_VERN_NUMBER = 2
36 | PCAP_MI_VERN_NUMBER = 4
37 | PCAP_LOCAL_CORECTIN = 0
38 | PCAP_ACCUR_TIMSTAMP = 0
39 | PCAP_MAX_LENGTH_CAP = 65535
40 | PCAP_DATA_LINK_TYPE = self.pcap_data_link_type
41 |
42 | pcap_header = struct.pack(
43 | PCAP_GLOBAL_HEADER_FMT,
44 | PCAP_MAGICAL_NUMBER,
45 | PCAP_MJ_VERN_NUMBER,
46 | PCAP_MI_VERN_NUMBER,
47 | PCAP_LOCAL_CORECTIN,
48 | PCAP_ACCUR_TIMSTAMP,
49 | PCAP_MAX_LENGTH_CAP,
50 | PCAP_DATA_LINK_TYPE,
51 | )
52 |
53 | DEVNULL = open(os.devnull, "wb")
54 |
55 | # Check if wireshark or wireshark-gtk is installed. If both are
56 | # present, default to wireshark.
57 | if os.path.isfile("/usr/bin/wireshark"):
58 | wireshark_binary = "wireshark"
59 | elif os.path.isfile("/usr/bin/wireshark-gtk"):
60 | wireshark_binary = "wireshark-gtk"
61 | elif os.path.isfile("/Applications/Wireshark.app/Contents/MacOS/Wireshark"):
62 | wireshark_binary = "/Applications/Wireshark.app/Contents/MacOS/Wireshark"
63 | else:
64 | print("Wireshark not found!")
65 | return False
66 |
67 | self.wireshark_process = subprocess.Popen(
68 | [wireshark_binary, "-k", "-i", "-"],
69 | stdin=subprocess.PIPE,
70 | stderr=DEVNULL,
71 | )
72 | self.wireshark_process.stdin.write(pcap_header)
73 |
74 | self.poll_timer = Timer(3, self._poll_timer, ())
75 | self.poll_timer.start()
76 | return True
77 |
78 | def _poll_timer(self) -> None:
79 | """
80 | A timer to check whether all processes are functioning as expected.
81 | If not, everything is terminated.
82 |
83 | @return: nothing
84 | """
85 | if self.running and self.wireshark_process is not None:
86 | if self.wireshark_process.poll() == 0:
87 | # Process has ended
88 | print("_pollTimer: Wireshark has terminated")
89 | self.kill_monitor()
90 | self.wireshark_process = None
91 | elif not self.check_input_monitor():
92 | self.kill_monitor()
93 | else:
94 | # schedule new timer
95 | self.poll_timer = Timer(3, self._poll_timer, ())
96 | self.poll_timer.start()
97 |
98 | def check_input_monitor(self) -> bool:
99 | """
100 | An abstract method to check whether input monitor is still running properly.
101 |
102 | @return: whether the input monitor is still running properly
103 | """
104 | return False
105 |
106 | def start_monitor(self) -> bool:
107 | """
108 | Starts the monitor
109 |
110 | @return:
111 | """
112 | if self.running:
113 | print("Monitor already running!")
114 | return False
115 |
116 | if self.wireshark_process is None:
117 | if not self._spawn_wireshark():
118 | print("Unable to start Wireshark.")
119 | return False
120 |
121 | if not self.start_input_monitor():
122 | return False
123 |
124 | self.running = True
125 |
126 | print("Monitor started.")
127 |
128 | return True
129 |
130 | def start_input_monitor(self) -> bool:
131 | """
132 | An abstract method to start the input monitor.
133 |
134 | @return: whether the start of the input monitor was successful
135 | """
136 | return True
137 |
138 | def feed_wireshark(self, data: str | bytes, packet_time: Optional[float] = None) -> None:
139 | """
140 | Sends packet data encoding as a hex string to Wireshark.
141 |
142 | @param data: the data of the packet encoded as a hex string
143 | @param packet_time: a UNIX timestamp indicating when the QMI packet was received, defaults to now
144 | @return: nothing
145 | """
146 | if not packet_time:
147 | packet_time = time.time()
148 |
149 | packet = bytes.fromhex(data) if type(data) == str else data
150 | length = len(packet)
151 | ts_sec = int(packet_time)
152 | ts_usec = int((packet_time % 1) * 1_000_000)
153 | pcap_packet = (
154 | struct.pack("@ I I I I", ts_sec, ts_usec, length, length) + packet
155 | )
156 | try:
157 | self.wireshark_process.stdin.write(pcap_packet)
158 | self.wireshark_process.stdin.flush()
159 | except IOError as e:
160 | print("Monitor: broken pipe. terminate." f"{e}")
161 | self.kill_monitor()
162 |
163 | def kill_monitor(self) -> None:
164 | """
165 | Kills the active monitor and terminates all associated processes.
166 |
167 | @return: nothing
168 | """
169 | if self.running:
170 | self.running = False
171 | print("Monitor stopped.")
172 | if self.poll_timer is not None:
173 | self.poll_timer.cancel()
174 | self.poll_timer = None
175 | if self.wireshark_process is not None:
176 | print("Killing Wireshark process...")
177 | try:
178 | self.wireshark_process.terminate()
179 | self.wireshark_process.wait()
180 | except OSError:
181 | print("Error during wireshark process termination")
182 | self.wireshark_process = None
183 | self.kill_input_monitor()
184 |
185 | def kill_input_monitor(self) -> None:
186 | """
187 | An abstract method to kill the input monitor and clean up everything.
188 |
189 | @return: nothing
190 | """
191 | pass
192 |
--------------------------------------------------------------------------------
/qmi-inject/README.md:
--------------------------------------------------------------------------------
1 | # iPhone QMI Glue
2 |
3 | The *glue* acts a connection layer between libqmi and the baseband processor of a smartphone.
4 |
5 | ## Supported devices
6 |
7 | We've tested this tooling with an iPhone 12 mini on iOS 14.2.1.
8 |
9 | You may have to adapt the method signatures for subsequent iOS versions.
10 |
11 | ## Setup
12 |
13 | Due to the build requirements of libqmi, you must use a Linux-based operating system.
14 | We recommend [Debian 11](https://www.debian.org/download) which you can either use as your host operating system or as a virtual machine with a shared network. This is the default setting if you're creating a VM with [UTM](https://mac.getutm.app) on a Mac.
15 |
16 | Install [Node.js for your system](https://github.com/nodesource/distributions/blob/master/README.md), the example script is built for Debian:
17 | ```bash
18 | ./scripts/install-nodejs.sh
19 | ```
20 |
21 | You can usually install Frida via pip:
22 | ```bash
23 | pip install frida-tools
24 | ```
25 | If there's an error, you can try to build & install Frida yourself using the provided script:
26 | ```bash
27 | ./scripts/install-frida.sh
28 | ```
29 |
30 | On all systems, you must install at least version 1.33.3 of libqmi. To build libqmi, you can use the provided script:
31 | ```bash
32 | ./scripts/install-libqmi.sh
33 | ```
34 |
35 | ## Usage
36 |
37 | ## Smartphone
38 |
39 | 1. Jailbreak the target smartphone:
40 | - iPhone 12 (mini) with iOS 14.2.1: [unc0ver (TrollStore)](https://ios.cfw.guide/installing-unc0ver-trollstore/)
41 | 2. Install [Frida using Cydia](https://frida.re/docs/ios/).
42 |
43 | ## glue
44 |
45 | First compile the agent script with
46 | ```bash
47 | npm install
48 | npm run build
49 | ```
50 |
51 | ### Linux-based host OS
52 |
53 | If you are running the Linux-based operating system as your host system, you can start the glue application with
54 | ```bash
55 | python3 glue.py -U
56 | ```
57 |
58 | ### VM
59 |
60 | If you are running the Linux-based operating system inside of a VM, you must relay the Frida TCP port [27042](https://github.com/frida/frida/issues/70#issuecomment-186019188) to the VM.
61 | 1. Install [libimobiledevice](https://libimobiledevice.org) on the host system:
62 | - Mac with [homebrew](https://brew.sh): `brew install libimobiledevice`
63 | 2. Find the IP address of your host system inside the shared network with the VM: `ifconfig` or `ip a`
64 | - Mac with UTM: `192.168.64.1`, which we'll use from now on as an example, replace it if you're host system has another address in the shared network
65 | 3. Make the port available in the shared network: `iproxy 27042:27042 -s 192.168.64.1`
66 |
67 | Now you can start the glue application:
68 | ```bash
69 | python3 glue.py -H 192.168.64.1
70 | ```
71 |
72 | ## Test
73 |
74 | Use qmicli on your Linux-based OS to test if everything works:
75 | ```bash
76 | qmicli -v -d ./qmux_socket --get-service-version-info
77 | ```
78 | Between all the packet data, you should see a list of QMI services and not an error.
79 |
--------------------------------------------------------------------------------
/qmi-inject/agent/index.ts:
--------------------------------------------------------------------------------
1 | import { injectQMI } from './send';
2 | import './receive';
3 |
4 | rpc.exports.injectqmi = injectQMI;
--------------------------------------------------------------------------------
/qmi-inject/agent/receive.ts:
--------------------------------------------------------------------------------
1 | // *** Receiving direction ***
2 | // Air -> Chip -> iPhone
3 |
4 | import { log, LogLevel } from "./tools";
5 |
6 | // libATCommandStudioDynamic.dylib
7 | // QMux::State::handleReadData(QMux::State *__hidden this, const unsigned __int8 *, unsigned int)
8 | // -> Part of the ICEPicker repository
9 |
10 | let lastQmux = 0; // save the last state of x0
11 |
12 | const handleReadData = Module.getExportByName('libATCommandStudioDynamic.dylib', '_ZN4QMux5State14handleReadDataEPKhj');
13 | Interceptor.attach(handleReadData, {
14 | onEnter: function (args) {
15 | log(LogLevel.DEBUG, 'libATCommandStudioDynamic:QMux::State::handleReadData');
16 |
17 | const armContext = this.context as Arm64CpuContext;
18 |
19 | // x0 points to __ZTVN4QMux5StateE + 8 (QMux::State)
20 | // there are qmux1 and qmux2 or so, so let's keep track of that.
21 | const currentQmux = parseInt(armContext.x0.toString());
22 | if (lastQmux != currentQmux) {
23 | log(LogLevel.DEBUG, `qmux pointer changed: ${currentQmux}`);
24 |
25 | send('data', [0x23]); // Indicate with 0x23, QMI always starts with 0x01
26 | lastQmux = currentQmux;
27 | }
28 |
29 | var dst = armContext.x1;
30 | var len = parseInt(armContext.x2.toString());
31 | var d = dst.readByteArray(len);
32 | log(LogLevel.DEBUG, d!);
33 |
34 | send('data', d);
35 | }
36 | });
37 |
--------------------------------------------------------------------------------
/qmi-inject/agent/send.ts:
--------------------------------------------------------------------------------
1 | // *** Sending direction ***
2 | // iPhone -> Chip -> Air
3 |
4 | import { ghidraAddress, log, LogLevel } from "./tools";
5 |
6 | // State required for injecting custom QMI packets
7 | let writeAsyncState: NativePointer | null = null;
8 |
9 | // Allocate 8 bytes of memory allowing a function to store its result
10 | const thTargetMemory = Memory.alloc(8);
11 |
12 | // libPCITransport.dylib!pci::system::info::get()::sInstance
13 | const pciSystemInfoGetSInstance = ghidraAddress('libPCITransport.dylib', '0x1c8474000', '0x1dffe19f0');
14 |
15 | // libPCITransport.dylib!pci::system::info::getTH
16 | const pciSystemInfoGetTHAddr = ghidraAddress('libPCITransport.dylib', '0x1c8474000', '0x1c8475234', 'ia');
17 | const pciSystemInfoGetTH = new NativeFunction(pciSystemInfoGetTHAddr, 'void', ['pointer', 'int']);
18 |
19 | function initializeWriteParameters(): void {
20 | // Prepare an interceptor of the function to be invoked
21 | let interceptor = Interceptor.attach(pciSystemInfoGetTH, {
22 | // Before we enter the function, we modify the register X8
23 | // X8 / XR is an indirect result return register: https://developer.arm.com/documentation/102374/0100/Procedure-Call-Standard
24 | // It points to a memory location where the 'this' pointer (result of the function invocation) will be written
25 | onEnter: function (args) {
26 | log(LogLevel.DEBUG, 'getTransportThis(): Interceptor: onEnter()');
27 | (this.context as Arm64CpuContext).x8 = thTargetMemory;
28 | },
29 | // After the function is complete, we read the 'this' pointer from the specified memory location,
30 | // detach the interceptor and start sending QMI packets.
31 | onLeave: function (returnValue) {
32 | const transportThis = thTargetMemory.readPointer();
33 | log(LogLevel.DEBUG, `getTransportThis(): Interceptor: onLeave() with writeAsyncState = ${transportThis}`);
34 |
35 | // Detach the interceptor as we don't require it anymore
36 | interceptor.detach();
37 |
38 | // Save the state information and signal it to the Python script
39 | writeAsyncState = transportThis;
40 | send('setup', [0x1]);
41 | }
42 | });
43 |
44 | // Read the first parameter from a static location in memory
45 | const param1 = pciSystemInfoGetSInstance.readPointer();
46 |
47 | // The second parameter (0x3) is a static value found by observation
48 | const param2 = 0x3;
49 |
50 | // Invoke the function with the two parameters
51 | log(LogLevel.DEBUG, `initializeWriteParameters(): pciSystemInfoGetTH(${param1}, ${param2})`);
52 | pciSystemInfoGetTH(param1, param2);
53 | }
54 |
55 | initializeWriteParameters();
56 |
57 | // libPCITransport.dylib
58 | // bool pci::transport::th::writeAsync(*th this, byte[] data, uint length, void (*)(callback*));
59 | // -> Found with using https://github.com/seemoo-lab/frida-scripts/blob/main/scripts/libdispatch.js
60 |
61 | // The maximum packet length is 0x7fff
62 | const payloadBuffer = Memory.alloc(0x8050);
63 |
64 | const writeAsyncAddr = ghidraAddress('libPCITransport.dylib', '0x1c8474000', '0x1c84868b4', 'ia');
65 | const writeAsync = new NativeFunction(writeAsyncAddr, "bool", ["pointer", "pointer", "uint", "pointer"]);
66 |
67 | // The callback function (4th parameter) used during a normal write operation points to
68 | // libmav_ipc_router_dynamic.dylib!mav_router::device::pci_shim::dtor
69 | const writeAsyncCallback = Module.getExportByName('libmav_ipc_router_dynamic.dylib', '_ZN10mav_router6device8pci_shim4dtorEPv');
70 |
71 | function injectQMI(payload: string) {
72 | if (!writeAsyncState || !writeAsyncCallback) {
73 | // TODO: Try to resend?
74 | console.warn("inject called although write state is not initialized");
75 | return
76 | }
77 |
78 | const payloadArray: number[] = [];
79 | const payloadLength = payload.length / 2;
80 |
81 | // Read hex strings from payload and convert to byte array (F4118456 -> [244 17 132 86])
82 | for (let i = 0; i < payload.length; i += 2) {
83 | payloadArray.push(parseInt(payload.substring(i, i + 2), 16));
84 | }
85 |
86 | // Write content of array to our payload buffer
87 | payloadBuffer.writeByteArray(payloadArray);
88 |
89 | log(LogLevel.DEBUG, "libPCITransport::pci::transport::th::writeAsync");
90 | // log(LogLevel.DEBUG, payload);
91 | log(LogLevel.DEBUG, payloadBuffer.readByteArray(payloadLength)!);
92 | log(LogLevel.DEBUG, `writeAsync: ${writeAsync}`);
93 | log(LogLevel.DEBUG, `writeAsyncState: ${writeAsyncState}`);
94 | log(LogLevel.DEBUG, `payloadBuffer: ${payloadBuffer}`);
95 | log(LogLevel.DEBUG, `payloadLength: ${payloadLength}`);
96 | log(LogLevel.DEBUG, `writeAsyncCallback: ${writeAsyncCallback}`);
97 | log(LogLevel.DEBUG, '');
98 |
99 | // Call the function writeAsync with the correct state and payload
100 | // For now we ignore the writeAsyncCallback as it blocks a write operation and it works perfectly fine without it :)
101 | writeAsync(writeAsyncState, payloadBuffer, payloadLength, new NativePointer("0x0"));
102 | }
103 |
104 | export { injectQMI }
--------------------------------------------------------------------------------
/qmi-inject/agent/tools.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Convert a Ghidra pointer to an absolute address in memory which can be used for function interception and invocation.
3 | *
4 | * @param library the name of the library e.g. `libPCITransport.dylib`
5 | * @param baseAddress the base address of the library in Ghidra
6 | * @param functionAddress the target address in Ghidra
7 | * @param sign how the resulting pointer should be signed with a PAC (can be omitted)
8 | * @returns the absolute address targeting the specified function
9 | */
10 | function ghidraAddress(library: string, baseAddress: string, functionAddress: string, sign?: PointerAuthenticationKey): NativePointer {
11 | // Get the base address of the library in memory and throw an exception if the library couldn't be found
12 | const memoryBaseAddress = Module.findBaseAddress(library);
13 | if (memoryBaseAddress == null) {
14 | throw `function at Ghidra address ${functionAddress} not found in ${library}`;
15 | }
16 |
17 | // Calculate the relative address in Ghidra and add it to the memory base address of the library
18 | const ghidraRelativeAddress = new NativePointer(functionAddress).sub(baseAddress);
19 | const absoluteAddress = memoryBaseAddress.add(ghidraRelativeAddress);
20 |
21 | // Sign the pointer if specified by the parameter
22 | if (sign) {
23 | return absoluteAddress.sign(sign);
24 | } else {
25 | return absoluteAddress;
26 | }
27 | }
28 |
29 | enum LogLevel {
30 | DEBUG,
31 | INFO,
32 | WARN,
33 | ERROR
34 | }
35 |
36 | const DEBUG = false;
37 |
38 | function log(level: LogLevel, message: string | object): void {
39 | if (typeof message === 'string') {
40 | message = `[iPhone] ${message}`;
41 | }
42 | switch (level) {
43 | case LogLevel.DEBUG:
44 | if (DEBUG) console.log(message);
45 | break;
46 | case LogLevel.INFO:
47 | console.log(message);
48 | break;
49 | case LogLevel.WARN:
50 | console.warn(message);
51 | break;
52 | case LogLevel.DEBUG:
53 | console.error(message);
54 | break;
55 | }
56 | }
57 |
58 | export {ghidraAddress, LogLevel, log}
--------------------------------------------------------------------------------
/qmi-inject/glue.py:
--------------------------------------------------------------------------------
1 | import argparse
2 | import binascii
3 | import os
4 | import socket
5 | import time
6 | import frida
7 |
8 |
9 | def get_argparse():
10 | parser = argparse.ArgumentParser(
11 | prog='iphone-qmi-glue',
12 | description='Connect an iPhone baseband chip via a unix domain socket to libqmi'
13 | )
14 | parser.add_argument('-U', '--usb', action='store_true',
15 | help='connect to USB device')
16 | parser.add_argument(
17 | '-H', '--host', help='connect to remote frida-server on HOST')
18 | return parser
19 |
20 |
21 | class IPhoneQMIGlue:
22 |
23 | # Arguments supplied by argparse
24 | args = None
25 |
26 | # Socket connection
27 | socket_connection = None
28 |
29 | # Counts the number of received & sent QMI packets
30 | received = 0
31 | sent = 0
32 |
33 | # We assume that there are two different QMUX queues
34 | qmux1 = False
35 |
36 | # Wait for writeAsync parameters to be initialized
37 | write_state_init = False
38 |
39 | def __init__(self, args) -> None:
40 | self.args = args
41 |
42 | ### FRIDA ###
43 |
44 | # frida Python package documentation:
45 | # https://github.com/frida/frida-python/blob/a2643260742285acd5b19da6837e7b08c528d3e9/frida/__init__.py
46 | # https://github.com/frida/frida-python/blob/a2643260742285acd5b19da6837e7b08c528d3e9/frida/core.py
47 |
48 | def on_message(self, message, data):
49 | try:
50 | if message['payload'] == "setup":
51 | self.write_state_init = True
52 | return
53 | except KeyError:
54 | print("[FRIDA] There's an error in the script, debug it manually!")
55 | return
56 |
57 | # Manage invalid data and data direction
58 | if data is None:
59 | print("[FRIDA] Empty data, did CommCenter crash?")
60 | return
61 | # Data starts with 01 in rx direction so we use other magic bytes
62 | elif data[0] == 0x23:
63 | # alternative qmux queue, I think there are just two queues
64 | self.qmux1 = not self.qmux1
65 | return
66 |
67 | # Relays the received data from the iPhone to libqmi
68 | if self.socket_connection:
69 | self.socket_connection.sendall(data)
70 |
71 | self.received += 1
72 |
73 | def connect_to_device(self):
74 | if self.args.usb:
75 | return frida.get_device_manager().get_usb_device()
76 | elif self.args.host:
77 | # Connects to the FRIDA server on port 27042 running on the device over the network
78 | # e.g. VM (192.168.64.3) --UTM network--> Mac (192.168.64.1) --iproxy--> iPhone
79 | return frida.get_device_manager().add_remote_device(self.args.host)
80 | else:
81 | get_argparse().print_help()
82 | print('Specify a target device either with --usb or --host')
83 | quit(1)
84 |
85 | def load_script(self):
86 | device = self.connect_to_device()
87 |
88 | # Attaches to the CommCenter process
89 | frida_session = device.attach("CommCenter")
90 |
91 | # Loads the script to be injected from an external file
92 | # It uses function-based symbol and works only with Qualcomm chips.
93 | # The symbols work on an iPhone 12 with iOS 14.2.1
94 | with open('_agent.js', 'r') as file:
95 | script_code = file.read()
96 |
97 | script = frida_session.create_script(script_code)
98 | script.on("message", self.on_message)
99 | script.load()
100 |
101 | print("[FRIDA] Collecting write state information...")
102 | # print("[FRIDA] Tip: Unlock your device to collect state information")
103 | while not self.write_state_init:
104 | time.sleep(0.1)
105 | print("[FRIDA] Got all required state information")
106 |
107 | return script
108 |
109 | ### SOCKET ###
110 |
111 | # https://pymotw.com/2/socket/uds.html
112 |
113 | def open_socket(self):
114 | script = self.load_script()
115 |
116 | socket_address = './qmux_socket'
117 |
118 | # Try to delete an existing socket
119 | try:
120 | os.unlink(socket_address)
121 | except OSError:
122 | if os.path.exists(socket_address):
123 | raise
124 |
125 | # Create a UDS sockets
126 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
127 |
128 | # Bind the socket to the port
129 | print(f'[Socket] Opening Socket {socket_address}')
130 | sock.bind(socket_address)
131 |
132 | # Listen for incoming connections
133 | sock.listen(1)
134 |
135 | while True:
136 | # Wait for connections
137 | print('[Socket] Waiting for a connection')
138 | self.socket_connection, client_address = sock.accept()
139 |
140 | # Accept a connection
141 | try:
142 | if client_address == '':
143 | client_address = 'unknown'
144 | print(f'[Socket] Connection from {client_address}')
145 |
146 | while True:
147 | data = self.socket_connection.recv(16)
148 |
149 | if data:
150 | # Send QMI packets data back to the phone
151 | # Convert binary data to hex strings as it needs to be JSON serializable for FRIDA
152 | # print(binascii.hexlify(data).decode('ascii'))
153 | script.exports_sync.injectQMI(
154 | binascii.hexlify(data).decode('ascii'))
155 | else:
156 | print('[Socket] No more data from client')
157 | break
158 | finally:
159 | # Cleanup the connection
160 | self.socket_connection.close()
161 | self.socket_connection = None
162 |
163 |
164 | def main():
165 | parser = get_argparse()
166 | glue = IPhoneQMIGlue(parser.parse_args())
167 | glue.open_socket()
168 |
169 |
170 | if __name__ == '__main__':
171 | main()
172 |
--------------------------------------------------------------------------------
/qmi-inject/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "iphone-qmi-glue",
3 | "version": "1.0.0",
4 | "description": "Connecting an iPhone baseband chip & libqmi",
5 | "private": true,
6 | "main": "agent/index.ts",
7 | "scripts": {
8 | "prepare": "npm run build",
9 | "build": "frida-compile agent/index.ts -o _agent.js -c",
10 | "watch": "frida-compile agent/index.ts -o _agent.js -w"
11 | },
12 | "devDependencies": {
13 | "@types/frida-gum": "^16.2.0",
14 | "@types/node": "^14.14.10",
15 | "frida-compile": "^10.0.0"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/qmi-inject/scripts/install-frida.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | FRIDA_VERSION="16.0.11"
4 |
5 | # Install python3 & pip
6 | sudo apt-get install python3 python3-pip
7 | # Install Python requirements for Frida
8 | pip install colorama prompt-toolkit pygments
9 |
10 | # Clone the Frida git repository
11 | git clone --recurse-submodules https://github.com/frida/frida.git
12 | # Go to the directory
13 | cd frida/
14 | # Checkout the wanted Frida version
15 | git checkout $FRIDA_VERSION
16 | # Update all git submodules
17 | git submodule update --recursive
18 |
19 | # Build frida & its tools & Python bindings
20 | make tools-linux-arm64
21 |
22 | # Add Frida tools to the $PATH
23 | echo "PATH=\"$(pwd)/build/frida-linux-arm64/bin:\$PATH"\" > ~/.profile
24 | # Add Frida Python bindings to the local installation
25 | echo "$(pwd)/build/frida-linux-arm64/lib/python3.9/site-packages" > ~/.local/lib/python3.9/site-packages/frida.pth
26 |
27 | # Exit the build directory
28 | cd ..
29 |
30 | echo "Frida installed successfully"
31 | echo "Restart your shell to access the Frida tools"
--------------------------------------------------------------------------------
/qmi-inject/scripts/install-libqmi.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | LIBQRTR_VERSION="qrtr-1-2"
4 | LIBQMI_VERSION="1.33.3"
5 |
6 | # Install all required dependencies
7 | sudo apt-get install meson python3 python3-setuptools python-is-python3 \
8 | libglib2.0-dev libglib2.0-dev libgudev-1.0-dev libmbim-glib-dev \
9 | libgirepository1.0-dev gtk-doc-tools help2man
10 |
11 | # Install libqrtr-glib
12 | git clone https://gitlab.freedesktop.org/mobile-broadband/libqrtr-glib.git
13 | cd libqrtr-glib
14 | git checkout $(LIBQRTR_VERSION)
15 | meson setup build --prefix=/usr
16 | ninja -C build
17 | sudo ninja -C build install
18 | cd ..
19 |
20 | # Install libqmi
21 | git clone https://gitlab.freedesktop.org/mobile-broadband/libqmi
22 | cd libqmi
23 | git checkout $(LIBQMI_VERSION)
24 | meson setup build --prefix=/usr
25 | ninja -C build
26 | sudo ninja -C build install
27 | cd ..
28 |
29 | echo "libqmi installed successfully"
--------------------------------------------------------------------------------
/qmi-inject/scripts/install-nodejs.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Sources:
4 | # - https://github.com/nodesource/distributions/blob/master/README.md#using-debian-as-root-1
5 | # - https://www.cyberciti.biz/faq/how-to-run-multiple-commands-in-sudo-under-linux-or-unix/
6 | sudo -- bash -c 'curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && apt-get install -y nodejs'
7 |
--------------------------------------------------------------------------------
/qmi-inject/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "lib": ["es2020"],
5 | "allowJs": true,
6 | "noEmit": true,
7 | "strict": true,
8 | "esModuleInterop": true
9 | }
10 | }
11 |
--------------------------------------------------------------------------------