Heresy

I made a neat little tool called heresy, which hooks the JavaScript and Hermes Bundle loader in React Native apps and allows you to inject your own JavaScript into the Hermes engine’s global context.

This is essentially the same as injecting a JavaScript snippet into a web page with GreaseMonkey or similar. This is especially useful if you don’t feel like digging through the Hermes bytecode.

Potential Use Cases

  • Inspecting HTTP or WebSocket traffic between a React Native application and a remote host
  • Modifying the UI at runtime
  • Inspecting and modifying application state
  • Finding which specific Hermes functions are being called when a button is pressed

High-Level Architecture

../img/heresy-graph.svg

The RPC Server only really exists as a way to provide a REPL from the main app to the Hermes runtime, but you can modify it as-needed to support your needs.

Setting Up Heresy

Prerequisites

  • NodeJS (I’m using v21.6.2)
  • SSH
  • frida-server running on the target device/emulator
  • (Optional) adb, which is used for viewing logs with logcat. You can also view logs directly on the device with the same command.

Get a copy of the source code locally.

git clone https://github.com/Pilfer/heresy.git

Copy the example configuration:

cp -r .heresy.example .heresy

Modify the ./heresy/heresy.json config file to suit your needs. At minimum, you’ll need to change the package_name and heresy_config.rpc values.

By default it’ll look like this:

{
  "package_name": "com.your.app",
  "rpc_port": 1337,
  "hermes_before": ".heresy/_before.js",
  "hermes_hook": ".heresy/_hook.js",
  "heresy_config": {
    "http": false,
    "react_native_elements": false,
    "rpc": "wss://<your_serveo_subdomain>.serveo.net"
  }
}
  • hermes_before is the script that loads before the index.android.bundle (main app code) is loaded.
  • hermes_hook is the script that loads after the index.android.bundle (main app code) is loaded. The contents are prepended with the Heresy class and instantiation prior to being loaded.

Without RPC/REPL

If you don’t want to use the RPC/REPL functionality, simply delete the rpc key from heresy_config.

With RPC/REPL

If you do want to use the RPC/REPL, you need to put in a valid Secure WebSocket URL. Heresy exposes a regular WebSocket server on the port specified with rpc_port.

I personally just use Serveo, which creates a remote tunnel and requires no additional tools beyond ssh. For this tutorial, this is what we’ll use.

Running the following command will initialize the tunnel between the local Heresy server and the internet.

ssh -R 80:localhost:1337 serveo.net

If you changed the rpc_port value, make sure you replaced 1337 with the correct value in the command above.

It will respond with:

Forwarding HTTP traffic from https://<some subdomain here>.serveo.net

Take that hostname and put it in the heresy_config.rpc field - and make sure the string starts with wss://.

Example value: wss://deadbeefdeadbeefdeadbeefdeadbeef.serveo.net

Installing Dependencies

It’s time to install the NodeJS dependencies. Do this by running:

npm install

Assuming there were no errors, we can now start hacking on some apps.

Get ready for logging

In a new terminal, run the following command to capture logs:

adb logcat "*:S" ReactNativeJS:I -v raw

Run heresy

Assuming your tunnel/http server of choice is running and everything is configured properly, you should be able to build the app and run it now.

Build with:

npm run build

This will build the three components of the project:

  • ./app - heresy’s main app, RPC WebSocket server, utils
  • ./frida_agent - The frida agent that writes the hooks to the filesystem and injects them into the runtime via CatalystInstanceImpl
  • ./hermes_agent - Implements the RPC Client, and has a few different hooks that run within the Hermes runtime.

If you extend or modify any of the files within the above directories, you have to run npm run build again in order for changes to persist.

Once everything is build, simply run:

npm run start

If everything worked properly and you haven’t modified too much of .heresy/_hook.js, you should see an alert('Hello, world!') toast and a bunch of logs in the terminal where you ran the adb logcat ... command.

Practical Usage

In every .heresy/_hook.js file, you have access to the heresy (located in ./src/hermes_agent/heresy.ts) class.

If you like using types, you can use JSDoc comments in your editor as follows:


/**
 * If you want to get typehints, you can use JSDoc comments like so:
 * 
 * @typedef {import('../src/hermes_agent/heresy').Heresy} Heresy
 * @typedef {import('../src/hermes_agent/heresy').HeresyEvent} HeresyEvent
 */

/** @type {Heresy} */
let h = globalThis.heresy

The following examples will assume you have this snippet in your hook file.

HTTP Traffic Inspection

You can view XMLHttpRequests by using heresy.http_callback, and enabled the http key in the heresy_config.json file.

h.http_callback = (event) => {
  let req = event.request; // method, url, headers, data
  let res = event.response; // status, headers, body
  
  console.log('[=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-]')
  
  console.table([{ method: req.method, url: req.url}]);
  
  if (req.data) console.log(req.data);

  console.log('\nRequest Headers:');
  console.table(Object.entries(req.headers).map(([Header, value]) => ({ key: Header, value })));
  
  console.log('\nResponse headers:');
  console.table(Object.entries(res.headers).map(([Header, value]) => ({ key: Header, value })));
};  

With the above, when making a request you’ll see the following output:

method | url
-------|---------------------------------------------
GET    | https://jsonplaceholder.typicode.com/todos/1

Request Headers:
key    | value
-------|----------------------------------
accept | application/json, text/plain, */*

Response headers:
key                              | value
---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
x-ratelimit-limit                | 1000
x-content-type-options           | nosniff
x-ratelimit-remaining            | 999
vary                             | Origin, Accept-Encoding
x-ratelimit-reset                | 1728548609
etag                             | W/"53-............+s"
date                             | Wed, 16 Oct 2024 16:30:58 GMT
via                              | 1.1 vegur
nel                              | {"report_to":"heroku-nel","max_age":3600,"success_fraction":0.005,"failure_fraction":0.05,"response_headers":["Via"]}
age                              | 465
cf-ray                           | 8d396fc07e837b9b-ATL
x-powered-by                     | Express
server                           | cloudflare
access-control-allow-credentials | true
report-to                        | {"group":"heroku-nel","max_age":3600,"endpoints":[{"url":"https://nel.heroku.com/......"}]}
pragma                           | no-cache

Modifying and Hooking React Native Elements

Hooking into React Native elements is cumbersome, but definitely possible (and effective!).

I created a very basic Hello World react native app and added a couple of elements to it. This is what it looks like without any hooks/modifications/etc.

../img/example_1_Original.png

And now we can start with the hooks.

A TL;DR is:

  1. We hook Map.set and identify some RN elements
  2. Access the props and stateNodes of said elements to modify state and add hooks
  3. We’re going to call enqueueForceUpdate on the updater to force the state and UI to update
// This is just a helper function to try and infer the type of Element we're dealing with
const getTagName = (value) => {
  if (value.type && value.type !== undefined) {
    if (typeof value?.type === 'string') return value.type;
    if (value.type && value.type.name) return value.type.name;
    if (typeof value?.elementType === 'string') return value.elementType;
    if (value.elementType && value.elementType.name) return value.elementType.name;
    if (value.elementType && value.elementType.displayName) return value.elementType.displayName;
    if (value.type.$$typeof && typeof value.type.$$typeof === 'object') return value.type.$$typeof?.displayName;
  } else {
    return null;
  }

  // This should only get called once, and is
  // the main root object that contains AppContainer in the `return` object.
  // We should check to be sure..
  if (value && value.return) {
    if (getTagName(value.return) === 'AppContainer') {
      console.log('[*][getTagName] Found AppContainer parent');
    }
  }

  return 'unknown';
};

// Another helper function to walk down the data structure until we find what we're looking for
// This is a giant pain - I may write a search function for this later, so folks can simply
// query by tag name and/or props.
const instrumentRNElement = (value) => {
  let out = {
    name: '',
    children: []
  };
  if (value && value !== undefined && value !== null) {
    if (value.type) {
      out.name = getTagName(value);

      if (out.name === 'RCTView') {
        let child = value.return;
        if (child.type && child.type.displayName) {
          if (child.type.displayName === 'View') {
            if (child.return.return) {
              if (getTagName(child.return.return) === 'Header') {

                let header = child.return.return;
                let headerProps = header.return.return.return.return.return;
                if (headerProps && headerProps.stateNode) {

                  console.log('[*] Found headerProps')
                  
                  let realProps = headerProps.stateNode.props;
                  
                  if (realProps.style) {
                    if (headerProps.stateNode.updater) {
                      let updater = headerProps.stateNode.updater;
                      window.updater = updater;
                      window.state = headerProps.stateNode;

                      const children = headerProps.stateNode.props.children;

                      // Grab the JSX elements we want to play with...
                      // We know this is a View based on previous logging
                      let view = children[1];

                      // This is the first element in the stack for this view
                      let button = view.props.children[0];

                      // This is the second element, a TextInput
                      let textInput = view.props.children[1];

                      // We can make this button a global variable and access it with the REPL if we want!
                      window.button = button;

                      // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= //
                      // Modify the props of the elements
                      // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= //

                      // Change the view background color to a bright red
                      view.props.backgroundColor = '#ff0000';

                      // Change the button text...
                      button.props.title = 'hello from injected javascript!';
                      
                      // Change the textInput to something a little darker
                      textInput.props.backgroundColor = '#3d3d3d';



                      // Hook the onPress event for the button, then call the original!
                      let originalOnPress = button.props.onPress;
                      button.props.onPress = function () {
                        console.log(`This log was called in an injected script`);
                        alert(`Hooked via ${window.__jsiExecutorDescription}!`); // This should say, "Hooked via HermesRuntime!""
                        originalOnPress.call(this, arguments);
                      };

                      // I really want to know when the text is changed, so same
                      // deal as above - hook it, do something, and call the 
                      // the original function.
                      let originalOnChangeText = textInput.props.onChangeText;
                      textInput.props.onChangeText = function (text) {
                        console.log(`This log was called in an injected script`);
                        console.log(`Text changed to: ${text}`);
                        originalOnChangeText.call(this, text);
                      };
                      

                      // Force the state update/re-render!
                      updater.enqueueForceUpdate(headerProps.stateNode);
                    }
                  }
                }
              }
            }
          }
        }
      }
    }

    return out;
  };
}


// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- //
// This is our main avenue for actually allowing the
// instrumentation of the react native elements.
//
// We hook Map.set, and look for any elements to come
// our way by sending the value parameter to the
// instrumentRNElement function.
//
// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- //
const originalMapSet = Map.prototype.set;
Map.prototype.set = function (key, value) {
  try {
    if (value && value.type) {
      instrumentRNElement(value);
    }
  } catch (error) {
    //
  }
  originalMapSet.call(this, key, value);
};

After Hooking

It looks like our UI modifications worked. Hell yeah, partner 🤠.

  • ✅ Button text went from “Do Thing” to “hello from injected javascript!”
  • ✅ View background color changed from white to red
  • ✅ TextInput background color is now #3d3d3d

../img/example_1_UI_Mods.png

But did the Button and TextInput event hooks work?

Spoiler: They absolutely did.

Button.onPress() Hook

../img/example_1_onPressed.png

TextField.onTextChanged() Hook

../img/example_1_onTextChanged.png

Moving Forward

I’m still playing around with this method, so the documented capabilities are sparse at the moment.

Feel free to read my other post on tinkering with things here: Reverse Engineering and Instrumenting React Native Apps

If you find anything cool or run into a problem directly related to the codebase, please create an issue on the heresy github repo!