Summary
When sshcrypto.node is compiled with Node 24.19.0 (or newer 24.x) headers, the whole
process aborts as soon as the garbage collector reclaims one of the native cipher objects:
# node[7]: void node::RemoveEnvironmentCleanupHook(v8::Isolate*, CleanupHook, void*) at ../../src/api/hooks.cc:142
# Assertion failed: (env) != nullptr
1: node::Assert(node::AssertionInfo const&) [node]
2: node::RemoveEnvironmentCleanupHook(v8::Isolate*, void (*)(void*), void*) [node]
3: AESGCMDecipher::~AESGCMDecipher() [<app>/node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node]
Seen in production on ssh2 1.17.0 with a long running service that opens an SFTP connection
every 30 seconds. No dependency changed; only the Node version used to build the addon moved
from 24.18.0 to 24.19.0.
Root cause is upstream, not in ssh2
Node 24.19.0 added cleanup hooks to node::ObjectWrap (nodejs/node#63642): its constructor
now calls AddEnvironmentCleanupHook and its destructor calls
RemoveEnvironmentCleanupHook. node_object_wrap.h is header only, so that code is compiled
into the addon. The destructor runs from the V8 weak callback, where no context is entered,
so Environment::GetCurrent() returns null and Node's CHECK_NOT_NULL(env) aborts.
On the 24.x line the matching runtime side fix was not backported, so every 24.x runtime is
affected. Details and a minimal reproducer without ssh2: nodejs/node#65262 and
nodejs/node#65446.
binding.cc picks up node::ObjectWrap through using namespace node; at the top, so all
of AESGCMCipher, AESGCMDecipher, ChaChaPoly* and Generic* inherit the affected
destructor.
Why the existing free() path does not save us
AESGCMDecipherBinding.free() in lib/protocol/crypto.js is an empty method, and the native
Free method only calls clear() (frees the EVP_CIPHER_CTX); it does not destroy the
wrapped object. So the ObjectWrap destructor always runs later, from GC, which is exactly
the path that aborts.
Suggestion
ssh2 already depends on NAN, and Nan::ObjectWrap (nan_object_wrap.h) has no cleanup hook
calls at all — its destructor only clears and resets the persistent handle. Inheriting from
Nan::ObjectWrap instead of node::ObjectWrap in binding.cc would make the addon
independent of this Node change.
Workarounds for users hitting this now
- Build the addon with Node 24.18.x headers (24.18.1 is the newest release on that line, so
no security fixes are given up).
- Or skip the native binding and let ssh2 fall back to its JavaScript crypto, e.g.
rm -rf node_modules/ssh2/lib/protocol/crypto/build after install.
Environment
- ssh2 1.17.0
- Node 24.19.0 headers, 24.18.0 and 24.19.0 runtimes (both abort)
- Linux x86_64, Debian 13 container; the upstream reproducer also aborts on Darwin arm64
Summary
When
sshcrypto.nodeis compiled with Node 24.19.0 (or newer 24.x) headers, the wholeprocess aborts as soon as the garbage collector reclaims one of the native cipher objects:
Seen in production on ssh2 1.17.0 with a long running service that opens an SFTP connection
every 30 seconds. No dependency changed; only the Node version used to build the addon moved
from 24.18.0 to 24.19.0.
Root cause is upstream, not in ssh2
Node 24.19.0 added cleanup hooks to
node::ObjectWrap(nodejs/node#63642): its constructornow calls
AddEnvironmentCleanupHookand its destructor callsRemoveEnvironmentCleanupHook.node_object_wrap.his header only, so that code is compiledinto the addon. The destructor runs from the V8 weak callback, where no context is entered,
so
Environment::GetCurrent()returns null and Node'sCHECK_NOT_NULL(env)aborts.On the 24.x line the matching runtime side fix was not backported, so every 24.x runtime is
affected. Details and a minimal reproducer without ssh2: nodejs/node#65262 and
nodejs/node#65446.
binding.ccpicks upnode::ObjectWrapthroughusing namespace node;at the top, so allof
AESGCMCipher,AESGCMDecipher,ChaChaPoly*andGeneric*inherit the affecteddestructor.
Why the existing
free()path does not save usAESGCMDecipherBinding.free()inlib/protocol/crypto.jsis an empty method, and the nativeFreemethod only callsclear()(frees theEVP_CIPHER_CTX); it does not destroy thewrapped object. So the
ObjectWrapdestructor always runs later, from GC, which is exactlythe path that aborts.
Suggestion
ssh2 already depends on NAN, and
Nan::ObjectWrap(nan_object_wrap.h) has no cleanup hookcalls at all — its destructor only clears and resets the persistent handle. Inheriting from
Nan::ObjectWrapinstead ofnode::ObjectWrapinbinding.ccwould make the addonindependent of this Node change.
Workarounds for users hitting this now
no security fixes are given up).
rm -rf node_modules/ssh2/lib/protocol/crypto/buildafter install.Environment