Skip to content

Native crypto addon aborts the process on Node 24.19.0 (RemoveEnvironmentCleanupHook assertion) #1511

Description

@roy-trgscreen

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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