Training
/
Masterclass
/
React Native

Understanding and Modifying the Hermes Bytecode

8
min read
Payatu Research Team
Beginner
Four people working with laptops around a futuristic transparent table at sunset, with an illuminated blue atom-like digital graphic and floating lines of code in the background.

The React Native Pentesting for Android Masterclass has taught us how to edit and patch React Native apps in the previous blog. Let’s now move on to the Hermes bytecode.

The React Native team created their own JavaScript engine, Hermes, which runs React Native applications. The JS source code is often compiled into the Hermes bytecode.

Hermes is an open-source JavaScript engine optimized for React Native. For many apps, enabling Hermes will result in improved start-up time, decreased memory usage, and smaller app size. Refer: https://reactnative.dev/docs/hermes

Thus, when you decompile the React Native application that used Hermes during compilation, the code in the “index.android.bundle” file will be converted into Hermes code. The contents of the file will look like this:

Screenshot of a text editor window containing a block of scrambled or encoded characters and symbols, indicating unreadable or encrypted data with a highlighted red border around the window.

Shoutout to bongtrop for creating hbctool. This tool lets us disassemble and reassemble the bundle file to return it to the Hermes instruction bytecode. We will learn how to assemble and disassemble the .bundle file later, but first, we need to make some sense of the messy Hermes bytecode.

Understanding Hermes bytecode

Currently, there is no way to convert disassembled Hermes bytecode to readable JavaScript code. We have to understand bytecode in bits and pieces to modify the behaviour of a specific function and, eventually, the application. Bytecode consists of a bunch of constants and functions that make up the logic of the application.

A man in a blue shirt and striped tie stands in front of a wall covered with papers and red string connecting them, gesturing animatedly as he explains something to another person.

Let’s look at some key elements in the Hermes bytecode and try to make some sense:

Oper[1]: String(strNumber)

This constant contains all strings either added by the user during development or strings of various JS libraries. But most of the time, this constant contains strings that we should look for. Examples of the strings are below:

Tip: Always search from the bottom of the “instructions.hasm” file to find strings that are added by the developer during development.

Screenshot of a code or text editor displaying search results for the string '1337.' The highlighted lines include code lines and string values such as 'Increase to 1337 for getting a flag' and 'Increase button has already been broken.' Various line numbers and string indices are visible, with some entries surrounded by colored highlights. The interface shows details like memory registers and string lengths.

createElement:

“createElement” string value refers to the JSX element which is created in React Native. Refer to the below side-by-side comparison of the JSX code vs Hermes bytecode:

Side-by-side comparison of React Native JSX code and its corresponding Hermes bytecode, highlighting text elements with their element types and values in red boxes and arrows.

LoadConstInt:

This element stores all integer values created within the application.

Screenshot of code snippet showing multiple LoadConstInt instructions with register and immediate 32-bit integer values, including Imm32:1336 highlighted in both green and red at top and bottom.

Relational Operators Identification:

The instruction code has different keywords for relational operators. Below are some of the important keywords and their meanings.

Table listing keywords, operators, and their meanings: JEqual (==) means Equal to; JNotEqual (!=) means Not equal to; JLess (<) means Less than; JGreater (>) means Greater than; JLessEqual (<=) means Lesser or equal than; JGreaterEqual (>=) means Greater or equal than.
Table showing comparison operators with their symbols and descriptions: JNotLessEqual (!<=) means not lesser or equal than, JNotGreaterEqual (!>=) means not greater or equal than, JEqualLong (==) means equal to long data type, JNotEqualLong (!=) means not equal to long data type, JStrictEqual (===) means strict equal to, JStrictNotEqual (!==) means strict not equal to.

Find a function name with a string:

We can search for any specific function with the help of a string.

For example, look at the application’s screenshot below:

Smartphone screen showing a grey background with a centered alert popup that reads 'Alert: Increase button has already been broken.' There is an OK button below the message and a plus icon button in the bottom right corner.

We can search with any keyword in the string shown in the screenshot below:

Screenshot of a code editor showing assembly-like code with a highlighted line containing the string 'Increase button has already been broken.' A search popup overlays the code, searching for the phrase 'Increase button has already been broken' and indicating one match found.

Copy the ID of the function as shown above and search for this ID in the file.

Screenshot of a text editor displaying code and search results. The code snippet shows a function named LoadFromEnvironment with lines including CreateClosure and PutById. Inside CreateClosure, a UInt16 value '3845' is highlighted in green, and a string 'onIncrement' is highlighted in red. Below the code, search results indicate 6 hits for '3845' within a file, each line showing different code references and the term '3845' highlighted in orange.

You will get the name of the function. For reference, here is a comparison of React Native JSX code and Hermes bytecode.

The Comparison:

React Native JSX code of “onIncrement” function: Hermes Bytecode of the “onIncrement” function:

Side-by-side display of JavaScript code and its corresponding low-level bytecode or disassembled function code; left shows a function incrementing a counter with conditions for max count and alerts, right shows the detailed bytecode instructions and references to the alert messages and state variables.

This way, we can link any function with its properties.

If you want to learn more about Hermes bytecode, there is a great playground for it:

hermesengine.dev

Now, let’s disassemble/assemble the obfuscated code into bytecode.

Steps:

Note: We will be solving a challenge created by “bongtrop”. More info here: “suam.wtf”

  1. Install the vulnerable application, and you will get the following screen:
Mobile screen showing a counter app with the text 'Counter: 0' and instruction 'Increase to 1337 for getting a flag' on a white background, with a circular plus button at the bottom right.
  1. We have to increase the counter value to 1337 to get the flag. But if we did try to increase the counter value with the “+” button at the bottom, we get the following error:
Mobile phone screen displaying a gray background with a white alert dialog that reads: 'Alert Increase button has already been broken.' with an OK button below the message.
  1. This means we have to set the counter value as 1337 directly.
  1. Change the extension of .apk file to .zip and open this file with WinZip.

5. Go to the “/assets/” folder and copy the “index.android.bundle” file in any folder on the system.

File archive software window showing the contents of HermesReversingLab.zip with the file index.android.bundle selected and a context menu open with options like 'Select all,' 'Copy files to clipboard,' and 'Extract to a specified folder.'

6. If you open this file, you will find a gibberish code.

Screenshot of a Notepad window with a file named 'index.android.bundle' containing mostly unreadable cryptic or binary text characters.
  1. Let’s convert this mess into a bytecode. Install hbctool with the following command:

pip install hbctool

8. Open the command prompt in the folder where the “index.android.bundle” file is pasted and run the following command to disassemble the file:

Terminal command example showing 'hbctool disasm <path-to-index.android.bundle> <output-folder>' and an example command 'hbctool disasm index.android.bundle output'.
Four men in a dimly lit room focused on a computer screen with text overlay: "HBCTOOL DECODING ALL MUMBO JUMBO IN .BUNDLE FILE" above a man leaning forward, and "ME WAITING FOR RESULTS..." above a seated man looking intently ahead.

9. Go to the “output” folder create, and there you will find the “instructions.hasm” file. You will find all of the React Native application’s JS code in bytecode format.

Screenshot of a code editor showing a portion of hasm assembly-like code with string operations and register instructions, including lines referencing strings such as 'join', 'state', 'counter', 'alert', and the message 'Increase button has already been broken.'

10. As we have to increase the counter value to 1337, first find the function that deals with the counter value. We can search with keywords of the error “Increase button has already been broken.”

Screenshot of function code displaying parameters, registers, and symbolic strings, including message 'Increase button has already been broken.'

11. As we observed above, the counter breaks when we try to increase the counter value beyond 10. Thus, the application performs a “Relational operation” to verify whether the counter value exceeds 10.

Screenshot of code with highlighted sections showing Function<>3845, LoadConstUInt8 and JNotEqual operations, and a string message 'Increase button has already been broken.'

12. Instead of increasing the counter’s value, we can change the target value, i.e. 1337, to 4. For this, we have to find the relational operator in the same function, which checks whether the counter value is greater than 1336 or equal to 1337.

Screenshot of source code with highlighted assembly-like instructions including 'GetById', 'LoadConstUInt8', 'Add', 'PutNewOwnById', 'Call2', 'LoadFromEnvironment', 'GetByIdShort', 'LoadConstInt', with a focus on instructions 'Reg8:1, Imm32:1336', 'JNotGreaterEqual', and string values like 'counter', 'state', 'alert', and 'decrypt'.

13. We have found a relational operation that says decrypt and alert the flag if the counter value reaches 1336 or greater. (Note that the flag is encrypted in this case.) Thus, we can change this value from 1336 to 10 or less. We are changing it to 4 here.

Screenshot of assembly-like code with instructions and registers including LoadFromEnvironment, GetById, LoadConstUint8, Add, Call2, LoadConstInt, JNotGreaterEqual, GetGlobalObject, TryGetById, and LoadConstString, showing register and immediate values with corresponding strings like 'state', 'counter', and 'alert'.

This means, if the counter value reaches greater than 4 then we will get the flag.

14. Save this file after making any changes and open the “.zip” file of the APK.

15. Now, we need to assemble the index file back to Hermes bytecode format.

16. Open a command prompt and run the following command:

Terminal screenshot showing usage of hbctool asm command to assemble a folder into index.android.bundle with confirmation messages indicating assembly to the specified path, source hash, HBC version, and done status.

17. Go to the “/assets” folder. Delete the original “index.android.bundle” file and paste this newly created file there.

18. As usual, we also need to remove the signature files. Go to the “/META-INF” folder and remove the following files:

  • CERT.RSA
  • CERT.SF
  • MANIFEST.MF
Archive manager window showing contents of HermesReversingLab.zip, a ZIP file with unpacked size about 40.9 MB; it contains META-INF folder and several Android-related version files such as androidx.lifecycle and androidx.viewpager, plus CERT.RSA, CERT.SF and MANIFEST.MF files in META-INF folder.

19. Exit the “Winzip” app and rename the file extension back to “.apk”

20. Now, we need to sign the modified APK with the certificate. To generate a custom certificate, run the following command and fill out the details:

Terminal screenshot showing the command 'keytool -genkey -v -keystore <keystore_name>.keystore -alias <keystore_alias_name> -keyalg RSA -keysize 2048 -validity 10000' and prompts for keystore password, personal and organizational details, followed by confirmation and generation of a 2048-bit RSA key pair with a 10,000-day validity. The keystore file named 'rnmodify.keystore' is stored.

21. We will sign our AOK with the generated keystore. Run the following command and enter the keystore password set while creating the keystore in step 6.

Command line screenshot showing usage of jarsigner tool with options -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore followed by a keystore file, APK file, and alias name, and the console output listing added and signed META-INF files for an Android APK.

22. Install the modified APK with adb, and the modified APK file will successfully get installed.

adb install <modified.apk>

23. Increase the counter value by tapping “+” button 5 times and you will get the flag.

Mobile device screen displaying an alert dialog box with the message 'flag{Hermes_Reversing_is_not_hard_right?}' and an OK button below it, on a gray background.

Understanding and analyzing Hermes bytecode can be a headache. However, certain patterns in the bytecode help us understand the flow of the functions, methods, and constants.

Root detection bypass

In React Native applications, the JailMonkey npm package is widely used to detect rooted android devices. It is also used to detect mocked locations, hooking statuses, and some basic device integrity checks.

What is JailMonkey?

As mentioned above, JailMonkey is a third-party npm package that provides functionality to check or detect whether the device is rooted. It utilizes the API “isJailBroken” to check the root status of the device by checking various pieces of information throughout the device, such as whether the “su” binary exists in the device, whether the “busy box” is installed, checking alternate paths for “su” binaries, etc.

We can bypass this check by modifying the “isJailBroken” function in the “index.android.bundle” file. Below is how to do it.

Note: Always try to modify the function instead of removing it altogether, as there might have been some references in the rest of the code.

Steps:

1. Open the vulnerable application, and you will see that it is detecting the device’s root status.

Mobile screen showing a root detection app with text 'Root Detection' and 'Root Status: This device is ROOTED' above a close-up image of a bald man's face.

2. Now change the extension of the APK file from “.apk” to “.zip” and open it with any file compression tool, such as 7z or WinZip.

3. Open the “/assets/index.android.bundle” file and search for the isJailBroken keyword. You can search the below keyword to reach the correct code line:

Screenshot of JavaScript code defining an isJailBroken function with comments about a console warning that 'JailMonkey' is not available and the app has been rebuilt, including a highlighted code snippet returning isJailBroken function calls.

4. Modify the function as shown below:

Screenshot of Android JavaScript code inspecting objects, defining properties, and handling different functions related to app detection, location, and asset registration in a bundle file.

We modify the function to return a “false” boolean value to the “isJailBroken” function.

5. Go to the “META-INF” folder and delete the following files

  1. CERT.RSA
  2. CERT.SF
  3. MANIFEST.SF

6. Change the file extension back to “.apk” and run the following command to generate the keystore file,

keytool -genkey -v -keystore <keyStoreName>.keystore -alias <keyStoreAlias> -keyalg RSA -keysize 2048 -validity 10000

7. Now, sign the APK with the newly generated keystore. Run the following command:

jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore

<my-keyname>.keystore

<VulnerableApp.apk> <alias_name>

8. Install the application into the device with

adb install VulnerableApp.apk

9. Open the application, and you will see root detection has been bypassed.

Screenshot of a mobile app showing root detection results with the heading 'Root Detection' and the status 'Device is NOT rooted' in bold text above a smiling man in a black tank top.

The example shown above is not limited to the test case shown. The implementation of the “isJailBroken” function may vary. Understanding the function implementation is important when modifying it as per our requirements.

Bonus:

Below is the actual project code snippet vs webpack compiled code for reference.

Two side-by-side code snippets comparing actual project code with Webpack compiled code, both checking if a device is jailbroken using isJailBroken condition to execute related code.

You are now fluent in understanding Hermes bytecode, some key elements in it, root detection bypass, and what JailMonkey is. You will learn about SSL certificate pinning bypass to pentest react native applications successfully in the next blog

Keep practising!