Table of Contents

What is React Native?

React Native is a cross-platform mobile application framework that was created by Facebook/Meta. It has a bespoke JavaScript runtime embedded within it called “Hermes”, which also has the ability to compile JavaScript to a bespoke bytecode. Said bytecode is loaded up into the VM and executed at runtime.

The usage of Hermes’ binary format is now default for all new React Native apps. Gone are the days of simply formatting and deobfuscating JavaScript in the shipped bundle.

Practical Example:

eval(`console.log(1+1)`);

Compiles to:

Function<global>(1 params, 11 registers, 0 symbols):
  GetGlobalObject  r0
  TryGetById  r2,  r0,  1,  "eval"
  LoadConstUndefined  r1
  LoadConstString  r0,  "console.log(1+1)"
  Call2  r0,  r2,  r1,  r0
  Ret  r0

Note: The strings above are typically used by reference to their index - I populated them with hermes_rs.

Obviously this has thrown a wrench into the gears of many reverse engineers and researchers out there. So, how do we hack on React Native apps now? Let’s take a look at the current tooling that we have available to us.


Tooling Landscape

At the time of me writing this post, the landscape for RE/DI tooling for React Native is lacking - especially when compared to what is available for other frameworks.

Official Hermes Resources

Docs: https://hermesengine.dev/
Source: https://github.com/facebook/hermes

The official Hermes source code repository contains a ton of very useful content. The hermes binary itself has a method to dump the bytecode of a pure .hbc file. It unfortunately rarely (if ever) works with Android/iOS bundles.

If you’re dealing with binary compiled with hermes directly, you could simply do hermes ./some_file.hbc -dump-bytecode and view the instructions, strings, etc.

The hermes binary is also quite useful for compiling your own kitchen sink JavaScript functions and using the generated instructions as a template to patch into existing binaries, assuming the HBC version is the same (currently at v96).

hermes-dec

Link & Source: https://github.com/P1sec/hermes-dec

hermes-dec describes itself as “A reverse engineering tool for decompiling and disassembling the React Native Hermes bytecode”, and the disassembler is quite good. The project structure is such that updating it to support newer versions of the bytecode is trivial.

The decompiler isn’t really fleshed out at this time (Oct 4, 2024), and the last commit was 6 months ago.

hbctool

Link & Source: https://github.com/bongtrop/hbctool

hbctool is a Hermes disassembler and assembler. You can decompile the binary, modify it in its textual format, reassemble it, and execute. The more recent supported HBC version is v85, so it’s woefully out of date with the current HBC version of 96.

I’ve personally used it a couple of times on older applications and it met the need quite well, but felt like it wasn’t a “complete” tool.

hasmer

Docs: https://lucasbaizer2.github.io/hasmer/
Link & Source: https://github.com/lucasbaizer2/hasmer

hasmer is still a WIP, but I’m very hopeful for this project. It has a suite of tooling built around it, and the approach the author has taken for the decompiler seems solid.

I haven’t used it personally, but it:

  • Has a VSCode extension
  • Has an autogenerator for updating Hermes versions (by reading the Bytecode macro definition “def” file)
  • Supports disassembling and assembling

hermes_rs

✨ Disclaimer: I made this ✨

Link & Source: https://github.com/Pilfer/hermes_rs
Crate: https://crates.io/crates/hermes_rs

hermes_rs is my dependency-free Hermes disassembler and assembler. This is the tool I use most frequently for my hermes/RN-related shenanigans, which has helped immensely with the development.

As of right now it supports HBC versions 89, 90, 93, 94, 95, and 96. Like hasmer and others, updating it to support newer HBC versions is trivial - just drop a .def file into the def_versions directory and execute the generator script.


Fun Instrumentation Methods

Injecting JavaScript into the React Native Global Context

In an Android React Native application, you can inject JS into the global context of the engine by using frida targeting the CatalystInstanceImpl class.

The loadScriptFromAssets method is what loads the compiled hermes bundle into the runtime within libhermes.so. Hooking this function and calling loadScriptFromFile with a valid script that exists in a directory the app has access to will immediately evaluate it.

Alternatively, you could simply patch the index.android.bundle file to do this same eval with better success - but that requires repackaging of the app and all that jazz. I’ll probably make a helper tool for this in hermes_rs eventually.

Source code of: example.js

// This is the app identifier you're trying to hook
const package_name = 'com.foo.bar';

// Write the hermes-hook.js payload to file
const f = new File(`/data/data/${package_name}/files/hermes-hook.js`, 'w');
f.write(`console.log(Object.keys(this)); console.log('hello from React Native!');`);
f.close();

Java.perform(function () {
  // Lazily wait for the class to be available to us  
  var looper = setInterval(function () {
    try {
      const CatalystInstanceImpl = Java.use("com.facebook.react.bridge.CatalystInstanceImpl");
      CatalystInstanceImpl.loadScriptFromAssets.implementation = function (assetManager, assetURL, z) {
        // Load the original index.android.bundle
        this.loadScriptFromAssets(assetManager, assetURL, z);

        // Load custom JS into the global hermes context
        this.loadScriptFromFile(`/data/data/${package_name}/files/hermes-hook.js`, `/data/data/${package_name}/files/hermes-hook.js`, z);
      };
      clearInterval(looper);
    } catch (error) {
      console.log('failed');
    }

  }, 10);
});

Then you’d simply run: frida -U -f com.foo.bar -l example.js.

Output should be something like:

[12:51:42] I | ReactNativeJS ▶︎ [ 'Promise',
'__jsiExecutorDescription',
'nativePerformanceNow',
'nativeModuleProxy',
'nativeFlushQueueImmediate',
'nativeCallSyncHook',
'globalEvalWithSourceUrl',
'nativeLoggingHook',
'nativeRuntimeScheduler',
'__BUNDLE_START_TIME__',
'__DEV__',
'process',
'__METRO_GLOBAL_PREFIX__',
'__r',
'__d',
'__c',
'__registerSegment',
'console',
'ErrorUtils',
'window',
'self',
'DOMRect',
'DOMRectReadOnly',
'__fbGenNativeModule',
'performance',
'setTimeout',
'clearTimeout',
'setInterval',
'clearInterval',
'requestAnimationFrame',
'cancelAnimationFrame',
'requestIdleCallback',
'cancelIdleCallback',
'queueMicrotask',
'setImmediate',
'clearImmediate',
'XMLHttpRequest',
'FormData',
'fetch',
'Headers',
'Request',
'Response',
'WebSocket',
'Blob',
'File',
'FileReader',
'URL',
'URLSearchParams',
'AbortController',
'AbortSignal',
'alert',
'navigator',
'__fetchSegment',
'RN$AppRegistry',
'RN$SurfaceRegistry' ]
                             │ hello from React Native!

As you can see, there are a variety of globals available to us. From what I can tell, this, self, and window all reference the same object.

I believe most of these are defined here: https://github.com/facebook/react-native/blob/main/packages/react-native/types/modules/globals.d.ts

If we look around a bit, we can find some potentially interesting things:

// Dump environment variables?
console.log(JSON.stringify(this.process)); // {"env":{"NODE_ENV":"production"}}

Intercepting React Native HTTP Traffic

In the section above, the output of Object.keys(this) showed XMLHTTPRequest. I’ve had some success hooking this in the past on frontend apps I’ve worked on - maybe this will work for React Native, too?

In my test React Native app, I have a button that fetches a remote API using axios when pressed.

import axios from 'axios';

// ....

const makeRequest = () => {
  axios.get('https://jsonplaceholder.typicode.com/todos/1').then((response) => {
    console.log(response.data);
  }).catch((error) => {
    console.error(error);
  });
};

// ....

<Button title="Make A Request" onPress={makeRequest} />

So let’s try hooking XMLHTTPRequest…


(function() {
  var origOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function() {
    let output = {};
    console.log('[*] Hooked XMLHttpRequest.prototype.open');
    output.method = arguments[0];
    output.url = arguments[1];

    console.log(Object.keys(this));
    console.log(JSON.stringify(arguments));

    this.addEventListener('load', function() {
      output.response = {
        status: this.status,
        statusText: this.statusText,
        responseText: this.responseText,
      };
      console.log(JSON.stringify(output));
    });

    origOpen.apply(this, arguments);
  };
})();

Aaaaand we get:

[13:07:23] I | ReactNativeJS ▶︎ {
  "method": "GET",
  "url": "https://jsonplaceholder.typicode.com/todos/1",
  "response": {
    "status": 200,
    "responseText": "{\n  \"userId\": 1,\n  \"id\": 1,\n  \"title\": \"delectus aut autem\",\n  \"completed\": false\n}"
  }
}

With just a little bit of extra code, one could get the full request and response - with headers included. Since WebSocket also exists in the global context, you can quite easily pipe all of those requests from your lab device or emulator and ingest it in whatever tooling you choose (ex: forward it to burp).

Edit: It looks like the React Native folks left interceptor function in the codebase. You can get the full request and response with:

const XHRInterceptor = {
  requestSent: (id, url, method, headers) => {
    console.log(`Request Sent: ID=${id}, URL=${url}, Method=${method}, Headers=${JSON.stringify(headers)}`);
  },
  responseReceived: (id, url, status, headers) => {
    console.log(`Response Received: ID=${id}, URL=${url}, Status=${status}, Headers=${JSON.stringify(headers)}`);
  },
  dataReceived: (id, data) => {
    console.log(`Data Received: ID=${id}, Data=${data}`);
  },
  loadingFinished: (id, encodedDataLength) => {
    console.log(`Loading Finished: ID=${id}, Encoded Data Length=${encodedDataLength}`);
  },
  loadingFailed: (id, error) => {
    console.log(`Loading Failed: ID=${id}, Error=${error}`);
  },
};

this.XMLHttpRequest._interceptor = XHRInterceptor;

Which produces:

▶︎ Request Sent: ID=1, URL=https://jsonplaceholder.typicode.com/todos/1,
  Method=GET,
  Headers={"accept":"application/json, text/plain, */*"}
▶︎ Response Received: ID=1, 
  URL=https://jsonplaceholder.typicode.com/todos/1, Status=200,
  Headers={"x-ratelimit-limit":"1000","x-content-type-options":"nosniff","x-ratelimit-remaining":"999","vary":"Origin, Accept-Encoding","x-ratelimit-reset":"1726774762","etag":"W/\"53-foobar+s\"","date":"Mon, 07 Oct 2024 19:35:13 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":"28539","cf-ray":"8cf05544aed66763-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/reports?ts=<foo>\"}]}","pragma":"no-cache","cache-control":"max-age=43200","content-type":"application/json; charset=utf-8","reporting-endpoints":"heroku-nel=https://nel.heroku.com/reports?ts=<foo>","expires":"-1","cf-cache-status":"HIT","alt-svc":"h3=\":443\"; ma=86400"}
▶︎ Data Received: ID=1, Data={
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}
  Loading Finished: ID=1, Encoded Data Length=83

What about JSON.parse and JSON.stringify?

var originalJSONStringify = JSON.stringify;
var originalJSONParse = JSON.parse;

JSON.stringify = function() {
  console.log('[*] Hooked JSON.stringify');
  let result = originalJSONStringify.apply(this, arguments);
  console.log(result);
  return result;
};

JSON.parse = function() {
  console.log('[*] Hooked JSON.parse');
  console.log(arguments[0]);
  return originalJSONParse.apply(this, arguments);
};

Returns:

[13:24:52] I | ReactNativeJS ▶︎ [*] Hooked JSON.stringify
[13:24:52] I | ReactNativeJS ▶︎ {
  "NativeModules": ["HeadlessJsTaskSupport", "UIManager", "PlatformConstants", "DeviceInfo", "DeviceEventManager", "Timing", "BlobModule", "Appearance", "SoundManager", "ImageLoader"],
  "TurboModules": [],
  "NotFound": ["NativePerformanceObserverCxx", "NativePerformanceCxx", "NativeReactNativeFeatureFlagsCxx", "RedBox", "BugReporting"]
}


# It looks like we captured the HTTP response body, too! 
[13:27:41] I | ReactNativeJS ▶︎ [*] Hooked JSON.parse
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}

[13:27:41] I | ReactNativeJS ▶︎ [*] Hooked JSON.stringify
"userId"
[*] Hooked JSON.stringify
"id"
[*] Hooked JSON.stringify
"delectus aut autem"
[*] Hooked JSON.stringify
"title"
[*] Hooked JSON.stringify
"completed"
{ userId: 1,
                             │ id: 1,
                             │ title: 'delectus aut autem',
                             └ completed: false }

Built-ins? Yes, please!

// Array.push - you can get clever with typeof and toString() here
const originalPush = Array.prototype.push;
Array.prototype.push = function(item) {
  console.log(item);
  originalPush.call(this, item);
};

Hooking onPress Events, and potential partial reclaimation of JSX?

So this one is pretty cool. Map.set is being used for what I assume is some kind of UI state functionality. While logging data and poking around, I started seeing some strings that were unique to my UI.

{
  style: {
    backgroundColor: "#F3F3F3"
  },
  children: [
    {
        "$$typeof": {},
        type: {
          [Function: StatusBar]
          _propsStack: [],
          _updateImmediate: null,
          _currentValues: null,
          currentHeight: 0,
          _updatePropsStack: [Function]
        },
        key: null,
        ref: null,
        props: {
          barStyle: "dark-content",
          backgroundColor: "#F3F3F3"
        },
        _owner: null
      },
      // .... more junk
            {
              "$$typeof": {},
              type: {
                "$$typeof": {},
                render: [Function],
                displayName: "View"
              },
              key: null,
              ref: null,
              props: {
                style: {
                  backgroundColor: "#FFF"
                },
                children: [{
                    "$$typeof": {},
                    type: {
                      "$$typeof": {},
                      render: [Function],
                      displayName: "Button"
                    },
                    key: null,
                    ref: null,
                    props: {
                      title: "Do Thing",
                      onPress: [Function: doThing]
                    },
                    _owner: null
                  },

// .... more stuff

The key for that datastructure being used was 275 for me, so I hardcoded it and started writing conditionals down the datastructure until I found my Button, which exists in App.tsx as:

<Button title="Do Thing" onPress={doThing} />

Once I had the button, I was able to actually call doThing() from my global context code that I loaded from the filesystem!

I won’t even try to lie to you, I was absolutely pumped that this worked.

// Sorry for the spaghetti - nullish coalescing was being a pain in the runtime, so I went old school 
const originalMapSet = Map.prototype.set;
Map.prototype.set = function(key, value) {
  if (key == 275) {
    try {
      let child = value.children[1];
      if (child && child.props) {
        if (child.props.children) {
          child.props.children.map(c => {

            if (c.type.displayName === 'View') {
              console.log('Found a view!')
              if (c.props && c.props.children) {
                let first = c.props.children[0];
                if (first.type.displayName === 'Button') {
                  console.log('[*] Found button', first)
                  console.log('[*] onPress handler:', first.props.onPress);

                  let originalOP = first.props.onPress;
                  
                  first.props.onPress = function() {
                    console.log('[*] We hooked onPress! Woohoo!');
                    originalOP.call(this, arguments)
                  };

                  console.log('[*] onPress handler called!', first.props.onPress());
                }
              }
            }
          })
        }
      }
    } catch (error) {
      console.error(error);
    }
  }
  originalMapSet.call(this, key, value);
};

I was unable to override the onPress function to hook it, nor was I able to modify any of the other props on the element to the point where they changed visually (I tried the button title). This could be due to the fact that a re-render needs to take place, or that this datastructure is merely a copy of the original that stores some function references.

There are so many cool objects to play with in this data structure. One could theoretically take it, parse it recursively, and “decompile” the frontend of a React Native app as it exists within the Hermes engine.

That may be a different task for a different day.