Skip to content

Possible use-after-free of a JS Buffer captured by the async WriteArea worker #108

Description

@OvOhao

Possible use-after-free of a JS Buffer captured by the async WriteArea worker

I found a possible borrowed-buffer-across-async use-after-free in S7Client::WriteArea.
When a completion callback is supplied, WriteArea takes the raw backing-store pointer of the
JS Buffer argument via node::Buffer::Data(info[5]) and hands it to a libuv-threadpool
IOWorker (caller WRITEAREA). The worker keeps only a bare void* pData — it never creates a
napi_reference/Nan::Persistent on the buffer and the JS wrapper (lib/node-snap7.js,
WriteArea/DBWrite/MBWrite/…) does not retain it either. Once the synchronous call returns
undefined, nothing keeps the Buffer alive; if JS drops its last reference the garbage
collector can free (or relocate) the backing store while the worker thread is still reading from
that pointer inside snap7Client->WriteArea(...), producing a use-after-free / read of freed
memory sent to the PLC.

File: src/node_snap7_client.cpp

Function: S7Client::WriteArea

} else {
    Nan::Callback *callback = new Nan::Callback(info[6].As<v8::Function>());
    Nan::AsyncQueueWorker(new IOWorker(callback, s7client, WRITEAREA
      , node::Buffer::Data(info[5].As<v8::Object>()), Nan::To<int32_t>(info[0]).FromJust()
      , Nan::To<int32_t>(info[1]).FromJust(), Nan::To<int32_t>(info[2]).FromJust(), Nan::To<int32_t>(info[3]).FromJust()
      , Nan::To<int32_t>(info[4]).FromJust()));
    info.GetReturnValue().SetUndefined();
}

The IOWorker constructor only stores the pointer (node_snap7_client.h):

IOWorker(Nan::Callback *callback, S7Client *s7client, DataIOFunction caller
  , void *arg1, int arg2, int arg3, int arg4, int arg5, int arg6)
  : Nan::AsyncWorker(callback), s7client(s7client), caller(caller)
  , pData(arg1), int1(arg2), int2(arg3), int3(arg4), int4(arg5), int5(arg6) {}

and IOWorker::Execute() (which runs on the threadpool thread) dereferences it:

case WRITEAREA:
    returnValue = s7client->snap7Client->WriteArea(int1, int2, int3, int4
      , int5, pData);

Path:

  1. writeArea(area, db, start, amount, wordlen, buf, cb) reaches the callback branch.
  2. node::Buffer::Data(info[5]) returns a pointer into buf's V8 backing store.
  3. That raw pointer is stored in the worker's pData and the worker is queued.
  4. There is no SaveToPersistent/Nan::Persistent/napi_create_reference anywhere pinning
    buf, and the sync call returns immediately.
  5. If JS no longer references buf, a GC before/while Execute() runs frees or moves the
    backing store; the worker thread then reads freed memory → UAF.

JS trigger (if applicable):

const snap7 = require('node-snap7');
const c = new snap7.S7Client();
// buf is not retained anywhere after this call returns
c.WriteArea(c.S7AreaDB, 1, 0, 100, c.S7WLByte, Buffer.alloc(100), (err) => {});
// allocate pressure to make GC reclaim the buffer while the worker thread runs
for (let i = 0; i < 1e6; i++) ({});

Suggested fix: pin the buffer for the worker's lifetime. Store a Nan::Persistent<v8::Object>
(e.g. via worker->SaveToPersistent("buffer", info[5])) inside the IOWorker for the
WRITEAREA/DOWNLOAD/WRITEMULTI callers, or copy the buffer bytes into a worker-owned heap
allocation before queuing, and release the reference / free the copy in HandleOKCallback.

Additional defect: unchecked buffer length vs Amount (out-of-bounds read)

Independently of the UAF, WriteArea never checks the caller Buffer's byte length against the
number of bytes snap7 will read from it. The bytes read are Amount * bytesPerWordLen(WordLen)
(info[3] = Amount, info[4] = WordLen), but only node::Buffer::HasInstance(info[5]) is
validated — node::Buffer::Length(info[5]) is never consulted. snap7's WriteArea then reads
Amount * bytesPerWordLen bytes from the borrowed pointer regardless of the buffer's real size:

if (!info[0]->IsInt32() || !info[1]->IsInt32() ||
    !info[2]->IsInt32() || !info[3]->IsInt32() ||
    !info[4]->IsInt32() || !node::Buffer::HasInstance(info[5]))   // length of info[5] never checked
  return Nan::ThrowTypeError("Wrong arguments");
...
// snap7Client->WriteArea(Area, DBNumber, Start, Amount=info[3], WordLen=info[4],
//                        pUsrData = Buffer::Data(info[5]))  reads Amount*bytesPerWordLen bytes

A caller passing a small Buffer with a large Amount makes snap7 read past the end of the
buffer's backing store and transmit those out-of-bounds bytes to the PLC — a deterministic OOB
read / heap-memory disclosure that occurs on both the sync and async paths (no GC race required).

Additional fix (OOB read): reject the call when
node::Buffer::Length(info[5]) < Amount * GetByteCountFromWordLen(WordLen) before using the
buffer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions