Saturday, January 30, 2021

Node.js/V8 dynamic code injection from outside of the process

tl;dr

Remote debugging is fun to play around with. This article describes a method to dynamically change the behavior of a running Node.js process by enabling the remote inspector interface and then use the Chrome debug protocol.

On Linux and MacOS, it is possible to send a SIGUSR1 signal to a running Node.js process. The process will open a websocket server listening on local interfaces only. By connecting to the websocket server, one can start a debugging session on the Node.js process and therefore inject code into it. Eventually, one can shut down the websocket interface before disconnecting from the socket.

Introduction

Instrumenting code is probably one of the coolest tasks I have ever been asked to perform. Sqreen for Node.js requires users to import the agent (an npm package named sqreen) into their application so it can provide security features directly within the process.

Recently, I decided to challenge myself by seeing if I could do the same with the constraint of instrumenting an already running application. What follows here is a fun example of remote debugging in Node.js.

Note: the method exposed in this blog post should not be used in production or in any real product. This article merely describes hacks I used to obtain an interesting result with remote debugging in Node.js. However, some of the tools I used here can be used to collect specific data regarding a production application. For instance, take a look at my older post about memory leak debugging.

Changing the state of a running Node.js process

As described in a previous article, is it possible to enable the debugger on a running Node.js process under the following conditions:

  • You have access to the machine where the process runs
  • You can identify the PID of the running Node.js process
  • You have enough rights to send a signal to the Node.js process
  • You have enough rights to open a websocket connection to the Node.js process
  • The Node.js process runs on MacOS or Linux

In this whole article, we will consider the following Node.js application as the target

If all these assumptions are true, then you can send the SIGUSR1 signal to the application. This can be done through the shell:

Or with a Node.js line of code

In both case, it ends up with the target application logging the following:

At this point, we have successfully changed the state of the Node.js process and enabled the debugger. This can be confirmed by using Google Chrome or Chromium and checking the content of chrome://inspect 

chrome://inspect list of Node.js processes running in debug mode
chrome://inspect list of Node.js processes running in debug mode

Connecting to the process and injecting scripts

This is where the fun begins. Now that the process is running in debug mode, we want to connect to it and start using the Chrome DevTools Protocol to find the instance of http.Server and inject what we want to it.

To do that, we will use the chrome-remote-interface package: it will help us use the DevTools Protocol with a friendly programmatic interface. What we want to do is too advanced for us to do it with the Chrome DevTools for Node.js.

The goal here is to obtain a pointer on the instance of http.Server running in the process to later change its state.

Let’s run this script in another process

There is a lot to unwrap here so let’s look at it.

This part used the chrome-remote-interface to:

  • Connect to the Websocket server on port 9229
  • Enable the Runtime Domain of the DevTools Protocol

The methods we use after are all part of this domain so we don’t need to enable any other domains. Should we need another one, we would need to enable it first too.

The Runtime.evaluate command runs an arbitrary expression in the remote process. In other words, we can run any code in the Node.js process we connected to. Here we do the following:

  • We run the code require('http').Server.prototype and we return the result of this call
  • We use the includeCommandLineAPI flag, without it, the require method would not be provided to the script environment and the execution would return an error

The return value of this method contains a pointer to the prototype of http.Server in the Node.js process heap. We will pass it to the following instruction

These two calls give us a pointer to every instance of http.Server based on the result of the previous call. ServerPrototypeResult.result.objectId is a string referencing the value of require('http').Server.prototype. On this string, we call Runtime.queryObjects, which returns a pointer to an array. This array contains the list of objects which have http.Server.prototype as a prototype.

We call Runtime.getProperties on this array to obtain the list of properties of the array. There will be a property named 0 that will point to the instance of the HTTP server we want to identify (and if there is more than one object in the process with this same prototype, we would have more numbered properties).

This piece of code logs the following:

serverInstance contains a string value that is our pointer to the instance of http.Server running in the target process!

What to do with the HTTP Server?

Let’s say we can run a function with the HTTP server as an argument, how would we make it log every incoming request? I propose the following function:

It is a simple function that:

  • Get the list of listeners on the ’request’ event of the HTTP server
  • Remove all request listeners from the server
  • Wrap the listeners with a function that will log the HTTP method and URL for all incoming HTTP requests
  • Add the wrapped listeners back to the server

Now, how do we inject that on the instance of http.Server? Well, let’s do it using the pointers we found in the previous part of this article. To do it, let’s add the following code to our remote debugger script.

The first call to Runtime.evaluate loads the patchListeners function and attaches it to process. This is because we can’t use the includeCommandLineAPI argument on Runtime.callFunctionOn and therefore, require will never be defined when using it.

Runtime.callFunctionOn will call a given function with the value for this defined by the objectId argument.

So we call the function function() { process.patchListeners(this) } with this being the value referred by the pointer in serverInstance. serverInstance is a pointer to the HTTP server!

After doing this, we call a script to delete the ugly pollution we added on the process object.

At the end, we can disable the debug mode and disconnect from the instance using

Demo time!

We now have two scripts:

The web server code:

And the injector (we assume that the patchListeners function is in a module named toInject.js):

When we start the server and run a few HTTP requests against the server, it logs nothing. Now, when we executed the injector script against it (after having placed it in debug mode), it produces the following logs:

Note that even if there are no logs to show it, the debugger has been disabled and we would need to send the USR1 signal again if we want to connect back to it.

Conclusion

In this article, we took a running Node.js HTTP server and, from another local Node.js process, we were able to inject a script into it to make it log all incoming HTTP requests.

This highlights the power of the Chrome DevTools protocol: it is possible to programmatically change anything in a running process.

I am not sure there is a real life/production usage for this method just yet, but there are countless cool tools that can be built to help debug and understand how a Node.js process works using the methods of this article. It was overall pretty fun to play with remote debugging in Node.js and hack around with such great tools.



from Hacker News https://ift.tt/39r9mzR

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.