Skip to content

The Rocky Road to Success with the Frida Tool

  • by

Frida, the code instrumentation toolkit, is excellent software. It gives you the power to observe and tamper with the memory of a running process by hooking functions and changing parameter values. But there is a lot to learn, and getting started can be a daunting task. To help guide the way, this post gives some advice on how to get started and an example of real-world use.

In this case, I describe using Frida to test an app on a stock iOS device. Jailbreaking is a powerful tool to be sure, but it is advantageous to have techniques available to perform security testing without root as well. The Frida official documentation is terse on the subject of rootless testing, briefly stating that Frida can inject into debuggable apps.  This capability is new in 2020, replacing the older method of inserting the Frida gadget in the app bundle before deploying it. Now you run the app, then attach Frida to it like you can with the llvm debugger in Xcode.

To supplement the official documentation, Frida sponsor, NowSecure, has published some additional details on the subject of rootless testing. Their article is very helpful, but I would add a few more things.

1. When resigning the app you must change the bundle identifier to something that uses your own domain. You cannot sign the app “com.example.mobilebanking” with a certificate from “com.calibersecurity”. Set the parameter –bundleid with node-applesign or use iOS App Signer. That tool automatically finds your provisioning files and handles changing the bundle ID. Just be sure to select a provisioning profile instead of Re-Sign Only.

2. When connecting Frida I have found better success using frida-ps utility to find the process ID, rather than connecting by using the app name. If you are unable to connect, try starting a fresh instance of the app and finding its process ID like this:

frida-ps -Ua

Then find your app process id in the output, .e.g 123, and connect Frida with:
frida -U -p 123 -l myscript.js

Getting Started Writing a Frida Script

I suggest installing VS Code and cloning frida-agent-example, which is maintained by the main Frida developer. The ‘prepare’ npm script in the project will, among other things, fetch the Typescript type definitions for you, so you get inline documentation when editing your script.

VS Code can use the type definitions to help catch problems in your script, possibly avoiding a frustrating debug exercise. You can of course write plain Javascript in a regular editor like Notepad++. Your code will run just fine, if you can get it right without any help from the type checker. But it isn’t difficult to learn enough Typescript to get by and the example project has everything set up to compile the Typescript for you. After compiling the Typescript to Javascript, you can execute it using Frida.

With the project framework in place, let’s do something with Frida. In a real project, Caliber tested an app that uses its own certificate store and its own copies of OpenSSL and libcurl to make requests. To enable traffic interception, we needed a way to subvert the intended certificate validation logic. In this post, we present a strategy of hooking the libcurl curl_easy_setopt() function responsible for configuring HTTPS requests.

Our script begins by importing a logging function from another file in the same way as the frida-agent-example project. The Typescript import system allows you to write script libraries instead of copy-pasting logic every time you need it. The other thing to note here is the strategy we have chosen for loading the right Module and finding the address of curl_easy_setopt().

import * as logger from “../lib/logger”;
function main() {
var mymodule = Process.enumerateModules()[0];
var mysymbols = load_symbols(mymodule);

Most of the script examples you can find use Module.findExportByName() to get the address of a function, but in our case curl_easy_setopt() isn’t exported. Frida has no corresponding Module.findSymbolByName(), and Module.enumerateSymbols() is an instance method not a class method. We need an instance of the right Module.

We can get an instance using Module.load(), but we need the full file path and name of the dylib rather than just the module name. A simpler way is to call Process.enumerateModules() and find the correct module from the list. It is the first one if you are hooking code that is part of the main app binary.

The load_symbols() function creates a mapping between symbol names and data. In Javascript we could use a basic Object, but in Typescript we have to create a Map with two type parameters. In this case, the type system isn’t very helpful because the result is essentially the same with extra steps.

function load_symbols(mymodule: Module) {
var i, sym, ret;
ret = new Map<String, ModuleSymbolDetails>();

sym  = mymodule.enumerateSymbols();
for (i = 0; i < sym.length; i++) {
ret.set(sym[i].name,  sym[i]);

return ret;

Most of the logic is in the with_symbols() function which operates with the symbol mapping we created. The call to the NativeFunction constructor looks strange because it declares nine parameters when the actual curl_easy_setopt() function has only three. Because there are different sorts of options to be set, curl_easy_setopt() is a variadic function where the second argument indicates the type for the third.

function with_symbols(sym: Map<String, ModuleSymbolDetails>) {
 var curl_setopt_ptr, curl_setopt_fn;
  curl_setopt_ptr = sym.get(“curl_easy_setopt”);
 if (!curl_setopt_ptr) {
    logger.log(“Could not find curl_easy_setopt”);

 curl_setopt_fn = new NativeFunction(curl_setopt_ptr.address, “int”,
   [“pointer”, “int32”, “int64”, “int64”, “int64”, “int64”, “int64”, “int64”, “int64”]);

 Interceptor.attach(curl_setopt_fn, {
 onEnter: function (args) {
     var opt = args[1].toInt32(),
       val = args[8];
     logger.log(`curl_easy_setopt ${opt} ${val}`);
     if (opt === 64) {
       args[8] = ptr(0);

   onLeave: function (retval) {

There is a complication here because on iOS/arm64, the ABI for variadic functions is different from the ABI for regular functions. The variable arguments are always passed on the stack, even if they would all fit in the registers.

Frida supports variadic functions, but it uses the standard C ABI which passes the first eight parameters in registers and the remaining parameters on the stack, if there are any. We declare extra dummy parameters to consume all the register-passed data, so the parameter we want is accessible as the 9th parameter, the first on the stack.

Our task in the hooking callback onEnter() is to tamper with the call which sets the certificate validation behavior. Specifically, when the caller sets option 64 (peer validation), we change the option value from 1 to 0, but the argument list is an array of NativePointer objects, and it isn’t immediately clear what you are supposed to do with a NativePointer. You expect an integer, not a pointer.

The name NativePointer is a bit confusing in that respect. A different way to think about this in the context of the onEnter() callback is that NativePointer simply represents a value stored in a register or on the stack, and it’s up to you to know what it represents. In the case when it is actually a pointer, you have methods to dereference it and read/write values.

But in the case where the value is an integer, dereferencing will crash the program. You have two choices to get the value from a NativePointer:

1. You can call toInt32() to get a regular Javascript number. Javascript numbers don’t have 64 bit precision though, so if you need all the bits you have to call toString() and you will get a hex string representation of the value that looks like “0xabc123”. 

2. Returning to our use case, if you want to replace one of the arguments with 0, you can use ptr(0), which is a convenience function to create a NativePointer object with the value 0.

Once we have all these details in order, we find that the script works! After connecting to the app and executing the script, the app no longer refuses to connect to our intercepting proxy and we can see traffic come through. We did not have to tamper with the app binary, and this technique works without resigning if we have a debug build to start with. Brilliant! Even though the result fits together nicely, getting to it involved stumbling again and again over technical issues. After reading this post, perhaps you will stumble a bit less.