Skip to content

Commit 90ac16c

Browse files
authored
fix: use-after-free in interop.bufferFromData under memory pressure (#372)
Without the retain, autorelease pool drains could free the NSData while the wrapped ArrayBuffer's backing pointer was still referencing it, causing EXC_BAD_ACCESS on subsequent reads from a Uint8Array view. Take a +1 retain on the NSData and release it from the BackingStore deleter when V8 finalizes the buffer.
1 parent a823a2a commit 90ac16c

2 files changed

Lines changed: 116 additions & 2 deletions

File tree

NativeScript/runtime/InteropTypes.mm

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,19 @@ new PrimitiveDataWrapper(sizeof(void*),
191191
size_t length = [obj length];
192192
void* data = const_cast<void*>([obj bytes]);
193193

194-
std::unique_ptr<v8::BackingStore> backingStore =
195-
ArrayBuffer::NewBackingStore(data, length, [](void*, size_t, void*) {}, nullptr);
194+
// Take a +1 retain so the NSData outlives autorelease pool drains while
195+
// the ArrayBuffer is alive. The deleter below releases this retain when
196+
// V8 finalises the BackingStore (i.e. the ArrayBuffer is GC'd / detached).
197+
[obj retain];
198+
199+
std::unique_ptr<v8::BackingStore> backingStore = ArrayBuffer::NewBackingStore(
200+
data, length,
201+
[](void* /*data*/, size_t /*length*/, void* deleter_data) {
202+
if (deleter_data != nullptr) {
203+
[(id)deleter_data release];
204+
}
205+
},
206+
obj);
196207

197208
Local<ArrayBuffer> result = ArrayBuffer::New(isolate, std::move(backingStore));
198209
info.GetReturnValue().Set(result);

TestRunner/app/tests/Marshalling/ObjCTypesTests.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,109 @@ describe(module.id, function () {
324324
//expect(interop.handleof(buffer)).toBe(data.bytes);
325325
});
326326

327+
it("interop.bufferFromData keeps NSData alive across autorelease drain + GC", function (done) {
328+
// The bug requires a MALLOC_LARGE allocation (>= 32 KB on iOS): tiny
329+
// allocations retain their bytes after free, so a stale pointer would
330+
// happen to read valid content. 64 KB plaintext -> ~87 KB base64 puts
331+
// enc squarely in the libmalloc large cache, which deterministically
332+
// reuses freed regions for a matching-size next allocation.
333+
var inputSize = 1 << 16;
334+
var plaintext = new Uint8Array(inputSize);
335+
for (var i = 0; i < inputSize; i++) plaintext[i] = i & 0xff;
336+
337+
// base64 of [0, 1, 2, 3, 4, 5] starts with "AAECAwQF" (ASCII).
338+
var expectedPrefix = [0x41, 0x41, 0x45, 0x43, 0x41, 0x77, 0x51, 0x46];
339+
340+
var view;
341+
(function () {
342+
var src = NSData.alloc().initWithBytesLength(plaintext, inputSize);
343+
// base64EncodedDataWithOptions returns an autoreleased NSData.
344+
var enc = src.base64EncodedDataWithOptions(0);
345+
view = new Uint8Array(interop.bufferFromData(enc));
346+
// src and enc are pure IIFE locals — no closure captures them — so
347+
// their JS wrappers become unreachable when this function returns.
348+
})();
349+
350+
// One yield is enough to drain the autorelease pool (removes enc's
351+
// autorelease +1); the gc() then finalizes the src/enc JS wrappers
352+
// (removes their wrapper +1, see ArgConverter::CreateJsWrapper).
353+
// Without the bufferFromData fix, enc is now freed.
354+
setTimeout(function () {
355+
gc();
356+
357+
// Allocate a same-sized NSData filled with a sentinel to push the
358+
// libmalloc large cache into reusing enc's freed VM region.
359+
var fillerSize = view.byteLength;
360+
var sentinel = new Uint8Array(fillerSize);
361+
sentinel.fill(0xab);
362+
var reused = NSData.alloc().initWithBytesLength(sentinel, fillerSize);
363+
364+
// byteLength lives on the ArrayBuffer record, not via the data
365+
// pointer, so it stays correct even with a stale pointer.
366+
expect(view.byteLength).toBe(Math.ceil(inputSize / 3) * 4);
367+
// Reading bytes dereferences the data pointer. Pre-fix this reads
368+
// 0xab (sentinel content that landed in enc's freed slot); post-fix
369+
// the BackingStore deleter keeps enc alive and we read the
370+
// original base64 bytes.
371+
for (var i = 0; i < expectedPrefix.length; i++) {
372+
expect(view[i]).toBe(expectedPrefix[i]);
373+
}
374+
// Spot-check a byte the sentinel would have stamped over.
375+
expect(view[fillerSize - 1]).not.toBe(0xab);
376+
377+
void reused; // keep referenced so its wrapper doesn't GC mid-spec
378+
done();
379+
}, 0);
380+
});
381+
382+
it("interop.bufferFromData survives repeated alloc/yield/GC cycles", function (done) {
383+
// Same MALLOC_LARGE shape as the single-shot repro above.
384+
var inputSize = 1 << 16;
385+
var plaintext = new Uint8Array(inputSize);
386+
for (var i = 0; i < inputSize; i++) plaintext[i] = i & 0xff;
387+
388+
// Production bug surfaced in <4s; with explicit gc() forcing wrapper
389+
// finalization every iteration, a handful of cycles is plenty to
390+
// catch a regression.
391+
var iterations = 4;
392+
var i = 0;
393+
var pending = null;
394+
395+
// Scope ns/enc inside this helper so they become unreachable when it
396+
// returns; the next yield + gc() then finalizes their wrappers.
397+
function alloc() {
398+
var ns = NSData.alloc().initWithBytesLength(plaintext, inputSize);
399+
var enc = ns.base64EncodedDataWithOptions(0);
400+
return new Uint8Array(interop.bufferFromData(enc));
401+
}
402+
403+
function step() {
404+
if (pending !== null) {
405+
// Previous setTimeout drained the autorelease pool; finalize
406+
// the ns/enc wrappers. Without the fix, enc is now freed.
407+
gc();
408+
409+
// First-byte check is enough — a stale pointer post-recycle
410+
// reads the sentinel/new-alloc bytes which won't be 0x41.
411+
expect(pending[0]).toBe(0x41);
412+
expect(pending[pending.byteLength - 1]).not.toBe(0);
413+
pending = null;
414+
}
415+
416+
if (i >= iterations) {
417+
expect(i).toBe(iterations);
418+
done();
419+
return;
420+
}
421+
422+
pending = alloc();
423+
i++;
424+
setTimeout(step, 0);
425+
}
426+
427+
step();
428+
});
429+
327430
it("should be possible to marshal an ArrayBuffer as void* parameter", () => {
328431
var data = NSData.alloc().initWithBase64EncodedStringOptions("MTIzNDU=", NSDataBase64DecodingIgnoreUnknownCharacters);
329432
var arr = new ArrayBuffer(5);

0 commit comments

Comments
 (0)