blog@​arhan.sh:~$

True Private State in JavaScript: a Chromium Rabbit Hole

Published August 11, 2024 • more posts


Table of Contents

What is “true” private state?

One of the first programming concepts you learn is the private variable, and the notion of encapsulation. These are fundamental concepts of object-oriented programming, a paradigm that emphasizes data structures, message passing, and abstraction.

For no reason at all, we’re going to casually set aside the intended theory, and take the term “private variable” (or “private state”) literally.

What do I think true private state should actually mean? For a class definition in a given programming language:

  1. There must not exist a way to directly access a private variable’s value given an instance of the class.
  2. There must exist exactly one indirect way to access a private variable’s value given an instance of the class.
  3. The first step of the private variable access must be performed from within the language (no CheatEngine/GDB).
  4. The class implementation’s source cannot be modified by the private variable access (to counter archaic solutions similar to this)
  5. The class implementation must not use cryptography or network programming.

Direct access refers to code that aliases into a private variable’s value, for example by property accessor syntax such as rectangle.length or direct memory access. Indirect access refers to code that evaluates to a private variable’s value, but doesn’t actually alias into it in the same manner. While the expression rectangle.getLength() retrieves the private variable’s value, the expression alone doesn’t alias into it.

My definitions inherently make the meaning of indirect access subjective. After all, these concepts and ideas are based on high-level programming abstractions. The processor sees every operation as just reading and writing to memory, thereby voiding the concept of indirect access at the low level. Hopefully, the following examples and the rest of the blog will help draw the line between what qualifies access as “direct” or “indirect”.

These five implementation requirements will be the key criteria in assessing whether or not a class implementation provides true private state. Generalizing them across many popular programming languages unmasks true identity of the term “private variable”… as a shameless misnomer!

Let’s first look at Java, a language whose primary purpose is to provide strong encapsulation boundaries. So, implementing true private state seems like it would simple enough, right?

public class Secret {
  private Object secret;

  public Secret(Object secret) {
    this.secret = secret;
  }

  public Object get() {
    return secret;
  }
}

Red buzzer. It’s easy to hijack private fields using Java’s reflection services, breaking the requirement of no direct memory access for true private state.

import java.lang.reflect.Field;

public class Main {
  public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
    Secret secret = new Secret(Character.toString(65).repeat(10));
    Field secretField = secret.getClass().getDeclaredField("secret");
    secretField.setAccessible(true);
    System.out.println(secretField.get(secret));
    // Output: AAAAAAAAAA
  }
}

(Note that we will use Character.toString(65).repeat(10) throughout the blog as our secret value as to not hardcode it)

You may point out that reflection can be disabled with the -Djava.security.manager flag. But because this flag is opt-in, it doesn’t matter; recall that there must not exist a way of direct access into a private variable. Even then, you could alternatively use Java’s Native Interface API as demonstrated here.

Looking at something else, Python barely even tries!

class Secret:
  def __init__(self, secret):
    self.__secret = secret

  def get(self):
    return self.__secret

secret = Secret(chr(65) * 10)
print(secret._Secret__secret)
# Output: AAAAAAAAAA

Such is the case with C++, C#, Ruby, and so on — there’s always a way to alias into private fields in such a way that breaks the requirements for true private state, most namely through foreign function interfaces.

I want to make clear that I understand that these escape hatches are the results of intentional and thought-out design efforts. If you discount cryptography and network programming and really think about it, private state in modern object-oriented programming is by obscurity. The general philosophy is to present a massive “Here be dragons” warning when people decide to access the internals of an object anyways, typically through discomfort and community disapproval.

In the Python example, _Secret__secret is intimidating enough to discourage its usage, actively making the programmer feel bad. Endowing the programmer with full access to the internals of an object is OK, because it is assumed that they understand the consequences of depending on undocumented and volatile class APIs.

True private state in JavaScript

In regards to true private state, what does JavaScript offer?

class Secret {
  #secret;

  constructor(secret) {
    this.#secret = secret;
  }

  get() {
    return this.#secret;
  }
}

let secret = new Secret(String.fromCharCode(65).repeat(10));
console.log(secret.get());
// Output: AAAAAAAAAA

Believe it or not, for how historically chaotically-evil JavaScript has proven itself as a language, this is our silly little answer. True private state! The proposal for private JavaScript class fields guarantees their soundness:

Private fields provide a strong encapsulation boundary: It’s impossible to access the private field from outside of the class, unless there is some explicit code to expose it (for example, providing a getter).

If this is it, what was the deal with the mumbo jumbo at the beginning? Well, you can see from the remaining length of this blog post that there’s far more to the story than just JavaScript private fields. Our treasure lands on another island; I’m going to introduce a seemingly arbitrary liberation to the challenge: Chromium DevTools. What you see when you “inspect element” a web page. As I will soon demonstrate, this environment enables a myriad of backdoors that make true private state far more interesting.

True private state in DevTools

An image demonstrating that you can directly access private variables in DevTools

Something is already wrong right off the bat. Some research unveils this documentation.

Code run in the Chrome console can access private properties outside the class. This is a DevTools-only relaxation of the JavaScript syntax restriction.

Okay, we just need to try a little harder.

var Secret = (() => {
  // The difference between WeakMap and
  // Map is irrelevant for this blog
  let _secrets = new WeakMap();
  return class Secret {
    constructor(secret) {
      _secrets.set(this, secret);
    }
    get() {
      return _secrets.get(this);
    }
  };
})();

let secret = new Secret(String.fromCharCode(65).repeat(10));
console.log(secret.get());
// Output: AAAAAAAAAA

This approach is widely used as a polyfill for private JavaScript class fields, by Babel for instance. There’s seemingly no way for external code to access the closure variable _secrets, is this true private state? Unfortunately, no. DevTools reveals the first trick of many up its sleeve.

An image demonstrating that you can directly access scope information in DevTools

Recall my definition of direct access as “aliasing into a private variable’s value”. This is evidently what’s happening, thus discrediting closure variables as a solution, perhaps our biggest weapon so far. The DevTools scope inspector gives you access to practically every internal variable within a class. To put the nail in the coffin, I developed a way to programmatically access closure variables within DevTools, and exactly how is a story for another blog.

let secret = new Secret(String.fromCharCode(65).repeat(10));
await scopeInspect("secret");
// {value: 'AAAAAAAAAA'}

Hm, let’s see if we can exploit the dynamic nature of the eval function. Directly calling eval still reveals closure variables to the scope inspector. Could we instead use a reference to eval to conditionally capture closure variables?

class Secret {
  constructor(secret) {
    this.get = () => window["ev" + "al"]("sec" + "ret");
  }
}

let secret = new Secret(String.fromCharCode(65).repeat(10));
console.log(secret.get());
// Output: AAAAAAAAAA

Considering that complete static analysis for a program has been proven to be undecidable, window["ev" + "al"] can only be evaluated at runtime. In theory, the scope inspector wouldn’t know to capture closure variables until the eval in the getter function is called.

As it turns out, the V8 engine is one step ahead of us. The above code throws a ReferenceError because smarter people than me have already thought of ways to compensate for this issue. eval captures local scope only when directly called and captures global scope otherwise, bringing us back to square one.

At this point during the research phase of this blog, I realized that what I was trying to achieve was going to be much more difficult than expected. A day of tinkering later, I stumbled upon this solution.

class Secret {
  constructor(secret) {
    console.warn("`Secret` may not properly serialize complex JavaScript values");
    this.get = Function(`return JSON.parse('${JSON.stringify(secret)}')`)
      .bind()
      .bind();
  }
}

let secret = new Secret(String.fromCharCode(65).repeat(10));
console.log(secret.get());
// Output: AAAAAAAAAA

To my credit, this was a surprisingly witty solution. It avoided the use of closures and utilized the Function constructor to hide the secret. Calling valueOf or toString on a function normally reveals its source code, however postfixing two .bind()s tricks DevTools into revealing function () { [native code] } instead.

It’s a shame that my friend Adrian debunked my findings two days later.

An image demonstrating that my solution was invalid
Check out Adrian's website, he makes some pretty cool stuff

Pivoting once again, does DevTools reveal WebAssembly memory? If not, we could interface with it to our advantage.

An image demonstrating that DevTools reveals WebAssembly memory

Yes, it does as a form of direct memory access.

Now, our first real breakthrough!

class Secret {
  constructor(secret) {
    this.init(secret);
  }

  init(secret) {
    this.secret = new Promise((resolve) => {
      this.updateState = resolve;
    }).then(this.init.bind(this, secret));
    return secret;
  }
}

let secret = new Secret(String.fromCharCode(65).repeat(10));
secret.updateState(), console.log(await secret.secret);
// Output: AAAAAAAAAA

This is similar to the closure solution except unlike the eval solution, we’ve actually tricked DevTools. For brevity I won’t show an image, but this time secret is hidden from the scope inspector. Binding this.updateState directly to resolve relinquishes its need to display secret because the binding doesn’t canonically create a closure. Simply calling the resolver and awaiting the promise gives back the original value!

In a cruel twist of fate, this solution is invalid as well.

An image showing off the DevTools heap profiler
The heap profiler exposes variables from web workers and popups as well

Enough teasing around. My definition of “true” private state within DevTools is impossible because of the heap profiler.

However, that doesn’t mean there isn’t more to the story. I was missing the bigger, more abstract picture. JavaScript is inherently a garbage collected language. Logically speaking, it’s impossible to remove all references to a secret variable or else it would become garbage collected. I mean, duh, but saying this out loud made me realize I needed to completely reapproach my strategy.

True private state in DevTools: the extension way

You could argue I’m pushing the limits of the DevTools environment, but I like to think of this as thinking outside the box. Chrome extensions provide a secure sandboxing environment to uphold their security guarantees. We can use this to our advantage.

Here is an implementation of private state in JavaScript that satisfies all five of my requirements. It utilizes IndexedDB on a Chrome extension background script to store secret variables in an isolated execution context (the chrome.storage API works too).

You’ve seen enough code blocks so far, so as to not ruin your patience I’ve taken the liberty of hiding the code implementation in a dropdown.

The code

manifest.json

{
  "name": "Secret",
  "description": "An extension that provides private state within DevTools",
  "version": "0.1",
  "manifest_version": 3,
  "host_permissions": ["http://*/*", "https://*/*"],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["contentScript.js"]
    },
    {
      "matches": ["<all_urls>"],
      "js": ["secret.js"],
      "world": "MAIN"
    }
  ]
}

secret.js

class Secret {
  static secretId = 0;

  constructor(secret) {
    this.secretId = Secret.secretId++;
    document.dispatchEvent(
      new CustomEvent("set", { detail: { secret, secretId: this.secretId } })
    );
  }

  async get() {
    document.dispatchEvent(new CustomEvent("get", { detail: this.secretId }));
    return await new Promise((resolve) => {
      document.addEventListener(
        "getResponse",
        (e) => {
          resolve(e.detail);
        },
        { once: true }
      );
    });
  }
}

contentScript.js

document.addEventListener("set", function ({ detail: { secret, secretId } }) {
  chrome.runtime.sendMessage({ type: "set", secret, secretId }, () => {
    document.dispatchEvent(new CustomEvent("setResponse"));
  });
});

document.addEventListener("get", function ({ detail: secretId }) {
  chrome.runtime.sendMessage({ type: "get", secretId }, (secret) => {
    document.dispatchEvent(new CustomEvent("getResponse", { detail: secret }));
  });
});

background.js

const DB_NAME = "SecretsDB";
const DB_VERSION = 1;
const OBJECT_STORE_NAME = "secrets";

chrome.runtime.onMessage.addListener(function (
  message,
  { documentId },
  sendResponse
) {
  if (message.type === "set") {
    openDbStore("readwrite").then((store) => {
      store.get(documentId).onsuccess = (e) => {
        let data = e.target.result || { documentId, secrets: {} };
        data.secrets[message.secretId] = message.secret;
        store.put(data).onsuccess = sendResponse;
      };
    });
    return true;
  } else if (message.type === "get") {
    openDbStore("readonly").then((store) => {
      store.get(documentId).onsuccess = (e) => {
        let data = e.target.result;
        sendResponse(data?.secrets[message.secretId]);
      };
    });
    return true;
  }
});

async function openDbStore(mode) {
  let request = indexedDB.open(DB_NAME, DB_VERSION);
  request.onupgradeneeded = function (e) {
    let db = e.target.result;
    db.createObjectStore(OBJECT_STORE_NAME, { keyPath: "documentId" });
  };
  let db = await new Promise((resolve) => {
    request.onsuccess = (e) => resolve(e.target.result);
  });
  let store = db
    .transaction(OBJECT_STORE_NAME, mode)
    .objectStore(OBJECT_STORE_NAME);
  return store;
}

Instantiate and retrieve the secret like so.

let secret = new Secret(String.fromCharCode(65).repeat(10));
console.log(await secret.get());
// Output: AAAAAAAAAA

We have our answer. There’s no way for code on the web page to “directly access” the background script’s IndexedDB. The secret can only be retrieved indirectly, through the get method.

I could just end the blog here, but there’s one thing left to address. It’s still possible to access the secret within Chrome if you navigate to chrome://extensions, open the background script DevTools window, and run this.

await openDbStore("readonly").then(
  (store) =>
    new Promise((resolve) => {
      store.getAll().onsuccess = (e) => resolve(e.target.result);
    })
);

For no reason at all, following the theme of this blog post, let’s make this even more unnecessarily complicated. Utilizing the native application API, it’s possible to store the secret outside of Chrome entirely! I’ve documented my exact findings in this GitHub repository. Instead of providing the code’s source like before, I had some free time and thought it would be cool to diagram how it worked. Some eye candy:

An image diagramming the native app secret implementation

Closing thoughts

It’s time to address the elephant in the room. The reason why this entire blog post isn’t even remotely practical is because of cryptography and networking services on the front-end.

First, cryptography. Take your pick. The WebAuthn large blob and credential management APIs use your operation system keychain to store credentials, with some obscure limitations. The browser.secureStorage API might become a reality soon. SubtleCrypto can be used with IndexedDB for cryptographically secure key management. Common password manager extensions use this method.

Networking is self-explanatory, and is what 99% websites do. Store the secret on the server, rather than risk the user meddling with its value.

So… was this blog pointless? Yes. I will openly admit that my requirements for “true” private state seem arbitrary. And I will also accept criticism that my blog deviated from the original intention behind object-oriented programming towards the end. It’s worth pointing out however that this topic is relevant in related fields of cybersecurity, such as in hardware security.

Was this blog a waste of your time? Maybe. Hopefully you learned something about JavaScript or DevTools debugging or the theory behind object-oriented programming. If you didn’t then I don’t really care. Until next time!