Skip to content

Commit c23571f

Browse files
committed
crypto: fix TLSWrap use-after-free on pending write
Two fixes for TLSWrap::Destroy() when called with in-flight writes: 1. Destructor path (GC weak callback): do not call InvokeQueued(). StreamReq::Done() mutates JS objects via v8::Object::Set() and may allocate on the V8 heap, violating V8's invariant that first-pass weak callbacks must not trigger heap mutation or nested GC. 2. BIO buffer use-after-free: EncOut() passes pointers from the enc_out_ BIO internal buffer to the underlying stream's uv_write(). If ssl_.reset() frees the BIO while that write is still in flight, the pointers become dangling. Use ssl_.release() instead when write_size_ != 0 so the BIO data stays alive. Also move RemoveStreamListener() before SSL cleanup so the underlying stream cannot call back into the destroyed TLSWrap. Refs: #62393 Made-with: Cursor
1 parent 0f68423 commit c23571f

3 files changed

Lines changed: 84 additions & 13 deletions

File tree

src/crypto/crypto_tls.cc

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,10 @@ TLSWrap::TLSWrap(Environment* env,
438438
}
439439

440440
TLSWrap::~TLSWrap() {
441-
Destroy();
441+
// Destructor may run from V8 weak callbacks during garbage collection.
442+
// Do not invoke JS-visible write callbacks (StreamReq::Done mutates
443+
// JS objects and may allocate on the V8 heap, violating GC invariants).
444+
Destroy(false);
442445
}
443446

444447
MaybeLocal<ArrayBufferView> TLSWrap::ocsp_response() const {
@@ -1307,25 +1310,37 @@ void TLSWrap::DestroySSL(const FunctionCallbackInfo<Value>& args) {
13071310
Debug(wrap, "DestroySSL() finished");
13081311
}
13091312

1310-
void TLSWrap::Destroy() {
1313+
void TLSWrap::Destroy(bool invoke_queued) {
13111314
if (!ssl_)
13121315
return;
13131316

1314-
// If there is a write happening, mark it as finished.
1315-
write_callback_scheduled_ = true;
1317+
if (invoke_queued) {
1318+
write_callback_scheduled_ = true;
1319+
InvokeQueued(UV_ECANCELED, "Canceled because of SSL destruction");
1320+
} else {
1321+
current_write_.reset();
1322+
current_empty_write_.reset();
1323+
write_callback_scheduled_ = false;
1324+
}
13161325

1317-
// And destroy
1318-
InvokeQueued(UV_ECANCELED, "Canceled because of SSL destruction");
1326+
// Detach from the underlying stream before releasing the SSL context.
1327+
if (underlying_stream() != nullptr)
1328+
underlying_stream()->RemoveStreamListener(this);
13191329

1320-
env()->external_memory_accounter()->Decrease(env()->isolate(), kExternalSize);
1321-
ssl_.reset();
1330+
// EncOut() passes pointers into the enc_out_ BIO internal buffer to the
1331+
// underlying stream via uv_write(). write_size_ is non-zero while that
1332+
// write is in flight. Freeing the SSL context would turn those pointers
1333+
// into dangling references (use-after-free when libuv completes the
1334+
// write). Release ownership without freeing so the BIO data stays alive.
1335+
if (write_size_ != 0) {
1336+
ssl_.release();
1337+
} else {
1338+
ssl_.reset();
1339+
}
13221340

1341+
env()->external_memory_accounter()->Decrease(env()->isolate(), kExternalSize);
13231342
enc_in_ = nullptr;
13241343
enc_out_ = nullptr;
1325-
1326-
if (underlying_stream() != nullptr)
1327-
underlying_stream()->RemoveStreamListener(this);
1328-
13291344
sc_.reset();
13301345
}
13311346

src/crypto/crypto_tls.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ class TLSWrap : public AsyncWrap,
159159
void EncOut(); // Write encrypted data from enc_out_ to underlying stream.
160160
void ClearIn(); // SSL_write() clear data "in" to SSL.
161161
void ClearOut(); // SSL_read() clear text "out" from SSL.
162-
void Destroy();
162+
void Destroy(bool invoke_queued = true);
163163

164164
// Call Done() on outstanding WriteWrap request.
165165
void InvokeQueued(int status, const char* error_str = nullptr);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict';
2+
3+
// Regression test for TLSWrap use-after-free when destroySSL() is called
4+
// while an underlying stream write is still in flight.
5+
//
6+
// EncOut() passes pointers into the enc_out_ BIO's internal buffer to the
7+
// underlying stream via uv_write(). If the SSL context is freed before that
8+
// write completes, libuv accesses freed memory (SIGSEGV).
9+
//
10+
// The GC weak callback path triggers the same Destroy() code when a
11+
// TLSSocket with pending writes is collected without an explicit destroy().
12+
13+
const common = require('../common');
14+
if (!common.hasCrypto)
15+
common.skip('missing crypto');
16+
17+
const fixtures = require('../common/fixtures');
18+
const tls = require('node:tls');
19+
20+
const server = tls.createServer({
21+
key: fixtures.readKey('agent1-key.pem'),
22+
cert: fixtures.readKey('agent1-cert.pem'),
23+
}, (socket) => {
24+
// Do not read — TCP backpressure keeps client writes pending in the
25+
// TLSWrap's underlying stream so write_size_ stays non-zero.
26+
socket.pause();
27+
});
28+
29+
server.listen(0, common.mustCall(() => {
30+
const { port } = server.address();
31+
let remaining = 10;
32+
33+
for (let i = 0; i < 10; i++) {
34+
const socket = tls.connect(
35+
{ port, host: '127.0.0.1', rejectUnauthorized: false },
36+
common.mustCall(() => {
37+
// Queue a write large enough to stay pending (server never reads).
38+
socket.write(Buffer.alloc(1024 * 1024));
39+
40+
// Simulate the GC weak callback path: free the SSL context while
41+
// the underlying stream write is still in flight.
42+
if (socket._handle && socket._handle.destroySSL) {
43+
socket._handle.destroySSL();
44+
}
45+
46+
setTimeout(() => {
47+
socket.destroy();
48+
if (--remaining === 0) {
49+
server.close();
50+
}
51+
}, 50);
52+
}),
53+
);
54+
socket.on('error', () => {});
55+
}
56+
}));

0 commit comments

Comments
 (0)