When This is Really That
A Brief Review
For many, this section will be a review, so feel free to skip ahead. However, since we are dealing with some features of the JavaScript language that most of us don’t use every day, I’m going to go ahead and give a brief refresher so the rest of this makes sense.
Object oriented programming in JavaScript can be a little bit unconventional. Typically, private methods and variables are expressed as locally declared variables, while public methods and variables are expressed as properties attached to the this
object, which is an object that refers to current variable scope.
This pattern fits well enough for most use cases, but what about the situation where you need a piece of data that is publicly visible, but only privately writable, such as in the case of the length
property of builtins?
Typically what happens is that the property is defined with the __defineSetter__
and __defineGetter__
methods, which allow a function to basically act as a gatekeeper to the property - denying write access in certain cases, and allowing it in others.
But what if this
could be modified? Enter Function.bind
– inherited from the function prototype, it is available on every function in JavaScript by default. It’s purpose is to take an existing function and call the function with a this
object specified when bind is called.
Can touch this
In many cases, native functions which access memory directly via v8 bindings have their memory access protected by length limits and metadata that exist inside this
, as mentioned above. The length
property of a Buffer is one example.
Well, we know now that function.bind can tamper with those pieces of metadata, which are used to handle special types of memory access when it comes to builtin modules and types.
In some modules this is handled sensibly – internal functions check to make sure that this
is a valid instance of the object it expects, and overrides any attempts to modify those special internal properties without changing the memory the object has allocated. As far as I know, all builtins in v8 use this method to protect sensitive data.
In Node.js, however, the majority of the added features have no protection against this kind of attack.
You can allocate a buffer with a length of 100, and fill it with 10000 characters if you want, by binding the Buffer prototype to a tampered context as shown in this example:
1 | var t = new Buffer(100) |
Here we tried to access unallocated memory, failed with a triggered assertion, and then crashed the process.
You Can’t Trust What You Don’t Control
The next issue is that the Node.js core modules are inconsistent with their protection in regards to verifying the integrity of the util
module. Some core modules create a local instantiation of critical functions used to check types, because they can have undesirable impacts if they are tampered with and the module is using the globally cached version.
There are also modules that do not and simply use the global version. Once again, thebuffer
module lies in the second group.
Buffer
uses util.isArray
internally to check and see if the data passed into the constructor is an array. If it is, it then pulls the length
property from the array and trusts it as the actual length of the data that it is going to be storing in the buffer.
But if util.isArray
has been overwritten, it is possible to pass in an object that meets the internal requirements for a valid “array,” yet is free from the safety constraints placed on a real array – namely that the length
property matches the length of the data.
Example:
1 | utils.isArray = function(){return true;} |
If you open up your console and play around with the above example you might notice that you can’t really write anything to the buffer. Once again, limited usefulness, but certainly buggy behavior.
Bringing it All Together
1 | var util = require('util'); |
Walking through what’s going on above we see:
- Overwriting
isArray
to allow passing in an object with an incorrect length - Make copies of all the prototypal methods on
Buffer
- a direct copy won’t do, it’ll just end up messing with the globalBuffer
object and prevent you from proceeding - Add a
length
property, and define getters/setters on thelength
to ensure that Buffer can’t update the length when it needs to - Fill an array at some fraction of the expected size with garbage
- Bind the
Buffer
constructor to the tampered context - Create a new
Buffer
instance with the binding, passing in the object masquerading as an array - Enumerate the
Buffer
with.toString()
, which relies heavily onthis.length
At this point, the process will stop responding while filling up memory and terminate with SIGILL. Crashwrangler, although not 100% accurate, reports this to be an exploitable bug.
I contacted the Node.js Security team and reported the issue – it will not be fixed. Given how specific the situation has to be in order to exploit, the only likely situation in which this could be exploited is in a sandbox implementation, which I mentioned when reporting. As a sandbox in Node.js is unlikely to ever be safe, this was deemed to be too low of a priority to be worth the dev teams time.
Update: I have had additional discussion with the Node.js team, and they are giving the issue additional consideration. I am still uncertain as to whether or not a fix will be issued, but from our conversation, it appears that it will be fixed at some point. They have been one of the most responsible OSS Security teams I’ve had the pleasure of working with, and this post is in no way intended to be critical of them.
Why it Matters
The impact here is that a useful Node.js sandbox without VM/OS level isolation will never be secure.
Even if the context is locked down to remove privileged features, the builtins added with Node.js add a significant risk for a sandbox breakout exploit. Combined with the response from the dev team that security measures primarily affecting sandboxes will not be fixed, the best thing to do here is to use another language for this purpose, or rely on isolation implemented outside of the language, such as a VM.