October 09, 2023

Experimenting with code obfuscation in Electron.js

As vulnerable as the current client-side technologies are, can we do anything to keep our Electron app away from trivial reverse engineering attempts?

As you may already know, JS code in Electron.js is packaged as it is with a Chromium’s wrapper. Such an architecture does not foresee binary compilation of our code at any stage, thus making the final product very easy to inspect and Electron itself mainly recommended for non-commercial apps.

While we could stay sure that the notorious electron-packager gifts us with a minimum-effort app encapsulation in a .asar file, we’ll se why it can still be worth taking a look at obfuscation, a well-known technique acting on the very JS constructs.

What obfuscating means

Beware, though: obfuscation is not a form of encryption. Our machine is still able to run an obfuscated JS file without deobfuscating it in the first place. At best, it will slow down an automatic reverse engineering attempt.

Enterprise-level solutions for securing Javascript via obfuscation exist and they enrich the technique with such extra services as code monitoring, locking and dynamic scrambling at build, which can lead to a positive cost-benefit ratio over time, in particular for web apps.

A basic implementation in Electron is not hard to achieve and, as it is portable from project to project, it is also fun to look into to see how it works and what it empowers us with.

How to implement it in an Electron app

A usual suspect, the Webpack package manager, comes to aid. Let’s start by adding the relevant dependencies to our webpack.config.js. We need copy-webpack-plugin for duplicating the project in a different location from the one used for our source files; webpack-node-externals, allowing us to define those external Node.js modules we want to skip during the obfuscation process; webpack-obfuscator , which will execute the actual obfuscation.

var path = require('path'),
    nodeExternals = require('webpack-node-externals'),
    Copy = require('copy-webpack-plugin'),
    Obfuscate = require('webpack-obfuscator')

Let’s then build our configuration as an exportable object, where we want the context to be the current directory and the mode set to production, which adds such optimizations as module file chaining.

We should also ensure the compilation is aimed at the Electron’s main process:

module.exports = {
    context: __dirname,
    mode: 'production',
    target: 'electron-main'
}

Let’s then add all our JS files in our app (together with any helper files) as our entry points.

module.exports = {
    ...
    entry: {
        main: path.resolve('./app/js/main.js'),
        renderer: path.resolve('./app/js/renderer.js'),
    }
}

The next step is to instantiate the copy module with the source and destination settings, further specifying any directories we would like not to include in the final build. Let’s exclude hidden files, too. For the obfuscation, we should at least add the rotateUnicodeArray parameter to further encode the strings. We might want to introduce deadCodeInjection in the code: in doing so, we have to take into account that the file code size might increment even by 200% and know that it can be mitigated by lowering the deadCodeInjectionThreshold. Finally, we have to make sure to arrange the two modules in a sequence of instances whereby copying is followed by obfuscation.

module.exports = {
   ...
   plugins: [
       new Copy([
           {
               from: './package.json',
               to: './package.json'
           },
           {
               from: './src',
               to: './src',
               globOptions: {
                  ignore: ['folder-to-ignore/**/*']
               }
           }
       ]),
       new Obfuscate({
           rotateUnicodeArray: true,
           deadCodeInjection: true
       })
   ]
}

In the node object, let’s reset the context directory to Webpack’s default and assert that we wish to include any “tuned” versions of the global dependencies.

As a last step, it is important to add the externals key to exclude any third-party Node modules. The webpack-node-externals package thereby specified will deal with looking up and excluding such modules. In particular, the modulesFromFileparameter will limit the module set to the one specified in our package.json.

module.exports = {
    ...
    node: {
        __dirname: false,
        global: true
    },
    output: {
        filename: 'src/js/[name].js',
        path: path.resolve('./dist/orig')
    },
    externals: [nodeExternals({ modulesFromFile: true })]
}

At last, here’s what an example output looks like:

var _0x8c46=['exports','call','defineProperty','undefined','toStringTag','Module','object','__esModule','default','hasOwnProperty','raw','toString','slice','rgba(','min','max','abs','toFixed','hsla(','%,\x20','cmyk(0%,\x200%,\x200%,\x201%)','cmyk(','UIColor('];(function(_0x1b842b,_0x251399){var _0xe321a4=function(_0x4fd383){while(--_0x4fd383){_0x1b842b['push'](_0x1b842b['shift']());}};_0xe321a4(++_0x251399);}(_0x8c46,0xa1));var _0x256a=function(_0x1b842b,_0x251399){_0x1b842b=_0x1b842b-0x0;var _0xe321a4=_0x8c46[_0x1b842b];return _0xe321a4;};!function(_0x251399){var _0xe321a4={};function _0x4fd383(_0x384783){if(_0xe321a4[_0x384783])return _0xe321a4[_0x384783][_0x256a('0x0')];var _0x82512c=_0xe321a4[_0x384783]={'i':_0x384783,'l':!0x1,'exports':{}};return _0x251399[_0x384783][_0x256a('0x1')]
...

The full code is available here.

Conclusion

Finally, what we are left wondering is whether a simple obfuscation technique is worth implementing in the context of a desktop app. My conclusion is that it is, as long as the following commandments are abided by:

  • Thou shall not expect obfuscation to act as an encryption method.
  • Thou shall not waste too much time on implementing obfuscation.*
  • Thou shall use obfuscation in a production build only — not during development.

* Your luck is such that, given a recipe like the one above, much of the struggle is dealt with for you already.

Copyright © 2024 Niccolò Mineo
Some rights reserved: CC BY-NC 4.0