Best Practices for Accessing Memory Buffers from Native Code with the XS JavaScript Engine

JavaScript applications for embedded systems frequently work with memory buffers to perform a wide variety of tasks. Scripts gain tremendous power by being able to operate directly on the native data of the classes they use. The XS JavaScript engine at the core of the Moddable SDK contains extensive support for working with memory buffers. These are just some of the places the Moddable SDK uses memory buffers to exchange data with JavaScript code:

  • File data
  • Network data
  • Over-the-Air updates
  • Compressed image data
  • Uncompressed pixels
  • Flash memory access
  • Audio samples
  • Sensor sample data
  • Cryptographic primitives
  • TLS
  • Preferences

JavaScript represents blocks of memory using buffer objects. Working with memory buffers is fundamental in many programming languages, and these buffer objects are how JavaScript integrates this low-level capability into its APIs. Using memory buffers efficiently is often key to achieving optimal performance.

Unfortunately, it is remarkably easy to make mistakes when writing code to access a buffer and those mistakes can lead to stability bugs and security vulnerabilities. Fortunately, JavaScript is intended to be a safe language, one that prevents scripts from performing operations that would cause crashes or security breaches. As a result, its buffer objects are carefully designed to avoid creating vulnerabilities.

While JavaScript guarantees operations on memory buffers by scripts are safe, it cannot prevent unsafe operations on memory buffers passed to native functions implemented in C and C++. Because these languages are not memory safe, there is always a risk of mistakes.

Every major JavaScript engine has had security vulnerabilities related to their use of memory buffers. The problems occur when the native code that implements JavaScript functions reads from and writes to memory buffers. To make memory buffers secure, it is important to address how native code accesses memory buffers to minimize the opportunities for mistakes.

Before getting into the details of how XS now does that now, it is helpful to review the different kinds of buffers in the JavaScript language and some details of how the XS JavaScript engine implements them.

All About Buffers

JavaScript has a handful of objects for working with memory buffers. XS augments those with capabilities that are essential to embedded systems.

Memory Buffers

JavaScript has two fundamental object types to hold memory buffers.

  • ArrayBuffer - This is the most commonly used type of memory buffer, the JavaScript equivalent of calloc in C.
  • SharedArrayBuffer - This is a memory buffer that is intended to be accessed by two or more different JavaScript virtual machines. Synchronized simultaneous access to the buffer is provided by the Atomics. If a buffer only needs to be accessed from a single virtual machine, it should be an ArrayBuffer.

The XS JavaScript engine in the Moddable SDK adds another fundamental buffer type.

  • HostBuffer - A memory buffer that is managed by native code in the runtime, rather than by the JavaScript engine. There is no way for JavaScript code to create a HostBuffer itself: native objects create HostBuffers and provide them to scripts.

Buffer Views

The memory contained in these three fundamental buffer types cannot be accessed directly in JavaScript. Instead, they must be wrapped in a view. Views allow scripts to access the bytes of a buffer as various kinds of numbers.

  • TypedArray - These are arrays of numbers. There are many different TypedArrays - Uint8Array for 8-bit unsigned integers, Int16Array for 16-bit signed integers, and more.
  • DataView - These are blocks of data that are accessed by APIs that read or write values at a specified offset in the buffer. A DataView provides greater flexibility than a TypedArray but is generally less convenient to work with.

XS Buffer Implementation Details

The XS implementation of buffers adds two more points that need to be considered:

  • Read-only buffers. The JavaScript language assumes all memory buffers can be read from and written to. In the real world, this isn't always the case. For example, the Moddable SDK provides accessed to memory-mapped data stored in flash memory. This data is read-only, and attempts to write to it using normal memory access generate a hardware exception. Therefore, XS allows a buffer to be marked as read-only. Native code that accesses buffers must respect the read-only state of a buffer. (There are proposals under consideration that could bring read-only buffers to the JavaScript standard in the future.)
  • Relocatable buffers. In order to make optimal use of the memory on resource constrained microcontrollers, many of which lack an MMU, XS stores ArrayBuffers in relocatable memory blocks. SharedArrayBuffers and HostBuffers are non-relocatable. This is invisible to scripts but requires native code to take care with how long it retains a pointer to a memory buffer, as the pointer may be invalidated when the garbage collector compacts memory.

These three types of memory buffers, combined with two types of views, means that native code that accesses the bytes of a memory buffer has nine different cases to support. Any of these combinations might be read-only, and some buffers are relocatable.

That's a lot of details to remember when implementing a native function that reads or writes a buffer. In fact, we got it wrong in the Moddable SDK, in one way or another, just about every time. Getting it wrong can be more than annoyance: it can create security vulnerabilities.

Developer Expectations About Buffers

Developers working in JavaScript expect to be able to pass any kind of memory buffer to an API that works with buffers. This is more-or-less how most APIs in the Web Platform work. It is also how Ecma-419, the ECMAScript Embedded Systems API Specification, defines its APIs. This behavior is convenient for JavaScript developers but makes implementing the native code correctly more complex. The next section introduces APIs recently added to the Moddable SDK to allow native code to safely provide the behavior developers expect with a minimum of effort.

Accessing Bytes in a Buffer

XS has always included APIs for native code to access buffers. These are part of the XS in C API used to bridge between native C code and JavaScript. Unfortunately, the internal data structures of XS were insufficient to implement every case safely. XS internals have been updated (notably with the new Buffer Info slot type) to address this. New XS in C APIs have been added to make it safe and easy for native functions to work with any kind of buffer.

The Easy Way

The good news is that XS now has two APIs to access the bytes of a buffer that take care of all the details regardless of the kind of buffers and views used.

The new xsmcGetBufferReadable API obtains the address and size of a buffer that will be read from. The xsmcGetBufferWritable has exactly the same arguments, but is used to obtain the address and size of a buffer that will written to (and may be read from as well). If a read-only buffer is passed to xsmcGetBufferWritable it throws an exception. Here's an example that copies memory from one buffer to another.

void xsMemCopy(xsMachine *the)
{
    void *src, *dst;
    xsUnsignedValue srcSize, dstSize;

    xsmcGetBufferReadable(xsArg(0), &src, &srcSize);
    xsmcGetBufferWritable(xsArg(1), &dst, &dstSize);
    if (srcSize > dstSize)
        srcSize = dstSize;

    memcpy(dst, src, srcSize);
}

When a view is passed to xsmcGetBufferReadable or xsmcGetBufferWritable, the byteOffset and byteLength are applied to the returned pointer and size, so the xsMemCopy doesn't need to take those into account itself.

The implementations of xsmcGetBufferReadable and xsmcGetBufferWritable are guaranteed not to perform an allocation or execute a script. That's important because it means that xsMemCopy can be certain that the pointer returned in src will remain valid after calling xsmcGetBufferWritable to get the dst pointer.

Checking for Non-relocatable Buffers

Most of the time native code can be written so that it works independently of whether a buffer is relocatable. However, there are situations where native code can only work with one of these, typically a non-relocatable block. One example of this is a buffer that is written to by an interrupt handler. If the block can relocate, it might be moving when the interrupt handler runs and there would be no way to safely write the data. The return value of xsmcGetBufferReadable and xsmcGetBufferWritable indicates whether the block is relocatable or not. Here's an example:

void *gInterruptBuffer;
xsUnsignedValue gInterruptBufferSize;

void xsSetInterruptBuffer(xsMachine *the)
{
    if (xsBufferRelocatable == xsmcGetBufferWritable(xsArg(0), & gInterruptBuffer, &gInterruptBufferSize))
        xsUnknownError("non-relocatable blocks only");
}

The constant xsBufferNonrelocatable is also available.

The Other Ways

There's seldom a reason to use functions other than xsmcGetBufferReadable and xsmcGetBufferWritable to access the bytes of a buffer. We have already updated most of the Moddable SDK to use these functions, and will finish that migration soon. These functions are often faster than the code they replaced because they are implemented by the XS engine itself. In many cases dozens of lines of code were replaced with a single call.

There are some situations where the buffer must be of a particular type, so it is reasonable to use a more specific function. Note that these functions do not check for read-only buffers. That needs to be done separately.

ArrayBuffer

For ArrayBuffers use xsmcToArrayBuffer to get the data pointer and xsmcGetArrayBufferLength to get the size.

void *data = xsmcToArrayBuffer(xsArg(0));
void *dataSize = xsmcGetArrayBufferLength(xsArg(0));

Note that these functions only accept ArrayBuffers, and not views that use an ArrayBuffer for their storage.

HostBuffer

For HostBuffers, use xsmccGetHostData to retrieve the data pointer and xsmcGetHostBufferLength to get the size:

void *data = xsmcGetHostData(xsArg(0));
void *dataSize = xsmcGetHostBufferLength(xsArg(0));

Note that xsmcGetHostData does not check that the object is a HostBuffer. It may be a HostObject instead. To safely use the result of xsmcGetHostData as a data buffer, be sure to also call xsmcGetHostBufferLength which throws if the argument is not a HostBuffer.

SharedArrayBuffer

The bytes of a SharedArrayBuffer are accessed in the same way as a HostBuffer.

TypedArray and DataView

Accessing views directly is too messy to be done safely. Instead use xsmcGetBufferReadable and xsmcGetBufferWritable.

Creating a Buffer

Because there are so many different types of memory buffers and views, there are many different ways to create them. This section shows the most common. For the most part the APIs are straightforward to use. HostBuffers are more complicated because they also give the most flexibility.

ArrayBuffer

Creating an ArrayBuffer is easy. The following code allocates an ArrayBuffer of 32 bytes.

xsmcSetArrayBuffer(xsResult, NULL, 32);

XS requires that the buffer it allocates be fully initialized to avoid making uninitialized memory accessible to scripts. In this example, the bytes are automatically initialized to 0, consistent with the behavior of the JavaScript ArrayBuffer constructor. If the ArrayBuffer is being created to store data that already exists, the initialization to 0 can be skipped by having xsmcSetArrayBuffer copy the data instead. Just pass a pointer to the memory buffer to use to initialize the new ArrayBuffer.

xsmcSetArrayBuffer(xsResult, dataToCopy, 32);

TypedArray and DataView

Views are created from native code by first allocating the buffer, for example an ArrayBuffer, and then passing that buffer to the TypedArray constructor. The following example returns a 12 element Uint16Array.

xsmcVars(1);
xsmcSetArrayBuffer(xsVar(0), NULL, 24);
xsResult = xsNew1(xsGlobals, xsID("Uint16Array"), xsVar(0));

Alternatively, the TypedArray constructor can allocate the buffer and then xsmcGetBufferWritable can be used to access the allocated buffer.

void *buffer;
xsUnsignedValue bufferSize;

xsmcVars(1);
xsmcSetInteger(xsVar(0), 12);
xsResult = xsNew1(xsGlobals, xsID("Uint16Array"), xsVar(0));

xsmcGetBufferWritable(xsResult, &buffer, &bufferSize);

HostBuffer

HostBuffers are extremely flexible, so there are many different ways to use them. There are three parts of a HostBuffer:

  • Data pointer
  • Data size
  • Data destructor

The data destructor is a function that is called to release the resources used by the HostBuffer when it is collected by the garbage collector. If the data pointer of a HostBuffer is allocated using malloc then the destructor functions calls free. On the other hand, if the data pointer of a HostBuffer points to a block of memory in non-volatile flash memory, there is nothing at all for the destructor function to do, and it can be an empty function or a NULL pointer. The following examples show both of these scenarios.

HostBuffer Allocated by malloc

The following example wraps a HostBuffer instance around a block of memory allocated by malloc. When the HostBuffer is collected by the garbage collector, free is called to release the memory. The destructor function is passed as the sole argument to xsNewHostObject.

xsUnsignedValue dataSize = 32;
void *data = malloc(dataSize);

xsmcVars(1);
xsResult = xsNewHostObject(free);
xsmcSetHostBuffer(xsThis, data, dataSize);
xsmcSetInteger(xsVar(0), dataSize);
xsDefine(xsResult, xsID_byteLength, xsVar(0), xsDontDelete | xsDontSet);

The call to xsDefine is strictly optional, but almost always necessary as it makes the length of the buffer visible to scripts. Here xsDefine makes byteLength read-only so that it cannot be unintentionally changed. Note that the true length of the buffer, the value passed to xsmcSetHostBuffer, is stored in an internal slot of the HostBuffer, so it remains correct regardless of the value of the byteLength property.

HostBuffer Referencing Static Data

The following example wraps a HostBuffer around a block of static data. Since there is no need to free the memory when the buffer is collected, the destructor argument to xsNewHostObject is NULL.

static const uint8_t gData[] = {0, 1, 2, 3, 4, 6, 7};

xsmcVars(1);
xsResult = xsNewHostObject(NULL);
xsmcSetHostBuffer(xsThis, gData, sizeof(gData));
xsmcSetInteger(xsVar(0), sizeof(gData));
xsDefine(xsResult, xsID_byteLength, xsVar(0), xsDefault);

Conclusion

The benefits of applying the xsmcGetBufferReadable and xsmcGetBufferWritable APIs across the Moddable SDK have been significant:

  • Less code – The number of lines of coded needed to access the bytes of a buffer has been reduced in nearly all cases.
  • More consistent – All buffers and views are consistently accepted by APIs across the Moddable SDK, simplifying the developer experience.
  • More secure – These changes eliminated several vulnerabilities that had allowed read/write access to arbitrary memory.
  • Faster - The implementations of xsmcGetBufferReadable and xsmcGetBufferWritable are part of XS and so directly access internal structures, which is faster than the code they replace.
  • Future Proof - Because the new APIs are more general, they can be easily updated to support additional types of buffers and views in the future without requiring changes to the calling code.