Skip to content

Instantly share code, notes, and snippets.

@legendtang
Created May 28, 2026 10:18
Show Gist options
  • Select an option

  • Save legendtang/e997969236031805821b54c75b3282c1 to your computer and use it in GitHub Desktop.

Select an option

Save legendtang/e997969236031805821b54c75b3282c1 to your computer and use it in GitHub Desktop.
Deno SendPtr Unsoundness PoC - Real N-API Usage

Deno SendPtr Unsoundness PoC

Summary

This PoC demonstrates a soundness bug in Deno's N-API implementation where SendPtr<T> implements Send for all types T, even when T is not Send.

The Bug

In ext/napi/util.rs:

#[repr(transparent)]
pub(crate) struct SendPtr<T>(pub *const T);

unsafe impl<T> Send for SendPtr<T> {}  // ❌ UNSOUND
unsafe impl<T> Sync for SendPtr<T> {}  // ❌ UNSOUND

This allows sending pointers to non-Send types across threads, violating Rust's safety guarantees.

How It's Triggered

The bug is triggered through N-API's threadsafe function mechanism:

  1. Native addon creates a threadsafe function via napi_create_threadsafe_function
  2. Worker thread calls napi_call_threadsafe_function with arbitrary data pointer
  3. Deno wraps the pointer in SendPtr<T> (node_api.rs:972)
  4. Data is sent to V8 thread via sender.spawn() (node_api.rs:980)
  5. V8 thread accesses the data (node_api.rs:995, 1019)

If the data pointer points to non-Send data, this causes undefined behavior.

Building and Running

Prerequisites

  • Node.js (for node-gyp)
  • Deno 2.8+
  • GCC/Clang

Build

cd /tmp/deno_sendptr_poc
node-gyp configure build

Run with Deno

deno run -A --unstable-detect-cjs test.js

Expected Output

=== Deno SendPtr PoC ===
This demonstrates that SendPtr<T> allows sending non-Send data across threads

[Main Thread] Created threadsafe function
[Main Thread] Spawned worker thread
[JavaScript] Waiting for async callback...
[Worker Thread] Starting...
[Worker Thread] Created data: value=42, ptr=0x7fe2240008a0
[Worker Thread] Successfully queued callback
[V8 Thread] Accessing data: value=42, ptr=0x7fe2240008a0
[V8 Thread] Dereferencing shared_ptr: 100
[JavaScript] Done

What This Demonstrates

  1. Worker Thread allocates data and sends it via napi_call_threadsafe_function
  2. SendPtr wrapping happens inside Deno's Rust code (transparent to C code)
  3. V8 Thread receives and accesses the data

The C code simulates non-thread-safe data (shared pointers). In a real exploit, this could be:

  • Rc<T> or Cell<T> in Rust native addons
  • Thread-local storage
  • Any data structure that assumes single-threaded access

Impact

  • Severity: Medium-High
  • Exploitability: Low (requires native addon)
  • Impact: Undefined behavior, potential memory corruption
  • Scope: N-API threadsafe functions only

Recommended Fix

// Option 1: Add proper bounds (but breaks because *const T is never Send)
unsafe impl<T: Send> Send for SendPtr<T> {}
unsafe impl<T: Sync> Sync for SendPtr<T> {}

// Option 2: Document the safety requirement
/// SAFETY: Caller must ensure T is Send
unsafe impl<T> Send for SendPtr<T> {}

// Option 3: Redesign to use Arc instead of raw pointers

Files

  • addon.c - N-API native addon that triggers the bug
  • test.js - JavaScript test that loads and calls the addon
  • binding.gyp - Build configuration
  • package.json - Node.js package metadata

Tested On

  • Deno 2.8.1
  • Node.js v26.2.0
  • Linux x86_64

References

#include <node_api.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
// Simulating non-thread-safe data
typedef struct {
int value;
int* shared_ptr; // Simulates data that shouldn't cross threads
} NonSendData;
static napi_threadsafe_function g_tsfn = NULL;
static void call_js_callback(napi_env env, napi_value js_callback,
void* context, void* data) {
NonSendData* my_data = (NonSendData*)data;
printf("[V8 Thread] Accessing data: value=%d, ptr=%p\n",
my_data->value, (void*)my_data->shared_ptr);
// This access happens on V8 thread after SendPtr sent it across threads
if (my_data->shared_ptr) {
printf("[V8 Thread] Dereferencing shared_ptr: %d\n", *my_data->shared_ptr);
}
free(my_data->shared_ptr);
free(my_data);
}
static void* worker_thread(void* arg) {
printf("[Worker Thread] Starting...\n");
sleep(1); // Simulate some work
// Allocate non-thread-safe data on worker thread
NonSendData* data = malloc(sizeof(NonSendData));
data->value = 42;
data->shared_ptr = malloc(sizeof(int));
*data->shared_ptr = 100;
printf("[Worker Thread] Created data: value=%d, ptr=%p\n",
data->value, (void*)data->shared_ptr);
// This will wrap data in SendPtr<T> and send to V8 thread
// The bug: SendPtr<T> is Send for all T, even non-Send types
napi_status status = napi_call_threadsafe_function(
g_tsfn, data, napi_tsfn_blocking);
if (status != napi_ok) {
printf("[Worker Thread] Failed to call tsfn: %d\n", status);
} else {
printf("[Worker Thread] Successfully queued callback\n");
}
return NULL;
}
static napi_value CreateBadTsfn(napi_env env, napi_callback_info info) {
napi_value async_resource_name;
napi_status status;
status = napi_create_string_utf8(env, "bad_tsfn", NAPI_AUTO_LENGTH,
&async_resource_name);
if (status != napi_ok) return NULL;
// Create threadsafe function
status = napi_create_threadsafe_function(
env,
NULL, // No JS function needed for this demo
NULL, // No async resource
async_resource_name,
0, // Max queue size (0 = unlimited)
1, // Initial thread count
NULL, // No finalize data
NULL, // No finalize callback
NULL, // No context
call_js_callback,
&g_tsfn);
if (status != napi_ok) {
printf("Failed to create threadsafe function: %d\n", status);
return NULL;
}
printf("[Main Thread] Created threadsafe function\n");
// Spawn worker thread that will send non-Send data
pthread_t thread;
if (pthread_create(&thread, NULL, worker_thread, NULL) != 0) {
printf("Failed to create thread\n");
return NULL;
}
pthread_detach(thread);
printf("[Main Thread] Spawned worker thread\n");
return NULL;
}
static napi_value Init(napi_env env, napi_value exports) {
napi_value fn;
napi_create_function(env, NULL, 0, CreateBadTsfn, NULL, &fn);
napi_set_named_property(env, exports, "createBadTsfn", fn);
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)
{
"targets": [
{
"target_name": "addon",
"sources": [ "addon.c" ],
"cflags": ["-pthread"],
"ldflags": ["-pthread"]
}
]
}
{
"name": "deno-sendptr-poc",
"version": "1.0.0",
"description": "PoC for Deno SendPtr unsoundness",
"main": "test.js",
"scripts": {
"build": "node-gyp rebuild",
"test": "node test.js"
},
"gypfile": true
}
// test.js - Triggers SendPtr unsoundness in Deno
const addon = require('./build/Release/addon.node');
console.log("=== Deno SendPtr PoC ===");
console.log("This demonstrates that SendPtr<T> allows sending non-Send data across threads");
console.log("");
// Call the native function that creates a threadsafe function
// and sends non-thread-safe data from a worker thread
addon.createBadTsfn();
console.log("[JavaScript] Waiting for async callback...");
// Keep the process alive to see the callback
setTimeout(() => {
console.log("[JavaScript] Done");
process.exit(0);
}, 3000);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment