Heresy - Inspect and Instrument React Native Apps
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
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
- Note: The version should match what is in heresy’s
package.json
file, which at this time is16.5.6
. - The corresponding release page is: https://github.com/frida/frida/releases/tag/16.5.6
- Frida has an official tutorial on setting up your Android device
- Note: The version should match what is in heresy’s
- (Optional)
adb
, which is used for viewing logs withlogcat
. 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 theHeresy
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 viaCatalystInstanceImpl
./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.
And now we can start with the hooks.
A TL;DR is:
- We hook
Map.set
and identify some RN elements - Access the props and stateNodes of said elements to modify state and add hooks
- 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
But did the Button and TextInput event hooks work?
Spoiler: They absolutely did.
Button.onPress() Hook
TextField.onTextChanged() Hook
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!