MacOS Native API calls in Electron

How to get your electron app to do Mac stuff.

So, this story begins with me deciding that I wanted to develop a standalone app that can make MacOS Native API calls for purposes that I will not dive into, just yet (I plan to develop the app and do not know yet, how well it will pan out in the end, so I don’t want to give it away in this article. More to come…).

I decided that I did not want to use Apple’s UIKit, because I don’t have much experience using it and I’m honestly not too impressed with the GUIs that it produces. I want something that looks better. I am pretty experienced in using bootstrap for UI development and thought that it would be cool if I could do something in JavaScript.

I was talking to my friend Christopher Ross (@xorrior), who suggested that it would be cool to make an Electron app that would do what I wanted my app to do (again, not yet divulging), but wasn’t sure how easy it would be to have it make MacOS Native API calls. I decided to find out…

At this point, all I knew about Electron is that it has been used to develop really cool apps like Slack, Discord, Facebook Messenger, Twitch and more. I did some research and found that Electron is an open source, cross-platform JavaScript framework that combines Node.js and the Chromium browser so that the developer can build standalone GUI applications using web technologies. Basically, your Electron app is a Node.js app running on Chromium. This sounded perfect for what I have set out to develop.

I started searching Node.js modules in npm for something that I could use. I found and tried several. All were out of date and unmaintained. I even tried repairing some of the modules myself, but I just couldn’t get past all of the errors.

Anyone who does any MacOS Red Team work should be familiar with Cody Thomas (@its_a_feature) and his work with Mythic Command & Control (C2) , and the Apfell payload. The Apfell payload is a JavaScript for Automation (JXA) payload that uses Objective-C API calls.

JXA allows MacOS users to control applications and the operating system using JavaScript. Among other ways, it can be invoked via the Open Scripting Architecture scripting language (osascript). Native to MacOS, osascript was originally designed for use with AppleScripts,but it will execute other OSA language scripts, including JavaScript. The real power of JXA is that is is JavaScript that can call Objective-C functions directly. This is what makes the Apfell payload so important in establishing C2 and for post exploitation for a red team. The JXA cookbook is a great resource for learning JXA.

Knowing how powerful the Apfell payload is and its ability to make Objective-C API calls, I knew I needed to find a way to use jxa within Node.js and my Electron application.

After hours of searching, trying, and editing node modules, I found the one I needed. It is simply titled osascript, by Mikael Brevik. I gave it a spin, and after some trial and error, I got it to work!

I am going to take you step by step in creating a test, Electron application. Some of you are familiar with Node.js and Electron, but for those of you who, like me are not, you can use this to easily get things up and running. I had a few roadblocks in getting things working on MacOS Catalina and Big Sur, so hopefully this will save you some time that I wasted figuring out how to jump hurdles.

  • Install Node.js: Navigate to https://nodejs.org/en/download/ and download and run the MacOS installer. Pretty simple. You can check to make sure everything is installed properly by running the following two command. If installed correctly the commands will output the corresponding version numbers:
node --version
npm --version
  • Create your test project: Make your directory and enter it:
mkdir electronHelloWorld && cd electronHelloWorld
  • Initialize node: From within your newly created directory you will initialize a node project. This will be creating the needed package.json file with the following command:
npm init

This will ask you a series of questions that will ultimately be used to build your json file. Just hit the enter key for every question, except for Author , where you can enter your name.

npm init
  • Install Electron: Now we need to install the latest version of Electron globally, but there is a problem doing this on MacOS. I found that the /usr/local/lib/node_modules/ directory was not writable by npm and that even using sudo to install it did not work. So, I had to give all users the write permission for the directory:
sudo chmod 715 /usr/local/lib/node_modules

NOW install the latest version of Electron globally:

npm install electron@latest -g

Now, you can check to make sure it has installed correctly with the following command. If the command outputs the current version of Electron, you are good to go:

electron --version
  • Install the osascript module: Install this module using the following command:
npm install osascript --save

Doing this will add a node_modules directory to your directory structure, and it will also add the osascript module to your package.json file.

package.json
  • Next, Create the following 3 files in your directory:
  1. index.html
<html>
<head>
<title>OS Module</title>
</head>
<body>
<h1>Test page</h1>
<script src="./objctest.js"></script>
</body>
</html>

You can do anything you want here, but the important part is the <script> tag calling ./objctest.js . Its also probably a good idea to add some test so that you can be sure things are working correctly when you run the app. Sometimes the text won’t render is there is an error in your application, and adding text to your html can help you to identify this.

2. main.js

const {app, BrowserWindow} = require('electron')
const url = require('url')
const path = require('path')
let winfunction createWindow() {
win = new BrowserWindow({width: 800, height: 600, webPreferences: {nodeIntegration: true}})
win.loadURL(url.format ({
pathname: path.join(__dirname, 'index.html'),
protocol: 'file:',
slashes: true
}))
}
app.on('ready', createWindow)

I’m not going to go too deep into this file, but just know that this is the entry point into your app. You will have Electron call this js file to start your app. One thing to note is that in older versions of Electron, nodeIntegration defaulted as true , but in the newest version you must manually set it to true as I did above. You can mess around with your browser window size and such later, but for now lets keep moving.

3. objctest.js

var osascript = require('osascript').eval;var script = 'ObjC.import("Cocoa"); let host = $.NSHost.currentHost; ObjC.deepUnwrap(host.addresses);';osascript(script, { type: 'JavaScript' }, function (err, data) {
console.log('osascript data:');
console.log(data);
if (err) {
console.log('osascript error:');
console.log(err);
}
});

Ok, so this is the bread and butter to using osascript in our Electron app. You’ll notice that the first thing I do is require the osascript module with .eval . ‘Require’ is telling node to use the osascript module. Eval is how you tell osascript that you are going to run a line of script and to not look for a filename for the script. This is equivalent to running osascript on the command line with -e like so: osascript -e '<a line of js here;>' .

The script I create variable is the line of js that I want to eval. Notice I am importing the Cocoa standard library using the ObjC object. This object deals with the JXA-Objective-C bridge and is how the JavaScript engine has access to, or interprets Objective-C objects.

Next you’ll notice I am creating the host variable calling the Objective-C, NSHost class and its currentHost method.

let host = $.NSHost.currentHost; .

This method returns the host information for the current process.

What about the $ Object? This is the main access point for all Objective-C function calls.

The final line of the script variable is ObjC.deepUnwrap(host.addresses); . The deepUnwrap() method is how you convert NS*objects to JavaScript. NS objects are Foundation, Objective-C Framework Objects. This Foundation framework uses the NS (NeXTStep) prefix and provides basic classes such as wrapper and data structure classes.

Finally I am calling the osacsript() function with my script variable as an argument. Notice that I specify Javascript as the type. This is equivalent to using the -l osascript flag on the command line.

For debugging purposes I have the data, and any errors logging to the browser console.

If I were to run this script from the command line it would look like this:

osascript -l JavaScript -e 'ObjC.import("Cocoa"); let host = $.NSHost.currentHost; ObjC.deepUnwrap(host.addresses);'

You can give it a try on your Mac right now if you’d like.

Let’s try Our Electron App

Our directory should have our 3 newly created files and look like this:

Directory Structure

Ok, now it is time to try our Electron app. If it works, our app should successfully use the osascript, Node.js module to make direct Objective-C calls and return our machine’s host information. From the terminal, and in your project directory, run:

electron ./main.js

Our app will open:

Electron App

To see our output, we’ll need to open developer tools by clicking theoption+command+I keys. If everything was done correctly, we will see our output in the console tab, if not we will see the error thrown by the OS:

Developer Tools and Output

Great Success! We have successfully retrieved the host data using Node.Js and the ObjC-JXA bridge with our Electron App.

UPDATE

I was able to get the osascript module to call local JavaScript files rather than just eval single lines of JavaScript. This is cool, because you can imbed and call large JavaScript files like the Apfell payload into your electron apps. You can test this by doing all of the steps above, except change your objctest.js file to the example below:

var osascript = require('osascript').file;  //Use 'file' instead of 'eval'osascript('apfell_payload.js', { type: 'JavaScript' }, function (err, data) {
console.log('osascript data:');
console.log(data);
if (err) {
console.log('osascript error:');
console.log(err);
}
});

Now just add the Apfell payload JavaScript file, move it to your Electron app directory, and name it apfell_payload.js , and run your Electron app as explained in the tutorial above. You will have your C2 callback awaiting you in Mythic.

Conclusion

So, that’s it. I am really stoked to discover that I can make Objective-C calls from an Electron app! Now I can move forward confidence in developing the project that I have set out to develop. As promised, I will publish more as the app development comes along. Hopefully this was informative and helpful to some folk out there.

Special Thanks

A special thanks to Christopher Ross for suggesting the Electron app idea to me and sending me down this rabbit hole. Also, for all of your work on Mythic, JXA and the MacOS red teaming space as well. Your work is truly inspirational.

Also, a special thanks Cody Thomas for all of the work you have done with Mythic, JXA, and MacOS red teaming. You’ve made immeasurable contributions to the Mac hacking community, and I would know absolutely nothing about JXA without you.

Also, a special thanks to Dwight Hohnstein, Leo Pitt, and everyone at SpecterOps . Again, your contributions to Mac hacking as well as red teaming in general are unmatched.

I highly recommend taking Mac Tradecraft training from SpecterOps. It is a huge wealth of knowledge in Mac hacking & red teaming and provides for an amazing, hands-on training experience.

Finally, a very special thanks to the creator the osascript, Node.js module, Mikael Brevik. Thank you so much for this module. I hope to make contact with you soon in order to collaborate and just so I can pick your brain on this module.

Thank you all!

4n7m4n

Red Team Pen Testing Nobody | OSCP | InfoSec | Tech Junkie | OIF Veteran | Tweets are mine, not yours, nor anyone else's... Certainly not my employer's