Aarno Labs Logo

Aarno Labs Blog

The latest news and research from Aarno Labs

Mitigating Supply Chain Attacks Through Fine-Grained Privilege Enforcement

Author: Eli Davis

9 min read

Posted 3 months, 3 weeks ago

On March 28, 2024 a multi-year-long campaign to add a backdoor to liblzma was discovered. The backdoor allowed arbitrary code execution on any system that relied on liblzma, and at the time it was found, bleeding edge systems had already been infected. The discovery was due in large part to luck, and had it happened later, huge swathes of the world would have been compromised.

While there are steps that can (and should) be taken to make such an attack harder in the future, the fact remains that this kind of attack is exceedingly difficult to fully defend against; the attack surface is simply too large, and the social engineering aspects are not something that can be perfectly avoided.

Thus, while prevention of deep dependency attacks is important, mitigation of a successful attack is equally so. Fortunately, in concept, there exists a relatively straightforward way to mitigate the takeover of a low-level library. This is the idea of library-level privileges. If a 3rd-party library is compromised, but that library's actions are limited by its allowed privileges, the possible damage of an attack is limited.  Library-level privileges can protect against both malicious backdoors and accidental vulnerabilities.

Unfortunately, library-level privileges have not seen widespread deployment in modern systems. In this post, we will explore the history of privileges and introduce Lucien. Lucien is a framework that allows fine-grained library-level privilege control of an application. While the current Lucien release supports NodeJS programs, its approach and principles can be applied to any language, and a Java version is currently in the works.

And while the XZ Backdoor happened to be in compiled code, similar attacks have occurred in more managed environments. As one example, in 2018, attackers managed to add a backdoor in the popular event-stream npm package in order to steal bitcoin: https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident

But before we get to Lucien, let’s talk privileges:

Why Privileges Matter

The Principle Of Least Privilege is not a new idea. The original formulation stems from James Salazar's 1974 paper, "Protection and the Control of Information Sharing in Multics". In this paper, Salazar lays out the basic idea that a program (or user) should "operate using the least amount of privilege necessary to complete the job."

In the original paper, Salazar speaks more about accident than malice: 

“The purpose of this principle is to reduce the number of potential interactions among privileged programs to the minimum necessary to operate correctly, so that one may develop confidence that unintentional, unwanted, or improper uses of privilege do not occur. If this principle is followed, the effect of accidents is reduced. Also, if a question related to misuse of a privilege occurs, the number of programs which must be audited is minimized.”

Fifty years later, while the original impetus is still valid, a much more pressing use of privileges has taken the forefront: protection against cyberattack. 

 

Using Privileges to Protect against Cyberattacks 

When an attacker targets a program, their goal is generally to achieve arbitrary code execution. With arbitrary code execution, the  attacker can make the compromised program do anything within said program's remit. If the compromised program has broad latitude to act on its host system, the attacker can make use of that full power. On the other hand, if the compromised program is sufficiently constrained, an attacker is similarly constrained in what they can accomplish. 

In acknowledgement of this principle, every major operating system has a notion of privileges, and it is generally considered bad practice to run a program with more privileges than it needs. 

For example, in Unix systems, if a program doesn't run as root, it cannot write to the most sensitive system files, meaning that if an attacker takes it over, they can't either. 

Unfortunately, OS-level privileges are extremely coarse-grained. If a program needs to be able to access *any* root-level privilege as part of its normal runtime, it will generally be run as root, which allows it to access *all* of them. And of course there are plenty of ways a compromised non-root program can cause problems:

Because programs are generally run as the user who launches them, even an unprivileged program can usually read (and write!) arbitrary files in the user's home directory. Any unprivileged program can open a (free, non-privileged) socket to read from or write to the network. The vast majority of programs should not be writing files or opening network sockets unless that is their specific purpose. And, of course, if one depended-upon library is compromised, it can do anything the loading program can do.  Thus, there is a need for privileges which are more fine-grained than the Unix operating system defaults. 

Unfortunately, for languages with a runtime, privileges are impractical to enforce on the operating system level; the operating system sees the runtime and the code it's executing as the same blob. Most programs shouldn't be reading arbitrary files, but the runtime does need to be able to read files in order to load the code it's supposed to be running. Thus, many languages have moved toward enforcing their own privilege models within their runtimes.

Privileges in Server-Side JavaScript

Now we will zoom specifically to the world of server-side JavaScript, which is the current target language for Lucien. While there are many JavaScript server runtimes, by far the most commonly used is Node. The latest versions of Node support a straightforward language-level privilege model: On startup, the user declares privileges for the whole program, and those privileges are enforced throughout the program’s lifetime. 

These privileges are noticeably better than the operating system defaults. A user can restrict the executed JavaScript code from reading or writing specific files or sets of files, while the Node process itself can still access all the privileges it needs in order to run. 

Unfortunately, these privileges still have real issues. First, Node’s current privilege implementation is very limited. It only handles filesystem accesses, while ignoring things like network accesses and command executions (which themselves can, of course, be used to access the filesystem).  Second, these privileges must be declared up front. While this is possible for Node’s extremely simplistic privileges, this becomes quite difficult (and thus another point of friction) for any more sophisticated model. And, lastly and most problematically, all of these privileges are still at the whole-program level. If you want to run a webserver, you must grant it network access. If you want your program to be able to read a file, the whole program needs to be able to access said file. 

The other complication with JavaScript is the proliferation of libraries. JavaScript programs tend to be comprised of hundreds or thousands of individual modules, many of which are a single simple function ([is-odd](https://www.npmjs.com/package/is-odd) being the canonical example). Note that most JavaScript programs do not, themselves, import a thousand libraries, but when each of the libraries they use themselves rely on fifty libraries, the total number of loaded third-party libraries can quickly become unauditable. Worse, many of these libraries are poorly written, leading to the many , many , many , JavaScript library vulnerabilities. 

And, unfortunately, if you need to run a JavaScript program with real privileges under the whole-program model of Node, each of those hundreds of questionable libraries inherit the whole-program privileges. 

The Lucien Privilege Model

The core of Lucien's privilege model is that each library has separate privileges.  

Under Lucien, when running a webserver, only the module which actually launches the webserver has the privilege to talk to the network. If a library four imports down tries to open a network port, it will still lack the privilege, even though the overall program is allowed to. 

Further, Lucien is library-trace dependent. Each Lucien privilege is in the form libA -> libB -> libC -> call

If a program calls into the npm package http-server from its main method to launch an http server, the generated privilege would be main -> http-server -> node:http.createServer. If one of the potentially-hundreds of other third-party libraries is compromised (compLib), it can't call node:http.createServer directly. But also, it can't be clever and try to call http-server -> node:http.createServer, because the Lucien-generated privilege for that call will be compLib -> http-server -> node:http.createServer, which does not match the allowed privilege. 

Another key difference between Lucien and the existing Node privileges is that Lucien does not require privileges to be defined at the start of the program's run. 

This relies on a few observations. First, many/most users are not willing to sacrifice application uptime for security. Second, successful attacks are rare. [Note: Remember, privileges-as-security are only relevant after an attacker has achieved code execution. For the purposes of this discussion, it doesn't matter how many probing attacks an application gets]. Thus, we conclude that it is more helpful to show which privileges an application is actually using, rather than expecting the programmer to correctly identify which privileges the application ought to have. 

Thus, when Lucien is first started, a user is presented with our dashboard showing all currently used privileges. The user can sort privileges by type or sensitivity, and as the program runs, new used privileges are added to the dashboard. When the user is satisfied with the displayed privileges, they can move to alert or block mode, which will, respectively, post an alert to the dashboard or actually block the illegitimate action. Expect a new blog post on dashboard usage in the near future. 

The combination of these features means that a user can immediately start using Lucien without deciding on a privilege model that they want to use. And, with that immediate use, they can see which libraries use which privileges and how those libraries are called. And they can use this information to quickly and easily see which libraries can be safely ignored (even if they have a CVE against them, if they don't have privileges, it doesn't matter) and which warrant further auditing/updating/etc. When a user is happy with their privilege model, they can then move to actually enforcing it, but if they simply wish to know more about what their app is doing, they can stay in observation mode forever. 

Summary

Limiting a program's privileges is an important exploit mitigation technique. Operating systems have default privilege models, but they are generally too coarse-grained to eliminate attacks. 

Especially in programs that need to run as a privileged user in order to work, a library-by-library privilege model would dramatically lower the risk of using buggy or malicious libraries. A model of this type would have rendered the XZ backdoor much less dangerous.  

The Lucien privilege model has several key benefits over standard OS (and Node-level) privileges. Most obviously, it supports much more fine-grained control, which allows the usage of potentially vulnerable third-party libraries, even in applications that need real privileges. But also it does not require its privileges to be defined beforehand, allowing developers insight into exactly which privileges different parts of their programs want to use, while allowing those programs to execute uninterrupted. 

Learn More

If you’re interested in trying out Lucien, please visit npm or GitHub . You can also contact us at [email protected]