ud2
ud216mo ago

Avoiding use-after-free

I encountered a use-after-free issue when writing an FFI binding. The code looks like this.
const lib = Deno.dlopen("libexample.dylib", {
/**
* Fills newly allocated memory with data sampled from an external source.
* @param {*mut usize} len Pointer to which the number of samples will be written.
* @returns {*mut f32} Pointer to the data.
*/
example_sample_data: {
parameters: ["buffer"],
result: "pointer",
},
/**
* Frees an allocation created by `example_sample_data`.
* @param {*mut f32} ptr Pointer to the data.
* @returns {()}
*/
example_free_data: {
parameters: ["pointer"],
result: "void",
},
});
const { example_sample_data, example_free_data } = lib.symbols;
const lenCell = new BigUint64Array(1);
const lenBuf = new Uint8Array(lenCell.buffer);
const finalizer = new FinalizationRegistry(example_free_data);

export function sampleData(): Float32Array {
const ptr = example_sample_data(lenBuf)!;
const len = Number(lenCell[0]);
let buf = Deno.UnsafePointerView.getArrayBuffer(
ptr,
len * Float32Array.BYTES_PER_ELEMENT,
);
// I could have avoided the issue by switching this to `false`, but I would like to minimize unnecessary copies.
const zeroCopy = true;
if (zeroCopy) {
finalizer.register(buf, ptr);
} else {
buf = buf.slice(0);
example_free_data(ptr);
}
return new Float32Array(buf);
}
const lib = Deno.dlopen("libexample.dylib", {
/**
* Fills newly allocated memory with data sampled from an external source.
* @param {*mut usize} len Pointer to which the number of samples will be written.
* @returns {*mut f32} Pointer to the data.
*/
example_sample_data: {
parameters: ["buffer"],
result: "pointer",
},
/**
* Frees an allocation created by `example_sample_data`.
* @param {*mut f32} ptr Pointer to the data.
* @returns {()}
*/
example_free_data: {
parameters: ["pointer"],
result: "void",
},
});
const { example_sample_data, example_free_data } = lib.symbols;
const lenCell = new BigUint64Array(1);
const lenBuf = new Uint8Array(lenCell.buffer);
const finalizer = new FinalizationRegistry(example_free_data);

export function sampleData(): Float32Array {
const ptr = example_sample_data(lenBuf)!;
const len = Number(lenCell[0]);
let buf = Deno.UnsafePointerView.getArrayBuffer(
ptr,
len * Float32Array.BYTES_PER_ELEMENT,
);
// I could have avoided the issue by switching this to `false`, but I would like to minimize unnecessary copies.
const zeroCopy = true;
if (zeroCopy) {
finalizer.register(buf, ptr);
} else {
buf = buf.slice(0);
example_free_data(ptr);
}
return new Float32Array(buf);
}
But the user could then do this.
let data = sampleData();
data = structuredClone(data, { transfer: [data.buffer as ArrayBuffer] });
// Original `ArrayBuffer` is no longer reachable.
// …
// Later, its cleanup callback is called, which frees the allocation.
console.log(data); // Use after free.
let data = sampleData();
data = structuredClone(data, { transfer: [data.buffer as ArrayBuffer] });
// Original `ArrayBuffer` is no longer reachable.
// …
// Later, its cleanup callback is called, which frees the allocation.
console.log(data); // Use after free.
How do I ensure that example_free_data is called only when no ArrayBuffer points to the allocation?
1 Reply
AapoAlas
AapoAlas16mo ago
Hmm... That is a good question. I don't think a native way to do this exists. The only option I can think of is to offer the user an API to call that would deregister the original AB and replace it with some other object, possibly any object (not just the new AB they cloned) if they wish to eg. share the AB with workers. I think it's also fair to just say: "This will lead to a crash, don't do it." There's a fair chance of leaking memory if you use Deno.UnsafePointer.of() on an AB that was created from a foreign pointer that is itself used in a FinalizationRegistry. ... I'll need to think on that.